diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9316afb6d09..6dc68d2275a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -274,8 +274,8 @@ jobs: - name: Run changed extension tests env: - EXTENSION_ID: ${{ matrix.extension }} - run: pnpm test:extension "$EXTENSION_ID" + OPENCLAW_CHANGED_EXTENSION: ${{ matrix.extension }} + run: pnpm test:extension "$OPENCLAW_CHANGED_EXTENSION" # Types, lint, and format check. check: @@ -304,8 +304,8 @@ jobs: - name: Enforce safe external URL opening policy run: pnpm lint:ui:no-raw-window-open - startup-memory: - name: "startup-memory" + build-smoke: + name: "build-smoke" needs: [docs-scope, changed-scope] if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 @@ -333,6 +333,34 @@ jobs: - name: Check CLI startup memory run: pnpm test:startup:memory + gateway-watch-regression: + name: "gateway-watch-regression" + needs: [docs-scope, changed-scope] + if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + use-sticky-disk: "false" + + - name: Run gateway watch regression harness + run: pnpm test:gateway:watch-regression + + - name: Upload gateway watch regression artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: gateway-watch-regression + path: .local/gateway-watch-regression/ + retention-days: 7 + # Validate docs (format, lint, broken links) only when docs files changed. check-docs: needs: [docs-scope] diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c090e31784..8930840332c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ Docs: https://docs.openclaw.ai - Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import. - Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant. - Plugins/testing: add a public `openclaw/plugin-sdk/testing` seam for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers. +- Plugins/Chutes: add a bundled Chutes provider with plugin-owned OAuth/API-key auth, dynamic model discovery, and default-on extension wiring. (#41416) Thanks @Veightor. +- Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo. ### Breaking @@ -101,6 +103,9 @@ Docs: https://docs.openclaw.ai - Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark. - Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman. - Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc. +- Tlon/DM auth: defer cited-message expansion until after DM authorization and owner command handling, so unauthorized DMs and owner approval/admin commands no longer trigger cross-channel cite fetches before the deny or command path. +- Docs/security audit: spell out that `gateway.controlUi.allowedOrigins: ["*"]` is an explicit allow-all browser-origin policy and should be avoided outside tightly controlled local testing. +- Gateway/auth: clear self-declared scopes for device-less trusted-proxy Control UI sessions so proxy-authenticated connects cannot claim admin or secrets scopes without a bound device identity. - Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc. - Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46515) Fixes #46411. Thanks @ademczuk. - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. @@ -111,12 +116,16 @@ Docs: https://docs.openclaw.ai - Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham. - Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw. - Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native OpenAI-completions providers so compatible backends report token usage and cost again instead of showing all zeros. (#46500) Fixes #46142. Thanks @ademczuk. +- ACP/acpx: keep plugin-local backend installs under `extensions/acpx` in live repo checkouts so rebuilds no longer delete the runtime binary, and avoid package-lock churn during runtime repair. - Plugins/subagents: preserve gateway-owned plugin subagent access across runtime, tool, and embedded-runner load paths so gateway plugin tools and context engines can still spawn and manage subagents after the loader cache split. (#46648) Thanks @jalehman. - Control UI/overview: keep the language dropdown aligned with the persisted locale during dashboard startup so refreshing the page does not fall back to English before locale hydration completes. (#48019) Thanks @git-jxj. - Agents/compaction: rerun transcript repair after `session.compact()` so orphaned `tool_result` blocks cannot survive compaction and break later Anthropic requests. (#16095) thanks @claw-sylphx. - Agents/compaction: trigger overflow recovery from the tool-result guard once post-compaction context still exceeds the safe threshold, so long tool loops compact before the next model call hard-fails. (#29371) thanks @keshav55. - macOS/exec approvals: harden exec-host request HMAC verification to use a timing-safe compare and keep malformed or truncated signatures fail-closed in focused IPC auth coverage. - Gateway/exec approvals: surface requested env override keys in gateway-host approval prompts so operators can review surviving env context without inheriting noisy base host env. +- Telegram/network: preserve sticky IPv4 fallback state across polling restarts so hosts with unstable IPv6 to `api.telegram.org` stop re-triggering repeated Telegram timeouts after each restart. (#48282) Thanks @yassinebkr. +- Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman. +- Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468. ### Fixes @@ -124,6 +133,10 @@ Docs: https://docs.openclaw.ai - Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc. - macOS/node service startup: use `openclaw node start/stop --json` from the Mac app instead of the removed `openclaw service node ...` command shape, so current CLI installs expose the full node exec surface again. (#46843) Fixes #43171. Thanks @Br1an67. - macOS/launch at login: stop emitting `KeepAlive` for the desktop app launch agent so OpenClaw no longer relaunches immediately after a manual quit while launch at login remains enabled. (#40213) Thanks @stablegenius49. +- ACP/gateway startup: use direct Telegram and Discord startup/status helpers instead of routing probes through the plugin runtime, and prepend the selected daemon Node bin dir to service PATH so plugin-local installs can still find `npm` and `pnpm`. +- ACP/configured bindings: reinitialize configured ACP sessions that are stuck in `error` state instead of reusing the failed runtime. +- Mattermost/DM send: retry transient direct-channel creation failures for DM deliveries, with configurable backoff and per-request timeout. (#42398) Thanks @JonathanJing. +- Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus. ## 2026.3.13 @@ -202,6 +215,7 @@ Docs: https://docs.openclaw.ai - Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057) - Auto-reply/context-engine compaction: persist the exact embedded-run metadata compaction count for main and followup runner session accounting, so metadata-only auto-compactions no longer undercount multi-compaction runs. (#42629) thanks @uf-hy. - Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar. +- Hooks/after_compaction: forward `sessionFile` for direct/manual compaction events and add `sessionFile` plus `sessionKey` to wired auto-compaction hook context so plugins receive the session metadata already declared in the hook types. (#40781) Thanks @jarimustonen. ## 2026.3.12 @@ -299,6 +313,7 @@ Docs: https://docs.openclaw.ai - Agents/Ollama overflow: rewrite Ollama `prompt too long` API payloads through the normal context-overflow sanitizer so embedded sessions keep the friendly overflow copy and auto-compaction trigger. (#34019) thanks @lishuaigit. - Control UI/auth: restore one-time legacy `?token=` imports for shared Control UI links while keeping `#token=` preferred, and carry pending query tokens through gateway URL confirmation so compatibility links still authenticate after confirmation. (#43979) Thanks @stim64045-spec. +- Plugins/context engines: retry legacy lifecycle calls once without `sessionKey` when older plugins reject that field, memoize legacy mode after the first strict-schema fallback, and preserve non-compat runtime errors without retry. (#44779) thanks @hhhhao28. ## 2026.3.11 @@ -441,6 +456,7 @@ Docs: https://docs.openclaw.ai - Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows. - macOS/remote gateway: stop PortGuardian from killing Docker Desktop and other external listeners on the gateway port in remote mode, so containerized and tunneled gateway setups no longer lose their port-forward owner on app startup. (#6755) Thanks @teslamint. - Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322. +- Exec/env sandbox: block JVM agent injection (`JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, `JDK_JAVA_OPTIONS`), Python breakpoint hijack (`PYTHONBREAKPOINT`), and .NET startup hooks (`DOTNET_STARTUP_HOOKS`) from the host exec environment. (#49025) ## 2026.3.8 diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index 932c9fc5e61..ecdbdd0d77c 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -23,7 +23,12 @@ enum HostEnvSecurityPolicy { "PS4", "GCONV_PATH", "IFS", - "SSLKEYLOGFILE" + "SSLKEYLOGFILE", + "JAVA_TOOL_OPTIONS", + "_JAVA_OPTIONS", + "JDK_JAVA_OPTIONS", + "PYTHONBREAKPOINT", + "DOTNET_STARTUP_HOOKS" ] static let blockedOverrideKeys: Set = [ diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 3003ae79f7b..fcd04955e8c 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -515,6 +515,8 @@ public struct PollParams: Codable, Sendable { public struct AgentParams: Codable, Sendable { public let message: String public let agentid: String? + public let provider: String? + public let model: String? public let to: String? public let replyto: String? public let sessionid: String? @@ -542,6 +544,8 @@ public struct AgentParams: Codable, Sendable { public init( message: String, agentid: String?, + provider: String?, + model: String?, to: String?, replyto: String?, sessionid: String?, @@ -568,6 +572,8 @@ public struct AgentParams: Codable, Sendable { { self.message = message self.agentid = agentid + self.provider = provider + self.model = model self.to = to self.replyto = replyto self.sessionid = sessionid @@ -596,6 +602,8 @@ public struct AgentParams: Codable, Sendable { private enum CodingKeys: String, CodingKey { case message case agentid = "agentId" + case provider + case model case to case replyto = "replyTo" case sessionid = "sessionId" diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 3003ae79f7b..fcd04955e8c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -515,6 +515,8 @@ public struct PollParams: Codable, Sendable { public struct AgentParams: Codable, Sendable { public let message: String public let agentid: String? + public let provider: String? + public let model: String? public let to: String? public let replyto: String? public let sessionid: String? @@ -542,6 +544,8 @@ public struct AgentParams: Codable, Sendable { public init( message: String, agentid: String?, + provider: String?, + model: String?, to: String?, replyto: String?, sessionid: String?, @@ -568,6 +572,8 @@ public struct AgentParams: Codable, Sendable { { self.message = message self.agentid = agentid + self.provider = provider + self.model = model self.to = to self.replyto = replyto self.sessionid = sessionid @@ -596,6 +602,8 @@ public struct AgentParams: Codable, Sendable { private enum CodingKeys: String, CodingKey { case message case agentid = "agentId" + case provider + case model case to case replyto = "replyTo" case sessionid = "sessionId" diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index 1efe91f11a7..dabe2cf9837 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -45294,6 +45294,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.*.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.*.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.*.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.*.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.acpx", "kind": "plugin", @@ -45560,6 +45612,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.acpx.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.acpx.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.acpx.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.acpx.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.amazon-bedrock", "kind": "plugin", @@ -45629,6 +45733,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.amazon-bedrock.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.amazon-bedrock.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.amazon-bedrock.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.amazon-bedrock.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.anthropic", "kind": "plugin", @@ -45698,6 +45854,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.anthropic.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.anthropic.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.anthropic.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.anthropic.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.bluebubbles", "kind": "plugin", @@ -45767,6 +45975,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.bluebubbles.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.bluebubbles.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.bluebubbles.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.bluebubbles.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.brave", "kind": "plugin", @@ -45836,6 +46096,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.brave.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.brave.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.brave.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.brave.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.byteplus", "kind": "plugin", @@ -45905,6 +46217,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.byteplus.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.byteplus.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.byteplus.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.byteplus.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.cloudflare-ai-gateway", "kind": "plugin", @@ -45974,6 +46338,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.cloudflare-ai-gateway.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.cloudflare-ai-gateway.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.cloudflare-ai-gateway.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.cloudflare-ai-gateway.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.copilot-proxy", "kind": "plugin", @@ -46043,6 +46459,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.copilot-proxy.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.copilot-proxy.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.copilot-proxy.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.copilot-proxy.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.device-pair", "kind": "plugin", @@ -46126,6 +46594,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.device-pair.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.device-pair.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.device-pair.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.device-pair.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.diagnostics-otel", "kind": "plugin", @@ -46195,6 +46715,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.diagnostics-otel.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.diagnostics-otel.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.diagnostics-otel.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.diagnostics-otel.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.diffs", "kind": "plugin", @@ -46601,6 +47173,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.diffs.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.diffs.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.diffs.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.diffs.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.discord", "kind": "plugin", @@ -46670,6 +47294,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.discord.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.discord.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.discord.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.discord.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.elevenlabs", "kind": "plugin", @@ -46739,6 +47415,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.elevenlabs.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.elevenlabs.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.elevenlabs.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.elevenlabs.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.feishu", "kind": "plugin", @@ -46808,6 +47536,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.feishu.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.feishu.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.feishu.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.feishu.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.firecrawl", "kind": "plugin", @@ -46877,6 +47657,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.firecrawl.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.firecrawl.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.firecrawl.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.firecrawl.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.github-copilot", "kind": "plugin", @@ -46946,6 +47778,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.github-copilot.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.github-copilot.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.github-copilot.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.github-copilot.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.google", "kind": "plugin", @@ -47015,6 +47899,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.google.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.google.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.google.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.google.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.googlechat", "kind": "plugin", @@ -47084,6 +48020,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.googlechat.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.googlechat.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.googlechat.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.googlechat.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.huggingface", "kind": "plugin", @@ -47153,6 +48141,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.huggingface.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.huggingface.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.huggingface.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.huggingface.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.imessage", "kind": "plugin", @@ -47222,6 +48262,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.imessage.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.imessage.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.imessage.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.imessage.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.irc", "kind": "plugin", @@ -47291,6 +48383,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.irc.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.irc.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.irc.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.irc.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.kilocode", "kind": "plugin", @@ -47360,6 +48504,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.kilocode.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.kilocode.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.kilocode.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.kilocode.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.kimi", "kind": "plugin", @@ -47429,6 +48625,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.kimi.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.kimi.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.kimi.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.kimi.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.line", "kind": "plugin", @@ -47498,6 +48746,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.line.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.line.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.line.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.line.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.llm-task", "kind": "plugin", @@ -47637,6 +48937,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.llm-task.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.llm-task.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.llm-task.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.llm-task.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.lobster", "kind": "plugin", @@ -47706,6 +49058,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.lobster.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.lobster.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.lobster.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.lobster.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.matrix", "kind": "plugin", @@ -47775,6 +49179,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.matrix.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.matrix.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.matrix.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.matrix.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.mattermost", "kind": "plugin", @@ -47844,6 +49300,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.mattermost.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.mattermost.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.mattermost.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.mattermost.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.memory-core", "kind": "plugin", @@ -47913,6 +49421,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.memory-core.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.memory-core.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.memory-core.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.memory-core.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.memory-lancedb", "kind": "plugin", @@ -48111,6 +49671,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.memory-lancedb.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.memory-lancedb.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.memory-lancedb.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.memory-lancedb.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.microsoft", "kind": "plugin", @@ -48180,6 +49792,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.microsoft.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.microsoft.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.microsoft.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.microsoft.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.minimax", "kind": "plugin", @@ -48249,6 +49913,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.minimax.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.minimax.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.minimax.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.minimax.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.mistral", "kind": "plugin", @@ -48318,6 +50034,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.mistral.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.mistral.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.mistral.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.mistral.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.modelstudio", "kind": "plugin", @@ -48387,6 +50155,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.modelstudio.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.modelstudio.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.modelstudio.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.modelstudio.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.moonshot", "kind": "plugin", @@ -48456,6 +50276,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.moonshot.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.moonshot.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.moonshot.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.moonshot.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.msteams", "kind": "plugin", @@ -48525,6 +50397,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.msteams.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.msteams.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.msteams.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.msteams.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.nextcloud-talk", "kind": "plugin", @@ -48594,6 +50518,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.nextcloud-talk.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.nextcloud-talk.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.nextcloud-talk.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.nextcloud-talk.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.nostr", "kind": "plugin", @@ -48663,6 +50639,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.nostr.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.nostr.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.nostr.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.nostr.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.nvidia", "kind": "plugin", @@ -48732,6 +50760,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.nvidia.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.nvidia.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.nvidia.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.nvidia.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.ollama", "kind": "plugin", @@ -48801,6 +50881,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.ollama.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.ollama.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.ollama.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.ollama.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.open-prose", "kind": "plugin", @@ -48870,6 +51002,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.open-prose.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.open-prose.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.open-prose.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.open-prose.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.openai", "kind": "plugin", @@ -48939,6 +51123,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.openai.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.openai.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.openai.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.openai.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.opencode", "kind": "plugin", @@ -49022,6 +51258,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.opencode-go.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode-go.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode-go.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.opencode-go.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.opencode.config", "kind": "plugin", @@ -49077,6 +51365,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.opencode.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.opencode.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.openrouter", "kind": "plugin", @@ -49146,6 +51486,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.openrouter.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.openrouter.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.openrouter.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.openrouter.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.openshell", "kind": "plugin", @@ -49382,6 +51774,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.openshell.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.openshell.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.openshell.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.perplexity", "kind": "plugin", @@ -49451,6 +51895,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.perplexity.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.perplexity.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.perplexity.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.perplexity.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.phone-control", "kind": "plugin", @@ -49520,6 +52016,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.phone-control.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.phone-control.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.phone-control.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.phone-control.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.qianfan", "kind": "plugin", @@ -49589,6 +52137,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.qianfan.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.qianfan.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.qianfan.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qianfan.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.qwen-portal-auth", "kind": "plugin", @@ -49658,6 +52258,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.qwen-portal-auth.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.qwen-portal-auth.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.qwen-portal-auth.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qwen-portal-auth.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.sglang", "kind": "plugin", @@ -49727,6 +52379,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.sglang.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.sglang.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.sglang.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.sglang.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.signal", "kind": "plugin", @@ -49796,6 +52500,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.signal.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.signal.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.signal.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.signal.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.slack", "kind": "plugin", @@ -49865,6 +52621,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.slack.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.slack.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.slack.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.slack.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.synology-chat", "kind": "plugin", @@ -49934,6 +52742,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.synology-chat.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.synology-chat.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.synology-chat.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.synology-chat.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.synthetic", "kind": "plugin", @@ -50003,6 +52863,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.synthetic.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.synthetic.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.synthetic.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.synthetic.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.talk-voice", "kind": "plugin", @@ -50072,6 +52984,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.talk-voice.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.talk-voice.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.talk-voice.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.talk-voice.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.telegram", "kind": "plugin", @@ -50141,6 +53105,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.telegram.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.telegram.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.telegram.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.telegram.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.thread-ownership", "kind": "plugin", @@ -50248,6 +53264,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.thread-ownership.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.thread-ownership.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.thread-ownership.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.thread-ownership.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.tlon", "kind": "plugin", @@ -50317,6 +53385,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.tlon.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.tlon.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.tlon.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.tlon.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.together", "kind": "plugin", @@ -50386,6 +53506,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.together.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.together.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.together.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.together.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.twitch", "kind": "plugin", @@ -50455,6 +53627,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.twitch.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.twitch.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.twitch.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.twitch.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.venice", "kind": "plugin", @@ -50524,6 +53748,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.venice.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.venice.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.venice.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.venice.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.vercel-ai-gateway", "kind": "plugin", @@ -50593,6 +53869,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.vercel-ai-gateway.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.vercel-ai-gateway.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.vercel-ai-gateway.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.vercel-ai-gateway.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.vllm", "kind": "plugin", @@ -50662,6 +53990,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.vllm.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.vllm.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.vllm.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.vllm.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.voice-call", "kind": "plugin", @@ -52087,6 +55467,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.voice-call.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.voice-call.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.voice-call.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.voice-call.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.volcengine", "kind": "plugin", @@ -52156,6 +55588,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.volcengine.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.volcengine.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.volcengine.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.volcengine.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.whatsapp", "kind": "plugin", @@ -52225,6 +55709,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.whatsapp.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.whatsapp.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.whatsapp.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.whatsapp.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.xai", "kind": "plugin", @@ -52294,6 +55830,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.xai.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.xai.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.xai.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.xai.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.xiaomi", "kind": "plugin", @@ -52363,6 +55951,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.xiaomi.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.xiaomi.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.xiaomi.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.xiaomi.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.zai", "kind": "plugin", @@ -52432,6 +56072,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.zai.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.zai.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.zai.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.zai.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.zalo", "kind": "plugin", @@ -52501,6 +56193,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.zalo.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.zalo.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.zalo.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.zalo.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.zalouser", "kind": "plugin", @@ -52570,6 +56314,58 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.zalouser.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.zalouser.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.zalouser.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.zalouser.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.installs", "kind": "core", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index caf0e22623c..7e76ecdcd3a 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5165} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5457} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -4020,6 +4020,10 @@ {"recordType":"path","path":"plugins.entries.*.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Enabled","help":"Per-plugin enablement override for a specific entry, applied on top of global plugin policy (restart required). Use this to stage plugin rollout gradually across environments.","hasChildren":false} {"recordType":"path","path":"plugins.entries.*.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.*.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.*.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.*.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.*.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.*.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.acpx","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACPX Runtime","help":"ACP runtime backend powered by acpx with configurable command path and version policy. (plugin: acpx)","hasChildren":true} {"recordType":"path","path":"plugins.entries.acpx.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACPX Runtime Config","help":"Plugin-defined config payload for acpx.","hasChildren":true} {"recordType":"path","path":"plugins.entries.acpx.config.command","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"acpx Command","help":"Optional path/command override for acpx (for example /home/user/repos/acpx/dist/cli.js). Leave unset to use plugin-local bundled acpx.","hasChildren":false} @@ -4040,52 +4044,92 @@ {"recordType":"path","path":"plugins.entries.acpx.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable ACPX Runtime","hasChildren":false} {"recordType":"path","path":"plugins.entries.acpx.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.acpx.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.acpx.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.acpx.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.acpx.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.acpx.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.amazon-bedrock","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/amazon-bedrock-provider","help":"OpenClaw Amazon Bedrock provider plugin (plugin: amazon-bedrock)","hasChildren":true} {"recordType":"path","path":"plugins.entries.amazon-bedrock.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/amazon-bedrock-provider Config","help":"Plugin-defined config payload for amazon-bedrock.","hasChildren":false} {"recordType":"path","path":"plugins.entries.amazon-bedrock.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/amazon-bedrock-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.amazon-bedrock.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.amazon-bedrock.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.amazon-bedrock.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.amazon-bedrock.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.amazon-bedrock.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.amazon-bedrock.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.anthropic","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/anthropic-provider","help":"OpenClaw Anthropic provider plugin (plugin: anthropic)","hasChildren":true} {"recordType":"path","path":"plugins.entries.anthropic.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/anthropic-provider Config","help":"Plugin-defined config payload for anthropic.","hasChildren":false} {"recordType":"path","path":"plugins.entries.anthropic.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/anthropic-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.anthropic.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.anthropic.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.anthropic.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.anthropic.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.anthropic.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.anthropic.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.bluebubbles","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/bluebubbles","help":"OpenClaw BlueBubbles channel plugin (plugin: bluebubbles)","hasChildren":true} {"recordType":"path","path":"plugins.entries.bluebubbles.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/bluebubbles Config","help":"Plugin-defined config payload for bluebubbles.","hasChildren":false} {"recordType":"path","path":"plugins.entries.bluebubbles.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/bluebubbles","hasChildren":false} {"recordType":"path","path":"plugins.entries.bluebubbles.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.bluebubbles.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.bluebubbles.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.bluebubbles.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.bluebubbles.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.bluebubbles.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.brave","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/brave-plugin","help":"OpenClaw Brave plugin (plugin: brave)","hasChildren":true} {"recordType":"path","path":"plugins.entries.brave.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/brave-plugin Config","help":"Plugin-defined config payload for brave.","hasChildren":false} {"recordType":"path","path":"plugins.entries.brave.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/brave-plugin","hasChildren":false} {"recordType":"path","path":"plugins.entries.brave.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.brave.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.brave.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.brave.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.brave.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.brave.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.byteplus","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/byteplus-provider","help":"OpenClaw BytePlus provider plugin (plugin: byteplus)","hasChildren":true} {"recordType":"path","path":"plugins.entries.byteplus.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/byteplus-provider Config","help":"Plugin-defined config payload for byteplus.","hasChildren":false} {"recordType":"path","path":"plugins.entries.byteplus.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/byteplus-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.byteplus.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.byteplus.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.byteplus.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider","help":"OpenClaw Cloudflare AI Gateway provider plugin (plugin: cloudflare-ai-gateway)","hasChildren":true} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider Config","help":"Plugin-defined config payload for cloudflare-ai-gateway.","hasChildren":false} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/cloudflare-ai-gateway-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.copilot-proxy","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/copilot-proxy","help":"OpenClaw Copilot Proxy provider plugin (plugin: copilot-proxy)","hasChildren":true} {"recordType":"path","path":"plugins.entries.copilot-proxy.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/copilot-proxy Config","help":"Plugin-defined config payload for copilot-proxy.","hasChildren":false} {"recordType":"path","path":"plugins.entries.copilot-proxy.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/copilot-proxy","hasChildren":false} {"recordType":"path","path":"plugins.entries.copilot-proxy.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.copilot-proxy.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.copilot-proxy.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.copilot-proxy.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.copilot-proxy.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.copilot-proxy.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.device-pair","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Device Pairing","help":"Generate setup codes and approve device pairing requests. (plugin: device-pair)","hasChildren":true} {"recordType":"path","path":"plugins.entries.device-pair.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Device Pairing Config","help":"Plugin-defined config payload for device-pair.","hasChildren":true} {"recordType":"path","path":"plugins.entries.device-pair.config.publicUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Gateway URL","help":"Public WebSocket URL used for /pair setup codes (ws/wss or http/https).","hasChildren":false} {"recordType":"path","path":"plugins.entries.device-pair.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Device Pairing","hasChildren":false} {"recordType":"path","path":"plugins.entries.device-pair.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.device-pair.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.device-pair.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.device-pair.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.device-pair.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.device-pair.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.diagnostics-otel","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["observability"],"label":"@openclaw/diagnostics-otel","help":"OpenClaw diagnostics OpenTelemetry exporter (plugin: diagnostics-otel)","hasChildren":true} {"recordType":"path","path":"plugins.entries.diagnostics-otel.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["observability"],"label":"@openclaw/diagnostics-otel Config","help":"Plugin-defined config payload for diagnostics-otel.","hasChildren":false} {"recordType":"path","path":"plugins.entries.diagnostics-otel.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["observability"],"label":"Enable @openclaw/diagnostics-otel","hasChildren":false} {"recordType":"path","path":"plugins.entries.diagnostics-otel.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.diagnostics-otel.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.diagnostics-otel.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.diagnostics-otel.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.diagnostics-otel.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.diagnostics-otel.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.diffs","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Diffs","help":"Read-only diff viewer and file renderer for agents. (plugin: diffs)","hasChildren":true} {"recordType":"path","path":"plugins.entries.diffs.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Diffs Config","help":"Plugin-defined config payload for diffs.","hasChildren":true} {"recordType":"path","path":"plugins.entries.diffs.config.defaults","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -4113,71 +4157,127 @@ {"recordType":"path","path":"plugins.entries.diffs.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Diffs","hasChildren":false} {"recordType":"path","path":"plugins.entries.diffs.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.diffs.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.diffs.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.diffs.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.diffs.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.diffs.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.discord","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/discord","help":"OpenClaw Discord channel plugin (plugin: discord)","hasChildren":true} {"recordType":"path","path":"plugins.entries.discord.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/discord Config","help":"Plugin-defined config payload for discord.","hasChildren":false} {"recordType":"path","path":"plugins.entries.discord.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/discord","hasChildren":false} {"recordType":"path","path":"plugins.entries.discord.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.discord.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.discord.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.discord.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.discord.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.discord.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.elevenlabs","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/elevenlabs-speech","help":"OpenClaw ElevenLabs speech plugin (plugin: elevenlabs)","hasChildren":true} {"recordType":"path","path":"plugins.entries.elevenlabs.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/elevenlabs-speech Config","help":"Plugin-defined config payload for elevenlabs.","hasChildren":false} {"recordType":"path","path":"plugins.entries.elevenlabs.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/elevenlabs-speech","hasChildren":false} {"recordType":"path","path":"plugins.entries.elevenlabs.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.elevenlabs.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.elevenlabs.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.feishu","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/feishu","help":"OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng) (plugin: feishu)","hasChildren":true} {"recordType":"path","path":"plugins.entries.feishu.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/feishu Config","help":"Plugin-defined config payload for feishu.","hasChildren":false} {"recordType":"path","path":"plugins.entries.feishu.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/feishu","hasChildren":false} {"recordType":"path","path":"plugins.entries.feishu.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.feishu.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.feishu.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.feishu.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.feishu.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.feishu.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.firecrawl","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/firecrawl-plugin","help":"OpenClaw Firecrawl plugin (plugin: firecrawl)","hasChildren":true} {"recordType":"path","path":"plugins.entries.firecrawl.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/firecrawl-plugin Config","help":"Plugin-defined config payload for firecrawl.","hasChildren":false} {"recordType":"path","path":"plugins.entries.firecrawl.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/firecrawl-plugin","hasChildren":false} {"recordType":"path","path":"plugins.entries.firecrawl.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.firecrawl.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.firecrawl.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.firecrawl.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.firecrawl.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.firecrawl.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.github-copilot","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/github-copilot-provider","help":"OpenClaw GitHub Copilot provider plugin (plugin: github-copilot)","hasChildren":true} {"recordType":"path","path":"plugins.entries.github-copilot.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/github-copilot-provider Config","help":"Plugin-defined config payload for github-copilot.","hasChildren":false} {"recordType":"path","path":"plugins.entries.github-copilot.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/github-copilot-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.github-copilot.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.github-copilot.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.github-copilot.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.github-copilot.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.github-copilot.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.github-copilot.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.google","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-plugin","help":"OpenClaw Google plugin (plugin: google)","hasChildren":true} {"recordType":"path","path":"plugins.entries.google.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-plugin Config","help":"Plugin-defined config payload for google.","hasChildren":false} {"recordType":"path","path":"plugins.entries.google.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/google-plugin","hasChildren":false} {"recordType":"path","path":"plugins.entries.google.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.google.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.google.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.google.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.google.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.google.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.googlechat","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/googlechat","help":"OpenClaw Google Chat channel plugin (plugin: googlechat)","hasChildren":true} {"recordType":"path","path":"plugins.entries.googlechat.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/googlechat Config","help":"Plugin-defined config payload for googlechat.","hasChildren":false} {"recordType":"path","path":"plugins.entries.googlechat.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/googlechat","hasChildren":false} {"recordType":"path","path":"plugins.entries.googlechat.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.googlechat.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.googlechat.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.googlechat.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.googlechat.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.googlechat.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.huggingface","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/huggingface-provider","help":"OpenClaw Hugging Face provider plugin (plugin: huggingface)","hasChildren":true} {"recordType":"path","path":"plugins.entries.huggingface.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/huggingface-provider Config","help":"Plugin-defined config payload for huggingface.","hasChildren":false} {"recordType":"path","path":"plugins.entries.huggingface.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/huggingface-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.huggingface.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.huggingface.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.huggingface.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.huggingface.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.huggingface.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.huggingface.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.imessage","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/imessage","help":"OpenClaw iMessage channel plugin (plugin: imessage)","hasChildren":true} {"recordType":"path","path":"plugins.entries.imessage.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/imessage Config","help":"Plugin-defined config payload for imessage.","hasChildren":false} {"recordType":"path","path":"plugins.entries.imessage.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/imessage","hasChildren":false} {"recordType":"path","path":"plugins.entries.imessage.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.imessage.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.imessage.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.imessage.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.imessage.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.imessage.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.irc","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/irc","help":"OpenClaw IRC channel plugin (plugin: irc)","hasChildren":true} {"recordType":"path","path":"plugins.entries.irc.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/irc Config","help":"Plugin-defined config payload for irc.","hasChildren":false} {"recordType":"path","path":"plugins.entries.irc.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/irc","hasChildren":false} {"recordType":"path","path":"plugins.entries.irc.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.irc.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.irc.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.irc.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.irc.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.irc.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.kilocode","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kilocode-provider","help":"OpenClaw Kilo Gateway provider plugin (plugin: kilocode)","hasChildren":true} {"recordType":"path","path":"plugins.entries.kilocode.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kilocode-provider Config","help":"Plugin-defined config payload for kilocode.","hasChildren":false} {"recordType":"path","path":"plugins.entries.kilocode.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/kilocode-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.kilocode.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.kilocode.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kilocode.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kilocode.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kilocode.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.kilocode.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.kimi","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kimi-provider","help":"OpenClaw Kimi provider plugin (plugin: kimi)","hasChildren":true} {"recordType":"path","path":"plugins.entries.kimi.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kimi-provider Config","help":"Plugin-defined config payload for kimi.","hasChildren":false} {"recordType":"path","path":"plugins.entries.kimi.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/kimi-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.kimi.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.kimi.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kimi.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kimi.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kimi.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.kimi.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.line","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/line","help":"OpenClaw LINE channel plugin (plugin: line)","hasChildren":true} {"recordType":"path","path":"plugins.entries.line.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/line Config","help":"Plugin-defined config payload for line.","hasChildren":false} {"recordType":"path","path":"plugins.entries.line.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/line","hasChildren":false} {"recordType":"path","path":"plugins.entries.line.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.line.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.line.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.line.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.line.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.line.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.llm-task","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"LLM Task","help":"Generic JSON-only LLM tool for structured tasks callable from workflows. (plugin: llm-task)","hasChildren":true} {"recordType":"path","path":"plugins.entries.llm-task.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"LLM Task Config","help":"Plugin-defined config payload for llm-task.","hasChildren":true} {"recordType":"path","path":"plugins.entries.llm-task.config.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -4190,26 +4290,46 @@ {"recordType":"path","path":"plugins.entries.llm-task.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable LLM Task","hasChildren":false} {"recordType":"path","path":"plugins.entries.llm-task.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.llm-task.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.llm-task.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.llm-task.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.llm-task.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.llm-task.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.lobster","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Lobster","help":"Typed workflow tool with resumable approvals. (plugin: lobster)","hasChildren":true} {"recordType":"path","path":"plugins.entries.lobster.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Lobster Config","help":"Plugin-defined config payload for lobster.","hasChildren":false} {"recordType":"path","path":"plugins.entries.lobster.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Lobster","hasChildren":false} {"recordType":"path","path":"plugins.entries.lobster.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.lobster.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.lobster.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.lobster.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.lobster.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.lobster.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.matrix","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/matrix","help":"OpenClaw Matrix channel plugin (plugin: matrix)","hasChildren":true} {"recordType":"path","path":"plugins.entries.matrix.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/matrix Config","help":"Plugin-defined config payload for matrix.","hasChildren":false} {"recordType":"path","path":"plugins.entries.matrix.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/matrix","hasChildren":false} {"recordType":"path","path":"plugins.entries.matrix.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.matrix.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.matrix.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.matrix.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.matrix.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.matrix.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.mattermost","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/mattermost","help":"OpenClaw Mattermost channel plugin (plugin: mattermost)","hasChildren":true} {"recordType":"path","path":"plugins.entries.mattermost.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/mattermost Config","help":"Plugin-defined config payload for mattermost.","hasChildren":false} {"recordType":"path","path":"plugins.entries.mattermost.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/mattermost","hasChildren":false} {"recordType":"path","path":"plugins.entries.mattermost.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.mattermost.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.mattermost.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.mattermost.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.mattermost.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.mattermost.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.memory-core","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/memory-core","help":"OpenClaw core memory search plugin (plugin: memory-core)","hasChildren":true} {"recordType":"path","path":"plugins.entries.memory-core.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/memory-core Config","help":"Plugin-defined config payload for memory-core.","hasChildren":false} {"recordType":"path","path":"plugins.entries.memory-core.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/memory-core","hasChildren":false} {"recordType":"path","path":"plugins.entries.memory-core.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.memory-core.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.memory-core.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.memory-core.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.memory-core.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.memory-core.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.memory-lancedb","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"@openclaw/memory-lancedb","help":"OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture (plugin: memory-lancedb)","hasChildren":true} {"recordType":"path","path":"plugins.entries.memory-lancedb.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"@openclaw/memory-lancedb Config","help":"Plugin-defined config payload for memory-lancedb.","hasChildren":true} {"recordType":"path","path":"plugins.entries.memory-lancedb.config.autoCapture","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Auto-Capture","help":"Automatically capture important information from conversations","hasChildren":false} @@ -4224,81 +4344,145 @@ {"recordType":"path","path":"plugins.entries.memory-lancedb.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Enable @openclaw/memory-lancedb","hasChildren":false} {"recordType":"path","path":"plugins.entries.memory-lancedb.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.memory-lancedb.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.memory-lancedb.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.memory-lancedb.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.memory-lancedb.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.memory-lancedb.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.microsoft","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/microsoft-speech","help":"OpenClaw Microsoft speech plugin (plugin: microsoft)","hasChildren":true} {"recordType":"path","path":"plugins.entries.microsoft.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/microsoft-speech Config","help":"Plugin-defined config payload for microsoft.","hasChildren":false} {"recordType":"path","path":"plugins.entries.microsoft.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/microsoft-speech","hasChildren":false} {"recordType":"path","path":"plugins.entries.microsoft.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.microsoft.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.microsoft.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.microsoft.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.microsoft.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.microsoft.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.minimax","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"@openclaw/minimax-provider","help":"OpenClaw MiniMax provider and OAuth plugin (plugin: minimax)","hasChildren":true} {"recordType":"path","path":"plugins.entries.minimax.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"@openclaw/minimax-provider Config","help":"Plugin-defined config payload for minimax.","hasChildren":false} {"recordType":"path","path":"plugins.entries.minimax.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Enable @openclaw/minimax-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.minimax.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.minimax.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.minimax.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.minimax.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.minimax.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.minimax.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.mistral","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/mistral-provider","help":"OpenClaw Mistral provider plugin (plugin: mistral)","hasChildren":true} {"recordType":"path","path":"plugins.entries.mistral.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/mistral-provider Config","help":"Plugin-defined config payload for mistral.","hasChildren":false} {"recordType":"path","path":"plugins.entries.mistral.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/mistral-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.mistral.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.mistral.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.mistral.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.mistral.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.mistral.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.mistral.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.modelstudio","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/modelstudio-provider","help":"OpenClaw Model Studio provider plugin (plugin: modelstudio)","hasChildren":true} {"recordType":"path","path":"plugins.entries.modelstudio.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/modelstudio-provider Config","help":"Plugin-defined config payload for modelstudio.","hasChildren":false} {"recordType":"path","path":"plugins.entries.modelstudio.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/modelstudio-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.modelstudio.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.modelstudio.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.modelstudio.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.modelstudio.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.modelstudio.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.modelstudio.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.moonshot","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/moonshot-provider","help":"OpenClaw Moonshot provider plugin (plugin: moonshot)","hasChildren":true} {"recordType":"path","path":"plugins.entries.moonshot.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/moonshot-provider Config","help":"Plugin-defined config payload for moonshot.","hasChildren":false} {"recordType":"path","path":"plugins.entries.moonshot.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/moonshot-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.moonshot.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.moonshot.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.moonshot.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.moonshot.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.moonshot.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.moonshot.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.msteams","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/msteams","help":"OpenClaw Microsoft Teams channel plugin (plugin: msteams)","hasChildren":true} {"recordType":"path","path":"plugins.entries.msteams.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/msteams Config","help":"Plugin-defined config payload for msteams.","hasChildren":false} {"recordType":"path","path":"plugins.entries.msteams.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/msteams","hasChildren":false} {"recordType":"path","path":"plugins.entries.msteams.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.msteams.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.msteams.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.msteams.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.msteams.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.msteams.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.nextcloud-talk","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nextcloud-talk","help":"OpenClaw Nextcloud Talk channel plugin (plugin: nextcloud-talk)","hasChildren":true} {"recordType":"path","path":"plugins.entries.nextcloud-talk.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nextcloud-talk Config","help":"Plugin-defined config payload for nextcloud-talk.","hasChildren":false} {"recordType":"path","path":"plugins.entries.nextcloud-talk.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/nextcloud-talk","hasChildren":false} {"recordType":"path","path":"plugins.entries.nextcloud-talk.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.nextcloud-talk.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.nextcloud-talk.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nextcloud-talk.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nextcloud-talk.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.nextcloud-talk.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.nostr","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nostr","help":"OpenClaw Nostr channel plugin for NIP-04 encrypted DMs (plugin: nostr)","hasChildren":true} {"recordType":"path","path":"plugins.entries.nostr.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nostr Config","help":"Plugin-defined config payload for nostr.","hasChildren":false} {"recordType":"path","path":"plugins.entries.nostr.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/nostr","hasChildren":false} {"recordType":"path","path":"plugins.entries.nostr.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.nostr.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.nostr.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nostr.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nostr.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.nostr.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.nvidia","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nvidia-provider","help":"OpenClaw NVIDIA provider plugin (plugin: nvidia)","hasChildren":true} {"recordType":"path","path":"plugins.entries.nvidia.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nvidia-provider Config","help":"Plugin-defined config payload for nvidia.","hasChildren":false} {"recordType":"path","path":"plugins.entries.nvidia.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/nvidia-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.nvidia.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.nvidia.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.nvidia.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nvidia.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nvidia.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.nvidia.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.ollama","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/ollama-provider","help":"OpenClaw Ollama provider plugin (plugin: ollama)","hasChildren":true} {"recordType":"path","path":"plugins.entries.ollama.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/ollama-provider Config","help":"Plugin-defined config payload for ollama.","hasChildren":false} {"recordType":"path","path":"plugins.entries.ollama.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/ollama-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.ollama.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.ollama.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.ollama.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.ollama.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.ollama.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.ollama.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.open-prose","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"OpenProse","help":"OpenProse VM skill pack with a /prose slash command. (plugin: open-prose)","hasChildren":true} {"recordType":"path","path":"plugins.entries.open-prose.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"OpenProse Config","help":"Plugin-defined config payload for open-prose.","hasChildren":false} {"recordType":"path","path":"plugins.entries.open-prose.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable OpenProse","hasChildren":false} {"recordType":"path","path":"plugins.entries.open-prose.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.open-prose.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.open-prose.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.open-prose.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.open-prose.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.open-prose.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.openai","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openai-provider","help":"OpenClaw OpenAI provider plugins (plugin: openai)","hasChildren":true} {"recordType":"path","path":"plugins.entries.openai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openai-provider Config","help":"Plugin-defined config payload for openai.","hasChildren":false} {"recordType":"path","path":"plugins.entries.openai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/openai-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.openai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.openai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openai.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openai.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openai.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.openai.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.opencode","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-provider","help":"OpenClaw OpenCode Zen provider plugin (plugin: opencode)","hasChildren":true} {"recordType":"path","path":"plugins.entries.opencode-go","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-go-provider","help":"OpenClaw OpenCode Go provider plugin (plugin: opencode-go)","hasChildren":true} {"recordType":"path","path":"plugins.entries.opencode-go.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-go-provider Config","help":"Plugin-defined config payload for opencode-go.","hasChildren":false} {"recordType":"path","path":"plugins.entries.opencode-go.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/opencode-go-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.opencode-go.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.opencode-go.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode-go.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode-go.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode-go.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode-go.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.opencode.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-provider Config","help":"Plugin-defined config payload for opencode.","hasChildren":false} {"recordType":"path","path":"plugins.entries.opencode.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/opencode-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.opencode.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.opencode.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.openrouter","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openrouter-provider","help":"OpenClaw OpenRouter provider plugin (plugin: openrouter)","hasChildren":true} {"recordType":"path","path":"plugins.entries.openrouter.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openrouter-provider Config","help":"Plugin-defined config payload for openrouter.","hasChildren":false} {"recordType":"path","path":"plugins.entries.openrouter.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/openrouter-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.openrouter.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.openrouter.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openrouter.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openrouter.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openrouter.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.openrouter.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.openshell","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"OpenShell Sandbox","help":"Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. (plugin: openshell)","hasChildren":true} {"recordType":"path","path":"plugins.entries.openshell.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"OpenShell Sandbox Config","help":"Plugin-defined config payload for openshell.","hasChildren":true} {"recordType":"path","path":"plugins.entries.openshell.config.autoProviders","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Auto-create Providers","help":"When enabled, pass --auto-providers during sandbox create.","hasChildren":false} @@ -4316,61 +4500,109 @@ {"recordType":"path","path":"plugins.entries.openshell.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable OpenShell Sandbox","hasChildren":false} {"recordType":"path","path":"plugins.entries.openshell.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.openshell.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openshell.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openshell.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.perplexity","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/perplexity-plugin","help":"OpenClaw Perplexity plugin (plugin: perplexity)","hasChildren":true} {"recordType":"path","path":"plugins.entries.perplexity.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/perplexity-plugin Config","help":"Plugin-defined config payload for perplexity.","hasChildren":false} {"recordType":"path","path":"plugins.entries.perplexity.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/perplexity-plugin","hasChildren":false} {"recordType":"path","path":"plugins.entries.perplexity.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.perplexity.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.perplexity.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.perplexity.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.perplexity.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.perplexity.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.phone-control","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Phone Control","help":"Arm/disarm high-risk phone node commands (camera/screen/writes) with an optional auto-expiry. (plugin: phone-control)","hasChildren":true} {"recordType":"path","path":"plugins.entries.phone-control.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Phone Control Config","help":"Plugin-defined config payload for phone-control.","hasChildren":false} {"recordType":"path","path":"plugins.entries.phone-control.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Phone Control","hasChildren":false} {"recordType":"path","path":"plugins.entries.phone-control.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.phone-control.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.phone-control.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.phone-control.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.phone-control.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.phone-control.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.qianfan","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/qianfan-provider","help":"OpenClaw Qianfan provider plugin (plugin: qianfan)","hasChildren":true} {"recordType":"path","path":"plugins.entries.qianfan.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/qianfan-provider Config","help":"Plugin-defined config payload for qianfan.","hasChildren":false} {"recordType":"path","path":"plugins.entries.qianfan.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/qianfan-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.qianfan.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.qianfan.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.qianfan.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qianfan.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qianfan.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qianfan.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.qwen-portal-auth","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"qwen-portal-auth","help":"Plugin entry for qwen-portal-auth.","hasChildren":true} {"recordType":"path","path":"plugins.entries.qwen-portal-auth.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"qwen-portal-auth Config","help":"Plugin-defined config payload for qwen-portal-auth.","hasChildren":false} {"recordType":"path","path":"plugins.entries.qwen-portal-auth.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable qwen-portal-auth","hasChildren":false} {"recordType":"path","path":"plugins.entries.qwen-portal-auth.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.qwen-portal-auth.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.qwen-portal-auth.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qwen-portal-auth.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qwen-portal-auth.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qwen-portal-auth.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.sglang","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/sglang-provider","help":"OpenClaw SGLang provider plugin (plugin: sglang)","hasChildren":true} {"recordType":"path","path":"plugins.entries.sglang.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/sglang-provider Config","help":"Plugin-defined config payload for sglang.","hasChildren":false} {"recordType":"path","path":"plugins.entries.sglang.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/sglang-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.sglang.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.sglang.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.sglang.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.sglang.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.sglang.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.sglang.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.signal","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/signal","help":"OpenClaw Signal channel plugin (plugin: signal)","hasChildren":true} {"recordType":"path","path":"plugins.entries.signal.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/signal Config","help":"Plugin-defined config payload for signal.","hasChildren":false} {"recordType":"path","path":"plugins.entries.signal.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/signal","hasChildren":false} {"recordType":"path","path":"plugins.entries.signal.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.signal.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.signal.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.signal.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.signal.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.signal.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.slack","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/slack","help":"OpenClaw Slack channel plugin (plugin: slack)","hasChildren":true} {"recordType":"path","path":"plugins.entries.slack.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/slack Config","help":"Plugin-defined config payload for slack.","hasChildren":false} {"recordType":"path","path":"plugins.entries.slack.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/slack","hasChildren":false} {"recordType":"path","path":"plugins.entries.slack.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.slack.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.slack.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.slack.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.slack.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.slack.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.synology-chat","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/synology-chat","help":"Synology Chat channel plugin for OpenClaw (plugin: synology-chat)","hasChildren":true} {"recordType":"path","path":"plugins.entries.synology-chat.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/synology-chat Config","help":"Plugin-defined config payload for synology-chat.","hasChildren":false} {"recordType":"path","path":"plugins.entries.synology-chat.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/synology-chat","hasChildren":false} {"recordType":"path","path":"plugins.entries.synology-chat.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.synology-chat.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.synology-chat.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.synology-chat.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.synology-chat.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.synology-chat.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.synthetic","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/synthetic-provider","help":"OpenClaw Synthetic provider plugin (plugin: synthetic)","hasChildren":true} {"recordType":"path","path":"plugins.entries.synthetic.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/synthetic-provider Config","help":"Plugin-defined config payload for synthetic.","hasChildren":false} {"recordType":"path","path":"plugins.entries.synthetic.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/synthetic-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.synthetic.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.synthetic.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.synthetic.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.synthetic.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.synthetic.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.synthetic.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.talk-voice","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Talk Voice","help":"Manage Talk voice selection (list/set). (plugin: talk-voice)","hasChildren":true} {"recordType":"path","path":"plugins.entries.talk-voice.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Talk Voice Config","help":"Plugin-defined config payload for talk-voice.","hasChildren":false} {"recordType":"path","path":"plugins.entries.talk-voice.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Talk Voice","hasChildren":false} {"recordType":"path","path":"plugins.entries.talk-voice.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.talk-voice.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.talk-voice.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.telegram","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/telegram","help":"OpenClaw Telegram channel plugin (plugin: telegram)","hasChildren":true} {"recordType":"path","path":"plugins.entries.telegram.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/telegram Config","help":"Plugin-defined config payload for telegram.","hasChildren":false} {"recordType":"path","path":"plugins.entries.telegram.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/telegram","hasChildren":false} {"recordType":"path","path":"plugins.entries.telegram.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.telegram.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.telegram.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.telegram.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.telegram.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.telegram.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.thread-ownership","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Thread Ownership","help":"Prevents multiple agents from responding in the same Slack thread. Uses HTTP calls to the slack-forwarder ownership API. (plugin: thread-ownership)","hasChildren":true} {"recordType":"path","path":"plugins.entries.thread-ownership.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Thread Ownership Config","help":"Plugin-defined config payload for thread-ownership.","hasChildren":true} {"recordType":"path","path":"plugins.entries.thread-ownership.config.abTestChannels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"A/B Test Channels","help":"Slack channel IDs where thread ownership is enforced","hasChildren":true} @@ -4379,36 +4611,64 @@ {"recordType":"path","path":"plugins.entries.thread-ownership.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Enable Thread Ownership","hasChildren":false} {"recordType":"path","path":"plugins.entries.thread-ownership.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.thread-ownership.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.thread-ownership.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.thread-ownership.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.thread-ownership.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.thread-ownership.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.tlon","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/tlon","help":"OpenClaw Tlon/Urbit channel plugin (plugin: tlon)","hasChildren":true} {"recordType":"path","path":"plugins.entries.tlon.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/tlon Config","help":"Plugin-defined config payload for tlon.","hasChildren":false} {"recordType":"path","path":"plugins.entries.tlon.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/tlon","hasChildren":false} {"recordType":"path","path":"plugins.entries.tlon.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.tlon.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.tlon.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.tlon.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.tlon.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.tlon.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.together","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/together-provider","help":"OpenClaw Together provider plugin (plugin: together)","hasChildren":true} {"recordType":"path","path":"plugins.entries.together.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/together-provider Config","help":"Plugin-defined config payload for together.","hasChildren":false} {"recordType":"path","path":"plugins.entries.together.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/together-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.together.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.together.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.together.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.together.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.together.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.together.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.twitch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/twitch","help":"OpenClaw Twitch channel plugin (plugin: twitch)","hasChildren":true} {"recordType":"path","path":"plugins.entries.twitch.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/twitch Config","help":"Plugin-defined config payload for twitch.","hasChildren":false} {"recordType":"path","path":"plugins.entries.twitch.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/twitch","hasChildren":false} {"recordType":"path","path":"plugins.entries.twitch.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.twitch.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.twitch.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.twitch.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.twitch.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.twitch.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.venice","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/venice-provider","help":"OpenClaw Venice provider plugin (plugin: venice)","hasChildren":true} {"recordType":"path","path":"plugins.entries.venice.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/venice-provider Config","help":"Plugin-defined config payload for venice.","hasChildren":false} {"recordType":"path","path":"plugins.entries.venice.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/venice-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.venice.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.venice.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.venice.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.venice.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.venice.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.venice.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.vercel-ai-gateway","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vercel-ai-gateway-provider","help":"OpenClaw Vercel AI Gateway provider plugin (plugin: vercel-ai-gateway)","hasChildren":true} {"recordType":"path","path":"plugins.entries.vercel-ai-gateway.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vercel-ai-gateway-provider Config","help":"Plugin-defined config payload for vercel-ai-gateway.","hasChildren":false} {"recordType":"path","path":"plugins.entries.vercel-ai-gateway.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/vercel-ai-gateway-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.vercel-ai-gateway.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.vercel-ai-gateway.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.vllm","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vllm-provider","help":"OpenClaw vLLM provider plugin (plugin: vllm)","hasChildren":true} {"recordType":"path","path":"plugins.entries.vllm.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vllm-provider Config","help":"Plugin-defined config payload for vllm.","hasChildren":false} {"recordType":"path","path":"plugins.entries.vllm.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/vllm-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.vllm.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.vllm.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.vllm.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.vllm.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.vllm.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.vllm.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.voice-call","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/voice-call","help":"OpenClaw voice-call plugin (plugin: voice-call)","hasChildren":true} {"recordType":"path","path":"plugins.entries.voice-call.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/voice-call Config","help":"Plugin-defined config payload for voice-call.","hasChildren":true} {"recordType":"path","path":"plugins.entries.voice-call.config.allowFrom","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Inbound Allowlist","hasChildren":true} @@ -4530,41 +4790,73 @@ {"recordType":"path","path":"plugins.entries.voice-call.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/voice-call","hasChildren":false} {"recordType":"path","path":"plugins.entries.voice-call.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.voice-call.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.voice-call.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.voice-call.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.voice-call.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.voice-call.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.volcengine","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/volcengine-provider","help":"OpenClaw Volcengine provider plugin (plugin: volcengine)","hasChildren":true} {"recordType":"path","path":"plugins.entries.volcengine.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/volcengine-provider Config","help":"Plugin-defined config payload for volcengine.","hasChildren":false} {"recordType":"path","path":"plugins.entries.volcengine.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/volcengine-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.volcengine.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.volcengine.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.volcengine.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.volcengine.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.volcengine.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.volcengine.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.whatsapp","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/whatsapp","help":"OpenClaw WhatsApp channel plugin (plugin: whatsapp)","hasChildren":true} {"recordType":"path","path":"plugins.entries.whatsapp.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/whatsapp Config","help":"Plugin-defined config payload for whatsapp.","hasChildren":false} {"recordType":"path","path":"plugins.entries.whatsapp.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/whatsapp","hasChildren":false} {"recordType":"path","path":"plugins.entries.whatsapp.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.whatsapp.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.whatsapp.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.whatsapp.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.whatsapp.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.whatsapp.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.xai","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xai-plugin","help":"OpenClaw xAI plugin (plugin: xai)","hasChildren":true} {"recordType":"path","path":"plugins.entries.xai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xai-plugin Config","help":"Plugin-defined config payload for xai.","hasChildren":false} {"recordType":"path","path":"plugins.entries.xai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/xai-plugin","hasChildren":false} {"recordType":"path","path":"plugins.entries.xai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.xai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xai.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xai.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xai.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.xai.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.xiaomi","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xiaomi-provider","help":"OpenClaw Xiaomi provider plugin (plugin: xiaomi)","hasChildren":true} {"recordType":"path","path":"plugins.entries.xiaomi.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xiaomi-provider Config","help":"Plugin-defined config payload for xiaomi.","hasChildren":false} {"recordType":"path","path":"plugins.entries.xiaomi.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/xiaomi-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.xiaomi.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.xiaomi.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xiaomi.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xiaomi.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xiaomi.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.xiaomi.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zai","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zai-provider","help":"OpenClaw Z.AI provider plugin (plugin: zai)","hasChildren":true} {"recordType":"path","path":"plugins.entries.zai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zai-provider Config","help":"Plugin-defined config payload for zai.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/zai-provider","hasChildren":false} {"recordType":"path","path":"plugins.entries.zai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.zai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.zai.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zai.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zai.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.zai.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalo","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zalo","help":"OpenClaw Zalo channel plugin (plugin: zalo)","hasChildren":true} {"recordType":"path","path":"plugins.entries.zalo.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zalo Config","help":"Plugin-defined config payload for zalo.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalo.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/zalo","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalo.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.zalo.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.zalo.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zalo.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zalo.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.zalo.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalouser","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zalouser","help":"OpenClaw Zalo Personal Account plugin via native zca-js integration (plugin: zalouser)","hasChildren":true} {"recordType":"path","path":"plugins.entries.zalouser.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zalouser Config","help":"Plugin-defined config payload for zalouser.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalouser.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/zalouser","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalouser.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.zalouser.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.zalouser.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zalouser.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zalouser.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.zalouser.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.installs","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Install Records","help":"CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).","hasChildren":true} {"recordType":"path","path":"plugins.installs.*","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"plugins.installs.*.installedAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Install Time","help":"ISO timestamp of last install/update.","hasChildren":false} diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md index b35ee9d4469..38676a8fdbe 100644 --- a/docs/automation/webhook.md +++ b/docs/automation/webhook.md @@ -38,6 +38,7 @@ Every request must include the hook token. Prefer headers: - `Authorization: Bearer ` (recommended) - `x-openclaw-token: ` - Query-string tokens are rejected (`?token=...` returns `400`). +- Treat `hooks.token` holders as full-trust callers for the hook ingress surface on that gateway. Hook payload content is still untrusted, but this is not a separate non-owner auth boundary. ## Endpoints @@ -205,6 +206,7 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \ - Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy. - Use a dedicated hook token; do not reuse gateway auth tokens. +- Prefer a dedicated hook agent with strict `tools.profile` and sandboxing so hook ingress has a narrower blast radius. - Repeated auth failures are rate-limited per client address to slow brute-force attempts. - If you use multi-agent routing, set `hooks.allowedAgentIds` to limit explicit `agentId` selection. - Keep `hooks.allowRequestSessionKey=false` unless you require caller-selected sessions. diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index 2ceb6c17626..41f6ffa19a0 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -191,6 +191,35 @@ OpenClaw resolves them **user-first**: If you need deterministic behavior, always use the explicit prefixes (`user:` / `channel:`). +## DM channel retry + +When OpenClaw sends to a Mattermost DM target and needs to resolve the direct channel first, it +retries transient direct-channel creation failures by default. + +Use `channels.mattermost.dmChannelRetry` to tune that behavior globally for the Mattermost plugin, +or `channels.mattermost.accounts..dmChannelRetry` for one account. + +```json5 +{ + channels: { + mattermost: { + dmChannelRetry: { + maxRetries: 3, + initialDelayMs: 1000, + maxDelayMs: 10000, + timeoutMs: 30000, + }, + }, + }, +} +``` + +Notes: + +- This applies only to DM channel creation (`/api/v4/channels/direct`), not every Mattermost API call. +- Retries apply to transient failures such as rate limits, 5xx responses, and network or timeout errors. +- 4xx client errors other than `429` are treated as permanent and are not retried. + ## Reactions (message tool) - Use `message action=react` with `channel=mattermost`. diff --git a/docs/cli/security.md b/docs/cli/security.md index 76a7ae75976..28b65f3629b 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -30,7 +30,7 @@ This is for cooperative/shared inbox hardening. A single Gateway shared by mutua It also emits `security.trust_model.multi_user_heuristic` when config suggests likely shared-user ingress (for example open DM/group policy, configured group targets, or wildcard sender rules), and reminds you that OpenClaw is a personal-assistant trust model by default. For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime. It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. -For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. +For webhook ingress, it warns when `hooks.token` reuses the Gateway token, when `hooks.defaultSessionKey` is unset, when `hooks.allowedAgentIds` is unrestricted, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy. It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records). It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`. diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 88cf928568e..6ed1d1de3ab 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -26,7 +26,7 @@ Related: - `agents.defaults.models` is the allowlist/catalog of models OpenClaw can use (plus aliases). - `agents.defaults.imageModel` is used **only when** the primary model can’t accept images. -- `agents.defaults.imageGenerationModel` is used by the shared image-generation capability. +- `agents.defaults.imageGenerationModel` is used by the shared image-generation capability. If omitted, `image_generate` can still infer a provider default from compatible auth-backed image-generation plugins. - Per-agent defaults can override `agents.defaults.model` via `agents.list[].model` plus bindings (see [/concepts/multi-agent](/concepts/multi-agent)). ## Quick model policy diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 0913040949b..6cf6272483e 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -905,6 +905,7 @@ Time format in system prompt. Default: `auto` (OS preference). - Also used as fallback routing when the selected/default model cannot accept image input. - `imageGenerationModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - Used by the shared image-generation capability and any future tool/plugin surface that generates images. + - If omitted, `image_generate` can still infer a best-effort provider default from compatible auth-backed image-generation providers. - `pdfModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - Used by the `pdf` tool for model routing. - If omitted, the PDF tool falls back to `imageModel`, then to best-effort provider defaults. @@ -2419,6 +2420,8 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio - `plugins.entries..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. - `plugins.entries..hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories. +- `plugins.entries..subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs. +- `plugins.entries..subagent.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted subagent overrides. Use `"*"` only when you intentionally want to allow any model. - `plugins.entries..config`: plugin-defined config object (validated by native OpenClaw plugin schema when available). - Enabled Claude bundle plugins can also contribute embedded Pi defaults from `settings.json`; OpenClaw applies those as sanitized agent settings, not as raw OpenClaw config patches. - `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index c3c1ee2eb1b..5fbd26a826e 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -243,7 +243,10 @@ High-signal `checkId` values you will most likely see in real deployments (not e | `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no | | `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no | | `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no | +| `hooks.token_reuse_gateway_token` | critical | Hook ingress token also unlocks Gateway auth | `hooks.token`, `gateway.auth.token` | no | | `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no | +| `hooks.default_session_key_unset` | warn | Hook agent runs fan out into generated per-request sessions | `hooks.defaultSessionKey` | no | +| `hooks.allowed_agent_ids_unrestricted` | warn/critical | Authenticated hook callers may route to any configured agent | `hooks.allowedAgentIds` | no | | `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no | | `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no | | `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes | @@ -355,6 +358,7 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - If the gateway itself terminates HTTPS, you can set `gateway.http.securityHeaders.strictTransportSecurity` to emit the HSTS header from OpenClaw responses. - Detailed deployment guidance is in [Trusted Proxy Auth](/gateway/trusted-proxy-auth#tls-termination-and-hsts). - For non-loopback Control UI deployments, `gateway.controlUi.allowedOrigins` is required by default. +- `gateway.controlUi.allowedOrigins: ["*"]` is an explicit allow-all browser-origin policy, not a hardened default. Avoid it outside tightly controlled local testing. - `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode; treat it as a dangerous operator-selected policy. - Treat DNS rebinding and proxy-host header behavior as deployment hardening concerns; keep `trustedProxies` tight and avoid exposing the gateway directly to the public internet. diff --git a/docs/tools/index.md b/docs/tools/index.md index 1dfe2b87703..f5eb956f13e 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -402,7 +402,7 @@ Notes: ### `image_generate` -Generate one or more images with the configured image-generation model. +Generate one or more images with the configured or inferred image-generation model. Core parameters: @@ -416,7 +416,8 @@ Core parameters: Notes: -- Only available when `agents.defaults.imageGenerationModel` is configured. +- Available when `agents.defaults.imageGenerationModel` is configured, or when OpenClaw can infer a compatible image-generation default from your enabled providers plus available auth. +- Explicit `agents.defaults.imageGenerationModel` still wins over any inferred default. - Use `action: "list"` to inspect registered providers, default models, supported model ids, sizes, resolutions, and edit support. - Returns local `MEDIA:` lines so channels can deliver the generated files directly. - Uses the image-generation model directly (independent of the main chat model). diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index b0eec032bcf..9f39b7d02bf 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -69,6 +69,42 @@ OpenClaw resolves known Claude marketplace names from `~/.claude/plugins/known_marketplaces.json`. You can also pass an explicit marketplace source with `--marketplace`. +## Conversation binding callbacks + +Plugins that bind a conversation can now react when an approval is resolved. + +Use `api.onConversationBindingResolved(...)` to receive a callback after a bind +request is approved or denied: + +```ts +export default { + id: "my-plugin", + register(api) { + api.onConversationBindingResolved(async (event) => { + if (event.status === "approved") { + // A binding now exists for this plugin + conversation. + console.log(event.binding?.conversationId); + return; + } + + // The request was denied; clear any local pending state. + console.log(event.request.conversation.conversationId); + }); + }, +}; +``` + +Callback payload fields: + +- `status`: `"approved"` or `"denied"` +- `decision`: `"allow-once"`, `"allow-always"`, or `"deny"` +- `binding`: the resolved binding for approved requests +- `request`: the original request summary, detach hint, sender id, and + conversation metadata + +This callback is notification-only. It does not change who is allowed to bind a +conversation, and it runs after core approval handling finishes. + ## Architecture OpenClaw's plugin system has four layers: @@ -862,6 +898,26 @@ Notes: - Returns `{ text: undefined }` when no transcription output is produced (for example skipped/unsupported input). - `api.runtime.stt.transcribeAudioFile(...)` remains as a compatibility alias. +Plugins can also launch background subagent runs through `api.runtime.subagent`: + +```ts +const result = await api.runtime.subagent.run({ + sessionKey: "agent:main:subagent:search-helper", + message: "Expand this query into focused follow-up searches.", + provider: "openai", + model: "gpt-4.1-mini", + deliver: false, +}); +``` + +Notes: + +- `provider` and `model` are optional per-run overrides, not persistent session changes. +- OpenClaw only honors those override fields for trusted callers. +- For plugin-owned fallback runs, operators must opt in with `plugins.entries..subagent.allowModelOverride: true`. +- Use `plugins.entries..subagent.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets, or `"*"` to allow any target explicitly. +- Untrusted plugin subagent runs still work, but override requests are rejected instead of silently falling back. + For web search, plugins can consume the shared runtime helper instead of reaching into the agent tool wiring: @@ -929,12 +985,24 @@ authoring plugins: - Domain subpaths such as `openclaw/plugin-sdk/channel-config-helpers`, `openclaw/plugin-sdk/channel-config-schema`, `openclaw/plugin-sdk/channel-policy`, + `openclaw/plugin-sdk/lazy-runtime`, `openclaw/plugin-sdk/reply-history`, `openclaw/plugin-sdk/routing`, `openclaw/plugin-sdk/runtime-store`, and `openclaw/plugin-sdk/directory-runtime` for shared runtime/config helpers. - `openclaw/plugin-sdk/compat` remains as a legacy migration surface for older - external plugins. Bundled plugins should not use it. + external plugins. Bundled plugins should not use it, and non-test imports emit + a one-time deprecation warning outside test environments. +- Bundled extension internals remain private. External plugins should use only + `openclaw/plugin-sdk/*` subpaths. OpenClaw core/test code may use the repo + public seams under `extensions//index.js`, `api.js`, `runtime-api.js`, + `setup-entry.js`, and narrowly scoped files such as `login-qr-api.js`. Never + import `extensions//src/*` from core or from another extension. +- Repo seam split: + `extensions//api.js` is the helper/types barrel, + `extensions//runtime-api.js` is the runtime-only barrel, + `extensions//index.js` is the bundled plugin entry, + and `extensions//setup-entry.js` is the setup plugin entry. - `openclaw/plugin-sdk/telegram` for Telegram channel plugin types and shared channel-facing helpers. Built-in Telegram implementation internals stay private to the bundled extension. - `openclaw/plugin-sdk/discord` for Discord channel plugin types and shared channel-facing helpers. Built-in Discord implementation internals stay private to the bundled extension. - `openclaw/plugin-sdk/slack` for Slack channel plugin types and shared channel-facing helpers. Built-in Slack implementation internals stay private to the bundled extension. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 3ad5feb80b4..9e156bb339a 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -250,6 +250,9 @@ Notes: - `gatewayUrl` is only accepted in a top-level window (not embedded) to prevent clickjacking. - Non-loopback Control UI deployments must set `gateway.controlUi.allowedOrigins` explicitly (full origins). This includes remote dev setups. +- Do not use `gateway.controlUi.allowedOrigins: ["*"]` except for tightly controlled + local testing. It means allow any browser origin, not “match whatever host I am + using.” - `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode, but it is a dangerous security mode. diff --git a/experiments/acp-pluginification-architecture-plan.md b/experiments/acp-pluginification-architecture-plan.md new file mode 100644 index 00000000000..b055c1800ce --- /dev/null +++ b/experiments/acp-pluginification-architecture-plan.md @@ -0,0 +1,519 @@ +# Bindings Capability Architecture Plan + +Status: in progress + +## Summary + +The goal is not to move all ACP code out of core. + +The goal is to make `bindings` a small core capability, keep the ACP session kernel in core, and move ACP-specific binding policy plus codex app server policy out of core. + +That gives us a lightweight core without hiding core semantics behind plugin indirection. + +## Current Conclusion + +The current architecture should converge on this split: + +- Core owns the generic binding capability. +- Core owns the generic ACP session kernel. +- Channel plugins own channel-specific binding semantics. +- ACP backend plugins own runtime protocol details. +- Product-level consumers like ACP configured bindings and the codex app server sit on top of the binding capability instead of hardcoding their own binding plumbing. + +This is different from "everything becomes a plugin". + +## Why This Changed + +The current codebase already shows that there are really three different layers: + +- binding and conversation ownership +- long-lived session and runtime-handle orchestration +- product-specific turn logic + +Those layers should not all be forced into one runtime engine. + +Today the duplication is mostly in the execution/control-plane shape, not in storage or binding plumbing: + +- the main harness has its own turn engine +- ACP has its own session control plane +- the codex app server plugin path likely owns its own app-level turn engine outside this repo + +The right move is to share the stable control-plane contracts, not to force all three into one giant executor. + +## Verified Current State + +### Generic binding pieces already exist + +- `src/infra/outbound/session-binding-service.ts` already provides a generic binding store and adapter model. +- `src/plugins/conversation-binding.ts` already lets plugins request a conversation binding and stores plugin-owned binding metadata. +- `src/plugins/types.ts` already exposes plugin-facing binding APIs. +- `src/plugins/types.ts` already exposes the generic `inbound_claim` hook. + +### ACP is only partially pluginified + +- `src/channels/plugins/configured-binding-registry.ts` now owns generic configured binding compilation and lookup. +- `src/channels/plugins/binding-routing.ts` and `src/channels/plugins/binding-targets.ts` now own the generic route and target lifecycle seams. +- ACP now plugs into that seam through `src/channels/plugins/acp-configured-binding-consumer.ts` and `src/channels/plugins/acp-stateful-target-driver.ts`. +- `src/acp/persistent-bindings.lifecycle.ts` still owns configured ACP ensure and reset behavior. +- runtime-created plugin conversation bindings still use a separate path in `src/plugins/conversation-binding.ts`. + +### Codex app server is already closer to the desired shape + +From this repo's side, the codex app server path is much thinner: + +- a plugin binds a conversation +- core stores that binding +- inbound dispatch targets the plugin's `inbound_claim` hook + +What core does not provide for the codex app server path is an ACP-like shared session kernel. If the app server needs retries, long-lived runtime handles, cancellation, or session health logic, it must own that itself today. + +## The Durable Split + +### 1. Core Binding Capability + +This should become the primary shared seam. + +Responsibilities: + +- canonical `ConversationRef` +- binding record storage +- configured binding compilation +- runtime-created binding storage +- fast binding lookup on inbound +- binding touch/unbind lifecycle +- generic dispatch handoff to the binding target + +What core binding capability must not own: + +- Discord thread rules +- Telegram topic rules +- Feishu chat rules +- ACP session orchestration +- codex app server business logic + +### 2. Core Stateful Target Kernel + +This is the small generic kernel for long-lived bound targets. + +Responsibilities: + +- ensure target ready +- run turn +- cancel turn +- close target +- reset target +- status and health +- persistence of target metadata +- retries and runtime-handle safety +- per-target serialization and concurrency + +ACP is the first real implementation of this shape. + +This kernel should stay in core because it is mandatory infrastructure and has strict startup, reset, and recovery semantics. + +### 3. Channel Binding Providers + +Each channel plugin should own the meaning of "this channel conversation maps to this binding rule". + +Responsibilities: + +- normalize configured binding targets +- normalize inbound conversations +- match inbound conversations against compiled bindings +- define channel-specific matching priority +- optionally provide binding description text for status and logs + +This is where Discord channel vs thread logic, Telegram topic rules, and Feishu conversation rules belong. + +### 4. Product Consumers + +Bindings are a shared capability. Different products should consume it differently. + +ACP configured bindings: + +- compile config rules +- resolve a target session +- ensure the ACP session is ready through the ACP kernel + +Codex app server: + +- create runtime-requested bindings +- claim inbound messages through plugin hooks +- optionally adopt the shared stateful target contract later if it really needs long-lived session orchestration + +Main harness: + +- does not need to become "a binding product" +- may eventually share small lifecycle contracts, but it should not be forced into the same engine as ACP + +## The Key Architectural Decision + +The shared abstraction should be: + +- `bindings` as the capability +- `stateful target drivers` as an optional lower-level contract + +The shared abstraction should not be: + +- "one runtime engine for main harness, ACP, and codex app server" + +That would overfit very different systems into one executor. + +## Stable Nouns + +Core should understand only stable nouns. + +The stable nouns are: + +- `ConversationRef` +- `BindingRule` +- `CompiledBinding` +- `BindingResolution` +- `BindingTargetDescriptor` +- `StatefulTargetDriver` +- `StatefulTargetHandle` + +ACP, codex app server, and future products should compile down to those nouns instead of leaking product-specific routing rules through core. + +## Proposed Capability Model + +### Binding capability + +The binding capability should support both configured bindings and runtime-created bindings. + +Required operations: + +- compile configured bindings at startup or reload +- resolve a binding from an inbound `ConversationRef` +- create a runtime binding +- touch and unbind an existing binding +- dispatch a resolved binding to its target + +### Binding target descriptor + +A resolved binding should point to a typed target descriptor rather than ad hoc ACP- or plugin-specific metadata blobs. + +The descriptor should be able to represent at least: + +- plugin-owned inbound claim targets +- stateful target drivers + +That means the same binding capability can support both: + +- codex app server plugin-bound conversations +- ACP configured bindings + +without pretending they are the same product. + +### Stateful target driver + +This is the reusable control-plane contract for long-lived bound targets. + +Required operations: + +- `ensureReady` +- `runTurn` +- `cancel` +- `close` +- `reset` +- `status` +- `health` + +ACP should remain the first built-in driver. + +If the codex app server later proves that it also needs durable session handles, it can either: + +- use a driver that consumes this contract, or +- keep its own product-owned runtime if that remains simpler + +That should be a product decision, not something forced by the binding capability. + +## Why ACP Kernel Stays In Core + +ACP's kernel should remain in core because session lifecycle, persistence, retries, cancellation, and runtime-handle safety are generic platform machinery. + +Those concerns are not channel-specific, and they are not codex-app-server-specific. + +If we move that machinery into an ordinary plugin, we create circular bootstrapping: + +- channels need it during startup and inbound routing +- reset and recovery need it when plugins may already be degraded +- failure semantics become special-case core logic anyway + +If we later wrap it in a "built-in capability module", that is still effectively core. + +## What Should Move Out Of Core + +The following should move out of ACP-shaped core code: + +- channel-specific configured binding matching +- channel-specific binding target normalization +- channel-specific recovery UX +- ACP-specific route wrapping helpers as named ACP seams +- codex app server fallback policy beyond generic plugin-bound dispatch behavior + +The following should stay: + +- generic binding storage and dispatch +- generic ACP control plane +- generic stateful target driver contract + +## Current Problems To Remove + +### Residual cleanup is now small + +Most ACP-era compatibility names are gone from the generic seam. + +The remaining cleanup is smaller: + +- `src/acp/persistent-bindings.ts` compatibility barrel can be deleted once tests stop importing it +- ACP-named tests and mocks can be renamed over time for consistency +- docs should stop describing already-removed ACP wrappers as if they still exist + +### Configured binding implementation is still too monolithic + +`src/channels/plugins/configured-binding-registry.ts` still mixes: + +- registry compilation +- cache invalidation +- inbound matching +- materialization of binding targets +- session-key reverse lookup + +That file is now generic, but still too large and too coupled. + +### Runtime-created plugin bindings still use a separate stack + +`src/plugins/conversation-binding.ts` is still a separate implementation path for plugin-created bindings. + +That means configured bindings and runtime-created bindings share storage, but not one consistent capability layer. + +### Generic registries still hardcode ACP as a built-in + +`src/channels/plugins/configured-binding-consumers.ts` and `src/channels/plugins/stateful-target-drivers.ts` still import ACP directly. + +That is acceptable for now, but the clean final shape is to keep ACP built in while registering it from a dedicated bootstrap point instead of wiring it inside the generic registry files. + +## Target Contracts + +### Channel binding provider contract + +Conceptually, each channel plugin should support: + +- `compileConfiguredBinding(binding, cfg) -> CompiledBinding | null` +- `resolveInboundConversation(event) -> ConversationRef | null` +- `matchInboundConversation(compiledBinding, conversation) -> BindingMatch | null` +- `describeBinding(compiledBinding) -> string | undefined` + +### Binding capability contract + +Core should support: + +- `compileConfiguredBindings(cfg, plugins) -> CompiledBindingRegistry` +- `resolveBinding(conversationRef) -> BindingResolution | null` +- `createRuntimeBinding(target, conversationRef, metadata) -> BindingRecord` +- `touchBinding(bindingId)` +- `unbindBinding(bindingId | target)` +- `dispatchResolvedBinding(bindingResolution, inboundEvent)` + +### Stateful target driver contract + +Core should support: + +- `ensureReady(targetRef, cfg)` +- `runTurn(targetRef, input)` +- `cancel(targetRef, reason)` +- `close(targetRef, reason)` +- `reset(targetRef, reason)` +- `status(targetRef)` +- `health(targetRef)` + +## File-Level Transition Plan + +### Keep + +- `src/infra/outbound/session-binding-service.ts` +- `src/acp/control-plane/*` +- `extensions/acpx/*` + +### Generalize + +- `src/plugins/conversation-binding.ts` + - fold runtime-created plugin bindings into the same generic binding capability instead of keeping a separate implementation stack +- `src/channels/plugins/configured-binding-registry.ts` + - split into compiler, matcher, and session-key resolution modules with a thin facade +- `src/channels/plugins/types.adapters.ts` + - finish removing ACP-era aliases after the deprecation window +- `src/plugin-sdk/conversation-runtime.ts` + - export only the generic binding capability surfaces +- `src/acp/persistent-bindings.lifecycle.ts` + - either become a generic stateful target driver consumer or be renamed to ACP driver-specific lifecycle code + +### Shrink Or Delete + +- `src/acp/persistent-bindings.ts` + - delete the compatibility barrel once tests import the real modules directly +- `src/acp/persistent-bindings.resolve.ts` + - keep only while ACP-specific compatibility helpers are still useful to internal callers +- ACP-named test files + - rename over time once the behavior is stable and there is no risk of mixing behavioral and naming churn + +## Recommended Refactor Order + +### Completed groundwork + +The current branch has already completed most of the first migration wave: + +- stable generic binding nouns exist +- configured bindings compile through a generic registry +- inbound routing goes through generic binding resolution +- configured binding lookup no longer performs fallback plugin discovery +- ACP is expressed as a configured-binding consumer plus a built-in stateful target driver + +The remaining work is cleanup and unification, not first-principles redesign. + +### Phase 1: Freeze the nouns + +Introduce and document the stable binding and target types: + +- `ConversationRef` +- `CompiledBinding` +- `BindingResolution` +- `BindingTargetDescriptor` +- `StatefulTargetDriver` + +Do this before more movement so the rest of the refactor has firm vocabulary. + +### Phase 2: Promote bindings to a first-class core capability + +Refactor the existing generic binding store into an explicit capability layer. + +Requirements: + +- runtime-created bindings stay supported +- configured bindings become first-class +- lookup becomes channel-agnostic + +### Phase 3: Compile configured bindings at startup and reload + +Move configured binding compilation off the inbound hot path. + +Requirements: + +- load enabled channel plugins once +- compile configured bindings once +- rebuild on config or plugin reload +- inbound path becomes pure registry lookup + +### Phase 4: Expand the channel provider seam + +Replace the ACP-specific adapter shape with a generic channel binding provider contract. + +Requirements: + +- channel plugins own normalization and matching +- core no longer knows channel-specific configured binding rules + +### Phase 5: Re-express ACP as a binding consumer plus built-in stateful target driver + +Move ACP configured binding policy to the new binding capability while keeping ACP runtime orchestration in core. + +Requirements: + +- ACP configured bindings resolve through the generic binding registry +- ACP target readiness uses the ACP driver contract +- ACP-specific naming disappears from generic binding code + +### Phase 6: Finish residual ACP cleanup + +Remove the last compatibility leftovers and stale naming. + +Requirements: + +- delete `src/acp/persistent-bindings.ts` +- rename ACP-named tests where that improves clarity without changing behavior +- keep docs synchronized with the actual generic seam instead of the earlier transition state + +### Phase 7: Split the configured binding registry by responsibility + +Refactor `src/channels/plugins/configured-binding-registry.ts` into smaller modules. + +Suggested split: + +- compiler module +- inbound matcher module +- session-key reverse lookup module +- thin public facade + +Requirements: + +- caching behavior remains unchanged +- matching behavior remains unchanged +- session-key resolution behavior remains unchanged + +### Phase 8: Keep codex app server on the same binding capability + +Do not force the codex app server into ACP semantics. + +Requirements: + +- codex app server keeps runtime-created bindings through the same binding capability +- inbound claim remains the default delivery path +- only adopt the stateful target driver seam if the app server truly needs long-lived target orchestration +- `src/plugins/conversation-binding.ts` stops being a separate binding stack and becomes a consumer of the generic binding capability + +### Phase 9: Decouple built-in ACP registration from generic registry files + +Keep ACP built in, but stop importing it directly from the generic registry modules. + +Requirements: + +- `src/channels/plugins/configured-binding-consumers.ts` no longer hardcodes ACP imports +- `src/channels/plugins/stateful-target-drivers.ts` no longer hardcodes ACP imports +- ACP still registers by default during normal startup +- generic registry files remain product-agnostic + +### Phase 10: Remove ACP-shaped compatibility facades + +Once all call sites are on the generic capability: + +- delete ACP-shaped routing helpers +- delete hot-path plugin bootstrapping logic +- keep only thin compatibility exports if external plugins still need a deprecation window + +## Success Criteria + +The architecture is done when all of these are true: + +- no inbound configured-binding resolution performs plugin discovery +- no channel-specific binding semantics remain in generic core binding code +- ACP still uses a core session kernel +- codex app server and ACP both sit on top of the same binding capability +- the binding capability can represent both configured and runtime-created bindings +- runtime-created plugin bindings do not use a separate implementation stack +- long-lived target orchestration is shared through a small core driver contract +- generic registry files do not import ACP directly +- ACP-era alias names are gone from the generic/plugin SDK surface +- the main harness is not forced into the ACP engine +- external plugins can use the same capability without internal imports + +## Non-Goals + +These are not goals of the remaining refactor: + +- moving the ACP session kernel into an ordinary plugin +- forcing the main harness, ACP, and codex app server into one executor +- making every channel implement its own retry and session-safety logic +- keeping ACP-shaped naming in the long-term generic binding layer + +## Bottom Line + +The right 20-year split is: + +- bindings are the shared core capability +- ACP session orchestration remains a small built-in core kernel +- channel plugins own binding semantics +- backend plugins own runtime protocol details +- product consumers like ACP configured bindings and codex app server build on the same binding capability without being forced into one runtime engine + +That is the leanest core that still has honest boundaries. diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts index 5a19d6f43e8..bd75ee1198d 100644 --- a/extensions/acpx/src/config.test.ts +++ b/extensions/acpx/src/config.test.ts @@ -39,6 +39,25 @@ describe("acpx plugin config parsing", () => { } }); + it("prefers the workspace plugin root for dist/extensions/acpx bundles", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-workspace-")); + const workspacePluginRoot = path.join(repoRoot, "extensions", "acpx"); + const bundledPluginRoot = path.join(repoRoot, "dist", "extensions", "acpx"); + try { + fs.mkdirSync(workspacePluginRoot, { recursive: true }); + fs.mkdirSync(bundledPluginRoot, { recursive: true }); + fs.writeFileSync(path.join(workspacePluginRoot, "package.json"), "{}\n", "utf8"); + fs.writeFileSync(path.join(workspacePluginRoot, "openclaw.plugin.json"), "{}\n", "utf8"); + fs.writeFileSync(path.join(bundledPluginRoot, "package.json"), "{}\n", "utf8"); + fs.writeFileSync(path.join(bundledPluginRoot, "openclaw.plugin.json"), "{}\n", "utf8"); + + const moduleUrl = pathToFileURL(path.join(bundledPluginRoot, "index.js")).href; + expect(resolveAcpxPluginRoot(moduleUrl)).toBe(workspacePluginRoot); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + it("resolves bundled acpx with pinned version by default", () => { const resolved = resolveAcpxPluginConfig({ rawConfig: { diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts index d6bfb3a44db..e604b69db7c 100644 --- a/extensions/acpx/src/config.ts +++ b/extensions/acpx/src/config.ts @@ -13,14 +13,18 @@ export const ACPX_PINNED_VERSION = "0.1.16"; export const ACPX_VERSION_ANY = "any"; const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx"; -export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string { +function isAcpxPluginRoot(dir: string): boolean { + return ( + fs.existsSync(path.join(dir, "openclaw.plugin.json")) && + fs.existsSync(path.join(dir, "package.json")) + ); +} + +function resolveNearestAcpxPluginRoot(moduleUrl: string): string { let cursor = path.dirname(fileURLToPath(moduleUrl)); for (let i = 0; i < 3; i += 1) { // Bundled entries live at the plugin root while source files still live under src/. - if ( - fs.existsSync(path.join(cursor, "openclaw.plugin.json")) && - fs.existsSync(path.join(cursor, "package.json")) - ) { + if (isAcpxPluginRoot(cursor)) { return cursor; } const parent = path.dirname(cursor); @@ -32,10 +36,29 @@ export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): stri return path.resolve(path.dirname(fileURLToPath(moduleUrl)), ".."); } +function resolveWorkspaceAcpxPluginRoot(currentRoot: string): string | null { + if ( + path.basename(currentRoot) !== "acpx" || + path.basename(path.dirname(currentRoot)) !== "extensions" || + path.basename(path.dirname(path.dirname(currentRoot))) !== "dist" + ) { + return null; + } + const workspaceRoot = path.resolve(currentRoot, "..", "..", "..", "extensions", "acpx"); + return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null; +} + +export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string { + const resolvedRoot = resolveNearestAcpxPluginRoot(moduleUrl); + // In a live repo checkout, dist/ can be rebuilt out from under the running gateway. + // Prefer the stable source plugin root when a built extension is running beside it. + return resolveWorkspaceAcpxPluginRoot(resolvedRoot) ?? resolvedRoot; +} + export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot(); export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME); export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSION): string { - return `npm install --omit=dev --no-save acpx@${version}`; + return `npm install --omit=dev --no-save --package-lock=false acpx@${version}`; } export const ACPX_LOCAL_INSTALL_COMMAND = buildAcpxLocalInstallCommand(); diff --git a/extensions/acpx/src/ensure.test.ts b/extensions/acpx/src/ensure.test.ts index c0bb5469b29..b834a671906 100644 --- a/extensions/acpx/src/ensure.test.ts +++ b/extensions/acpx/src/ensure.test.ts @@ -85,7 +85,13 @@ describe("acpx ensure", () => { }); expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({ command: "npm", - args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`], + args: [ + "install", + "--omit=dev", + "--no-save", + "--package-lock=false", + `acpx@${ACPX_PINNED_VERSION}`, + ], cwd: "/plugin", stripProviderAuthEnvVars, }); diff --git a/extensions/acpx/src/ensure.ts b/extensions/acpx/src/ensure.ts index 9b85d53f618..05825b75bc9 100644 --- a/extensions/acpx/src/ensure.ts +++ b/extensions/acpx/src/ensure.ts @@ -233,7 +233,13 @@ export async function ensureAcpx(params: { const install = await spawnAndCollect({ command: "npm", - args: ["install", "--omit=dev", "--no-save", `acpx@${installVersion}`], + args: [ + "install", + "--omit=dev", + "--no-save", + "--package-lock=false", + `acpx@${installVersion}`, + ], cwd: pluginRoot, stripProviderAuthEnvVars: params.stripProviderAuthEnvVars, }); diff --git a/extensions/acpx/src/runtime-internals/process.test.ts b/extensions/acpx/src/runtime-internals/process.test.ts index ef0492308ae..90b7560c47e 100644 --- a/extensions/acpx/src/runtime-internals/process.test.ts +++ b/extensions/acpx/src/runtime-internals/process.test.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -64,6 +64,58 @@ describe("resolveSpawnCommand", () => { }); }); + it("routes node shebang wrappers through the current node runtime on posix", async () => { + const dir = await createTempDir(); + const scriptPath = path.join(dir, "acpx"); + await writeFile(scriptPath, "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8"); + await chmod(scriptPath, 0o755); + + const resolved = resolveSpawnCommand( + { + command: scriptPath, + args: ["--help"], + }, + undefined, + { + platform: "linux", + env: {}, + execPath: "/custom/node", + }, + ); + + expect(resolved).toEqual({ + command: "/custom/node", + args: [scriptPath, "--help"], + }); + }); + + it("routes PATH-resolved node shebang wrappers through the current node runtime on posix", async () => { + const dir = await createTempDir(); + const binDir = path.join(dir, "bin"); + const scriptPath = path.join(binDir, "acpx"); + await mkdir(binDir, { recursive: true }); + await writeFile(scriptPath, "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8"); + await chmod(scriptPath, 0o755); + + const resolved = resolveSpawnCommand( + { + command: "acpx", + args: ["--help"], + }, + undefined, + { + platform: "linux", + env: { PATH: binDir }, + execPath: "/custom/node", + }, + ); + + expect(resolved).toEqual({ + command: "/custom/node", + args: [scriptPath, "--help"], + }); + }); + it("routes .js command execution through node on windows", () => { const resolved = resolveSpawnCommand( { diff --git a/extensions/acpx/src/runtime-internals/process.ts b/extensions/acpx/src/runtime-internals/process.ts index 2724f467ab1..60b85114bcb 100644 --- a/extensions/acpx/src/runtime-internals/process.ts +++ b/extensions/acpx/src/runtime-internals/process.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { existsSync } from "node:fs"; +import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; import type { WindowsSpawnProgram, WindowsSpawnProgramCandidate, @@ -57,11 +58,76 @@ const DEFAULT_RUNTIME: SpawnRuntime = { execPath: process.execPath, }; +function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean { + try { + const stat = statSync(filePath); + if (!stat.isFile()) { + return false; + } + if (platform === "win32") { + return true; + } + accessSync(filePath, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +function resolveExecutableFromPath(command: string, runtime: SpawnRuntime): string | undefined { + const pathEnv = runtime.env.PATH ?? runtime.env.Path; + if (!pathEnv) { + return undefined; + } + for (const entry of pathEnv.split(path.delimiter).filter(Boolean)) { + const candidate = path.join(entry, command); + if (isExecutableFile(candidate, runtime.platform)) { + return candidate; + } + } + return undefined; +} + +function resolveNodeShebangScriptPath(command: string, runtime: SpawnRuntime): string | undefined { + const commandPath = + path.isAbsolute(command) || command.includes(path.sep) + ? command + : resolveExecutableFromPath(command, runtime); + if (!commandPath || !isExecutableFile(commandPath, runtime.platform)) { + return undefined; + } + try { + const firstLine = readFileSync(commandPath, "utf8").split(/\r?\n/, 1)[0] ?? ""; + if (/^#!.*(?:\/usr\/bin\/env\s+node\b|\/node(?:js)?\b)/.test(firstLine)) { + return commandPath; + } + } catch { + return undefined; + } + return undefined; +} + export function resolveSpawnCommand( params: { command: string; args: string[] }, options?: SpawnCommandOptions, runtime: SpawnRuntime = DEFAULT_RUNTIME, ): ResolvedSpawnCommand { + if (runtime.platform !== "win32") { + const nodeShebangScript = resolveNodeShebangScriptPath(params.command, runtime); + if (nodeShebangScript) { + options?.onResolved?.({ + command: params.command, + cacheHit: false, + strictWindowsCmdWrapper: options?.strictWindowsCmdWrapper === true, + resolution: "direct", + }); + return { + command: runtime.execPath, + args: [nodeShebangScript, ...params.args], + }; + } + } + const strictWindowsCmdWrapper = options?.strictWindowsCmdWrapper === true; const cacheKey = params.command; const cachedProgram = options?.cache; diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 198a0367b59..5c65b032f34 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -154,6 +154,90 @@ describe("AcpxRuntime", () => { expect(resumeArgs[resumeFlagIndex + 1]).toBe(resumeSessionId); }); + it("replaces dead named sessions returned by sessions ensure", async () => { + process.env.MOCK_ACPX_STATUS_STATUS = "dead"; + process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable"; + try { + const { runtime, logPath } = await createMockRuntimeFixture(); + const sessionKey = "agent:codex:acp:dead-session"; + + const handle = await runtime.ensureSession({ + sessionKey, + agent: "codex", + mode: "persistent", + }); + + expect(handle.backend).toBe("acpx"); + const logs = await readMockRuntimeLogEntries(logPath); + const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure"); + const statusIndex = logs.findIndex((entry) => entry.kind === "status"); + const newIndex = logs.findIndex((entry) => entry.kind === "new"); + expect(ensureIndex).toBeGreaterThanOrEqual(0); + expect(statusIndex).toBeGreaterThan(ensureIndex); + expect(newIndex).toBeGreaterThan(statusIndex); + } finally { + delete process.env.MOCK_ACPX_STATUS_STATUS; + delete process.env.MOCK_ACPX_STATUS_SUMMARY; + } + }); + + it("reuses a live named session when sessions ensure exits before returning identifiers", async () => { + process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1"; + process.env.MOCK_ACPX_STATUS_STATUS = "alive"; + try { + const { runtime, logPath } = await createMockRuntimeFixture(); + const sessionKey = "agent:codex:acp:ensure-fallback-alive"; + + const handle = await runtime.ensureSession({ + sessionKey, + agent: "codex", + mode: "persistent", + }); + + expect(handle.backend).toBe("acpx"); + expect(handle.acpxRecordId).toBe("rec-" + sessionKey); + const logs = await readMockRuntimeLogEntries(logPath); + const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure"); + const statusIndex = logs.findIndex((entry) => entry.kind === "status"); + const newIndex = logs.findIndex((entry) => entry.kind === "new"); + expect(ensureIndex).toBeGreaterThanOrEqual(0); + expect(statusIndex).toBeGreaterThan(ensureIndex); + expect(newIndex).toBe(-1); + } finally { + delete process.env.MOCK_ACPX_ENSURE_EXIT_1; + delete process.env.MOCK_ACPX_STATUS_STATUS; + } + }); + + it("creates a fresh named session when sessions ensure exits and status is dead", async () => { + process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1"; + process.env.MOCK_ACPX_STATUS_STATUS = "dead"; + process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable"; + try { + const { runtime, logPath } = await createMockRuntimeFixture(); + const sessionKey = "agent:codex:acp:ensure-fallback-dead"; + + const handle = await runtime.ensureSession({ + sessionKey, + agent: "codex", + mode: "persistent", + }); + + expect(handle.backend).toBe("acpx"); + const logs = await readMockRuntimeLogEntries(logPath); + const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure"); + const statusIndex = logs.findIndex((entry) => entry.kind === "status"); + const newIndex = logs.findIndex((entry) => entry.kind === "new"); + expect(ensureIndex).toBeGreaterThanOrEqual(0); + expect(statusIndex).toBeGreaterThan(ensureIndex); + expect(newIndex).toBeGreaterThan(statusIndex); + } finally { + delete process.env.MOCK_ACPX_ENSURE_EXIT_1; + delete process.env.MOCK_ACPX_STATUS_STATUS; + delete process.env.MOCK_ACPX_STATUS_SUMMARY; + } + }); + it("serializes text plus image attachments into ACP prompt blocks", async () => { const { runtime, logPath } = await createMockRuntimeFixture(); diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index e55ef360424..a528de476af 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -92,6 +92,26 @@ function formatAcpxExitMessage(params: { return stderr || `acpx exited with code ${params.exitCode ?? "unknown"}`; } +function summarizeLogText(text: string, maxChars = 240): string { + const normalized = text.trim().replace(/\s+/g, " "); + if (!normalized) { + return ""; + } + if (normalized.length <= maxChars) { + return normalized; + } + return `${normalized.slice(0, maxChars)}...`; +} + +function findSessionIdentifierEvent(events: AcpxJsonObject[]): AcpxJsonObject | undefined { + return events.find( + (event) => + asOptionalString(event.agentSessionId) || + asOptionalString(event.acpxSessionId) || + asOptionalString(event.acpxRecordId), + ); +} + export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string { const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url"); return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`; @@ -252,6 +272,146 @@ export class AcpxRuntime implements AcpRuntime { this.healthy = result.ok; } + private async createNamedSession(params: { + agent: string; + cwd: string; + sessionName: string; + resumeSessionId?: string; + }): Promise { + const command = params.resumeSessionId + ? [ + "sessions", + "new", + "--name", + params.sessionName, + "--resume-session", + params.resumeSessionId, + ] + : ["sessions", "new", "--name", params.sessionName]; + return await this.runControlCommand({ + args: await this.buildVerbArgs({ + agent: params.agent, + cwd: params.cwd, + command, + }), + cwd: params.cwd, + fallbackCode: "ACP_SESSION_INIT_FAILED", + }); + } + + private async shouldReplaceEnsuredSession(params: { + sessionName: string; + agent: string; + cwd: string; + }): Promise { + const args = await this.buildVerbArgs({ + agent: params.agent, + cwd: params.cwd, + command: ["status", "--session", params.sessionName], + }); + let events: AcpxJsonObject[]; + try { + events = await this.runControlCommand({ + args, + cwd: params.cwd, + fallbackCode: "ACP_SESSION_INIT_FAILED", + ignoreNoSession: true, + }); + } catch (error) { + this.logger?.warn?.( + `acpx ensureSession status probe failed: session=${params.sessionName} cwd=${params.cwd} error=${summarizeLogText(error instanceof Error ? error.message : String(error)) || ""}`, + ); + return false; + } + + const noSession = events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION"); + if (noSession) { + this.logger?.warn?.( + `acpx ensureSession replacing missing named session: session=${params.sessionName} cwd=${params.cwd}`, + ); + return true; + } + + const detail = events.find((event) => !toAcpxErrorEvent(event)); + const status = asTrimmedString(detail?.status)?.toLowerCase(); + if (status === "dead") { + const summary = summarizeLogText(asOptionalString(detail?.summary) ?? ""); + this.logger?.warn?.( + `acpx ensureSession replacing dead named session: session=${params.sessionName} cwd=${params.cwd} status=${status} summary=${summary || ""}`, + ); + return true; + } + + return false; + } + + private async recoverEnsureFailure(params: { + sessionName: string; + agent: string; + cwd: string; + error: unknown; + }): Promise { + const errorMessage = summarizeLogText( + params.error instanceof Error ? params.error.message : String(params.error), + ); + this.logger?.warn?.( + `acpx ensureSession probing named session after ensure failure: session=${params.sessionName} cwd=${params.cwd} error=${errorMessage || ""}`, + ); + const args = await this.buildVerbArgs({ + agent: params.agent, + cwd: params.cwd, + command: ["status", "--session", params.sessionName], + }); + let events: AcpxJsonObject[]; + try { + events = await this.runControlCommand({ + args, + cwd: params.cwd, + fallbackCode: "ACP_SESSION_INIT_FAILED", + ignoreNoSession: true, + }); + } catch (statusError) { + this.logger?.warn?.( + `acpx ensureSession status fallback failed: session=${params.sessionName} cwd=${params.cwd} error=${summarizeLogText(statusError instanceof Error ? statusError.message : String(statusError)) || ""}`, + ); + return null; + } + + const noSession = events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION"); + if (noSession) { + this.logger?.warn?.( + `acpx ensureSession creating named session after ensure failure and missing status: session=${params.sessionName} cwd=${params.cwd}`, + ); + return await this.createNamedSession({ + agent: params.agent, + cwd: params.cwd, + sessionName: params.sessionName, + }); + } + + const detail = events.find((event) => !toAcpxErrorEvent(event)); + const status = asTrimmedString(detail?.status)?.toLowerCase(); + if (status === "dead") { + this.logger?.warn?.( + `acpx ensureSession replacing dead named session after ensure failure: session=${params.sessionName} cwd=${params.cwd}`, + ); + return await this.createNamedSession({ + agent: params.agent, + cwd: params.cwd, + sessionName: params.sessionName, + }); + } + + if (status === "alive" || findSessionIdentifierEvent(events)) { + this.logger?.warn?.( + `acpx ensureSession reusing live named session after ensure failure: session=${params.sessionName} cwd=${params.cwd} status=${status || "unknown"}`, + ); + return events; + } + + return null; + } + async ensureSession(input: AcpRuntimeEnsureInput): Promise { const sessionName = asTrimmedString(input.sessionKey); if (!sessionName) { @@ -264,45 +424,80 @@ export class AcpxRuntime implements AcpRuntime { const cwd = asTrimmedString(input.cwd) || this.config.cwd; const mode = input.mode; const resumeSessionId = asTrimmedString(input.resumeSessionId); - const ensureSubcommand = resumeSessionId - ? ["sessions", "new", "--name", sessionName, "--resume-session", resumeSessionId] - : ["sessions", "ensure", "--name", sessionName]; - const ensureCommand = await this.buildVerbArgs({ - agent, - cwd, - command: ensureSubcommand, - }); - - let events = await this.runControlCommand({ - args: ensureCommand, - cwd, - fallbackCode: "ACP_SESSION_INIT_FAILED", - }); - let ensuredEvent = events.find( - (event) => - asOptionalString(event.agentSessionId) || - asOptionalString(event.acpxSessionId) || - asOptionalString(event.acpxRecordId), - ); - - if (!ensuredEvent && !resumeSessionId) { - const newCommand = await this.buildVerbArgs({ + let events: AcpxJsonObject[]; + if (resumeSessionId) { + events = await this.createNamedSession({ agent, cwd, - command: ["sessions", "new", "--name", sessionName], + sessionName, + resumeSessionId, }); - events = await this.runControlCommand({ - args: newCommand, - cwd, - fallbackCode: "ACP_SESSION_INIT_FAILED", - }); - ensuredEvent = events.find( - (event) => - asOptionalString(event.agentSessionId) || - asOptionalString(event.acpxSessionId) || - asOptionalString(event.acpxRecordId), + } else { + try { + events = await this.runControlCommand({ + args: await this.buildVerbArgs({ + agent, + cwd, + command: ["sessions", "ensure", "--name", sessionName], + }), + cwd, + fallbackCode: "ACP_SESSION_INIT_FAILED", + }); + } catch (error) { + const recovered = await this.recoverEnsureFailure({ + sessionName, + agent, + cwd, + error, + }); + if (!recovered) { + throw error; + } + events = recovered; + } + } + if (events.length === 0) { + this.logger?.warn?.( + `acpx ensureSession returned no events after sessions ensure: session=${sessionName} agent=${agent} cwd=${cwd}`, ); } + let ensuredEvent = findSessionIdentifierEvent(events); + + if ( + ensuredEvent && + !resumeSessionId && + (await this.shouldReplaceEnsuredSession({ + sessionName, + agent, + cwd, + })) + ) { + events = await this.createNamedSession({ + agent, + cwd, + sessionName, + }); + if (events.length === 0) { + this.logger?.warn?.( + `acpx ensureSession returned no events after replacing dead session: session=${sessionName} agent=${agent} cwd=${cwd}`, + ); + } + ensuredEvent = findSessionIdentifierEvent(events); + } + + if (!ensuredEvent && !resumeSessionId) { + events = await this.createNamedSession({ + agent, + cwd, + sessionName, + }); + if (events.length === 0) { + this.logger?.warn?.( + `acpx ensureSession returned no events after sessions new: session=${sessionName} agent=${agent} cwd=${cwd}`, + ); + } + ensuredEvent = findSessionIdentifierEvent(events); + } if (!ensuredEvent) { throw new AcpRuntimeError( "ACP_SESSION_INIT_FAILED", diff --git a/extensions/acpx/src/test-utils/runtime-fixtures.ts b/extensions/acpx/src/test-utils/runtime-fixtures.ts index ebf5052f450..4ebe57b3e2a 100644 --- a/extensions/acpx/src/test-utils/runtime-fixtures.ts +++ b/extensions/acpx/src/test-utils/runtime-fixtures.ts @@ -76,6 +76,17 @@ const setValue = command === "set" ? String(args[commandIndex + 2] || "") : ""; if (command === "sessions" && args[commandIndex + 1] === "ensure") { writeLog({ kind: "ensure", agent, args, sessionName: ensureName }); + if (process.env.MOCK_ACPX_ENSURE_EXIT_1 === "1") { + emitJson({ + jsonrpc: "2.0", + id: null, + error: { + code: -32603, + message: "mock ensure failure", + }, + }); + process.exit(1); + } if (process.env.MOCK_ACPX_ENSURE_EMPTY === "1") { emitJson({ action: "session_ensured", name: ensureName }); } else { @@ -173,11 +184,14 @@ if (command === "set") { if (command === "status") { writeLog({ kind: "status", agent, args, sessionName: sessionFromOption }); + const status = process.env.MOCK_ACPX_STATUS_STATUS || (sessionFromOption ? "alive" : "no-session"); + const summary = process.env.MOCK_ACPX_STATUS_SUMMARY || ""; emitJson({ acpxRecordId: sessionFromOption ? "rec-" + sessionFromOption : null, acpxSessionId: sessionFromOption ? "sid-" + sessionFromOption : null, agentSessionId: sessionFromOption ? "inner-" + sessionFromOption : null, - status: sessionFromOption ? "alive" : "no-session", + status, + ...(summary ? { summary } : {}), pid: 4242, uptime: 120, }); @@ -382,6 +396,9 @@ export async function readMockRuntimeLogEntries( export async function cleanupMockRuntimeFixtures(): Promise { delete process.env.MOCK_ACPX_LOG; delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS; + delete process.env.MOCK_ACPX_ENSURE_EXIT_1; + delete process.env.MOCK_ACPX_STATUS_STATUS; + delete process.env.MOCK_ACPX_STATUS_SUMMARY; sharedMockCliScriptPath = null; logFileSequence = 0; while (tempDirs.length > 0) { diff --git a/extensions/bluebubbles/api.ts b/extensions/bluebubbles/api.ts new file mode 100644 index 00000000000..414efd5531e --- /dev/null +++ b/extensions/bluebubbles/api.ts @@ -0,0 +1 @@ +export { bluebubblesPlugin } from "./src/channel.js"; diff --git a/extensions/bluebubbles/index.ts b/extensions/bluebubbles/index.ts index 778cbd8ae8f..3e4ab2b4ff8 100644 --- a/extensions/bluebubbles/index.ts +++ b/extensions/bluebubbles/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { bluebubblesPlugin } from "./src/channel.js"; import { setBlueBubblesRuntime } from "./src/runtime.js"; +export { bluebubblesPlugin } from "./src/channel.js"; +export { setBlueBubblesRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "bluebubbles", name: "BlueBubbles", diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index 0560567c5fb..a7a9e549051 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -1,7 +1,12 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { bluebubblesMessageActions } from "./actions.js"; +import { sendBlueBubblesAttachment } from "./attachments.js"; +import { editBlueBubblesMessage, setGroupIconBlueBubbles } from "./chat.js"; +import { resolveBlueBubblesMessageId } from "./monitor.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { sendBlueBubblesReaction } from "./reactions.js"; +import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; vi.mock("./accounts.js", async () => { const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); @@ -277,7 +282,6 @@ describe("bluebubblesMessageActions", () => { }); it("throws when chatGuid cannot be resolved", async () => { - const { resolveChatGuidForTarget } = await import("./send.js"); vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce(null); const cfg: OpenClawConfig = { @@ -299,8 +303,6 @@ describe("bluebubblesMessageActions", () => { }); it("sends reaction successfully with chatGuid", async () => { - const { sendBlueBubblesReaction } = await import("./reactions.js"); - const result = await runReactAction({ emoji: "❤️", messageId: "msg-123", @@ -321,8 +323,6 @@ describe("bluebubblesMessageActions", () => { }); it("sends reaction removal successfully", async () => { - const { sendBlueBubblesReaction } = await import("./reactions.js"); - const result = await runReactAction({ emoji: "❤️", messageId: "msg-123", @@ -342,8 +342,6 @@ describe("bluebubblesMessageActions", () => { }); it("resolves chatGuid from to parameter", async () => { - const { sendBlueBubblesReaction } = await import("./reactions.js"); - const { resolveChatGuidForTarget } = await import("./send.js"); vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15559876543"); const cfg: OpenClawConfig = { @@ -374,8 +372,6 @@ describe("bluebubblesMessageActions", () => { }); it("passes partIndex when provided", async () => { - const { sendBlueBubblesReaction } = await import("./reactions.js"); - const cfg: OpenClawConfig = { channels: { bluebubbles: { @@ -404,8 +400,6 @@ describe("bluebubblesMessageActions", () => { }); it("uses toolContext currentChannelId when no explicit target is provided", async () => { - const { sendBlueBubblesReaction } = await import("./reactions.js"); - const { resolveChatGuidForTarget } = await import("./send.js"); vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111"); const cfg: OpenClawConfig = { @@ -442,8 +436,6 @@ describe("bluebubblesMessageActions", () => { }); it("resolves short messageId before reacting", async () => { - const { resolveBlueBubblesMessageId } = await import("./monitor.js"); - const { sendBlueBubblesReaction } = await import("./reactions.js"); vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid"); const cfg: OpenClawConfig = { @@ -475,7 +467,6 @@ describe("bluebubblesMessageActions", () => { }); it("propagates short-id errors from the resolver", async () => { - const { resolveBlueBubblesMessageId } = await import("./monitor.js"); vi.mocked(resolveBlueBubblesMessageId).mockImplementationOnce(() => { throw new Error("short id expired"); }); @@ -504,8 +495,6 @@ describe("bluebubblesMessageActions", () => { }); it("accepts message param for edit action", async () => { - const { editBlueBubblesMessage } = await import("./chat.js"); - const cfg: OpenClawConfig = { channels: { bluebubbles: { @@ -530,8 +519,6 @@ describe("bluebubblesMessageActions", () => { }); it("accepts message/target aliases for sendWithEffect", async () => { - const { sendMessageBlueBubbles } = await import("./send.js"); - const cfg: OpenClawConfig = { channels: { bluebubbles: { @@ -563,8 +550,6 @@ describe("bluebubblesMessageActions", () => { }); it("passes asVoice through sendAttachment", async () => { - const { sendBlueBubblesAttachment } = await import("./attachments.js"); - const cfg: OpenClawConfig = { channels: { bluebubbles: { @@ -619,8 +604,6 @@ describe("bluebubblesMessageActions", () => { }); it("sets group icon successfully with chatGuid and buffer", async () => { - const { setGroupIconBlueBubbles } = await import("./chat.js"); - const cfg: OpenClawConfig = { channels: { bluebubbles: { @@ -658,8 +641,6 @@ describe("bluebubblesMessageActions", () => { }); it("uses default filename when not provided for setGroupIcon", async () => { - const { setGroupIconBlueBubbles } = await import("./chat.js"); - const cfg: OpenClawConfig = { channels: { bluebubbles: { diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 47eedf97511..c9d96cb29ee 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -11,18 +11,16 @@ import { type ChannelMessageActionAdapter, type ChannelMessageActionName, } from "openclaw/plugin-sdk/bluebubbles"; -import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js"; import type { BlueBubblesSendTarget } from "./types.js"; -type BlueBubblesActionsRuntime = typeof import("./actions.runtime.js").blueBubblesActionsRuntime; - -const loadBlueBubblesActionsRuntime = createLazyRuntimeSurface( +const loadBlueBubblesActionsRuntime = createLazyRuntimeNamedExport( () => import("./actions.runtime.js"), - ({ blueBubblesActionsRuntime }) => blueBubblesActionsRuntime, + "blueBubblesActionsRuntime", ); const providerId = "bluebubbles"; diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index f3f3cdd7eb3..9d9e49e74ab 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -18,7 +18,7 @@ import { buildAccountScopedDmSecurityPolicy, collectOpenGroupPolicyRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; -import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { listBlueBubblesAccountIds, type ResolvedBlueBubblesAccount, @@ -38,11 +38,9 @@ import { parseBlueBubblesTarget, } from "./targets.js"; -type BlueBubblesChannelRuntime = typeof import("./channel.runtime.js").blueBubblesChannelRuntime; - -const loadBlueBubblesChannelRuntime = createLazyRuntimeSurface( +const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), - ({ blueBubblesChannelRuntime }) => blueBubblesChannelRuntime, + "blueBubblesChannelRuntime", ); const meta = { diff --git a/extensions/chutes/index.ts b/extensions/chutes/index.ts new file mode 100644 index 00000000000..a61cd4ec93f --- /dev/null +++ b/extensions/chutes/index.ts @@ -0,0 +1,184 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { + buildOauthProviderAuthResult, + createProviderApiKeyAuthMethod, + loginChutes, + resolveOAuthApiKeyMarker, + type ProviderAuthContext, + type ProviderAuthResult, +} from "openclaw/plugin-sdk/provider-auth"; +import { + CHUTES_DEFAULT_MODEL_REF, + applyChutesApiKeyConfig, + applyChutesProviderConfig, +} from "./onboard.js"; +import { buildChutesProvider } from "./provider-catalog.js"; + +const PROVIDER_ID = "chutes"; + +async function runChutesOAuth(ctx: ProviderAuthContext): Promise { + const isRemote = ctx.isRemote; + const redirectUri = + process.env.CHUTES_OAUTH_REDIRECT_URI?.trim() || "http://127.0.0.1:1456/oauth-callback"; + const scopes = process.env.CHUTES_OAUTH_SCOPES?.trim() || "openid profile chutes:invoke"; + const clientId = + process.env.CHUTES_CLIENT_ID?.trim() || + String( + await ctx.prompter.text({ + message: "Enter Chutes OAuth client id", + placeholder: "cid_xxx", + validate: (value: string) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined; + + await ctx.prompter.note( + isRemote + ? [ + "You are running in a remote/VPS environment.", + "A URL will be shown for you to open in your LOCAL browser.", + "After signing in, paste the redirect URL back here.", + "", + `Redirect URI: ${redirectUri}`, + ].join("\n") + : [ + "Browser will open for Chutes authentication.", + "If the callback doesn't auto-complete, paste the redirect URL.", + "", + `Redirect URI: ${redirectUri}`, + ].join("\n"), + "Chutes OAuth", + ); + + const progress = ctx.prompter.progress("Starting Chutes OAuth…"); + try { + const { onAuth, onPrompt } = ctx.oauth.createVpsAwareHandlers({ + isRemote, + prompter: ctx.prompter, + runtime: ctx.runtime, + spin: progress, + openUrl: ctx.openUrl, + localBrowserMessage: "Complete sign-in in browser…", + }); + + const creds = await loginChutes({ + app: { + clientId, + clientSecret, + redirectUri, + scopes: scopes.split(/\s+/).filter(Boolean), + }, + manual: isRemote, + onAuth, + onPrompt, + onProgress: (message) => progress.update(message), + }); + + progress.stop("Chutes OAuth complete"); + + return buildOauthProviderAuthResult({ + providerId: PROVIDER_ID, + defaultModel: CHUTES_DEFAULT_MODEL_REF, + access: creds.access, + refresh: creds.refresh, + expires: creds.expires, + email: typeof creds.email === "string" ? creds.email : undefined, + credentialExtra: { + clientId, + ...("accountId" in creds && typeof creds.accountId === "string" + ? { accountId: creds.accountId } + : {}), + }, + configPatch: applyChutesProviderConfig({}), + notes: [ + "Chutes OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.", + `Redirect URI: ${redirectUri}`, + ], + }); + } catch (err) { + progress.stop("Chutes OAuth failed"); + await ctx.prompter.note( + [ + "Trouble with OAuth?", + "Verify CHUTES_CLIENT_ID (and CHUTES_CLIENT_SECRET if required).", + `Verify the OAuth app redirect URI includes: ${redirectUri}`, + "Chutes docs: https://chutes.ai/docs/sign-in-with-chutes/overview", + ].join("\n"), + "OAuth help", + ); + throw err; + } +} + +export default definePluginEntry({ + id: PROVIDER_ID, + name: "Chutes Provider", + description: "Bundled Chutes.ai provider plugin", + register(api) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Chutes", + docsPath: "/providers/chutes", + envVars: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"], + auth: [ + { + id: "oauth", + label: "Chutes OAuth", + hint: "Browser sign-in", + kind: "oauth", + wizard: { + choiceId: "chutes", + choiceLabel: "Chutes (OAuth)", + choiceHint: "Browser sign-in", + groupId: "chutes", + groupLabel: "Chutes", + groupHint: "OAuth + API key", + }, + run: async (ctx) => await runChutesOAuth(ctx), + }, + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Chutes API key", + hint: "Open-source models including Llama, DeepSeek, and more", + optionKey: "chutesApiKey", + flagName: "--chutes-api-key", + envVar: "CHUTES_API_KEY", + promptMessage: "Enter Chutes API key", + noteTitle: "Chutes", + noteMessage: [ + "Chutes provides access to leading open-source models including Llama, DeepSeek, and more.", + "Get your API key at: https://chutes.ai/settings/api-keys", + ].join("\n"), + defaultModel: CHUTES_DEFAULT_MODEL_REF, + expectedProviders: ["chutes"], + applyConfig: (cfg) => applyChutesApiKeyConfig(cfg), + wizard: { + choiceId: "chutes-api-key", + choiceLabel: "Chutes API key", + groupId: "chutes", + groupLabel: "Chutes", + groupHint: "OAuth + API key", + }, + }), + ], + catalog: { + order: "profile", + run: async (ctx) => { + const { apiKey, discoveryApiKey } = ctx.resolveProviderAuth(PROVIDER_ID, { + oauthMarker: resolveOAuthApiKeyMarker(PROVIDER_ID), + }); + if (!apiKey) { + return null; + } + return { + provider: { + ...(await buildChutesProvider(discoveryApiKey)), + apiKey, + }, + }; + }, + }, + }); + }, +}); diff --git a/extensions/chutes/onboard.ts b/extensions/chutes/onboard.ts new file mode 100644 index 00000000000..f51914c3ca8 --- /dev/null +++ b/extensions/chutes/onboard.ts @@ -0,0 +1,67 @@ +import { + CHUTES_BASE_URL, + CHUTES_DEFAULT_MODEL_REF, + CHUTES_MODEL_CATALOG, + buildChutesModelDefinition, +} from "openclaw/plugin-sdk/provider-models"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; + +export { CHUTES_DEFAULT_MODEL_REF }; + +/** + * Apply Chutes provider configuration without changing the default model. + * Registers all catalog models and sets provider aliases (chutes-fast, etc.). + */ +export function applyChutesProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + for (const m of CHUTES_MODEL_CATALOG) { + models[`chutes/${m.id}`] = { + ...models[`chutes/${m.id}`], + }; + } + + models["chutes-fast"] = { alias: "chutes/zai-org/GLM-4.7-FP8" }; + models["chutes-vision"] = { alias: "chutes/chutesai/Mistral-Small-3.2-24B-Instruct-2506" }; + models["chutes-pro"] = { alias: "chutes/deepseek-ai/DeepSeek-V3.2-TEE" }; + + const chutesModels = CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition); + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "chutes", + api: "openai-completions", + baseUrl: CHUTES_BASE_URL, + catalogModels: chutesModels, + }); +} + +/** + * Apply Chutes provider configuration AND set Chutes as the default model. + */ +export function applyChutesConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyChutesProviderConfig(cfg); + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + primary: CHUTES_DEFAULT_MODEL_REF, + fallbacks: ["chutes/deepseek-ai/DeepSeek-V3.2-TEE", "chutes/Qwen/Qwen3-32B"], + }, + imageModel: { + primary: "chutes/chutesai/Mistral-Small-3.2-24B-Instruct-2506", + fallbacks: ["chutes/chutesai/Mistral-Small-3.1-24B-Instruct-2503"], + }, + }, + }, + }; +} + +export function applyChutesApiKeyConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyChutesProviderConfig(cfg), CHUTES_DEFAULT_MODEL_REF); +} diff --git a/extensions/chutes/openclaw.plugin.json b/extensions/chutes/openclaw.plugin.json new file mode 100644 index 00000000000..26174f31b3a --- /dev/null +++ b/extensions/chutes/openclaw.plugin.json @@ -0,0 +1,39 @@ +{ + "id": "chutes", + "enabledByDefault": true, + "providers": ["chutes"], + "providerAuthEnvVars": { + "chutes": ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"] + }, + "providerAuthChoices": [ + { + "provider": "chutes", + "method": "oauth", + "choiceId": "chutes", + "choiceLabel": "Chutes (OAuth)", + "choiceHint": "Browser sign-in", + "groupId": "chutes", + "groupLabel": "Chutes", + "groupHint": "OAuth + API key" + }, + { + "provider": "chutes", + "method": "api-key", + "choiceId": "chutes-api-key", + "choiceLabel": "Chutes API key", + "choiceHint": "Open-source models including Llama, DeepSeek, and more", + "groupId": "chutes", + "groupLabel": "Chutes", + "groupHint": "OAuth + API key", + "optionKey": "chutesApiKey", + "cliFlag": "--chutes-api-key", + "cliOption": "--chutes-api-key ", + "cliDescription": "Chutes API key" + } + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/chutes/package.json b/extensions/chutes/package.json new file mode 100644 index 00000000000..be860172a27 --- /dev/null +++ b/extensions/chutes/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/chutes-provider", + "version": "2026.3.17", + "private": true, + "description": "OpenClaw Chutes.ai provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/chutes/provider-catalog.ts b/extensions/chutes/provider-catalog.ts new file mode 100644 index 00000000000..1467f405dde --- /dev/null +++ b/extensions/chutes/provider-catalog.ts @@ -0,0 +1,21 @@ +import { + CHUTES_BASE_URL, + CHUTES_MODEL_CATALOG, + buildChutesModelDefinition, + discoverChutesModels, + type ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-models"; + +/** + * Build the Chutes provider with dynamic model discovery. + * Falls back to the static catalog on failure. + * Accepts an optional access token (API key or OAuth access token) for authenticated discovery. + */ +export async function buildChutesProvider(accessToken?: string): Promise { + const models = await discoverChutesModels(accessToken); + return { + baseUrl: CHUTES_BASE_URL, + api: "openai-completions", + models: models.length > 0 ? models : CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition), + }; +} diff --git a/extensions/device-pair/api.ts b/extensions/device-pair/api.ts new file mode 100644 index 00000000000..299ad90f05d --- /dev/null +++ b/extensions/device-pair/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/device-pair"; diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index ce007756389..defd3b5c4c6 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -1,4 +1,5 @@ import os from "node:os"; +import qrcode from "qrcode-terminal"; import { approveDevicePairing, definePluginEntry, @@ -8,8 +9,7 @@ import { runPluginCommandWithTimeout, resolveTailnetHostWithRunner, type OpenClawPluginApi, -} from "openclaw/plugin-sdk/device-pair"; -import qrcode from "qrcode-terminal"; +} from "./api.js"; import { armPairNotifyOnce, formatPendingRequests, diff --git a/extensions/device-pair/notify.ts b/extensions/device-pair/notify.ts index 3ef3005cf73..ba45e856372 100644 --- a/extensions/device-pair/notify.ts +++ b/extensions/device-pair/notify.ts @@ -1,7 +1,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/device-pair"; -import { listDevicePairing } from "openclaw/plugin-sdk/device-pair"; +import type { OpenClawPluginApi } from "./api.js"; +import { listDevicePairing } from "./api.js"; const NOTIFY_STATE_FILE = "device-pair-notify.json"; const NOTIFY_POLL_INTERVAL_MS = 10_000; diff --git a/extensions/diffs/api.ts b/extensions/diffs/api.ts new file mode 100644 index 00000000000..e6fbaf9022a --- /dev/null +++ b/extensions/diffs/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/diffs"; diff --git a/extensions/diffs/index.test.ts b/extensions/diffs/index.test.ts index 3e7fd3c474b..02ce339e47c 100644 --- a/extensions/diffs/index.test.ts +++ b/extensions/diffs/index.test.ts @@ -1,8 +1,8 @@ import type { IncomingMessage } from "node:http"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; import { describe, expect, it, vi } from "vitest"; import { createMockServerResponse } from "../../test/helpers/extensions/mock-http-response.js"; import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; +import type { OpenClawPluginApi } from "./api.js"; import plugin from "./index.js"; describe("diffs plugin registration", () => { diff --git a/extensions/diffs/index.ts b/extensions/diffs/index.ts index b1547b1087d..5ce8c94fabd 100644 --- a/extensions/diffs/index.ts +++ b/extensions/diffs/index.ts @@ -1,6 +1,6 @@ import path from "node:path"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/diffs"; +import type { OpenClawPluginApi } from "./api.js"; +import { resolvePreferredOpenClawTmpDir } from "./api.js"; import { diffsPluginConfigSchema, resolveDiffsPluginDefaults, diff --git a/extensions/diffs/src/browser.test.ts b/extensions/diffs/src/browser.test.ts index c0b03d62cc0..8c16530ec15 100644 --- a/extensions/diffs/src/browser.test.ts +++ b/extensions/diffs/src/browser.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/diffs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../api.js"; import { createTempDiffRoot } from "./test-helpers.js"; const { launchMock } = vi.hoisted(() => ({ diff --git a/extensions/diffs/src/browser.ts b/extensions/diffs/src/browser.ts index 904996946b6..53794ef83ee 100644 --- a/extensions/diffs/src/browser.ts +++ b/extensions/diffs/src/browser.ts @@ -1,8 +1,8 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/diffs"; import { chromium } from "playwright-core"; +import type { OpenClawConfig } from "../api.js"; import type { DiffRenderOptions, DiffTheme } from "./types.js"; import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js"; diff --git a/extensions/diffs/src/config.ts b/extensions/diffs/src/config.ts index fbc9a108060..faaa8535bde 100644 --- a/extensions/diffs/src/config.ts +++ b/extensions/diffs/src/config.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/diffs"; +import type { OpenClawPluginConfigSchema } from "../api.js"; import { DIFF_IMAGE_QUALITY_PRESETS, DIFF_INDICATORS, diff --git a/extensions/diffs/src/http.ts b/extensions/diffs/src/http.ts index 445500b2340..48d9341bfce 100644 --- a/extensions/diffs/src/http.ts +++ b/extensions/diffs/src/http.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import type { PluginLogger } from "openclaw/plugin-sdk/diffs"; +import type { PluginLogger } from "../api.js"; import type { DiffArtifactStore } from "./store.js"; import { DIFF_ARTIFACT_ID_PATTERN, DIFF_ARTIFACT_TOKEN_PATTERN } from "./types.js"; import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js"; diff --git a/extensions/diffs/src/store.ts b/extensions/diffs/src/store.ts index e53a555356c..baab4757384 100644 --- a/extensions/diffs/src/store.ts +++ b/extensions/diffs/src/store.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import type { PluginLogger } from "openclaw/plugin-sdk/diffs"; +import type { PluginLogger } from "../api.js"; import type { DiffArtifactMeta, DiffOutputFormat } from "./types.js"; const DEFAULT_TTL_MS = 30 * 60 * 1000; diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index b0e019f33e2..f79098dd907 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestPluginApi } from "../../../test/helpers/extensions/plugin-api.js"; +import type { OpenClawPluginApi } from "../api.js"; import type { DiffScreenshotter } from "./browser.js"; import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js"; import { DiffArtifactStore } from "./store.js"; diff --git a/extensions/diffs/src/tool.ts b/extensions/diffs/src/tool.ts index c6eb4b528c4..b20f11fee15 100644 --- a/extensions/diffs/src/tool.ts +++ b/extensions/diffs/src/tool.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import { Static, Type } from "@sinclair/typebox"; -import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; +import type { AnyAgentTool, OpenClawPluginApi } from "../api.js"; import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js"; import { resolveDiffImageRenderOptions } from "./config.js"; import { renderDiffDocument } from "./render.js"; diff --git a/extensions/diffs/src/url.ts b/extensions/diffs/src/url.ts index feee5c7af05..e8c40e05753 100644 --- a/extensions/diffs/src/url.ts +++ b/extensions/diffs/src/url.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/diffs"; +import type { OpenClawConfig } from "../api.js"; const DEFAULT_GATEWAY_PORT = 18789; diff --git a/extensions/discord/api.ts b/extensions/discord/api.ts new file mode 100644 index 00000000000..858255c0495 --- /dev/null +++ b/extensions/discord/api.ts @@ -0,0 +1,12 @@ +export * from "./src/account-inspect.js"; +export * from "./src/accounts.js"; +export * from "./src/actions/handle-action.guild-admin.js"; +export * from "./src/actions/handle-action.js"; +export * from "./src/components.js"; +export * from "./src/normalize.js"; +export * from "./src/pluralkit.js"; +export * from "./src/session-key-normalization.js"; +export * from "./src/status-issues.js"; +export * from "./src/targets.js"; +export type { DiscordSendComponents, DiscordSendEmbeds } from "./src/send.shared.js"; +export type { DiscordSendResult } from "./src/send.types.js"; diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index 7c179623e23..6d3c754edb4 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -3,6 +3,9 @@ import { discordPlugin } from "./src/channel.js"; import { setDiscordRuntime } from "./src/runtime.js"; import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js"; +export { discordPlugin } from "./src/channel.js"; +export { setDiscordRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "discord", name: "Discord", diff --git a/extensions/discord/runtime-api.ts b/extensions/discord/runtime-api.ts new file mode 100644 index 00000000000..3850143c4ef --- /dev/null +++ b/extensions/discord/runtime-api.ts @@ -0,0 +1,14 @@ +export * from "./src/audit.js"; +export * from "./src/channel-actions.js"; +export * from "./src/directory-live.js"; +export * from "./src/monitor.js"; +export * from "./src/monitor/gateway-plugin.js"; +export * from "./src/monitor/gateway-registry.js"; +export * from "./src/monitor/presence-cache.js"; +export * from "./src/monitor/thread-bindings.js"; +export * from "./src/monitor/thread-bindings.manager.js"; +export * from "./src/monitor/timeouts.js"; +export * from "./src/probe.js"; +export * from "./src/resolve-channels.js"; +export * from "./src/resolve-users.js"; +export * from "./src/send.js"; diff --git a/extensions/discord/setup-entry.ts b/extensions/discord/setup-entry.ts index e59c812ff4b..e2c4689ed39 100644 --- a/extensions/discord/setup-entry.ts +++ b/extensions/discord/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { discordSetupPlugin } from "./src/channel.setup.js"; +export { discordSetupPlugin } from "./src/channel.setup.js"; + export default defineSetupPluginEntry(discordSetupPlugin); diff --git a/extensions/discord/src/channel.setup.ts b/extensions/discord/src/channel.setup.ts index c45ed85fb0b..efec8990442 100644 --- a/extensions/discord/src/channel.setup.ts +++ b/extensions/discord/src/channel.setup.ts @@ -1,10 +1,15 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/discord"; +import { + buildChannelConfigSchema, + DiscordConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/discord"; import { type ResolvedDiscordAccount } from "./accounts.js"; import { discordSetupAdapter } from "./setup-core.js"; import { createDiscordPluginBase } from "./shared.js"; export const discordSetupPlugin: ChannelPlugin = { ...createDiscordPluginBase({ + configSchema: buildChannelConfigSchema(DiscordConfigSchema), setup: discordSetupAdapter, }), }; diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index 0a4ead6c3fd..5e47dda6334 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -1,8 +1,87 @@ -import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/discord"; -import { describe, expect, it, vi } from "vitest"; +import type { + ChannelAccountSnapshot, + ChannelGatewayContext, + OpenClawConfig, + PluginRuntime, +} from "openclaw/plugin-sdk/discord"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import type { ResolvedDiscordAccount } from "./accounts.js"; import { discordPlugin } from "./channel.js"; import { setDiscordRuntime } from "./runtime.js"; +const probeDiscordMock = vi.hoisted(() => vi.fn()); +const monitorDiscordProviderMock = vi.hoisted(() => vi.fn()); +const auditDiscordChannelPermissionsMock = vi.hoisted(() => vi.fn()); + +vi.mock("./probe.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + probeDiscord: probeDiscordMock, + }; +}); + +vi.mock("./monitor.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + monitorDiscordProvider: monitorDiscordProviderMock, + }; +}); + +vi.mock("./audit.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + auditDiscordChannelPermissions: auditDiscordChannelPermissionsMock, + }; +}); + +function createCfg(): OpenClawConfig { + return { + channels: { + discord: { + enabled: true, + token: "discord-token", + }, + }, + } as OpenClawConfig; +} + +function createStartAccountCtx(params: { + cfg: OpenClawConfig; + accountId: string; + runtime: ReturnType; +}): ChannelGatewayContext { + const account = discordPlugin.config.resolveAccount( + params.cfg, + params.accountId, + ) as ResolvedDiscordAccount; + const snapshot: ChannelAccountSnapshot = { + accountId: params.accountId, + configured: true, + enabled: true, + running: false, + }; + return { + accountId: params.accountId, + account, + cfg: params.cfg, + runtime: params.runtime, + abortSignal: new AbortController().signal, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getStatus: () => snapshot, + setStatus: vi.fn(), + }; +} + +afterEach(() => { + probeDiscordMock.mockReset(); + monitorDiscordProviderMock.mockReset(); + auditDiscordChannelPermissionsMock.mockReset(); +}); + describe("discordPlugin outbound", () => { it("forwards mediaLocalRoots to sendMessageDiscord", async () => { const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" })); @@ -33,4 +112,100 @@ describe("discordPlugin outbound", () => { ); expect(result).toMatchObject({ channel: "discord", messageId: "m1" }); }); + + it("uses direct Discord probe helpers for status probes", async () => { + const runtimeProbeDiscord = vi.fn(async () => { + throw new Error("runtime Discord probe should not be used"); + }); + setDiscordRuntime({ + channel: { + discord: { + probeDiscord: runtimeProbeDiscord, + }, + }, + logging: { + shouldLogVerbose: () => false, + }, + } as unknown as PluginRuntime); + probeDiscordMock.mockResolvedValue({ + ok: true, + bot: { username: "Bob" }, + application: { + intents: { + messageContent: "limited", + guildMembers: "disabled", + presence: "disabled", + }, + }, + elapsedMs: 1, + }); + + const cfg = createCfg(); + const account = discordPlugin.config.resolveAccount(cfg, "default"); + + await discordPlugin.status!.probeAccount!({ + account, + timeoutMs: 5000, + cfg, + }); + + expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 5000, { + includeApplication: true, + }); + expect(runtimeProbeDiscord).not.toHaveBeenCalled(); + }); + + it("uses direct Discord startup helpers before monitoring", async () => { + const runtimeProbeDiscord = vi.fn(async () => { + throw new Error("runtime Discord probe should not be used"); + }); + const runtimeMonitorDiscordProvider = vi.fn(async () => { + throw new Error("runtime Discord monitor should not be used"); + }); + setDiscordRuntime({ + channel: { + discord: { + probeDiscord: runtimeProbeDiscord, + monitorDiscordProvider: runtimeMonitorDiscordProvider, + }, + }, + logging: { + shouldLogVerbose: () => false, + }, + } as unknown as PluginRuntime); + probeDiscordMock.mockResolvedValue({ + ok: true, + bot: { username: "Bob" }, + application: { + intents: { + messageContent: "limited", + guildMembers: "disabled", + presence: "disabled", + }, + }, + elapsedMs: 1, + }); + monitorDiscordProviderMock.mockResolvedValue(undefined); + + const cfg = createCfg(); + await discordPlugin.gateway!.startAccount!( + createStartAccountCtx({ + cfg, + accountId: "default", + runtime: createRuntimeEnv(), + }), + ); + + expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 2500, { + includeApplication: true, + }); + expect(monitorDiscordProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + token: "discord-token", + accountId: "default", + }), + ); + expect(runtimeProbeDiscord).not.toHaveBeenCalled(); + expect(runtimeMonitorDiscordProvider).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index dff011825b0..c4ff4827038 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -13,8 +13,10 @@ import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { buildComputedAccountStatusSnapshot, + buildChannelConfigSchema, buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, + DiscordConfigSchema, getChatChannelMeta, listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, @@ -33,17 +35,18 @@ import { resolveDiscordAccount, type ResolvedDiscordAccount, } from "./accounts.js"; -import { collectDiscordAuditChannelIds } from "./audit.js"; +import { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./audit.js"; import { isDiscordExecApprovalClientEnabled, shouldSuppressLocalDiscordExecApprovalPrompt, } from "./exec-approvals.js"; +import { monitorDiscordProvider } from "./monitor.js"; import { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, } from "./normalize.js"; -import type { DiscordProbe } from "./probe.js"; +import { probeDiscord, type DiscordProbe } from "./probe.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { getDiscordRuntime } from "./runtime.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; @@ -275,6 +278,7 @@ function resolveDiscordOutboundSessionRoute(params: { export const discordPlugin: ChannelPlugin = { ...createDiscordPluginBase({ + configSchema: buildChannelConfigSchema(DiscordConfigSchema), setup: discordSetupAdapter, }), pairing: { @@ -488,11 +492,15 @@ export const discordPlugin: ChannelPlugin = { silent: silent ?? undefined, }), }, - acpBindings: { - normalizeConfiguredBindingTarget: ({ conversationId }) => + bindings: { + compileConfiguredBinding: ({ conversationId }) => normalizeDiscordAcpConversationId(conversationId), - matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) => - matchDiscordAcpConversation({ bindingConversationId, conversationId, parentConversationId }), + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => + matchDiscordAcpConversation({ + bindingConversationId: compiledBinding.conversationId, + conversationId, + parentConversationId, + }), }, status: { defaultRuntime: { @@ -511,7 +519,7 @@ export const discordPlugin: ChannelPlugin = { buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot, { includeMode: false }), probeAccount: async ({ account, timeoutMs }) => - getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, { + probeDiscord(account.token, timeoutMs, { includeApplication: true, }), formatCapabilitiesProbe: ({ probe }) => { @@ -617,7 +625,7 @@ export const discordPlugin: ChannelPlugin = { elapsedMs: 0, }; } - const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({ + const audit = await auditDiscordChannelPermissions({ token: botToken, accountId: account.accountId, channelIds, @@ -658,7 +666,7 @@ export const discordPlugin: ChannelPlugin = { const token = account.token.trim(); let discordBotLabel = ""; try { - const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, { + const probe = await probeDiscord(token, 2500, { includeApplication: true, }); const username = probe.ok ? probe.bot?.username?.trim() : null; @@ -686,7 +694,7 @@ export const discordPlugin: ChannelPlugin = { } } ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`); - return getDiscordRuntime().channel.discord.monitorDiscordProvider({ + return monitorDiscordProvider({ token, accountId: account.accountId, config: ctx.cfg, diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 109135a3684..5acab8d5339 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -9,6 +9,7 @@ import WebSocket from "ws"; const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot"; const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/"; +const DISCORD_GATEWAY_INFO_TIMEOUT_MS = 10_000; type DiscordGatewayMetadataResponse = Pick; type DiscordGatewayFetchInit = Record & { @@ -19,6 +20,8 @@ type DiscordGatewayFetch = ( init?: DiscordGatewayFetchInit, ) => Promise; +type DiscordGatewayMetadataError = Error & { transient?: boolean }; + export function resolveDiscordGatewayIntents( intentsConfig?: import("openclaw/plugin-sdk/config-runtime").DiscordIntentsConfig, ): number { @@ -64,14 +67,36 @@ function createGatewayMetadataError(params: { transient: boolean; cause?: unknown; }): Error { - if (params.transient) { - return new Error("Failed to get gateway information from Discord: fetch failed", { - cause: params.cause ?? new Error(params.detail), - }); - } - return new Error(`Failed to get gateway information from Discord: ${params.detail}`, { - cause: params.cause, + const error = new Error( + params.transient + ? "Failed to get gateway information from Discord: fetch failed" + : `Failed to get gateway information from Discord: ${params.detail}`, + { + cause: params.cause ?? (params.transient ? new Error(params.detail) : undefined), + }, + ) as DiscordGatewayMetadataError; + Object.defineProperty(error, "transient", { + value: params.transient, + enumerable: false, }); + return error; +} + +function isTransientGatewayMetadataError(error: unknown): boolean { + return Boolean((error as DiscordGatewayMetadataError | undefined)?.transient); +} + +function createDefaultGatewayInfo(): APIGatewayBotInfo { + return { + url: DEFAULT_DISCORD_GATEWAY_URL, + shards: 1, + session_start_limit: { + total: 1, + remaining: 1, + reset_after: 0, + max_concurrency: 1, + }, + }; } async function fetchDiscordGatewayInfo(params: { @@ -134,6 +159,65 @@ async function fetchDiscordGatewayInfo(params: { } } +async function fetchDiscordGatewayInfoWithTimeout(params: { + token: string; + fetchImpl: DiscordGatewayFetch; + fetchInit?: DiscordGatewayFetchInit; + timeoutMs?: number; +}): Promise { + const timeoutMs = Math.max(1, params.timeoutMs ?? DISCORD_GATEWAY_INFO_TIMEOUT_MS); + const abortController = new AbortController(); + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + abortController.abort(); + reject( + createGatewayMetadataError({ + detail: `Discord API /gateway/bot timed out after ${timeoutMs}ms`, + transient: true, + cause: new Error("gateway metadata timeout"), + }), + ); + }, timeoutMs); + timeoutId.unref?.(); + }); + + try { + return await Promise.race([ + fetchDiscordGatewayInfo({ + token: params.token, + fetchImpl: params.fetchImpl, + fetchInit: { + ...params.fetchInit, + signal: abortController.signal, + }, + }), + timeoutPromise, + ]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} + +function resolveGatewayInfoWithFallback(params: { runtime?: RuntimeEnv; error: unknown }): { + info: APIGatewayBotInfo; + usedFallback: boolean; +} { + if (!isTransientGatewayMetadataError(params.error)) { + throw params.error; + } + const message = params.error instanceof Error ? params.error.message : String(params.error); + params.runtime?.log?.( + `discord: gateway metadata lookup failed transiently; using default gateway url (${message})`, + ); + return { + info: createDefaultGatewayInfo(), + usedFallback: true, + }; +} + function createGatewayPlugin(params: { options: { reconnect: { maxAttempts: number }; @@ -143,19 +227,29 @@ function createGatewayPlugin(params: { fetchImpl: DiscordGatewayFetch; fetchInit?: DiscordGatewayFetchInit; wsAgent?: HttpsProxyAgent; + runtime?: RuntimeEnv; }): GatewayPlugin { class SafeGatewayPlugin extends GatewayPlugin { + private gatewayInfoUsedFallback = false; + constructor() { super(params.options); } override async registerClient(client: Parameters[0]) { - if (!this.gatewayInfo) { - this.gatewayInfo = await fetchDiscordGatewayInfo({ + if (!this.gatewayInfo || this.gatewayInfoUsedFallback) { + const resolved = await fetchDiscordGatewayInfoWithTimeout({ token: client.options.token, fetchImpl: params.fetchImpl, fetchInit: params.fetchInit, - }); + }) + .then((info) => ({ + info, + usedFallback: false, + })) + .catch((error) => resolveGatewayInfoWithFallback({ runtime: params.runtime, error })); + this.gatewayInfo = resolved.info; + this.gatewayInfoUsedFallback = resolved.usedFallback; } return super.registerClient(client); } @@ -187,6 +281,7 @@ export function createDiscordGatewayPlugin(params: { return createGatewayPlugin({ options, fetchImpl: (input, init) => fetch(input, init as RequestInit), + runtime: params.runtime, }); } @@ -201,12 +296,14 @@ export function createDiscordGatewayPlugin(params: { fetchImpl: (input, init) => undiciFetch(input, init), fetchInit: { dispatcher: fetchAgent }, wsAgent, + runtime: params.runtime, }); } catch (err) { params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`)); return createGatewayPlugin({ options, fetchImpl: (input, init) => fetch(input, init as RequestInit), + runtime: params.runtime, }); } } diff --git a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts index 01bac15e856..982b9589b22 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts @@ -1,14 +1,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn()); -const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn()); +const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn()); +const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn()); -vi.mock("../../../../src/acp/persistent-bindings.js", () => ({ - ensureConfiguredAcpBindingSession: (...args: unknown[]) => - ensureConfiguredAcpBindingSessionMock(...args), - resolveConfiguredAcpBindingRecord: (...args: unknown[]) => - resolveConfiguredAcpBindingRecordMock(...args), -})); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureConfiguredBindingRouteReady: (...args: unknown[]) => + ensureConfiguredBindingRouteReadyMock(...args), + resolveConfiguredBindingRoute: (...args: unknown[]) => + resolveConfiguredBindingRouteMock(...args), + }; +}); import { __testing as sessionBindingTesting } from "../../../../src/infra/outbound/session-binding-service.js"; import { preflightDiscordMessage } from "./message-handler.preflight.js"; @@ -52,6 +56,77 @@ function createConfiguredDiscordBinding() { } as const; } +function createConfiguredDiscordRoute() { + const configuredBinding = createConfiguredDiscordBinding(); + return { + bindingResolution: { + conversation: { + channel: "discord", + accountId: "default", + conversationId: CHANNEL_ID, + }, + compiledBinding: { + channel: "discord", + accountPattern: "default", + binding: { + type: "acp", + agentId: "codex", + match: { + channel: "discord", + accountId: "default", + peer: { + kind: "channel", + id: CHANNEL_ID, + }, + }, + }, + bindingConversationId: CHANNEL_ID, + target: { + conversationId: CHANNEL_ID, + }, + agentId: "codex", + provider: { + compileConfiguredBinding: () => ({ conversationId: CHANNEL_ID }), + matchInboundConversation: () => ({ conversationId: CHANNEL_ID }), + }, + targetFactory: { + driverId: "acp", + materialize: () => ({ + record: configuredBinding.record, + statefulTarget: { + kind: "stateful", + driverId: "acp", + sessionKey: configuredBinding.record.targetSessionKey, + agentId: configuredBinding.spec.agentId, + }, + }), + }, + }, + match: { + conversationId: CHANNEL_ID, + }, + record: configuredBinding.record, + statefulTarget: { + kind: "stateful", + driverId: "acp", + sessionKey: configuredBinding.record.targetSessionKey, + agentId: configuredBinding.spec.agentId, + }, + }, + configuredBinding, + boundSessionKey: configuredBinding.record.targetSessionKey, + route: { + agentId: "codex", + accountId: "default", + channel: "discord", + sessionKey: configuredBinding.record.targetSessionKey, + mainSessionKey: "agent:codex:main", + matchedBy: "binding.channel", + lastRoutePolicy: "bound", + }, + } as const; +} + function createBasePreflightParams(overrides?: Record) { const message = createDiscordMessage({ id: "m-1", @@ -94,13 +169,10 @@ function createBasePreflightParams(overrides?: Record) { describe("preflightDiscordMessage configured ACP bindings", () => { beforeEach(() => { sessionBindingTesting.resetSessionBindingAdaptersForTests(); - ensureConfiguredAcpBindingSessionMock.mockReset(); - resolveConfiguredAcpBindingRecordMock.mockReset(); - resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredDiscordBinding()); - ensureConfiguredAcpBindingSessionMock.mockResolvedValue({ - ok: true, - sessionKey: "agent:codex:acp:binding:discord:default:abc123", - }); + ensureConfiguredBindingRouteReadyMock.mockReset(); + resolveConfiguredBindingRouteMock.mockReset(); + resolveConfiguredBindingRouteMock.mockReturnValue(createConfiguredDiscordRoute()); + ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true }); }); it("does not initialize configured ACP bindings for rejected messages", async () => { @@ -121,8 +193,8 @@ describe("preflightDiscordMessage configured ACP bindings", () => { ); expect(result).toBeNull(); - expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1); - expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled(); + expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1); + expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled(); }); it("initializes configured ACP bindings only after preflight accepts the message", async () => { @@ -144,8 +216,176 @@ describe("preflightDiscordMessage configured ACP bindings", () => { ); expect(result).not.toBeNull(); - expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1); - expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1); + expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1); + expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123"); }); + + it("accepts plain messages in configured ACP-bound channels without a mention", async () => { + const message = createDiscordMessage({ + id: "m-no-mention", + channelId: CHANNEL_ID, + content: "hello", + mentionedUsers: [], + author: { + id: "user-1", + bot: false, + username: "alice", + }, + }); + + const result = await preflightDiscordMessage( + createBasePreflightParams({ + data: createGuildEvent({ + channelId: CHANNEL_ID, + guildId: GUILD_ID, + author: message.author, + message, + }), + guildEntries: { + [GUILD_ID]: { + id: GUILD_ID, + channels: { + [CHANNEL_ID]: { + allow: true, + enabled: true, + requireMention: true, + }, + }, + }, + }, + }), + ); + + expect(result).not.toBeNull(); + expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); + expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123"); + }); + + it("hydrates empty guild message payloads from REST before ensuring configured ACP bindings", async () => { + const message = createDiscordMessage({ + id: "m-rest", + channelId: CHANNEL_ID, + content: "", + author: { + id: "user-1", + bot: false, + username: "alice", + }, + }); + const restGet = vi.fn(async () => ({ + id: "m-rest", + content: "hello from rest", + attachments: [], + embeds: [], + mentions: [], + mention_roles: [], + mention_everyone: false, + author: { + id: "user-1", + username: "alice", + }, + })); + const client = { + ...createGuildTextClient(CHANNEL_ID), + rest: { + get: restGet, + }, + } as unknown as Parameters[0]["client"]; + + const result = await preflightDiscordMessage( + createBasePreflightParams({ + client, + data: createGuildEvent({ + channelId: CHANNEL_ID, + guildId: GUILD_ID, + author: message.author, + message, + }), + guildEntries: { + [GUILD_ID]: { + id: GUILD_ID, + channels: { + [CHANNEL_ID]: { + allow: true, + enabled: true, + requireMention: false, + }, + }, + }, + }, + }), + ); + + expect(restGet).toHaveBeenCalledTimes(1); + expect(result?.messageText).toBe("hello from rest"); + expect(result?.data.message.content).toBe("hello from rest"); + expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); + }); + + it("hydrates sticker-only guild message payloads from REST before ensuring configured ACP bindings", async () => { + const message = createDiscordMessage({ + id: "m-rest-sticker", + channelId: CHANNEL_ID, + content: "", + author: { + id: "user-1", + bot: false, + username: "alice", + }, + }); + const restGet = vi.fn(async () => ({ + id: "m-rest-sticker", + content: "", + attachments: [], + embeds: [], + mentions: [], + mention_roles: [], + mention_everyone: false, + sticker_items: [ + { + id: "sticker-1", + name: "wave", + }, + ], + author: { + id: "user-1", + username: "alice", + }, + })); + const client = { + ...createGuildTextClient(CHANNEL_ID), + rest: { + get: restGet, + }, + } as unknown as Parameters[0]["client"]; + + const result = await preflightDiscordMessage( + createBasePreflightParams({ + client, + data: createGuildEvent({ + channelId: CHANNEL_ID, + guildId: GUILD_ID, + author: message.author, + message, + }), + guildEntries: { + [GUILD_ID]: { + id: GUILD_ID, + channels: { + [CHANNEL_ID]: { + allow: true, + enabled: true, + requireMention: false, + }, + }, + }, + }, + }), + ); + + expect(restGet).toHaveBeenCalledTimes(1); + expect(result?.messageText).toBe(" (1 sticker)"); + expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index 2fb14bafe8e..bd55cd2ead2 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const transcribeFirstAudioMock = vi.hoisted(() => vi.fn()); -vi.mock("../../../../src/media-understanding/audio-preflight.js", () => ({ +vi.mock("./preflight-audio.runtime.js", () => ({ transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args), })); import { @@ -229,16 +229,16 @@ describe("resolvePreflightMentionRequirement", () => { expect( resolvePreflightMentionRequirement({ shouldRequireMention: true, - isBoundThreadSession: false, + bypassMentionRequirement: false, }), ).toBe(true); }); - it("disables mention requirement for bound thread sessions", () => { + it("disables mention requirement when the route explicitly bypasses mentions", () => { expect( resolvePreflightMentionRequirement({ shouldRequireMention: true, - isBoundThreadSession: true, + bypassMentionRequirement: true, }), ).toBe(false); }); @@ -247,7 +247,7 @@ describe("resolvePreflightMentionRequirement", () => { expect( resolvePreflightMentionRequirement({ shouldRequireMention: false, - isBoundThreadSession: false, + bypassMentionRequirement: false, }), ).toBe(false); }); @@ -378,6 +378,68 @@ describe("preflightDiscordMessage", () => { expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey); }); + it("drops hydrated bound-thread webhook echoes after fetching an empty payload", async () => { + const threadBinding = createThreadBinding({ + targetKind: "session", + targetSessionKey: "agent:main:acp:discord-thread-1", + }); + const threadId = "thread-webhook-hydrated-1"; + const parentId = "channel-parent-webhook-hydrated-1"; + const message = createDiscordMessage({ + id: "m-webhook-hydrated-1", + channelId: threadId, + content: "", + author: { + id: "relay-bot-1", + bot: true, + username: "Relay", + }, + }); + const restGet = vi.fn(async () => ({ + id: message.id, + content: "webhook relay", + webhook_id: "wh-1", + attachments: [], + embeds: [], + mentions: [], + mention_roles: [], + mention_everyone: false, + author: { + id: "relay-bot-1", + username: "Relay", + bot: true, + }, + })); + const client = { + ...createThreadClient({ threadId, parentId }), + rest: { + get: restGet, + }, + } as unknown as DiscordClient; + + const result = await preflightDiscordMessage({ + ...createPreflightArgs({ + cfg: DEFAULT_PREFLIGHT_CFG, + discordConfig: { + allowBots: true, + } as DiscordConfig, + data: createGuildEvent({ + channelId: threadId, + guildId: "guild-1", + author: message.author, + message, + }), + client, + }), + threadBindings: { + getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined), + } as import("./thread-bindings.js").ThreadBindingManager, + }); + + expect(restGet).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); + it("bypasses mention gating in bound threads for allowed bot senders", async () => { const threadBinding = createThreadBinding(); const threadId = "thread-bot-focus"; @@ -655,8 +717,8 @@ describe("preflightDiscordMessage", () => { }, }); - const result = await preflightDiscordMessage( - createPreflightArgs({ + const result = await preflightDiscordMessage({ + ...createPreflightArgs({ cfg: { ...DEFAULT_PREFLIGHT_CFG, messages: { @@ -674,7 +736,17 @@ describe("preflightDiscordMessage", () => { }), client, }), - ); + guildEntries: { + "guild-1": { + channels: { + [channelId]: { + allow: true, + requireMention: true, + }, + }, + }, + }, + }); expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1); expect(transcribeFirstAudioMock).toHaveBeenCalledWith( diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 0a402518927..9094cabb645 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -1,4 +1,5 @@ -import { ChannelType, MessageType, type User } from "@buape/carbon"; +import { ChannelType, MessageType, type Message, type User } from "@buape/carbon"; +import { Routes, type APIMessage } from "discord-api-types/v10"; import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime"; import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; @@ -6,8 +7,8 @@ import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runt import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { - ensureConfiguredAcpRouteReady, - resolveConfiguredAcpRoute, + ensureConfiguredBindingRouteReady, + resolveConfiguredBindingRoute, } from "openclaw/plugin-sdk/conversation-runtime"; import { getSessionBindingService, @@ -95,12 +96,12 @@ function isBoundThreadBotSystemMessage(params: { export function resolvePreflightMentionRequirement(params: { shouldRequireMention: boolean; - isBoundThreadSession: boolean; + bypassMentionRequirement: boolean; }): boolean { if (!params.shouldRequireMention) { return false; } - return !params.isBoundThreadSession; + return !params.bypassMentionRequirement; } export function shouldIgnoreBoundThreadWebhookMessage(params: { @@ -131,6 +132,95 @@ export function shouldIgnoreBoundThreadWebhookMessage(params: { return webhookId === boundWebhookId; } +function mergeFetchedDiscordMessage(base: Message, fetched: APIMessage): Message { + const baseReferenced = ( + base as unknown as { + referencedMessage?: { + mentionedUsers?: unknown[]; + mentionedRoles?: unknown[]; + mentionedEveryone?: boolean; + }; + } + ).referencedMessage; + const fetchedMentions = Array.isArray(fetched.mentions) + ? fetched.mentions.map((mention) => ({ + ...mention, + globalName: mention.global_name ?? undefined, + })) + : undefined; + const referencedMessage = fetched.referenced_message + ? ({ + ...((base as { referencedMessage?: object }).referencedMessage ?? {}), + ...fetched.referenced_message, + mentionedUsers: Array.isArray(fetched.referenced_message.mentions) + ? fetched.referenced_message.mentions.map((mention) => ({ + ...mention, + globalName: mention.global_name ?? undefined, + })) + : (baseReferenced?.mentionedUsers ?? []), + mentionedRoles: + fetched.referenced_message.mention_roles ?? baseReferenced?.mentionedRoles ?? [], + mentionedEveryone: + fetched.referenced_message.mention_everyone ?? baseReferenced?.mentionedEveryone ?? false, + } satisfies Record) + : (base as { referencedMessage?: Message }).referencedMessage; + const rawData = { + ...((base as { rawData?: Record }).rawData ?? {}), + message_snapshots: + fetched.message_snapshots ?? + (base as { rawData?: { message_snapshots?: unknown } }).rawData?.message_snapshots, + sticker_items: + (fetched as { sticker_items?: unknown }).sticker_items ?? + (base as { rawData?: { sticker_items?: unknown } }).rawData?.sticker_items, + }; + return { + ...base, + ...fetched, + content: fetched.content ?? base.content, + attachments: fetched.attachments ?? base.attachments, + embeds: fetched.embeds ?? base.embeds, + stickers: + (fetched as { stickers?: unknown }).stickers ?? + (fetched as { sticker_items?: unknown }).sticker_items ?? + base.stickers, + mentionedUsers: fetchedMentions ?? base.mentionedUsers, + mentionedRoles: fetched.mention_roles ?? base.mentionedRoles, + mentionedEveryone: fetched.mention_everyone ?? base.mentionedEveryone, + referencedMessage, + rawData, + } as unknown as Message; +} + +async function hydrateDiscordMessageIfEmpty(params: { + client: DiscordMessagePreflightParams["client"]; + message: Message; + messageChannelId: string; +}): Promise { + const currentText = resolveDiscordMessageText(params.message, { + includeForwarded: true, + }); + if (currentText) { + return params.message; + } + const rest = params.client.rest as { get?: (route: string) => Promise } | undefined; + if (typeof rest?.get !== "function") { + return params.message; + } + try { + const fetched = (await rest.get( + Routes.channelMessage(params.messageChannelId, params.message.id), + )) as APIMessage | null | undefined; + if (!fetched) { + return params.message; + } + logVerbose(`discord: hydrated empty inbound payload via REST for ${params.message.id}`); + return mergeFetchedDiscordMessage(params.message, fetched); + } catch (err) { + logVerbose(`discord: failed to hydrate message ${params.message.id}: ${String(err)}`); + return params.message; + } +} + export async function preflightDiscordMessage( params: DiscordMessagePreflightParams, ): Promise { @@ -138,7 +228,7 @@ export async function preflightDiscordMessage( return null; } const logger = getChildLogger({ module: "discord-auto-reply" }); - const message = params.data.message; + let message = params.data.message; const author = params.data.author; if (!author) { return null; @@ -160,6 +250,15 @@ export async function preflightDiscordMessage( return null; } + message = await hydrateDiscordMessageIfEmpty({ + client: params.client, + message, + messageChannelId, + }); + if (isPreflightAborted(params.abortSignal)) { + return null; + } + const pluralkitConfig = params.discordConfig?.pluralkit; const webhookId = resolveDiscordWebhookId(message); const shouldCheckPluralKit = Boolean(pluralkitConfig?.enabled) && !webhookId; @@ -197,6 +296,7 @@ export async function preflightDiscordMessage( } const isDirectMessage = channelInfo?.type === ChannelType.DM; const isGroupDm = channelInfo?.type === ChannelType.GroupDM; + const data = message === params.data.message ? params.data : { ...params.data, message }; logDebug( `[discord-preflight] channelId=${messageChannelId} guild_id=${params.data.guild_id} channelType=${channelInfo?.type} isGuild=${isGuildMessage} isDM=${isDirectMessage} isGroupDm=${isGroupDm}`, ); @@ -359,16 +459,18 @@ export async function preflightDiscordMessage( }) ?? undefined; const configuredRoute = threadBinding == null - ? resolveConfiguredAcpRoute({ + ? resolveConfiguredBindingRoute({ cfg: freshCfg, route, - channel: "discord", - accountId: params.accountId, - conversationId: messageChannelId, - parentConversationId: earlyThreadParentId, + conversation: { + channel: "discord", + accountId: params.accountId, + conversationId: messageChannelId, + parentConversationId: earlyThreadParentId, + }, }) : null; - const configuredBinding = configuredRoute?.configuredBinding ?? null; + const configuredBinding = configuredRoute?.bindingResolution ?? null; if (!threadBinding && configuredBinding) { threadBinding = configuredBinding.record; } @@ -394,6 +496,7 @@ export async function preflightDiscordMessage( }); const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined; const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel); + const bypassMentionRequirement = isBoundThreadSession || Boolean(configuredBinding); if ( isBoundThreadBotSystemMessage({ isBoundThreadSession, @@ -579,7 +682,7 @@ export async function preflightDiscordMessage( }); const shouldRequireMention = resolvePreflightMentionRequirement({ shouldRequireMention: shouldRequireMentionByConfig, - isBoundThreadSession, + bypassMentionRequirement, }); // Preflight audio transcription for mention detection in guilds. @@ -764,13 +867,13 @@ export async function preflightDiscordMessage( return null; } if (configuredBinding) { - const ensured = await ensureConfiguredAcpRouteReady({ + const ensured = await ensureConfiguredBindingRouteReady({ cfg: freshCfg, - configuredBinding, + bindingResolution: configuredBinding, }); if (!ensured.ok) { logVerbose( - `discord: configured ACP binding unavailable for channel ${configuredBinding.spec.conversationId}: ${ensured.error}`, + `discord: configured ACP binding unavailable for channel ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`, ); return null; } @@ -794,7 +897,7 @@ export async function preflightDiscordMessage( replyToMode: params.replyToMode, ackReactionScope: params.ackReactionScope, groupPolicy: params.groupPolicy, - data: params.data, + data, client: params.client, message, messageChannelId, diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index 1009c583a81..2b49292b037 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -2,6 +2,7 @@ import { ChannelType } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js"; import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js"; +import type { ChatType } from "../../../../src/channels/chat-type.js"; import type { OpenClawConfig } from "../../../../src/config/config.js"; import * as pluginCommandsModule from "../../../../src/plugins/commands.js"; import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js"; @@ -11,17 +12,17 @@ import { } from "./native-command.test-helpers.js"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; -type ResolveConfiguredAcpBindingRecordFn = - typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredAcpRoute; -type EnsureConfiguredAcpBindingSessionFn = - typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredAcpRouteReady; +type ResolveConfiguredBindingRouteFn = + typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute; +type EnsureConfiguredBindingRouteReadyFn = + typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady; const persistentBindingMocks = vi.hoisted(() => ({ - resolveConfiguredAcpBindingRecord: vi.fn((params) => ({ - configuredBinding: null, + resolveConfiguredAcpBindingRecord: vi.fn((params) => ({ + bindingResolution: null, route: params.route, })), - ensureConfiguredAcpBindingSession: vi.fn(async () => ({ + ensureConfiguredAcpBindingSession: vi.fn(async () => ({ ok: true, })), })); @@ -30,8 +31,8 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - resolveConfiguredAcpRoute: persistentBindingMocks.resolveConfiguredAcpBindingRecord, - ensureConfiguredAcpRouteReady: persistentBindingMocks.ensureConfiguredAcpBindingSession, + resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredAcpBindingRecord, + ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredAcpBindingSession, }; }); @@ -65,12 +66,7 @@ function createConfig(): OpenClawConfig { } as OpenClawConfig; } -function createStatusCommand(cfg: OpenClawConfig) { - const commandSpec: NativeCommandSpec = { - name: "status", - description: "Status", - acceptsArgs: false, - }; +function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec) { return createDiscordNativeCommand({ command: commandSpec, cfg, @@ -147,39 +143,145 @@ async function expectPairCommandReply(params: { ); } -function setConfiguredBinding(channelId: string, boundSessionKey: string) { - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({ - configuredBinding: { - spec: { - channel: "discord", - accountId: params.accountId, - conversationId: channelId, - parentConversationId: params.parentConversationId, - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: `config:acp:discord:${params.accountId}:${channelId}`, - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "discord", - accountId: params.accountId, - conversationId: channelId, - }, - status: "active", - boundAt: 0, - }, - }, - boundSessionKey, - boundAgentId: "codex", - route: { - ...params.route, +function createStatusCommand(cfg: OpenClawConfig) { + return createNativeCommand(cfg, { + name: "status", + description: "Status", + acceptsArgs: false, + }); +} + +function resolveConversationFromParams(params: Parameters[0]) { + if ("conversation" in params) { + return params.conversation; + } + return { + channel: params.channel, + accountId: params.accountId, + conversationId: params.conversationId, + ...(params.parentConversationId ? { parentConversationId: params.parentConversationId } : {}), + }; +} + +function createConfiguredBindingResolution(params: { + conversation: ReturnType; + boundSessionKey: string; +}) { + const peerKind: ChatType = params.conversation.conversationId.startsWith("dm-") + ? "direct" + : "channel"; + const configuredBinding = { + spec: { + channel: "discord" as const, + accountId: params.conversation.accountId, + conversationId: params.conversation.conversationId, + ...(params.conversation.parentConversationId + ? { parentConversationId: params.conversation.parentConversationId } + : {}), agentId: "codex", - sessionKey: boundSessionKey, - matchedBy: "binding.channel", + mode: "persistent" as const, }, - })); + record: { + bindingId: `config:acp:discord:${params.conversation.accountId}:${params.conversation.conversationId}`, + targetSessionKey: params.boundSessionKey, + targetKind: "session" as const, + conversation: params.conversation, + status: "active" as const, + boundAt: 0, + }, + }; + return { + conversation: params.conversation, + compiledBinding: { + channel: "discord" as const, + binding: { + type: "acp" as const, + agentId: "codex", + match: { + channel: "discord", + accountId: params.conversation.accountId, + peer: { + kind: peerKind, + id: params.conversation.conversationId, + }, + }, + acp: { + mode: "persistent" as const, + }, + }, + bindingConversationId: params.conversation.conversationId, + target: { + conversationId: params.conversation.conversationId, + ...(params.conversation.parentConversationId + ? { parentConversationId: params.conversation.parentConversationId } + : {}), + }, + agentId: "codex", + provider: { + compileConfiguredBinding: () => ({ + conversationId: params.conversation.conversationId, + ...(params.conversation.parentConversationId + ? { parentConversationId: params.conversation.parentConversationId } + : {}), + }), + matchInboundConversation: () => ({ + conversationId: params.conversation.conversationId, + ...(params.conversation.parentConversationId + ? { parentConversationId: params.conversation.parentConversationId } + : {}), + }), + }, + targetFactory: { + driverId: "acp" as const, + materialize: () => ({ + record: configuredBinding.record, + statefulTarget: { + kind: "stateful" as const, + driverId: "acp", + sessionKey: params.boundSessionKey, + agentId: "codex", + }, + }), + }, + }, + match: { + conversationId: params.conversation.conversationId, + ...(params.conversation.parentConversationId + ? { parentConversationId: params.conversation.parentConversationId } + : {}), + }, + record: configuredBinding.record, + statefulTarget: { + kind: "stateful" as const, + driverId: "acp", + sessionKey: params.boundSessionKey, + agentId: "codex", + }, + }; +} + +function setConfiguredBinding(channelId: string, boundSessionKey: string) { + persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => { + const conversation = resolveConversationFromParams(params); + const bindingResolution = createConfiguredBindingResolution({ + conversation: { + ...conversation, + conversationId: channelId, + }, + boundSessionKey, + }); + return { + bindingResolution, + boundSessionKey, + boundAgentId: "codex", + route: { + ...params.route, + agentId: "codex", + sessionKey: boundSessionKey, + matchedBy: "binding.channel", + }, + }; + }); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ ok: true, }); @@ -234,7 +336,7 @@ describe("Discord native plugin command dispatch", () => { clearPluginCommands(); persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReset(); persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({ - configuredBinding: null, + bindingResolution: null, route: params.route, })); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockReset(); @@ -519,4 +621,64 @@ describe("Discord native plugin command dispatch", () => { boundSessionKey, }); }); + + it("allows recovery commands through configured ACP bindings even when ensure fails", async () => { + const guildId = "1459246755253325866"; + const channelId = "1479098716916023408"; + const boundSessionKey = "agent:codex:acp:binding:discord:default:feedface"; + const cfg = { + commands: { + useAccessGroups: false, + }, + bindings: [ + { + type: "acp", + agentId: "codex", + match: { + channel: "discord", + accountId: "default", + peer: { kind: "channel", id: channelId }, + }, + acp: { + mode: "persistent", + }, + }, + ], + } as OpenClawConfig; + const interaction = createInteraction({ + channelType: ChannelType.GuildText, + channelId, + guildId, + guildName: "Ops", + }); + const command = createNativeCommand(cfg, { + name: "new", + description: "Start a new session.", + acceptsArgs: true, + }); + + setConfiguredBinding(channelId, boundSessionKey); + persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ + ok: false, + error: "acpx exited with code 1", + }); + vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); + const dispatchSpy = createDispatchSpy(); + + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { + ctx?: { SessionKey?: string; CommandTargetSessionKey?: string }; + }; + expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey); + expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); + expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); + expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled(); + expect(interaction.reply).not.toHaveBeenCalledWith( + expect.objectContaining({ + content: "Configured ACP binding is unavailable right now. Please try again.", + }), + ); + }); }); diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index ed50aff52a3..a292f6d4bfc 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -24,8 +24,8 @@ import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runti import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import { - ensureConfiguredAcpRouteReady, - resolveConfiguredAcpRoute, + ensureConfiguredBindingRouteReady, + resolveConfiguredBindingRoute, } from "openclaw/plugin-sdk/conversation-runtime"; import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; @@ -55,7 +55,7 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { chunkItems } from "openclaw/plugin-sdk/text-runtime"; import { withTimeout } from "openclaw/plugin-sdk/text-runtime"; -import { loadWebMedia } from "../../../whatsapp/src/media.js"; +import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { @@ -194,6 +194,11 @@ function buildDiscordCommandOptions(params: { }) satisfies CommandOptions; } +function shouldBypassConfiguredAcpEnsure(commandName: string): boolean { + const normalized = commandName.trim().toLowerCase(); + return normalized === "acp" || normalized === "new" || normalized === "reset"; +} + function readDiscordCommandArgs( interaction: CommandInteraction, definitions?: CommandArgDefinition[], @@ -1617,24 +1622,27 @@ async function dispatchDiscordCommandInteraction(params: { const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined; const configuredRoute = threadBinding == null - ? resolveConfiguredAcpRoute({ + ? resolveConfiguredBindingRoute({ cfg, route, - channel: "discord", - accountId, - conversationId: channelId, - parentConversationId: threadParentId, + conversation: { + channel: "discord", + accountId, + conversationId: channelId, + parentConversationId: threadParentId, + }, }) : null; - const configuredBinding = configuredRoute?.configuredBinding ?? null; - if (configuredBinding) { - const ensured = await ensureConfiguredAcpRouteReady({ + const configuredBinding = configuredRoute?.bindingResolution ?? null; + const commandName = command.nativeName ?? command.key; + if (configuredBinding && !shouldBypassConfiguredAcpEnsure(commandName)) { + const ensured = await ensureConfiguredBindingRouteReady({ cfg, - configuredBinding, + bindingResolution: configuredBinding, }); if (!ensured.ok) { logVerbose( - `discord native command: configured ACP binding unavailable for channel ${configuredBinding.spec.conversationId}: ${ensured.error}`, + `discord native command: configured ACP binding unavailable for channel ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`, ); await respond("Configured ACP binding is unavailable right now. Please try again."); return; diff --git a/extensions/discord/src/monitor/provider.lifecycle.test.ts b/extensions/discord/src/monitor/provider.lifecycle.test.ts index f03dce881c2..9de21e92d0d 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.test.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.test.ts @@ -228,6 +228,65 @@ describe("runDiscordGatewayLifecycle", () => { expect(connectedCall![0].lastConnectedAt).toBeTypeOf("number"); }); + it("forces a fresh reconnect when startup never reaches READY, then recovers", async () => { + vi.useFakeTimers(); + try { + const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); + const { emitter, gateway } = createGatewayHarness(); + getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); + gateway.connect.mockImplementation((_resume?: boolean) => { + setTimeout(() => { + gateway.isConnected = true; + }, 1_000); + }); + + const { lifecycleParams, runtimeError } = createLifecycleHarness({ gateway }); + const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams); + await vi.advanceTimersByTimeAsync(15_000 + 1_000); + await expect(lifecyclePromise).resolves.toBeUndefined(); + + expect(runtimeError).toHaveBeenCalledWith( + expect.stringContaining("gateway was not ready after 15000ms"), + ); + expect(gateway.disconnect).toHaveBeenCalledTimes(1); + expect(gateway.connect).toHaveBeenCalledTimes(1); + expect(gateway.connect).toHaveBeenCalledWith(false); + } finally { + vi.useRealTimers(); + } + }); + + it("fails fast when startup never reaches READY after a forced reconnect", async () => { + vi.useFakeTimers(); + try { + const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); + const { emitter, gateway } = createGatewayHarness(); + getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); + const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } = + createLifecycleHarness({ gateway }); + + const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams); + lifecyclePromise.catch(() => {}); + await vi.advanceTimersByTimeAsync(15_000 * 2 + 1_000); + await expect(lifecyclePromise).rejects.toThrow( + "discord gateway did not reach READY within 15000ms after a forced reconnect", + ); + + expect(gateway.disconnect).toHaveBeenCalledTimes(1); + expect(gateway.connect).toHaveBeenCalledTimes(1); + expect(gateway.connect).toHaveBeenCalledWith(false); + expectLifecycleCleanup({ + start, + stop, + threadStop, + waitCalls: 0, + releaseEarlyGatewayErrorGuard, + }); + } finally { + vi.useRealTimers(); + } + }); + it("handles queued disallowed intents errors without waiting for gateway events", async () => { const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { @@ -276,6 +335,51 @@ describe("runDiscordGatewayLifecycle", () => { }); }); + it("surfaces fatal startup gateway errors while waiting for READY", async () => { + vi.useFakeTimers(); + try { + const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); + const pendingGatewayErrors: unknown[] = []; + const { emitter, gateway } = createGatewayHarness(); + getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); + const { + lifecycleParams, + start, + stop, + threadStop, + runtimeError, + releaseEarlyGatewayErrorGuard, + } = createLifecycleHarness({ + gateway, + pendingGatewayErrors, + }); + + setTimeout(() => { + pendingGatewayErrors.push(new Error("Fatal Gateway error: 4001")); + }, 1_000); + + const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams); + lifecyclePromise.catch(() => {}); + await vi.advanceTimersByTimeAsync(1_500); + await expect(lifecyclePromise).rejects.toThrow("Fatal Gateway error: 4001"); + + expect(runtimeError).toHaveBeenCalledWith( + expect.stringContaining("discord gateway error: Error: Fatal Gateway error: 4001"), + ); + expect(gateway.disconnect).not.toHaveBeenCalled(); + expect(gateway.connect).not.toHaveBeenCalled(); + expectLifecycleCleanup({ + start, + stop, + threadStop, + waitCalls: 0, + releaseEarlyGatewayErrorGuard, + }); + } finally { + vi.useRealTimers(); + } + }); + it("retries stalled HELLO with resume before forcing fresh identify", async () => { vi.useFakeTimers(); try { @@ -288,8 +392,11 @@ describe("runDiscordGatewayLifecycle", () => { }, sequence: 123, }); + gateway.isConnected = true; getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); waitForDiscordGatewayStopMock.mockImplementationOnce(async () => { + emitter.emit("debug", "WebSocket connection closed with code 1006"); + gateway.isConnected = false; await emitGatewayOpenAndWait(emitter); await emitGatewayOpenAndWait(emitter); await emitGatewayOpenAndWait(emitter); @@ -324,8 +431,13 @@ describe("runDiscordGatewayLifecycle", () => { }, sequence: 456, }); + gateway.isConnected = true; getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); waitForDiscordGatewayStopMock.mockImplementationOnce(async () => { + emitter.emit("debug", "WebSocket connection closed with code 1006"); + gateway.isConnected = false; + await emitGatewayOpenAndWait(emitter); + await emitGatewayOpenAndWait(emitter); // Successful reconnect (READY/RESUMED sets isConnected=true), then @@ -342,10 +454,11 @@ describe("runDiscordGatewayLifecycle", () => { const { lifecycleParams } = createLifecycleHarness({ gateway }); await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined(); - expect(gateway.connect).toHaveBeenCalledTimes(3); + expect(gateway.connect).toHaveBeenCalledTimes(4); expect(gateway.connect).toHaveBeenNthCalledWith(1, true); expect(gateway.connect).toHaveBeenNthCalledWith(2, true); expect(gateway.connect).toHaveBeenNthCalledWith(3, true); + expect(gateway.connect).toHaveBeenNthCalledWith(4, true); expect(gateway.connect).not.toHaveBeenCalledWith(false); } finally { vi.useRealTimers(); @@ -357,6 +470,7 @@ describe("runDiscordGatewayLifecycle", () => { try { const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { emitter, gateway } = createGatewayHarness(); + gateway.isConnected = true; getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); waitForDiscordGatewayStopMock.mockImplementationOnce( (waitParams: WaitForDiscordGatewayStopParams) => @@ -382,6 +496,7 @@ describe("runDiscordGatewayLifecycle", () => { try { const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { emitter, gateway } = createGatewayHarness(); + gateway.isConnected = true; getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); let resolveWait: (() => void) | undefined; waitForDiscordGatewayStopMock.mockImplementationOnce( diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index 0d5fbd66b25..b2a9e8a6019 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -15,6 +15,37 @@ type ExecApprovalsHandler = { stop: () => Promise; }; +const DISCORD_GATEWAY_READY_TIMEOUT_MS = 15_000; +const DISCORD_GATEWAY_READY_POLL_MS = 250; + +type GatewayReadyWaitResult = "ready" | "timeout" | "stopped"; + +async function waitForDiscordGatewayReady(params: { + gateway?: Pick; + abortSignal?: AbortSignal; + timeoutMs: number; + beforePoll?: () => Promise<"continue" | "stop"> | "continue" | "stop"; +}): Promise { + const deadlineAt = Date.now() + params.timeoutMs; + while (!params.abortSignal?.aborted) { + const pollDecision = await params.beforePoll?.(); + if (pollDecision === "stop") { + return "stopped"; + } + if (params.gateway?.isConnected) { + return "ready"; + } + if (Date.now() >= deadlineAt) { + return "timeout"; + } + await new Promise((resolve) => { + const timeout = setTimeout(resolve, DISCORD_GATEWAY_READY_POLL_MS); + timeout.unref?.(); + }); + } + return "stopped"; +} + export async function runDiscordGatewayLifecycle(params: { accountId: string; client: Client; @@ -242,20 +273,6 @@ export async function runDiscordGatewayLifecycle(params: { }; gatewayEmitter?.on("debug", onGatewayDebug); - // If the gateway is already connected when the lifecycle starts (the - // "WebSocket connection opened" debug event was emitted before we - // registered the listener above), push the initial connected status now. - // Guard against lifecycleStopping: if the abortSignal was already aborted, - // onAbort() ran synchronously above and pushed connected: false — don't - // contradict it with a spurious connected: true. - if (gateway?.isConnected && !lifecycleStopping) { - const at = Date.now(); - pushStatus({ - ...createConnectedChannelStatusPatch(at), - lastDisconnect: null, - }); - } - let sawDisallowedIntents = false; const logGatewayError = (err: unknown) => { if (params.isDisallowedIntentsError(err)) { @@ -277,28 +294,107 @@ export async function runDiscordGatewayLifecycle(params: { params.isDisallowedIntentsError(err) ); }; + const drainPendingGatewayErrors = (): "continue" | "stop" => { + const pendingGatewayErrors = params.pendingGatewayErrors ?? []; + if (pendingGatewayErrors.length === 0) { + return "continue"; + } + const queuedErrors = [...pendingGatewayErrors]; + pendingGatewayErrors.length = 0; + for (const err of queuedErrors) { + logGatewayError(err); + if (!shouldStopOnGatewayError(err)) { + continue; + } + if (params.isDisallowedIntentsError(err)) { + return "stop"; + } + throw err; + } + return "continue"; + }; try { if (params.execApprovalsHandler) { await params.execApprovalsHandler.start(); } // Drain gateway errors emitted before lifecycle listeners were attached. - const pendingGatewayErrors = params.pendingGatewayErrors ?? []; - if (pendingGatewayErrors.length > 0) { - const queuedErrors = [...pendingGatewayErrors]; - pendingGatewayErrors.length = 0; - for (const err of queuedErrors) { - logGatewayError(err); - if (!shouldStopOnGatewayError(err)) { - continue; - } - if (params.isDisallowedIntentsError(err)) { + if (drainPendingGatewayErrors() === "stop") { + return; + } + + // Carbon starts the gateway during client construction, before OpenClaw can + // attach lifecycle listeners. Require a READY/RESUMED-connected gateway + // before continuing so the monitor does not look healthy while silently + // missing inbound events. + if (gateway && !gateway.isConnected && !lifecycleStopping) { + const initialReady = await waitForDiscordGatewayReady({ + gateway, + abortSignal: params.abortSignal, + timeoutMs: DISCORD_GATEWAY_READY_TIMEOUT_MS, + beforePoll: drainPendingGatewayErrors, + }); + if (initialReady === "stopped" || lifecycleStopping) { + return; + } + if (initialReady === "timeout" && !lifecycleStopping) { + params.runtime.error?.( + danger( + `discord: gateway was not ready after ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms; forcing a fresh reconnect`, + ), + ); + const startupRetryAt = Date.now(); + pushStatus({ + connected: false, + lastEventAt: startupRetryAt, + lastDisconnect: { + at: startupRetryAt, + error: "startup-not-ready", + }, + }); + gateway?.disconnect(); + gateway?.connect(false); + const reconnected = await waitForDiscordGatewayReady({ + gateway, + abortSignal: params.abortSignal, + timeoutMs: DISCORD_GATEWAY_READY_TIMEOUT_MS, + beforePoll: drainPendingGatewayErrors, + }); + if (reconnected === "stopped" || lifecycleStopping) { return; } - throw err; + if (reconnected === "timeout" && !lifecycleStopping) { + const error = new Error( + `discord gateway did not reach READY within ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms after a forced reconnect`, + ); + const startupFailureAt = Date.now(); + pushStatus({ + connected: false, + lastEventAt: startupFailureAt, + lastDisconnect: { + at: startupFailureAt, + error: "startup-reconnect-timeout", + }, + lastError: error.message, + }); + throw error; + } } } + // If the gateway is already connected when the lifecycle starts (or becomes + // connected during the startup readiness guard), push the initial connected + // status now. Guard against lifecycleStopping: if the abortSignal was + // already aborted, onAbort() ran synchronously above and pushed connected: + // false, so don't contradict it with a spurious connected: true. + if (gateway?.isConnected && !lifecycleStopping) { + const at = Date.now(); + pushStatus({ + ...createConnectedChannelStatusPatch(at), + lastDisconnect: null, + }); + } + await waitForDiscordGatewayStop({ gateway: gateway ? { diff --git a/extensions/discord/src/monitor/provider.proxy.test.ts b/extensions/discord/src/monitor/provider.proxy.test.ts index 72da5136c7a..f8e9f52c198 100644 --- a/extensions/discord/src/monitor/provider.proxy.test.ts +++ b/extensions/discord/src/monitor/provider.proxy.test.ts @@ -142,11 +142,30 @@ describe("createDiscordGatewayPlugin", () => { }); await expect(registerGatewayClient(plugin)).rejects.toThrow( - "Failed to get gateway information from Discord: fetch failed", + "Failed to get gateway information from Discord", ); expect(baseRegisterClientSpy).not.toHaveBeenCalled(); } + async function expectGatewayRegisterFallback(response: Response) { + const runtime = createRuntime(); + globalFetchMock.mockResolvedValue(response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: {}, + runtime, + }); + + await registerGatewayClient(plugin); + + expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); + expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe( + "wss://gateway.discord.gg/", + ); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("discord: gateway metadata lookup failed transiently"), + ); + } + async function registerGatewayClientWithMetadata(params: { plugin: unknown; fetchMock: typeof globalFetchMock; @@ -161,6 +180,7 @@ describe("createDiscordGatewayPlugin", () => { beforeEach(() => { vi.stubGlobal("fetch", globalFetchMock); + vi.useRealTimers(); baseRegisterClientSpy.mockClear(); globalFetchMock.mockClear(); restProxyAgentSpy.mockClear(); @@ -190,7 +210,7 @@ describe("createDiscordGatewayPlugin", () => { }); it("maps plain-text Discord 503 responses to fetch failed", async () => { - await expectGatewayRegisterFetchFailure({ + await expectGatewayRegisterFallback({ ok: false, status: 503, text: async () => @@ -198,6 +218,14 @@ describe("createDiscordGatewayPlugin", () => { } as Response); }); + it("keeps fatal Discord metadata failures fatal", async () => { + await expectGatewayRegisterFetchFailure({ + ok: false, + status: 401, + text: async () => "401: Unauthorized", + } as Response); + }); + it("uses proxy agent for gateway WebSocket when configured", async () => { const runtime = createRuntime(); @@ -255,7 +283,7 @@ describe("createDiscordGatewayPlugin", () => { }); it("maps body read failures to fetch failed", async () => { - await expectGatewayRegisterFetchFailure({ + await expectGatewayRegisterFallback({ ok: true, status: 200, text: async () => { @@ -263,4 +291,68 @@ describe("createDiscordGatewayPlugin", () => { }, } as unknown as Response); }); + + it("falls back to the default gateway url when metadata lookup times out", async () => { + vi.useFakeTimers(); + const runtime = createRuntime(); + globalFetchMock.mockImplementation(() => new Promise(() => {})); + const plugin = createDiscordGatewayPlugin({ + discordConfig: {}, + runtime, + }); + + const registerPromise = registerGatewayClient(plugin); + await vi.advanceTimersByTimeAsync(10_000); + await registerPromise; + + expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); + expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe( + "wss://gateway.discord.gg/", + ); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("discord: gateway metadata lookup failed transiently"), + ); + }); + + it("refreshes fallback gateway metadata on the next register attempt", async () => { + const runtime = createRuntime(); + globalFetchMock + .mockResolvedValueOnce({ + ok: false, + status: 503, + text: async () => + "upstream connect error or disconnect/reset before headers. reset reason: overflow", + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + url: "wss://gateway.discord.gg/?v=10", + shards: 8, + session_start_limit: { + total: 1000, + remaining: 999, + reset_after: 120_000, + max_concurrency: 16, + }, + }), + } as Response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: {}, + runtime, + }); + + await registerGatewayClient(plugin); + await registerGatewayClient(plugin); + + expect(globalFetchMock).toHaveBeenCalledTimes(2); + expect(baseRegisterClientSpy).toHaveBeenCalledTimes(2); + expect( + (plugin as unknown as { gatewayInfo?: { url?: string; shards?: number } }).gatewayInfo, + ).toMatchObject({ + url: "wss://gateway.discord.gg/?v=10", + shards: 8, + }); + }); }); diff --git a/extensions/discord/src/monitor/provider.test-support.ts b/extensions/discord/src/monitor/provider.test-support.ts deleted file mode 100644 index 360210e3604..00000000000 --- a/extensions/discord/src/monitor/provider.test-support.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../../../test/helpers/extensions/discord-provider.test-support.js"; diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts index ed221645fcf..237cc6b8081 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { ChannelType } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { clearRuntimeConfigSnapshot, @@ -12,12 +13,12 @@ import { getSessionBindingService } from "../../../../src/infra/outbound/session const hoisted = vi.hoisted(() => { const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({})); const sendWebhookMessageDiscord = vi.fn(async (_text: string, _opts?: unknown) => ({})); - const restGet = vi.fn(async () => ({ + const restGet = vi.fn(async (..._args: unknown[]) => ({ id: "thread-1", type: 11, parent_id: "parent-1", })); - const restPost = vi.fn(async () => ({ + const restPost = vi.fn(async (..._args: unknown[]) => ({ id: "wh-created", token: "tok-created", })); @@ -45,47 +46,151 @@ vi.mock("../send.js", () => ({ sendWebhookMessageDiscord: hoisted.sendWebhookMessageDiscord, })); -vi.mock("../client.js", () => ({ - createDiscordRestClient: hoisted.createDiscordRestClient, -})); - vi.mock("../send.messages.js", () => ({ createThreadDiscord: hoisted.createThreadDiscord, })); -vi.mock("../../../../src/acp/runtime/session-meta.js", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - readAcpSessionEntry: hoisted.readAcpSessionEntry, - }; -}); - +const { __testing, createThreadBindingManager } = await import("./thread-bindings.manager.js"); const { - __testing, autoBindSpawnedDiscordSubagent, - createThreadBindingManager, reconcileAcpThreadBindingsOnStartup, - resolveThreadBindingInactivityExpiresAt, - resolveThreadBindingIntroText, - resolveThreadBindingMaxAgeExpiresAt, setThreadBindingIdleTimeoutBySessionKey, setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, -} = await import("./thread-bindings.js"); +} = await import("./thread-bindings.lifecycle.js"); +const { resolveThreadBindingInactivityExpiresAt, resolveThreadBindingMaxAgeExpiresAt } = + await import("./thread-bindings.state.js"); +const { resolveThreadBindingIntroText } = await import("./thread-bindings.messages.js"); +const discordClientModule = await import("../client.js"); +const discordThreadBindingApi = await import("./thread-bindings.discord-api.js"); +const acpRuntime = await import("openclaw/plugin-sdk/acp-runtime"); describe("thread binding lifecycle", () => { beforeEach(() => { __testing.resetThreadBindingsForTests(); clearRuntimeConfigSnapshot(); - hoisted.sendMessageDiscord.mockClear(); - hoisted.sendWebhookMessageDiscord.mockClear(); - hoisted.restGet.mockClear(); - hoisted.restPost.mockClear(); - hoisted.createDiscordRestClient.mockClear(); - hoisted.createThreadDiscord.mockClear(); + vi.restoreAllMocks(); + hoisted.sendMessageDiscord.mockReset().mockResolvedValue({}); + hoisted.sendWebhookMessageDiscord.mockReset().mockResolvedValue({}); + hoisted.restGet.mockReset().mockResolvedValue({ + id: "thread-1", + type: 11, + parent_id: "parent-1", + }); + hoisted.restPost.mockReset().mockResolvedValue({ + id: "wh-created", + token: "tok-created", + }); + hoisted.createDiscordRestClient.mockReset().mockImplementation((..._args: unknown[]) => ({ + rest: { + get: hoisted.restGet, + post: hoisted.restPost, + }, + })); + hoisted.createThreadDiscord.mockReset().mockResolvedValue({ id: "thread-created" }); hoisted.readAcpSessionEntry.mockReset().mockReturnValue(null); + vi.spyOn(discordClientModule, "createDiscordRestClient").mockImplementation( + (...args) => + hoisted.createDiscordRestClient(...args) as unknown as ReturnType< + typeof discordClientModule.createDiscordRestClient + >, + ); + vi.spyOn(discordThreadBindingApi, "createWebhookForChannel").mockImplementation( + async (params) => { + const rest = hoisted.createDiscordRestClient( + { + accountId: params.accountId, + token: params.token, + }, + params.cfg, + ).rest; + const created = (await rest.post("mock:channel-webhook")) as { + id?: string; + token?: string; + }; + return { + webhookId: typeof created?.id === "string" ? created.id.trim() || undefined : undefined, + webhookToken: + typeof created?.token === "string" ? created.token.trim() || undefined : undefined, + }; + }, + ); + vi.spyOn(discordThreadBindingApi, "resolveChannelIdForBinding").mockImplementation( + async (params) => { + const explicit = params.channelId?.trim(); + if (explicit) { + return explicit; + } + const rest = hoisted.createDiscordRestClient( + { + accountId: params.accountId, + token: params.token, + }, + params.cfg, + ).rest; + const channel = (await rest.get("mock:channel-resolve")) as { + id?: string; + type?: number; + parent_id?: string; + parentId?: string; + }; + const channelId = typeof channel?.id === "string" ? channel.id.trim() : ""; + const parentId = + typeof channel?.parent_id === "string" + ? channel.parent_id.trim() + : typeof channel?.parentId === "string" + ? channel.parentId.trim() + : ""; + const isThreadType = + channel?.type === ChannelType.PublicThread || + channel?.type === ChannelType.PrivateThread || + channel?.type === ChannelType.AnnouncementThread; + if (parentId && isThreadType) { + return parentId; + } + return channelId || null; + }, + ); + vi.spyOn(discordThreadBindingApi, "createThreadForBinding").mockImplementation( + async (params) => { + const created = await hoisted.createThreadDiscord( + params.channelId, + { + name: params.threadName, + autoArchiveMinutes: 60, + }, + { + accountId: params.accountId, + token: params.token, + cfg: params.cfg, + }, + ); + return typeof created?.id === "string" ? created.id.trim() || null : null; + }, + ); + vi.spyOn(discordThreadBindingApi, "maybeSendBindingMessage").mockImplementation( + async (params) => { + if ( + params.preferWebhook !== false && + params.record.webhookId && + params.record.webhookToken + ) { + await hoisted.sendWebhookMessageDiscord(params.text, { + cfg: params.cfg, + webhookId: params.record.webhookId, + webhookToken: params.record.webhookToken, + accountId: params.record.accountId, + threadId: params.record.threadId, + }); + return; + } + await hoisted.sendMessageDiscord(`channel:${params.record.threadId}`, params.text, { + cfg: params.cfg, + accountId: params.record.accountId, + }); + }, + ); + vi.spyOn(acpRuntime, "readAcpSessionEntry").mockImplementation(hoisted.readAcpSessionEntry); vi.useRealTimers(); }); @@ -93,7 +198,7 @@ describe("thread binding lifecycle", () => { createThreadBindingManager({ accountId: "default", persist: false, - enableSweeper: true, + enableSweeper: false, idleTimeoutMs: 24 * 60 * 60 * 1000, maxAgeMs: 0, }); @@ -139,7 +244,7 @@ describe("thread binding lifecycle", () => { const manager = createThreadBindingManager({ accountId: "default", persist: false, - enableSweeper: true, + enableSweeper: false, idleTimeoutMs: 60_000, maxAgeMs: 0, }); @@ -159,6 +264,7 @@ describe("thread binding lifecycle", () => { hoisted.sendWebhookMessageDiscord.mockClear(); await vi.advanceTimersByTimeAsync(120_000); + await __testing.runThreadBindingSweepForAccount("default"); expect(manager.getByThreadId("thread-1")).toBeUndefined(); expect(hoisted.restGet).not.toHaveBeenCalled(); @@ -177,7 +283,7 @@ describe("thread binding lifecycle", () => { const manager = createThreadBindingManager({ accountId: "default", persist: false, - enableSweeper: true, + enableSweeper: false, idleTimeoutMs: 0, maxAgeMs: 60_000, }); @@ -195,6 +301,7 @@ describe("thread binding lifecycle", () => { hoisted.sendMessageDiscord.mockClear(); await vi.advanceTimersByTimeAsync(120_000); + await __testing.runThreadBindingSweepForAccount("default"); expect(manager.getByThreadId("thread-1")).toBeUndefined(); expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1); @@ -214,6 +321,7 @@ describe("thread binding lifecycle", () => { hoisted.restGet.mockRejectedValueOnce(new Error("ECONNRESET")); await vi.advanceTimersByTimeAsync(120_000); + await __testing.runThreadBindingSweepForAccount("default"); expect(manager.getByThreadId("thread-1")).toBeDefined(); expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled(); @@ -234,6 +342,7 @@ describe("thread binding lifecycle", () => { }); await vi.advanceTimersByTimeAsync(120_000); + await __testing.runThreadBindingSweepForAccount("default"); expect(manager.getByThreadId("thread-1")).toBeUndefined(); expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled(); @@ -334,7 +443,7 @@ describe("thread binding lifecycle", () => { const manager = createThreadBindingManager({ accountId: "default", persist: false, - enableSweeper: true, + enableSweeper: false, idleTimeoutMs: 60_000, maxAgeMs: 0, }); @@ -358,6 +467,7 @@ describe("thread binding lifecycle", () => { expect(updated[0]?.idleTimeoutMs).toBe(0); await vi.advanceTimersByTimeAsync(240_000); + await __testing.runThreadBindingSweepForAccount("default"); expect(manager.getByThreadId("thread-1")).toBeDefined(); } finally { @@ -371,7 +481,7 @@ describe("thread binding lifecycle", () => { const manager = createThreadBindingManager({ accountId: "default", persist: false, - enableSweeper: true, + enableSweeper: false, idleTimeoutMs: 60_000, maxAgeMs: 0, }); @@ -417,6 +527,7 @@ describe("thread binding lifecycle", () => { hoisted.sendMessageDiscord.mockClear(); await vi.advanceTimersByTimeAsync(120_000); + await __testing.runThreadBindingSweepForAccount("default"); expect(manager.getByThreadId("thread-2")).toBeDefined(); expect(hoisted.sendMessageDiscord).not.toHaveBeenCalled(); diff --git a/extensions/discord/src/monitor/thread-bindings.manager.ts b/extensions/discord/src/monitor/thread-bindings.manager.ts index f6d5f7d3d90..5c37ac4bbf0 100644 --- a/extensions/discord/src/monitor/thread-bindings.manager.ts +++ b/extensions/discord/src/monitor/thread-bindings.manager.ts @@ -69,6 +69,8 @@ function unregisterManager(accountId: string, manager: ThreadBindingManager) { } } +const SWEEPERS_BY_ACCOUNT_ID = new Map Promise>(); + function resolveEffectiveBindingExpiresAt(params: { record: ThreadBindingRecord; defaultIdleTimeoutMs: number; @@ -200,6 +202,111 @@ export function createThreadBindingManager( const resolveCurrentToken = () => getThreadBindingToken(accountId) ?? params.token; let sweepTimer: NodeJS.Timeout | null = null; + const runSweepOnce = async () => { + const bindings = manager.listBindings(); + if (bindings.length === 0) { + return; + } + let rest: ReturnType["rest"] | null = null; + for (const snapshotBinding of bindings) { + // Re-read live state after any awaited work from earlier iterations. + // This avoids unbinding based on stale snapshot data when activity touches + // happen while the sweeper loop is in-flight. + const binding = manager.getByThreadId(snapshotBinding.threadId); + if (!binding) { + continue; + } + const now = Date.now(); + const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({ + record: binding, + defaultIdleTimeoutMs: idleTimeoutMs, + }); + const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({ + record: binding, + defaultMaxAgeMs: maxAgeMs, + }); + const expirationCandidates: Array<{ + reason: "idle-expired" | "max-age-expired"; + at: number; + }> = []; + if (inactivityExpiresAt != null && now >= inactivityExpiresAt) { + expirationCandidates.push({ reason: "idle-expired", at: inactivityExpiresAt }); + } + if (maxAgeExpiresAt != null && now >= maxAgeExpiresAt) { + expirationCandidates.push({ reason: "max-age-expired", at: maxAgeExpiresAt }); + } + if (expirationCandidates.length > 0) { + expirationCandidates.sort((a, b) => a.at - b.at); + const reason = expirationCandidates[0]?.reason ?? "idle-expired"; + manager.unbindThread({ + threadId: binding.threadId, + reason, + sendFarewell: true, + farewellText: resolveThreadBindingFarewellText({ + reason, + idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({ + record: binding, + defaultIdleTimeoutMs: idleTimeoutMs, + }), + maxAgeMs: resolveThreadBindingMaxAgeMs({ + record: binding, + defaultMaxAgeMs: maxAgeMs, + }), + }), + }); + continue; + } + if (isDirectConversationBindingId(binding.threadId)) { + continue; + } + if (!rest) { + try { + const cfg = resolveCurrentCfg(); + rest = createDiscordRestClient( + { + accountId, + token: resolveCurrentToken(), + }, + cfg, + ).rest; + } catch { + return; + } + } + try { + const channel = await rest.get(Routes.channel(binding.threadId)); + if (!channel || typeof channel !== "object") { + logVerbose( + `discord thread binding sweep probe returned invalid payload for ${binding.threadId}`, + ); + continue; + } + if (isThreadArchived(channel)) { + manager.unbindThread({ + threadId: binding.threadId, + reason: "thread-archived", + sendFarewell: true, + }); + } + } catch (err) { + if (isDiscordThreadGoneError(err)) { + logVerbose( + `discord thread binding sweep removing stale binding ${binding.threadId}: ${summarizeDiscordError(err)}`, + ); + manager.unbindThread({ + threadId: binding.threadId, + reason: "thread-delete", + sendFarewell: false, + }); + continue; + } + logVerbose( + `discord thread binding sweep probe failed for ${binding.threadId}: ${summarizeDiscordError(err)}`, + ); + } + } + }; + SWEEPERS_BY_ACCOUNT_ID.set(accountId, runSweepOnce); const manager: ThreadBindingManager = { accountId, @@ -444,6 +551,7 @@ export function createThreadBindingManager( clearInterval(sweepTimer); sweepTimer = null; } + SWEEPERS_BY_ACCOUNT_ID.delete(accountId); unregisterManager(accountId, manager); unregisterSessionBindingAdapter({ channel: "discord", @@ -455,110 +563,13 @@ export function createThreadBindingManager( if (params.enableSweeper !== false) { sweepTimer = setInterval(() => { - void (async () => { - const bindings = manager.listBindings(); - if (bindings.length === 0) { - return; - } - let rest; - try { - const cfg = resolveCurrentCfg(); - rest = createDiscordRestClient( - { - accountId, - token: resolveCurrentToken(), - }, - cfg, - ).rest; - } catch { - return; - } - for (const snapshotBinding of bindings) { - // Re-read live state after any awaited work from earlier iterations. - // This avoids unbinding based on stale snapshot data when activity touches - // happen while the sweeper loop is in-flight. - const binding = manager.getByThreadId(snapshotBinding.threadId); - if (!binding) { - continue; - } - const now = Date.now(); - const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({ - record: binding, - defaultIdleTimeoutMs: idleTimeoutMs, - }); - const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({ - record: binding, - defaultMaxAgeMs: maxAgeMs, - }); - const expirationCandidates: Array<{ - reason: "idle-expired" | "max-age-expired"; - at: number; - }> = []; - if (inactivityExpiresAt != null && now >= inactivityExpiresAt) { - expirationCandidates.push({ reason: "idle-expired", at: inactivityExpiresAt }); - } - if (maxAgeExpiresAt != null && now >= maxAgeExpiresAt) { - expirationCandidates.push({ reason: "max-age-expired", at: maxAgeExpiresAt }); - } - if (expirationCandidates.length > 0) { - expirationCandidates.sort((a, b) => a.at - b.at); - const reason = expirationCandidates[0]?.reason ?? "idle-expired"; - manager.unbindThread({ - threadId: binding.threadId, - reason, - sendFarewell: true, - farewellText: resolveThreadBindingFarewellText({ - reason, - idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({ - record: binding, - defaultIdleTimeoutMs: idleTimeoutMs, - }), - maxAgeMs: resolveThreadBindingMaxAgeMs({ - record: binding, - defaultMaxAgeMs: maxAgeMs, - }), - }), - }); - continue; - } - if (isDirectConversationBindingId(binding.threadId)) { - continue; - } - try { - const channel = await rest.get(Routes.channel(binding.threadId)); - if (!channel || typeof channel !== "object") { - logVerbose( - `discord thread binding sweep probe returned invalid payload for ${binding.threadId}`, - ); - continue; - } - if (isThreadArchived(channel)) { - manager.unbindThread({ - threadId: binding.threadId, - reason: "thread-archived", - sendFarewell: true, - }); - } - } catch (err) { - if (isDiscordThreadGoneError(err)) { - logVerbose( - `discord thread binding sweep removing stale binding ${binding.threadId}: ${summarizeDiscordError(err)}`, - ); - manager.unbindThread({ - threadId: binding.threadId, - reason: "thread-delete", - sendFarewell: false, - }); - continue; - } - logVerbose( - `discord thread binding sweep probe failed for ${binding.threadId}: ${summarizeDiscordError(err)}`, - ); - } - } - })(); + void runSweepOnce(); }, THREAD_BINDINGS_SWEEP_INTERVAL_MS); - sweepTimer.unref?.(); + // Keep the production process free to exit, but avoid breaking fake-timer + // sweeper tests where unref'd intervals may never fire. + if (!(process.env.VITEST || process.env.NODE_ENV === "test")) { + sweepTimer.unref?.(); + } } registerSessionBindingAdapter({ @@ -690,4 +701,10 @@ export const __testing = { resolveThreadBindingsPath, resolveThreadBindingThreadName, resetThreadBindingsForTests, + runThreadBindingSweepForAccount: async (accountId?: string) => { + const sweep = SWEEPERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)); + if (sweep) { + await sweep(); + } + }, }; diff --git a/extensions/discord/src/monitor/thread-session-close.test.ts b/extensions/discord/src/monitor/thread-session-close.test.ts index f2109150c66..a5cca87119c 100644 --- a/extensions/discord/src/monitor/thread-session-close.test.ts +++ b/extensions/discord/src/monitor/thread-session-close.test.ts @@ -6,10 +6,14 @@ const hoisted = vi.hoisted(() => { return { updateSessionStore, resolveStorePath }; }); -vi.mock("../../../../src/config/sessions.js", () => ({ - updateSessionStore: hoisted.updateSessionStore, - resolveStorePath: hoisted.resolveStorePath, -})); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + updateSessionStore: hoisted.updateSessionStore, + resolveStorePath: hoisted.resolveStorePath, + }; +}); const { closeDiscordThreadSessions } = await import("./thread-session-close.js"); diff --git a/extensions/discord/src/send.components.ts b/extensions/discord/src/send.components.ts index 9c641ba596d..de620fc2250 100644 --- a/extensions/discord/src/send.components.ts +++ b/extensions/discord/src/send.components.ts @@ -7,7 +7,7 @@ import { import { ChannelType, Routes } from "discord-api-types/v10"; import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; -import { loadWebMedia } from "../../whatsapp/src/media.js"; +import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordAccount } from "./accounts.js"; import { registerDiscordComponentEntries } from "./components-registry.js"; import { diff --git a/extensions/discord/src/send.emojis-stickers.ts b/extensions/discord/src/send.emojis-stickers.ts index 601b8372e74..a1f005c49fb 100644 --- a/extensions/discord/src/send.emojis-stickers.ts +++ b/extensions/discord/src/send.emojis-stickers.ts @@ -1,5 +1,5 @@ import { Routes } from "discord-api-types/v10"; -import { loadWebMediaRaw } from "../../whatsapp/src/media.js"; +import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media"; import { normalizeEmojiName, resolveDiscordRest } from "./send.shared.js"; import type { DiscordEmojiUpload, DiscordReactOpts, DiscordStickerUpload } from "./send.types.js"; import { DISCORD_MAX_EMOJI_BYTES, DISCORD_MAX_STICKER_BYTES } from "./send.types.js"; diff --git a/extensions/discord/src/send.outbound.ts b/extensions/discord/src/send.outbound.ts index cc71330b192..e0a674d557e 100644 --- a/extensions/discord/src/send.outbound.ts +++ b/extensions/discord/src/send.outbound.ts @@ -14,7 +14,7 @@ import { unlinkIfExists } from "openclaw/plugin-sdk/media-runtime"; import type { PollInput } from "openclaw/plugin-sdk/media-runtime"; import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; -import { loadWebMediaRaw } from "../../whatsapp/src/media.js"; +import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordAccount } from "./accounts.js"; import { rewriteDiscordKnownMentions } from "./mentions.js"; import { diff --git a/extensions/discord/src/send.shared.ts b/extensions/discord/src/send.shared.ts index 115356510d2..d3b248a3c6f 100644 --- a/extensions/discord/src/send.shared.ts +++ b/extensions/discord/src/send.shared.ts @@ -18,7 +18,7 @@ import { type PollInput, } from "openclaw/plugin-sdk/media-runtime"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; -import { loadWebMedia } from "../../whatsapp/src/media.js"; +import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordAccount } from "./accounts.js"; import { chunkDiscordTextWithMode } from "./chunk.js"; import { createDiscordClient, resolveDiscordRest } from "./client.js"; diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index f7722a35af5..a05a9af65b1 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -2,7 +2,6 @@ import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID, createEnvPatchedAccountSetupAdapter, - formatDocsLink, noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, @@ -17,6 +16,7 @@ import { type ChannelSetupDmPolicy, type ChannelSetupWizard, } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js"; diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index 801f7bf7838..d27c7862c99 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,11 +1,11 @@ import { - formatDocsLink, type OpenClawConfig, promptLegacyChannelAllowFrom, resolveSetupAccountId, type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js"; import { normalizeDiscordSlug } from "./monitor/allow-list.js"; import { diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts index 03174404bdb..7558b27394a 100644 --- a/extensions/discord/src/shared.ts +++ b/extensions/discord/src/shared.ts @@ -8,7 +8,7 @@ import { DiscordConfigSchema, getChatChannelMeta, type ChannelPlugin, -} from "openclaw/plugin-sdk/discord"; +} from "openclaw/plugin-sdk/discord-core"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index 5f9f66242ad..e7d3b099fe4 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -623,6 +623,7 @@ export class DiscordVoiceManager { agentId: entry.route.agentId, messageChannel: "discord", senderIsOwner: speaker.senderIsOwner, + allowModelOverride: false, deliver: false, }, this.params.runtime, diff --git a/extensions/feishu/api.ts b/extensions/feishu/api.ts new file mode 100644 index 00000000000..df5c00a43e3 --- /dev/null +++ b/extensions/feishu/api.ts @@ -0,0 +1,4 @@ +export * from "./src/conversation-id.js"; +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; +export * from "./src/thread-bindings.js"; diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts index 27f90f66479..837ffa28671 100644 --- a/extensions/feishu/index.ts +++ b/extensions/feishu/index.ts @@ -9,6 +9,8 @@ import { setFeishuRuntime } from "./src/runtime.js"; import { registerFeishuSubagentHooks } from "./src/subagent-hooks.js"; import { registerFeishuWikiTools } from "./src/wiki.js"; +export { feishuPlugin } from "./src/channel.js"; +export { setFeishuRuntime } from "./src/runtime.js"; export { monitorFeishuProvider } from "./src/monitor.js"; export { sendMessageFeishu, diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index ea7dbcb51ec..0995632e3a1 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -21,8 +21,8 @@ const { mockResolveAgentRoute, mockReadSessionUpdatedAt, mockResolveStorePath, - mockResolveConfiguredAcpRoute, - mockEnsureConfiguredAcpRouteReady, + mockResolveConfiguredBindingRoute, + mockEnsureConfiguredBindingRouteReady, mockResolveBoundConversation, mockTouchBinding, } = vi.hoisted(() => ({ @@ -50,11 +50,12 @@ const { })), mockReadSessionUpdatedAt: vi.fn(), mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"), - mockResolveConfiguredAcpRoute: vi.fn(({ route }) => ({ + mockResolveConfiguredBindingRoute: vi.fn(({ route }) => ({ + bindingResolution: null, configuredBinding: null, route, })), - mockEnsureConfiguredAcpRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })), + mockEnsureConfiguredBindingRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })), mockResolveBoundConversation: vi.fn(() => null), mockTouchBinding: vi.fn(), })); @@ -77,9 +78,21 @@ vi.mock("./client.js", () => ({ createFeishuClient: mockCreateFeishuClient, })); -vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ - resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params), - ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params), +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveConfiguredBindingRoute: (params: unknown) => mockResolveConfiguredBindingRoute(params), + ensureConfiguredBindingRouteReady: (params: unknown) => + mockEnsureConfiguredBindingRouteReady(params), + getSessionBindingService: () => ({ + resolveByConversation: mockResolveBoundConversation, + touch: mockTouchBinding, + }), + }; +}); + +vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({ getSessionBindingService: () => ({ resolveByConversation: mockResolveBoundConversation, touch: mockTouchBinding, @@ -133,14 +146,15 @@ describe("buildFeishuAgentBody", () => { describe("handleFeishuMessage ACP routing", () => { beforeEach(() => { vi.clearAllMocks(); - mockResolveConfiguredAcpRoute.mockReset().mockImplementation( + mockResolveConfiguredBindingRoute.mockReset().mockImplementation( ({ route }) => ({ + bindingResolution: null, configuredBinding: null, route, }) as any, ); - mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true }); + mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true }); mockResolveBoundConversation.mockReset().mockReturnValue(null); mockTouchBinding.mockReset(); mockResolveAgentRoute.mockReset().mockReturnValue({ @@ -213,7 +227,37 @@ describe("handleFeishuMessage ACP routing", () => { }); it("ensures configured ACP routes for Feishu DMs", async () => { - mockResolveConfiguredAcpRoute.mockReturnValue({ + mockResolveConfiguredBindingRoute.mockReturnValue({ + bindingResolution: { + configuredBinding: { + spec: { + channel: "feishu", + accountId: "default", + conversationId: "ou_sender_1", + agentId: "codex", + mode: "persistent", + }, + record: { + bindingId: "config:acp:feishu:default:ou_sender_1", + targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123", + targetKind: "session", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "ou_sender_1", + }, + status: "active", + boundAt: 0, + metadata: { source: "config" }, + }, + }, + statefulTarget: { + kind: "stateful", + driverId: "acp", + sessionKey: "agent:codex:acp:binding:feishu:default:abc123", + agentId: "codex", + }, + }, configuredBinding: { spec: { channel: "feishu", @@ -263,12 +307,42 @@ describe("handleFeishuMessage ACP routing", () => { }, }); - expect(mockResolveConfiguredAcpRoute).toHaveBeenCalledTimes(1); - expect(mockEnsureConfiguredAcpRouteReady).toHaveBeenCalledTimes(1); + expect(mockResolveConfiguredBindingRoute).toHaveBeenCalledTimes(1); + expect(mockEnsureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1); }); it("surfaces configured ACP initialization failures to the Feishu conversation", async () => { - mockResolveConfiguredAcpRoute.mockReturnValue({ + mockResolveConfiguredBindingRoute.mockReturnValue({ + bindingResolution: { + configuredBinding: { + spec: { + channel: "feishu", + accountId: "default", + conversationId: "ou_sender_1", + agentId: "codex", + mode: "persistent", + }, + record: { + bindingId: "config:acp:feishu:default:ou_sender_1", + targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123", + targetKind: "session", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "ou_sender_1", + }, + status: "active", + boundAt: 0, + metadata: { source: "config" }, + }, + }, + statefulTarget: { + kind: "stateful", + driverId: "acp", + sessionKey: "agent:codex:acp:binding:feishu:default:abc123", + agentId: "codex", + }, + }, configuredBinding: { spec: { channel: "feishu", @@ -300,7 +374,7 @@ describe("handleFeishuMessage ACP routing", () => { matchedBy: "binding.channel", }, } as any); - mockEnsureConfiguredAcpRouteReady.mockResolvedValue({ + mockEnsureConfiguredBindingRouteReady.mockResolvedValue({ ok: false, error: "runtime unavailable", } as any); @@ -428,14 +502,15 @@ describe("handleFeishuMessage command authorization", () => { mockListFeishuThreadMessages.mockReset().mockResolvedValue([]); mockReadSessionUpdatedAt.mockReturnValue(undefined); mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json"); - mockResolveConfiguredAcpRoute.mockReset().mockImplementation( + mockResolveConfiguredBindingRoute.mockReset().mockImplementation( ({ route }) => ({ + bindingResolution: null, configuredBinding: null, route, }) as any, ); - mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true }); + mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true }); mockResolveBoundConversation.mockReset().mockReturnValue(null); mockTouchBinding.mockReset(); mockResolveAgentRoute.mockReturnValue({ diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 6181d32f4af..bc47d6d934f 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1,6 +1,6 @@ import { - ensureConfiguredAcpRouteReady, - resolveConfiguredAcpRoute, + ensureConfiguredBindingRouteReady, + resolveConfiguredBindingRoute, } from "openclaw/plugin-sdk/conversation-runtime"; import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime"; import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; @@ -1251,15 +1251,17 @@ export async function handleFeishuMessage(params: { const parentConversationId = isGroup ? (parentPeer?.id ?? ctx.chatId) : undefined; let configuredBinding = null; if (feishuAcpConversationSupported) { - const configuredRoute = resolveConfiguredAcpRoute({ + const configuredRoute = resolveConfiguredBindingRoute({ cfg: effectiveCfg, route, - channel: "feishu", - accountId: account.accountId, - conversationId: currentConversationId, - parentConversationId, + conversation: { + channel: "feishu", + accountId: account.accountId, + conversationId: currentConversationId, + parentConversationId, + }, }); - configuredBinding = configuredRoute.configuredBinding; + configuredBinding = configuredRoute.bindingResolution; route = configuredRoute.route; // Bound Feishu conversations intentionally require an exact live conversation-id match. @@ -1292,9 +1294,9 @@ export async function handleFeishuMessage(params: { } if (configuredBinding) { - const ensured = await ensureConfiguredAcpRouteReady({ + const ensured = await ensureConfiguredBindingRouteReady({ cfg: effectiveCfg, - configuredBinding, + bindingResolution: configuredBinding, }); if (!ensured.ok) { const replyTargetMessageId = diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index c2df79e0028..6111eeabffa 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -12,7 +12,7 @@ import { PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk/feishu"; import type { ChannelMessageActionName } from "openclaw/plugin-sdk/feishu"; -import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { resolveFeishuAccount, resolveFeishuCredentials, @@ -42,11 +42,9 @@ const meta: ChannelMeta = { order: 70, }; -type FeishuChannelRuntime = typeof import("./channel.runtime.js").feishuChannelRuntime; - -const loadFeishuChannelRuntime = createLazyRuntimeSurface( +const loadFeishuChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), - ({ feishuChannelRuntime }) => feishuChannelRuntime, + "feishuChannelRuntime", ); function setFeishuNamedAccountEnabled( @@ -824,11 +822,15 @@ export const feishuPlugin: ChannelPlugin = { }); }, }, - acpBindings: { - normalizeConfiguredBindingTarget: ({ conversationId }) => + bindings: { + compileConfiguredBinding: ({ conversationId }) => normalizeFeishuAcpConversationId(conversationId), - matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) => - matchFeishuAcpConversation({ bindingConversationId, conversationId, parentConversationId }), + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => + matchFeishuAcpConversation({ + bindingConversationId: compiledBinding.conversationId, + conversationId, + parentConversationId, + }), }, setup: feishuSetupAdapter, setupWizard: feishuSetupWizard, diff --git a/extensions/feishu/src/client.test.ts b/extensions/feishu/src/client.test.ts index 6efda0cbb4e..fe4e04dc310 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -1,7 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js"; -const clientCtorMock = vi.hoisted(() => vi.fn()); +type CreateFeishuClient = typeof import("./client.js").createFeishuClient; +type CreateFeishuWSClient = typeof import("./client.js").createFeishuWSClient; +type ClearClientCache = typeof import("./client.js").clearClientCache; +type SetFeishuClientRuntimeForTest = typeof import("./client.js").setFeishuClientRuntimeForTest; + +const clientCtorMock = vi.hoisted(() => + vi.fn(function clientCtor() { + return { connected: true }; + }), +); const wsClientCtorMock = vi.hoisted(() => vi.fn(function wsClientCtor() { return { connected: true }; @@ -12,7 +21,6 @@ const httpsProxyAgentCtorMock = vi.hoisted(() => return { proxyUrl }; }), ); - const mockBaseHttpInstance = vi.hoisted(() => ({ request: vi.fn().mockResolvedValue({}), get: vi.fn().mockResolvedValue({}), @@ -23,19 +31,17 @@ const mockBaseHttpInstance = vi.hoisted(() => ({ head: vi.fn().mockResolvedValue({}), options: vi.fn().mockResolvedValue({}), })); -import { - createFeishuClient, - createFeishuWSClient, - clearClientCache, - FEISHU_HTTP_TIMEOUT_MS, - FEISHU_HTTP_TIMEOUT_MAX_MS, - FEISHU_HTTP_TIMEOUT_ENV_VAR, - setFeishuClientRuntimeForTest, -} from "./client.js"; - const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const; type ProxyEnvKey = (typeof proxyEnvKeys)[number]; +let createFeishuClient: CreateFeishuClient; +let createFeishuWSClient: CreateFeishuWSClient; +let clearClientCache: ClearClientCache; +let setFeishuClientRuntimeForTest: SetFeishuClientRuntimeForTest; +let FEISHU_HTTP_TIMEOUT_MS: number; +let FEISHU_HTTP_TIMEOUT_MAX_MS: number; +let FEISHU_HTTP_TIMEOUT_ENV_VAR: string; + let priorProxyEnv: Partial> = {}; let priorFeishuTimeoutEnv: string | undefined; @@ -55,7 +61,31 @@ function firstWsClientOptions(): { agent?: unknown } { return calls[0]?.[0] ?? {}; } -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); + vi.doMock("@larksuiteoapi/node-sdk", () => ({ + AppType: { SelfBuild: "self" }, + Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" }, + LoggerLevel: { info: "info" }, + Client: clientCtorMock, + WSClient: wsClientCtorMock, + EventDispatcher: vi.fn(), + defaultHttpInstance: mockBaseHttpInstance, + })); + vi.doMock("https-proxy-agent", () => ({ + HttpsProxyAgent: httpsProxyAgentCtorMock, + })); + + ({ + createFeishuClient, + createFeishuWSClient, + clearClientCache, + setFeishuClientRuntimeForTest, + FEISHU_HTTP_TIMEOUT_MS, + FEISHU_HTTP_TIMEOUT_MAX_MS, + FEISHU_HTTP_TIMEOUT_ENV_VAR, + } = await import("./client.js")); + priorProxyEnv = {}; priorFeishuTimeoutEnv = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR]; delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR]; @@ -104,7 +134,7 @@ describe("createFeishuClient HTTP timeout", () => { }); const getLastClientHttpInstance = () => { - const calls = clientCtorMock.mock.calls; + const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>; const lastCall = calls[calls.length - 1]?.[0] as | { httpInstance?: { get: (...args: unknown[]) => Promise } } | undefined; @@ -124,21 +154,22 @@ describe("createFeishuClient HTTP timeout", () => { it("passes a custom httpInstance with default timeout to Lark.Client", () => { createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); // pragma: allowlist secret - const calls = clientCtorMock.mock.calls; - const lastCall = calls[calls.length - 1][0] as { httpInstance?: unknown }; - expect(lastCall.httpInstance).toBeDefined(); + const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>; + const lastCall = calls[calls.length - 1]?.[0] as { httpInstance?: unknown } | undefined; + expect(lastCall?.httpInstance).toBeDefined(); }); it("injects default timeout into HTTP request options", async () => { createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); // pragma: allowlist secret - const calls = clientCtorMock.mock.calls; - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { post: (...args: unknown[]) => Promise }; - }; - const httpInstance = lastCall.httpInstance; + const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>; + const lastCall = calls[calls.length - 1]?.[0] as + | { httpInstance: { post: (...args: unknown[]) => Promise } } + | undefined; + const httpInstance = lastCall?.httpInstance; - await httpInstance.post( + expect(httpInstance).toBeDefined(); + await httpInstance?.post( "https://example.com/api", { data: 1 }, { headers: { "X-Custom": "yes" } }, @@ -154,13 +185,14 @@ describe("createFeishuClient HTTP timeout", () => { it("allows explicit timeout override per-request", async () => { createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); // pragma: allowlist secret - const calls = clientCtorMock.mock.calls; - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { get: (...args: unknown[]) => Promise }; - }; - const httpInstance = lastCall.httpInstance; + const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>; + const lastCall = calls[calls.length - 1]?.[0] as + | { httpInstance: { get: (...args: unknown[]) => Promise } } + | undefined; + const httpInstance = lastCall?.httpInstance; - await httpInstance.get("https://example.com/api", { timeout: 5_000 }); + expect(httpInstance).toBeDefined(); + await httpInstance?.get("https://example.com/api", { timeout: 5_000 }); expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( "https://example.com/api", @@ -243,13 +275,14 @@ describe("createFeishuClient HTTP timeout", () => { config: { httpTimeoutMs: 45_000 }, }); - const calls = clientCtorMock.mock.calls; + const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>; expect(calls.length).toBe(2); - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { get: (...args: unknown[]) => Promise }; - }; - await lastCall.httpInstance.get("https://example.com/api"); + const lastCall = calls[calls.length - 1]?.[0] as + | { httpInstance: { get: (...args: unknown[]) => Promise } } + | undefined; + expect(lastCall?.httpInstance).toBeDefined(); + await lastCall?.httpInstance.get("https://example.com/api"); expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( "https://example.com/api", @@ -264,7 +297,7 @@ describe("createFeishuWSClient proxy handling", () => { expect(httpsProxyAgentCtorMock).not.toHaveBeenCalled(); const options = firstWsClientOptions(); - expect(options?.agent).toBeUndefined(); + expect(options.agent).toBeUndefined(); }); it("uses proxy env precedence: https_proxy first, then HTTPS_PROXY, then http_proxy/HTTP_PROXY", () => { diff --git a/extensions/feishu/src/probe.test.ts b/extensions/feishu/src/probe.test.ts index ec1ebdc5b77..f394aec8b3e 100644 --- a/extensions/feishu/src/probe.test.ts +++ b/extensions/feishu/src/probe.test.ts @@ -1,19 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const clientCtorMock = vi.hoisted(() => vi.fn()); -const mockBaseHttpInstance = vi.hoisted(() => ({ - request: vi.fn().mockResolvedValue({}), - get: vi.fn().mockResolvedValue({}), - post: vi.fn().mockResolvedValue({}), - put: vi.fn().mockResolvedValue({}), - patch: vi.fn().mockResolvedValue({}), - delete: vi.fn().mockResolvedValue({}), - head: vi.fn().mockResolvedValue({}), - options: vi.fn().mockResolvedValue({}), +const createFeishuClientMock = vi.hoisted(() => vi.fn()); + +vi.mock("./client.js", () => ({ + createFeishuClient: createFeishuClientMock, })); -import { clearClientCache, setFeishuClientRuntimeForTest } from "./client.js"; -import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js"; +async function importProbeModule(scope: string) { + void scope; + vi.resetModules(); + return await import("./probe.js"); +} + +let FEISHU_PROBE_REQUEST_TIMEOUT_MS: typeof import("./probe.js").FEISHU_PROBE_REQUEST_TIMEOUT_MS; +let probeFeishu: typeof import("./probe.js").probeFeishu; +let clearProbeCache: typeof import("./probe.js").clearProbeCache; const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret const DEFAULT_SUCCESS_RESPONSE = { @@ -35,15 +36,9 @@ function makeRequestFn(response: Record) { return vi.fn().mockResolvedValue(response); } -function installClientCtor(requestFn: unknown) { - clientCtorMock.mockImplementation(function MockFeishuClient(this: { request: unknown }) { - this.request = requestFn; - } as never); -} - function setupClient(response: Record) { const requestFn = makeRequestFn(response); - installClientCtor(requestFn); + createFeishuClientMock.mockReturnValue({ request: requestFn }); return requestFn; } @@ -53,7 +48,12 @@ function setupSuccessClient() { async function expectDefaultSuccessResult( creds = DEFAULT_CREDS, - expected: Awaited> = DEFAULT_SUCCESS_RESULT, + expected: { + ok: true; + appId: string; + botName: string; + botOpenId: string; + } = DEFAULT_SUCCESS_RESULT, ) { const result = await probeFeishu(creds); expect(result).toEqual(expected); @@ -73,7 +73,7 @@ async function expectErrorResultCached(params: { expectedError: string; ttlMs: number; }) { - installClientCtor(params.requestFn); + createFeishuClientMock.mockReturnValue({ request: params.requestFn }); const first = await probeFeishu(DEFAULT_CREDS); const second = await probeFeishu(DEFAULT_CREDS); @@ -106,27 +106,16 @@ async function readSequentialDefaultProbePair() { } describe("probeFeishu", () => { - beforeEach(() => { + beforeEach(async () => { + ({ FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } = await importProbeModule( + `probe-${Date.now()}-${Math.random()}`, + )); clearProbeCache(); - clearClientCache(); - vi.clearAllMocks(); - setFeishuClientRuntimeForTest({ - sdk: { - AppType: { SelfBuild: "self" } as never, - Domain: { - Feishu: "https://open.feishu.cn", - Lark: "https://open.larksuite.com", - } as never, - Client: clientCtorMock as never, - defaultHttpInstance: mockBaseHttpInstance as never, - }, - }); + vi.restoreAllMocks(); }); afterEach(() => { clearProbeCache(); - clearClientCache(); - setFeishuClientRuntimeForTest(); }); it("returns error when credentials are missing", async () => { @@ -168,7 +157,7 @@ describe("probeFeishu", () => { it("returns timeout error when request exceeds timeout", async () => { await withFakeTimers(async () => { const requestFn = vi.fn().mockImplementation(() => new Promise(() => {})); - installClientCtor(requestFn); + createFeishuClientMock.mockReturnValue({ request: requestFn }); const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 }); await vi.advanceTimersByTimeAsync(1_000); @@ -179,6 +168,7 @@ describe("probeFeishu", () => { }); it("returns aborted when abort signal is already aborted", async () => { + createFeishuClientMock.mockClear(); const abortController = new AbortController(); abortController.abort(); @@ -188,7 +178,7 @@ describe("probeFeishu", () => { ); expect(result).toMatchObject({ ok: false, error: "probe aborted" }); - expect(clientCtorMock).not.toHaveBeenCalled(); + expect(createFeishuClientMock).not.toHaveBeenCalled(); }); it("returns cached result on subsequent calls within TTL", async () => { const requestFn = setupSuccessClient(); diff --git a/extensions/googlechat/api.ts b/extensions/googlechat/api.ts new file mode 100644 index 00000000000..8f7fe4d268b --- /dev/null +++ b/extensions/googlechat/api.ts @@ -0,0 +1,2 @@ +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/googlechat/index.ts b/extensions/googlechat/index.ts index 414bfc9557b..850bd4b6a87 100644 --- a/extensions/googlechat/index.ts +++ b/extensions/googlechat/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { googlechatPlugin } from "./src/channel.js"; import { setGoogleChatRuntime } from "./src/runtime.js"; +export { googlechatPlugin } from "./src/channel.js"; +export { setGoogleChatRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "googlechat", name: "Google Chat", diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 84715321ce8..95aeccfbac2 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -27,7 +27,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/googlechat"; import { GoogleChatConfigSchema } from "openclaw/plugin-sdk/googlechat"; -import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listGoogleChatAccountIds, @@ -48,11 +48,9 @@ import { const meta = getChatChannelMeta("googlechat"); -type GoogleChatChannelRuntime = typeof import("./channel.runtime.js").googleChatChannelRuntime; - -const loadGoogleChatChannelRuntime = createLazyRuntimeSurface( +const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), - ({ googleChatChannelRuntime }) => googleChatChannelRuntime, + "googleChatChannelRuntime", ); const formatAllowFromEntry = (entry: string) => diff --git a/extensions/imessage/api.ts b/extensions/imessage/api.ts new file mode 100644 index 00000000000..a311d13fec5 --- /dev/null +++ b/extensions/imessage/api.ts @@ -0,0 +1,3 @@ +export * from "./src/accounts.js"; +export * from "./src/target-parsing-helpers.js"; +export * from "./src/targets.js"; diff --git a/extensions/imessage/index.ts b/extensions/imessage/index.ts index aea014f06d4..6ed01ad9da4 100644 --- a/extensions/imessage/index.ts +++ b/extensions/imessage/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { imessagePlugin } from "./src/channel.js"; import { setIMessageRuntime } from "./src/runtime.js"; +export { imessagePlugin } from "./src/channel.js"; +export { setIMessageRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "imessage", name: "iMessage", diff --git a/extensions/imessage/runtime-api.ts b/extensions/imessage/runtime-api.ts new file mode 100644 index 00000000000..4f4acfa3328 --- /dev/null +++ b/extensions/imessage/runtime-api.ts @@ -0,0 +1,3 @@ +export * from "./src/monitor.js"; +export * from "./src/probe.js"; +export * from "./src/send.js"; diff --git a/extensions/imessage/setup-entry.ts b/extensions/imessage/setup-entry.ts index ed6936ca387..7c4c55967a8 100644 --- a/extensions/imessage/setup-entry.ts +++ b/extensions/imessage/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { imessageSetupPlugin } from "./src/channel.setup.js"; +export { imessageSetupPlugin } from "./src/channel.setup.js"; + export default defineSetupPluginEntry(imessageSetupPlugin); diff --git a/extensions/imessage/src/channel.setup.ts b/extensions/imessage/src/channel.setup.ts index 4f715cab88c..a6f2f90d9f0 100644 --- a/extensions/imessage/src/channel.setup.ts +++ b/extensions/imessage/src/channel.setup.ts @@ -1,10 +1,15 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/imessage"; +import { + buildChannelConfigSchema, + IMessageConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/imessage"; import { type ResolvedIMessageAccount } from "./accounts.js"; import { imessageSetupAdapter } from "./setup-core.js"; import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; export const imessageSetupPlugin: ChannelPlugin = { ...createIMessagePluginBase({ + configSchema: buildChannelConfigSchema(IMessageConfigSchema), setupWizard: imessageSetupWizard, setup: imessageSetupAdapter, }), diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 973456af7bb..fe20327e463 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -6,15 +6,18 @@ import { import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { + buildChannelConfigSchema, collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, formatTrimmedAllowFromEntries, + IMessageConfigSchema, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, type ChannelPlugin, } from "openclaw/plugin-sdk/imessage"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { type RoutePeer } from "openclaw/plugin-sdk/routing"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; @@ -23,12 +26,7 @@ import { imessageSetupAdapter } from "./setup-core.js"; import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js"; -let imessageChannelRuntimePromise: Promise | null = null; - -async function loadIMessageChannelRuntime() { - imessageChannelRuntimePromise ??= import("./channel.runtime.js"); - return imessageChannelRuntimePromise; -} +const loadIMessageChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js")); function buildIMessageBaseSessionKey(params: { cfg: Parameters[0]["cfg"]; @@ -102,6 +100,7 @@ function resolveIMessageOutboundSessionRoute(params: { export const imessagePlugin: ChannelPlugin = { ...createIMessagePluginBase({ + configSchema: buildChannelConfigSchema(IMessageConfigSchema), setupWizard: imessageSetupWizard, setup: imessageSetupAdapter, }), diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index 9da4e99b1ef..6ea7382106a 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -1,6 +1,5 @@ import { createPatchedAccountSetupAdapter, - formatDocsLink, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, @@ -14,6 +13,7 @@ import type { ChannelSetupWizard, ChannelSetupWizardTextInput, } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index 54511d284c4..ae6cdb2fcc1 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -1,8 +1,5 @@ -import { - detectBinary, - setSetupChannelEnabled, - type ChannelSetupWizard, -} from "openclaw/plugin-sdk/setup"; +import { setSetupChannelEnabled, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { detectBinary } from "openclaw/plugin-sdk/setup-tools"; import { listIMessageAccountIds, resolveIMessageAccount } from "./accounts.js"; import { createIMessageCliPathTextInput, diff --git a/extensions/imessage/src/shared.ts b/extensions/imessage/src/shared.ts index 935546721da..301b1848f99 100644 --- a/extensions/imessage/src/shared.ts +++ b/extensions/imessage/src/shared.ts @@ -13,7 +13,7 @@ import { resolveIMessageConfigDefaultTo, setAccountEnabledInConfigSection, type ChannelPlugin, -} from "openclaw/plugin-sdk/imessage"; +} from "openclaw/plugin-sdk/imessage-core"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, diff --git a/extensions/irc/api.ts b/extensions/irc/api.ts new file mode 100644 index 00000000000..4fae8e966ee --- /dev/null +++ b/extensions/irc/api.ts @@ -0,0 +1,2 @@ +export * from "./src/accounts.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/irc/index.ts b/extensions/irc/index.ts index 5ae8619812d..7a746c551cf 100644 --- a/extensions/irc/index.ts +++ b/extensions/irc/index.ts @@ -3,6 +3,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { ircPlugin } from "./src/channel.js"; import { setIrcRuntime } from "./src/runtime.js"; +export { ircPlugin } from "./src/channel.js"; +export { setIrcRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "irc", name: "IRC", diff --git a/extensions/line/api.ts b/extensions/line/api.ts new file mode 100644 index 00000000000..8f7fe4d268b --- /dev/null +++ b/extensions/line/api.ts @@ -0,0 +1,2 @@ +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/line/index.ts b/extensions/line/index.ts index fabf1c9d5b7..22f2c184e70 100644 --- a/extensions/line/index.ts +++ b/extensions/line/index.ts @@ -3,6 +3,9 @@ import { registerLineCardCommand } from "./src/card-command.js"; import { linePlugin } from "./src/channel.js"; import { setLineRuntime } from "./src/runtime.js"; +export { linePlugin } from "./src/channel.js"; +export { setLineRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "line", name: "LINE", diff --git a/extensions/line/setup-entry.ts b/extensions/line/setup-entry.ts index 97ed5fa30c6..ce23aecd544 100644 --- a/extensions/line/setup-entry.ts +++ b/extensions/line/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { lineSetupPlugin } from "./src/channel.setup.js"; +export { lineSetupPlugin } from "./src/channel.setup.js"; + export default defineSetupPluginEntry(lineSetupPlugin); diff --git a/extensions/llm-task/api.ts b/extensions/llm-task/api.ts new file mode 100644 index 00000000000..8eebdd06e0b --- /dev/null +++ b/extensions/llm-task/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/llm-task"; diff --git a/extensions/llm-task/index.ts b/extensions/llm-task/index.ts index a3920e5806e..68dd70503c2 100644 --- a/extensions/llm-task/index.ts +++ b/extensions/llm-task/index.ts @@ -1,8 +1,4 @@ -import { - definePluginEntry, - type AnyAgentTool, - type OpenClawPluginApi, -} from "openclaw/plugin-sdk/llm-task"; +import { definePluginEntry, type AnyAgentTool, type OpenClawPluginApi } from "./api.js"; import { createLlmTaskTool } from "./src/llm-task-tool.js"; export default definePluginEntry({ diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index bcc422290c6..47c7efbea76 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -8,8 +8,8 @@ import { normalizeThinkLevel, resolvePreferredOpenClawTmpDir, supportsXHighThinking, -} from "openclaw/plugin-sdk/llm-task"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/llm-task"; +} from "../api.js"; +import type { OpenClawPluginApi } from "../api.js"; function stripCodeFences(s: string): string { const trimmed = s.trim(); diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index b154e067116..62c0fed6d81 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -49,6 +49,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerImageGenerationProvider() {}, registerWebSearchProvider() {}, registerInteractiveHandler() {}, + onConversationBindingResolved() {}, registerHook() {}, registerHttpRoute() {}, registerCommand() {}, diff --git a/extensions/matrix/api.ts b/extensions/matrix/api.ts new file mode 100644 index 00000000000..8f7fe4d268b --- /dev/null +++ b/extensions/matrix/api.ts @@ -0,0 +1,2 @@ +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/matrix/index.ts b/extensions/matrix/index.ts index 5400a9b94c6..08e9133197c 100644 --- a/extensions/matrix/index.ts +++ b/extensions/matrix/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { matrixPlugin } from "./src/channel.js"; import { setMatrixRuntime } from "./src/runtime.js"; +export { matrixPlugin } from "./src/channel.js"; +export { setMatrixRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "matrix", name: "Matrix", diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 03007151d18..a7cc18208c3 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -7,6 +7,7 @@ import { buildOpenGroupPolicyWarning, collectAllowlistProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-policy"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { buildChannelConfigSchema, buildProbeChannelStatusSummary, @@ -15,7 +16,6 @@ import { PAIRING_APPROVED_MESSAGE, type ChannelPlugin, } from "openclaw/plugin-sdk/matrix"; -import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js"; import { matrixMessageActions } from "./actions.js"; import { MatrixConfigSchema } from "./config-schema.js"; @@ -39,11 +39,9 @@ import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) let matrixStartupLock: Promise = Promise.resolve(); -type MatrixChannelRuntime = typeof import("./channel.runtime.js").matrixChannelRuntime; - -const loadMatrixChannelRuntime = createLazyRuntimeSurface( +const loadMatrixChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), - ({ matrixChannelRuntime }) => matrixChannelRuntime, + "matrixChannelRuntime", ); const meta = { diff --git a/extensions/mattermost/api.ts b/extensions/mattermost/api.ts new file mode 100644 index 00000000000..4968757a94e --- /dev/null +++ b/extensions/mattermost/api.ts @@ -0,0 +1 @@ +export { mattermostPlugin } from "./src/channel.js"; diff --git a/extensions/mattermost/index.ts b/extensions/mattermost/index.ts index f5086aba465..a40971bf850 100644 --- a/extensions/mattermost/index.ts +++ b/extensions/mattermost/index.ts @@ -3,6 +3,9 @@ import { mattermostPlugin } from "./src/channel.js"; import { registerSlashCommandRoute } from "./src/mattermost/slash-state.js"; import { setMattermostRuntime } from "./src/runtime.js"; +export { mattermostPlugin } from "./src/channel.js"; +export { setMattermostRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "mattermost", name: "Mattermost", diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 16ee615454c..d578de86e9a 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -9,6 +9,32 @@ import { z } from "zod"; import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; import { buildSecretInputSchema } from "./secret-input.js"; +const DmChannelRetrySchema = z + .object({ + /** Maximum number of retry attempts for DM channel creation (default: 3) */ + maxRetries: z.number().int().min(0).max(10).optional(), + /** Initial delay in milliseconds before first retry (default: 1000) */ + initialDelayMs: z.number().int().min(100).max(60000).optional(), + /** Maximum delay in milliseconds between retries (default: 10000) */ + maxDelayMs: z.number().int().min(1000).max(60000).optional(), + /** Timeout for each individual DM channel creation request in milliseconds (default: 30000) */ + timeoutMs: z.number().int().min(5000).max(120000).optional(), + }) + .strict() + .refine( + (data) => { + if (data.initialDelayMs !== undefined && data.maxDelayMs !== undefined) { + return data.initialDelayMs <= data.maxDelayMs; + } + return true; + }, + { + message: "initialDelayMs must be less than or equal to maxDelayMs", + path: ["initialDelayMs"], + }, + ) + .optional(); + const MattermostSlashCommandsSchema = z .object({ /** Enable native slash commands. "auto" resolves to false (opt-in). */ @@ -58,6 +84,8 @@ const MattermostAccountSchemaBase = z allowedSourceIps: z.array(z.string()).optional(), }) .optional(), + /** Retry configuration for DM channel creation */ + dmChannelRetry: DmChannelRetrySchema, }) .strict(); diff --git a/extensions/mattermost/src/mattermost/client.retry.test.ts b/extensions/mattermost/src/mattermost/client.retry.test.ts new file mode 100644 index 00000000000..c5f62357fe4 --- /dev/null +++ b/extensions/mattermost/src/mattermost/client.retry.test.ts @@ -0,0 +1,466 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { createMattermostClient, createMattermostDirectChannelWithRetry } from "./client.js"; + +describe("createMattermostDirectChannelWithRetry", () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + function createMockClient() { + return createMattermostClient({ + baseUrl: "https://mattermost.example.com", + botToken: "test-token", + fetchImpl: mockFetch as unknown as typeof fetch, + }); + } + + function createFetchFailedError(params: { message: string; code?: string }): TypeError { + const cause = Object.assign(new Error(params.message), { + code: params.code, + }); + return Object.assign(new TypeError("fetch failed"), { cause }); + } + + it("succeeds on first attempt without retries", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-123" }), + } as Response); + + const client = createMockClient(); + const onRetry = vi.fn(); + + const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + onRetry, + }); + + expect(result.id).toBe("dm-channel-123"); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it("retries on 429 rate limit error and succeeds", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 429, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Too many requests" }), + text: async () => "Too many requests", + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-456" }), + } as Response); + + const client = createMockClient(); + const onRetry = vi.fn(); + + const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + onRetry, + }); + + expect(result.id).toBe("dm-channel-456"); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onRetry).toHaveBeenCalledWith( + 1, + expect.any(Number), + expect.objectContaining({ message: expect.stringContaining("429") }), + ); + }); + + it("retries on port 443 connection errors (not misclassified as 4xx)", async () => { + // This tests that port numbers like :443 don't trigger false 4xx classification + mockFetch + .mockRejectedValueOnce(new Error("connect ECONNRESET 104.18.32.10:443")) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-port" }), + } as Response); + + const client = createMockClient(); + + const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }); + + // Should retry and succeed on second attempt (port 443 should NOT be treated as 4xx) + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(result.id).toBe("dm-channel-port"); + }); + + it("does not retry on 400 even if error message contains '429' text", async () => { + // This tests that "429" in error detail doesn't trigger false rate-limit retry + // e.g., "Invalid user ID: 4294967295" should NOT be retried + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Invalid user ID: 4294967295" }), + text: async () => "Invalid user ID: 4294967295", + } as Response); + + const client = createMockClient(); + + await expect( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }), + ).rejects.toThrow(); + + // Should not retry - only called once (400 is a client error, even though message contains "429") + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("retries on 5xx server errors", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 503, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Service unavailable" }), + text: async () => "Service unavailable", + } as Response) + .mockResolvedValueOnce({ + ok: false, + status: 502, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Bad gateway" }), + text: async () => "Bad gateway", + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-789" }), + } as Response); + + const client = createMockClient(); + + const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }); + + expect(result.id).toBe("dm-channel-789"); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it("retries on network errors", async () => { + mockFetch + .mockRejectedValueOnce(new Error("Network error: connection refused")) + .mockRejectedValueOnce(new Error("ECONNRESET")) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-abc" }), + } as Response); + + const client = createMockClient(); + + const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }); + + expect(result.id).toBe("dm-channel-abc"); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it("retries on fetch failed errors when the cause carries a transient code", async () => { + mockFetch + .mockRejectedValueOnce( + createFetchFailedError({ + message: "connect ECONNREFUSED 127.0.0.1:81", + code: "ECONNREFUSED", + }), + ) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-fetch-failed" }), + } as Response); + + const client = createMockClient(); + + const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }); + + expect(result.id).toBe("dm-channel-fetch-failed"); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("does not retry on 4xx client errors (except 429)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Bad request" }), + text: async () => "Bad request", + } as Response); + + const client = createMockClient(); + + await expect( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }), + ).rejects.toThrow("400"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("does not retry on 404 not found", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "User not found" }), + text: async () => "User not found", + } as Response); + + const client = createMockClient(); + + await expect( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }), + ).rejects.toThrow("404"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("throws after exhausting all retries", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Service unavailable" }), + text: async () => "Service unavailable", + } as Response); + + const client = createMockClient(); + + await expect( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 2, + initialDelayMs: 10, + }), + ).rejects.toThrow(); + + expect(mockFetch).toHaveBeenCalledTimes(3); // initial + 2 retries + }); + + it("respects custom timeout option and aborts fetch", async () => { + let abortSignal: AbortSignal | undefined; + let abortListenerCalled = false; + + mockFetch.mockImplementationOnce((url, init) => { + abortSignal = init?.signal; + if (abortSignal) { + abortSignal.addEventListener("abort", () => { + abortListenerCalled = true; + }); + } + // Return a promise that rejects when aborted, otherwise never resolves + return new Promise((_, reject) => { + if (abortSignal) { + const checkAbort = () => { + if (abortSignal?.aborted) { + reject(new Error("AbortError")); + } else { + setTimeout(checkAbort, 10); + } + }; + setTimeout(checkAbort, 10); + } + }); + }); + + const client = createMockClient(); + + await expect( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + timeoutMs: 50, + maxRetries: 0, + initialDelayMs: 10, + }), + ).rejects.toThrow(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(abortSignal).toBeDefined(); + expect(abortListenerCalled).toBe(true); + }); + + it("uses exponential backoff with jitter between retries", async () => { + const delays: number[] = []; + mockFetch + .mockRejectedValueOnce(new Error("Mattermost API 503 Service Unavailable")) + .mockRejectedValueOnce(new Error("Mattermost API 503 Service Unavailable")) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-delay" }), + } as Response); + + const client = createMockClient(); + + await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 100, + maxDelayMs: 1000, + onRetry: (attempt, delayMs) => { + delays.push(delayMs); + }, + }); + + expect(delays).toHaveLength(2); + // First retry: exponentialDelay = 100ms, jitter = 0-100ms, total = 100-200ms + expect(delays[0]).toBeGreaterThanOrEqual(100); + expect(delays[0]).toBeLessThanOrEqual(200); + // Second retry: exponentialDelay = 200ms, jitter = 0-200ms, total = 200-400ms + expect(delays[1]).toBeGreaterThanOrEqual(200); + expect(delays[1]).toBeLessThanOrEqual(400); + }); + + it("respects maxDelayMs cap", async () => { + const delays: number[] = []; + mockFetch + .mockRejectedValueOnce(new Error("Mattermost API 503")) + .mockRejectedValueOnce(new Error("Mattermost API 503")) + .mockRejectedValueOnce(new Error("Mattermost API 503")) + .mockRejectedValueOnce(new Error("Mattermost API 503")) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-max" }), + } as Response); + + const client = createMockClient(); + + await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 4, + initialDelayMs: 1000, + maxDelayMs: 2500, + onRetry: (attempt, delayMs) => { + delays.push(delayMs); + }, + }); + + expect(delays).toHaveLength(4); + // All delays should be capped at maxDelayMs + delays.forEach((delay) => { + expect(delay).toBeLessThanOrEqual(2500); + }); + }); + + it("does not retry on 4xx errors even if message contains retryable keywords", async () => { + // This tests the fix for false positives where a 400 error with "timeout" in the message + // would incorrectly be retried + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Request timeout: connection timed out" }), + text: async () => "Request timeout: connection timed out", + } as Response); + + const client = createMockClient(); + + await expect( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }), + ).rejects.toThrow("400"); + + // Should not retry - only called once + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("does not retry on 403 Forbidden even with 'abort' in message", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ message: "Request aborted: forbidden" }), + text: async () => "Request aborted: forbidden", + } as Response); + + const client = createMockClient(); + + await expect( + createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }), + ).rejects.toThrow("403"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("passes AbortSignal to fetch for timeout support", async () => { + let capturedSignal: AbortSignal | undefined; + mockFetch.mockImplementationOnce((url, init) => { + capturedSignal = init?.signal; + return Promise.resolve({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-signal" }), + } as Response); + }); + + const client = createMockClient(); + await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + timeoutMs: 5000, + }); + + expect(capturedSignal).toBeDefined(); + expect(capturedSignal).toBeInstanceOf(AbortSignal); + }); + + it("retries on 5xx even if error message contains 4xx substring", async () => { + // This tests the fix for the ordering bug: 503 with "upstream 404" should be retried + mockFetch + .mockRejectedValueOnce(new Error("Mattermost API 503: upstream returned 404 Not Found")) + .mockResolvedValueOnce({ + ok: true, + status: 201, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ id: "dm-channel-5xx-with-404" }), + } as Response); + + const client = createMockClient(); + + const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], { + maxRetries: 3, + initialDelayMs: 10, + }); + + // Should retry and succeed on second attempt + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(result.id).toBe("dm-channel-5xx-with-404"); + }); +}); diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts index 1a8219340b9..c514160590f 100644 --- a/extensions/mattermost/src/mattermost/client.ts +++ b/extensions/mattermost/src/mattermost/client.ts @@ -168,13 +168,270 @@ export async function sendMattermostTyping( export async function createMattermostDirectChannel( client: MattermostClient, userIds: string[], + signal?: AbortSignal, ): Promise { return await client.request("/channels/direct", { method: "POST", body: JSON.stringify(userIds), + signal, }); } +export type CreateDmChannelRetryOptions = { + /** Maximum number of retry attempts (default: 3) */ + maxRetries?: number; + /** Initial delay in milliseconds (default: 1000) */ + initialDelayMs?: number; + /** Maximum delay in milliseconds (default: 10000) */ + maxDelayMs?: number; + /** Timeout for each individual request in milliseconds (default: 30000) */ + timeoutMs?: number; + /** Optional logger for retry events */ + onRetry?: (attempt: number, delayMs: number, error: Error) => void; +}; + +const RETRYABLE_NETWORK_ERROR_CODES = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", + "ESOCKETTIMEDOUT", + "ECONNABORTED", + "ENOTFOUND", + "EAI_AGAIN", + "EHOSTUNREACH", + "ENETUNREACH", + "EPIPE", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_DNS_RESOLVE_FAILED", + "UND_ERR_CONNECT", + "UND_ERR_SOCKET", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", +]); + +const RETRYABLE_NETWORK_ERROR_NAMES = new Set([ + "AbortError", + "TimeoutError", + "ConnectTimeoutError", + "HeadersTimeoutError", + "BodyTimeoutError", +]); + +const RETRYABLE_NETWORK_MESSAGE_SNIPPETS = [ + "network error", + "timeout", + "timed out", + "abort", + "connection refused", + "econnreset", + "econnrefused", + "etimedout", + "enotfound", + "socket hang up", + "getaddrinfo", +]; + +/** + * Creates a Mattermost DM channel with exponential backoff retry logic. + * Retries on transient errors (429, 5xx, network errors) but not on + * client errors (4xx except 429) or permanent failures. + */ +export async function createMattermostDirectChannelWithRetry( + client: MattermostClient, + userIds: string[], + options: CreateDmChannelRetryOptions = {}, +): Promise { + const { + maxRetries = 3, + initialDelayMs = 1000, + maxDelayMs = 10000, + timeoutMs = 30000, + onRetry, + } = options; + + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + // Use AbortController for per-request timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const result = await createMattermostDirectChannel(client, userIds, controller.signal); + return result; + } finally { + clearTimeout(timeoutId); + } + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + + // Don't retry on the last attempt + if (attempt >= maxRetries) { + break; + } + + // Check if error is retryable + if (!isRetryableError(lastError)) { + throw lastError; + } + + // Calculate exponential backoff delay with full-jitter + // Jitter is proportional to the exponential delay, not a fixed 1000ms + // This ensures backoff behaves correctly for small delay configurations + const exponentialDelay = initialDelayMs * Math.pow(2, attempt); + const jitter = Math.random() * exponentialDelay; + const delayMs = Math.min(exponentialDelay + jitter, maxDelayMs); + + if (onRetry) { + onRetry(attempt + 1, delayMs, lastError); + } + + // Wait before retrying + await sleep(delayMs); + } + } + + throw lastError ?? new Error("Failed to create DM channel after retries"); +} + +function isRetryableError(error: Error): boolean { + const candidates = collectErrorCandidates(error); + const messages = candidates + .map((candidate) => readErrorMessage(candidate)?.toLowerCase()) + .filter((message): message is string => Boolean(message)); + + // Retry on 5xx server errors FIRST (before checking 4xx) + // Use "mattermost api" prefix to avoid matching port numbers (e.g., :443) or IP octets + // This prevents misclassification when a 5xx error detail contains a 4xx substring + // e.g., "Mattermost API 503: upstream returned 404" + if (messages.some((message) => /mattermost api 5\d{2}\b/.test(message))) { + return true; + } + + // Check for explicit 429 rate limiting FIRST (before generic "429" text match) + // This avoids retrying when error detail contains "429" but it's not the status code + if ( + messages.some( + (message) => /mattermost api 429\b/.test(message) || message.includes("too many requests"), + ) + ) { + return true; + } + + // Check for explicit 4xx status codes - these are client errors and should NOT be retried + // (except 429 which is handled above) + // Use "mattermost api" prefix to avoid matching port numbers like :443 + for (const message of messages) { + const clientErrorMatch = message.match(/mattermost api (4\d{2})\b/); + if (!clientErrorMatch) { + continue; + } + const statusCode = parseInt(clientErrorMatch[1], 10); + if (statusCode >= 400 && statusCode < 500) { + return false; + } + } + + // Retry on network/transient errors only if no explicit Mattermost API status code is present + // This avoids false positives like: + // - "400 Bad Request: connection timed out" (has status code) + // - "connect ECONNRESET 104.18.32.10:443" (has port number, not status) + const hasMattermostApiStatusCode = messages.some((message) => + /mattermost api \d{3}\b/.test(message), + ); + if (hasMattermostApiStatusCode) { + return false; + } + + const codes = candidates + .map((candidate) => readErrorCode(candidate)) + .filter((code): code is string => Boolean(code)); + if (codes.some((code) => RETRYABLE_NETWORK_ERROR_CODES.has(code))) { + return true; + } + + const names = candidates + .map((candidate) => readErrorName(candidate)) + .filter((name): name is string => Boolean(name)); + if (names.some((name) => RETRYABLE_NETWORK_ERROR_NAMES.has(name))) { + return true; + } + + return messages.some((message) => + RETRYABLE_NETWORK_MESSAGE_SNIPPETS.some((pattern) => message.includes(pattern)), + ); +} + +function collectErrorCandidates(error: unknown): unknown[] { + const queue: unknown[] = [error]; + const seen = new Set(); + const candidates: unknown[] = []; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || seen.has(current)) { + continue; + } + seen.add(current); + candidates.push(current); + + if (typeof current !== "object") { + continue; + } + + const nested = current as { + cause?: unknown; + reason?: unknown; + errors?: unknown; + }; + queue.push(nested.cause, nested.reason); + if (Array.isArray(nested.errors)) { + queue.push(...nested.errors); + } + } + + return candidates; +} + +function readErrorMessage(error: unknown): string | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + const message = (error as { message?: unknown }).message; + return typeof message === "string" && message.trim() ? message : undefined; +} + +function readErrorName(error: unknown): string | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + const name = (error as { name?: unknown }).name; + return typeof name === "string" && name.trim() ? name : undefined; +} + +function readErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + const { code, errno } = error as { + code?: unknown; + errno?: unknown; + }; + const raw = typeof code === "string" && code.trim() ? code : errno; + if (typeof raw === "string" && raw.trim()) { + return raw.trim().toUpperCase(); + } + if (typeof raw === "number" && Number.isFinite(raw)) { + return String(raw); + } + return undefined; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export async function createMattermostPost( client: MattermostClient, params: { diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts index 15cf05eb541..784b27677e6 100644 --- a/extensions/mattermost/src/mattermost/send.test.ts +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -13,9 +13,11 @@ const mockState = vi.hoisted(() => ({ accountId: "default", botToken: "bot-token", baseUrl: "https://mattermost.example.com", + config: {}, })), createMattermostClient: vi.fn(), createMattermostDirectChannel: vi.fn(), + createMattermostDirectChannelWithRetry: vi.fn(), createMattermostPost: vi.fn(), fetchMattermostChannelByName: vi.fn(), fetchMattermostMe: vi.fn(), @@ -37,6 +39,7 @@ vi.mock("./accounts.js", () => ({ vi.mock("./client.js", () => ({ createMattermostClient: mockState.createMattermostClient, createMattermostDirectChannel: mockState.createMattermostDirectChannel, + createMattermostDirectChannelWithRetry: mockState.createMattermostDirectChannelWithRetry, createMattermostPost: mockState.createMattermostPost, fetchMattermostChannelByName: mockState.fetchMattermostChannelByName, fetchMattermostMe: mockState.fetchMattermostMe, @@ -77,10 +80,12 @@ describe("sendMessageMattermost", () => { accountId: "default", botToken: "bot-token", baseUrl: "https://mattermost.example.com", + config: {}, }); mockState.loadOutboundMediaFromUrl.mockReset(); mockState.createMattermostClient.mockReset(); mockState.createMattermostDirectChannel.mockReset(); + mockState.createMattermostDirectChannelWithRetry.mockReset(); mockState.createMattermostPost.mockReset(); mockState.fetchMattermostChannelByName.mockReset(); mockState.fetchMattermostMe.mockReset(); @@ -91,6 +96,7 @@ describe("sendMessageMattermost", () => { resetMattermostOpaqueTargetCacheForTests(); mockState.createMattermostClient.mockReturnValue({}); mockState.createMattermostPost.mockResolvedValue({ id: "post-1" }); + mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-1" }); mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-user" }); mockState.fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }]); mockState.fetchMattermostChannelByName.mockResolvedValue({ id: "town-square" }); @@ -105,6 +111,12 @@ describe("sendMessageMattermost", () => { }, }, }; + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "work", + botToken: "provided-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); await sendMessageMattermost("channel:town-square", "hello", { cfg: providedCfg as any, @@ -128,6 +140,12 @@ describe("sendMessageMattermost", () => { }, }; mockState.loadConfig.mockReturnValueOnce(runtimeCfg); + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "runtime-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); await sendMessageMattermost("channel:town-square", "hello"); @@ -146,6 +164,12 @@ describe("sendMessageMattermost", () => { contentType: "image/png", kind: "image", }); + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "bot-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); await sendMessageMattermost("channel:town-square", "hello", { mediaUrl: "file:///tmp/agent-workspace/photo.png", @@ -169,6 +193,13 @@ describe("sendMessageMattermost", () => { }); it("builds interactive button props when buttons are provided", async () => { + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "bot-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); + await sendMessageMattermost("channel:town-square", "Pick a model", { buttons: [[{ callback_data: "mdlprov", text: "Browse providers" }]], }); @@ -196,8 +227,13 @@ describe("sendMessageMattermost", () => { it("resolves a bare Mattermost user id as a DM target before upload", async () => { const userId = "dthcxgoxhifn3pwh65cut3ud3w"; + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "bot-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); - mockState.createMattermostDirectChannel.mockResolvedValueOnce({ id: "dm-channel-1" }); mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({ buffer: Buffer.from("media-bytes"), fileName: "photo.png", @@ -211,7 +247,11 @@ describe("sendMessageMattermost", () => { }); expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, userId); - expect(mockState.createMattermostDirectChannel).toHaveBeenCalledWith({}, ["bot-user", userId]); + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-user", userId], + expect.any(Object), + ); expect(mockState.uploadMattermostFile).toHaveBeenCalledWith( {}, expect.objectContaining({ @@ -223,6 +263,12 @@ describe("sendMessageMattermost", () => { it("falls back to a channel target when bare Mattermost id is not a user", async () => { const channelId = "aaaaaaaaaaaaaaaaaaaaaaaaaa"; + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "bot-token", + baseUrl: "https://mattermost.example.com", + config: {}, + }); mockState.fetchMattermostUser.mockRejectedValueOnce( new Error("Mattermost API 404 Not Found: user not found"), ); @@ -239,7 +285,7 @@ describe("sendMessageMattermost", () => { }); expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, channelId); - expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled(); + expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled(); expect(mockState.uploadMattermostFile).toHaveBeenCalledWith( {}, expect.objectContaining({ @@ -337,11 +383,12 @@ describe("parseMattermostTarget", () => { // userIdResolutionCache and dmChannelCache are module singletons that survive across tests. // Using unique cache keys per test ensures full isolation without needing a cache reset API. describe("sendMessageMattermost user-first resolution", () => { - function makeAccount(token: string) { + function makeAccount(token: string, config = {}) { return { accountId: "default", botToken: token, baseUrl: "https://mattermost.example.com", + config, }; } @@ -350,6 +397,7 @@ describe("sendMessageMattermost user-first resolution", () => { mockState.createMattermostClient.mockReturnValue({}); mockState.createMattermostPost.mockResolvedValue({ id: "post-id" }); mockState.createMattermostDirectChannel.mockResolvedValue({ id: "dm-channel-id" }); + mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" }); mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" }); }); @@ -362,7 +410,7 @@ describe("sendMessageMattermost user-first resolution", () => { const res = await sendMessageMattermost(userId, "hello"); expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1); - expect(mockState.createMattermostDirectChannel).toHaveBeenCalledTimes(1); + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1); const params = mockState.createMattermostPost.mock.calls[0]?.[1]; expect(params.channelId).toBe("dm-channel-id"); expect(res.channelId).toBe("dm-channel-id"); @@ -379,7 +427,7 @@ describe("sendMessageMattermost user-first resolution", () => { const res = await sendMessageMattermost(channelId, "hello"); expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1); - expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled(); + expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled(); const params = mockState.createMattermostPost.mock.calls[0]?.[1]; expect(params.channelId).toBe(channelId); expect(res.channelId).toBe(channelId); @@ -403,7 +451,7 @@ describe("sendMessageMattermost user-first resolution", () => { vi.clearAllMocks(); mockState.createMattermostClient.mockReturnValue({}); mockState.createMattermostPost.mockResolvedValue({ id: "post-id-2" }); - mockState.createMattermostDirectChannel.mockResolvedValue({ id: "dm-channel-id" }); + mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" }); mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" }); mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenB)); mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); @@ -417,11 +465,12 @@ describe("sendMessageMattermost user-first resolution", () => { // Unique token + id — explicit user: prefix bypasses probe, goes straight to DM const userId = "dddddd4444444444dddddd4444"; // 26 chars mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-user-t4")); + mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" }); const res = await sendMessageMattermost(`user:${userId}`, "hello"); expect(mockState.fetchMattermostUser).not.toHaveBeenCalled(); - expect(mockState.createMattermostDirectChannel).toHaveBeenCalledTimes(1); + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1); expect(res.channelId).toBe("dm-channel-id"); }); @@ -433,9 +482,101 @@ describe("sendMessageMattermost user-first resolution", () => { const res = await sendMessageMattermost(`channel:${chanId}`, "hello"); expect(mockState.fetchMattermostUser).not.toHaveBeenCalled(); - expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled(); + expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled(); const params = mockState.createMattermostPost.mock.calls[0]?.[1]; expect(params.channelId).toBe(chanId); expect(res.channelId).toBe(chanId); }); + + it("passes dmRetryOptions from opts to createMattermostDirectChannelWithRetry", async () => { + const userId = "ffffff6666666666ffffff6666"; // 26 chars + mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-retry-opts-t6")); + mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); + + const retryOptions = { + maxRetries: 5, + initialDelayMs: 500, + maxDelayMs: 5000, + timeoutMs: 10000, + }; + + await sendMessageMattermost(`user:${userId}`, "hello", { + dmRetryOptions: retryOptions, + }); + + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-id", userId], + expect.objectContaining(retryOptions), + ); + }); + + it("uses dmChannelRetry from account config when opts.dmRetryOptions not provided", async () => { + const userId = "gggggg7777777777gggggg7777"; // 26 chars + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "token-retry-config-t7", + baseUrl: "https://mattermost.example.com", + config: { + dmChannelRetry: { + maxRetries: 4, + initialDelayMs: 2000, + maxDelayMs: 8000, + timeoutMs: 15000, + }, + }, + }); + mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); + + await sendMessageMattermost(`user:${userId}`, "hello"); + + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-id", userId], + expect.objectContaining({ + maxRetries: 4, + initialDelayMs: 2000, + maxDelayMs: 8000, + timeoutMs: 15000, + }), + ); + }); + + it("opts.dmRetryOptions overrides provided fields and preserves account defaults", async () => { + const userId = "hhhhhh8888888888hhhhhh8888"; // 26 chars + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "token-retry-override-t8", + baseUrl: "https://mattermost.example.com", + config: { + dmChannelRetry: { + maxRetries: 2, + initialDelayMs: 1000, + }, + }, + }); + mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); + + const overrideOptions = { + maxRetries: 7, + timeoutMs: 20000, + }; + + await sendMessageMattermost(`user:${userId}`, "hello", { + dmRetryOptions: overrideOptions, + }); + + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-id", userId], + expect.objectContaining(overrideOptions), + ); + expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( + {}, + ["bot-id", userId], + expect.objectContaining({ + initialDelayMs: 1000, + }), + ); + }); }); diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index 4655dab2f7d..c589c8829a0 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -3,7 +3,7 @@ import { getMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, - createMattermostDirectChannel, + createMattermostDirectChannelWithRetry, createMattermostPost, fetchMattermostChannelByName, fetchMattermostMe, @@ -12,6 +12,7 @@ import { normalizeMattermostBaseUrl, uploadMattermostFile, type MattermostUser, + type CreateDmChannelRetryOptions, } from "./client.js"; import { buildButtonProps, @@ -32,6 +33,8 @@ export type MattermostSendOpts = { props?: Record; buttons?: Array; attachmentText?: string; + /** Retry options for DM channel creation */ + dmRetryOptions?: CreateDmChannelRetryOptions; }; export type MattermostSendResult = { @@ -182,11 +185,40 @@ async function resolveChannelIdByName(params: { throw new Error(`Mattermost channel "#${name}" not found in any team the bot belongs to`); } -async function resolveTargetChannelId(params: { +type ResolveTargetChannelIdParams = { target: MattermostTarget; baseUrl: string; token: string; -}): Promise { + dmRetryOptions?: CreateDmChannelRetryOptions; + logger?: { debug?: (msg: string) => void; warn?: (msg: string) => void }; +}; + +function mergeDmRetryOptions( + base?: CreateDmChannelRetryOptions, + override?: CreateDmChannelRetryOptions, +): CreateDmChannelRetryOptions | undefined { + const merged: CreateDmChannelRetryOptions = { + maxRetries: override?.maxRetries ?? base?.maxRetries, + initialDelayMs: override?.initialDelayMs ?? base?.initialDelayMs, + maxDelayMs: override?.maxDelayMs ?? base?.maxDelayMs, + timeoutMs: override?.timeoutMs ?? base?.timeoutMs, + onRetry: override?.onRetry, + }; + + if ( + merged.maxRetries === undefined && + merged.initialDelayMs === undefined && + merged.maxDelayMs === undefined && + merged.timeoutMs === undefined && + merged.onRetry === undefined + ) { + return undefined; + } + + return merged; +} + +async function resolveTargetChannelId(params: ResolveTargetChannelIdParams): Promise { if (params.target.kind === "channel") { return params.target.id; } @@ -214,7 +246,20 @@ async function resolveTargetChannelId(params: { baseUrl: params.baseUrl, botToken: params.token, }); - const channel = await createMattermostDirectChannel(client, [botUser.id, userId]); + + const channel = await createMattermostDirectChannelWithRetry(client, [botUser.id, userId], { + ...params.dmRetryOptions, + onRetry: (attempt, delayMs, error) => { + // Call user's onRetry if provided + params.dmRetryOptions?.onRetry?.(attempt, delayMs, error); + // Log if verbose mode is enabled + if (params.logger) { + params.logger.warn?.( + `DM channel creation retry ${attempt} after ${delayMs}ms: ${error.message}`, + ); + } + }, + }); dmChannelCache.set(dmKey, channel.id); return channel.id; } @@ -232,6 +277,7 @@ async function resolveMattermostSendContext( opts: MattermostSendOpts = {}, ): Promise { const core = getCore(); + const logger = core.logging.getChildLogger({ module: "mattermost" }); const cfg = opts.cfg ?? core.config.loadConfig(); const account = resolveMattermostAccount({ cfg, @@ -262,10 +308,23 @@ async function resolveMattermostSendContext( : opaqueTarget?.kind === "channel" ? { kind: "channel" as const, id: opaqueTarget.id } : parseMattermostTarget(trimmedTo); + // Build retry options from account config, allowing opts to override + const accountRetryConfig: CreateDmChannelRetryOptions | undefined = account.config.dmChannelRetry + ? { + maxRetries: account.config.dmChannelRetry.maxRetries, + initialDelayMs: account.config.dmChannelRetry.initialDelayMs, + maxDelayMs: account.config.dmChannelRetry.maxDelayMs, + timeoutMs: account.config.dmChannelRetry.timeoutMs, + } + : undefined; + const dmRetryOptions = mergeDmRetryOptions(accountRetryConfig, opts.dmRetryOptions); + const channelId = await resolveTargetChannelId({ target, baseUrl, token, + dmRetryOptions, + logger: core.logging.shouldLogVerbose() ? logger : undefined, }); return { diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index f4038ac6920..e6fcc19098c 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -90,6 +90,17 @@ export type MattermostAccountConfig = { */ allowedSourceIps?: string[]; }; + /** Retry configuration for DM channel creation */ + dmChannelRetry?: { + /** Maximum number of retry attempts (default: 3) */ + maxRetries?: number; + /** Initial delay in milliseconds before first retry (default: 1000) */ + initialDelayMs?: number; + /** Maximum delay in milliseconds between retries (default: 10000) */ + maxDelayMs?: number; + /** Timeout for each individual request in milliseconds (default: 30000) */ + timeoutMs?: number; + }; }; export type MattermostConfig = { diff --git a/extensions/memory-lancedb/api.ts b/extensions/memory-lancedb/api.ts new file mode 100644 index 00000000000..c1bd12dd4b7 --- /dev/null +++ b/extensions/memory-lancedb/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/memory-lancedb"; diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index b3033b118c9..96f77d0a90b 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -10,7 +10,7 @@ import { randomUUID } from "node:crypto"; import type * as LanceDB from "@lancedb/lancedb"; import { Type } from "@sinclair/typebox"; import OpenAI from "openai"; -import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/memory-lancedb"; +import { definePluginEntry, type OpenClawPluginApi } from "./api.js"; import { DEFAULT_CAPTURE_MAX_CHARS, MEMORY_CATEGORIES, diff --git a/extensions/msteams/api.ts b/extensions/msteams/api.ts new file mode 100644 index 00000000000..8f7fe4d268b --- /dev/null +++ b/extensions/msteams/api.ts @@ -0,0 +1,2 @@ +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/msteams/index.ts b/extensions/msteams/index.ts index c190ea49224..edffd1452f4 100644 --- a/extensions/msteams/index.ts +++ b/extensions/msteams/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { msteamsPlugin } from "./src/channel.js"; import { setMSTeamsRuntime } from "./src/runtime.js"; +export { msteamsPlugin } from "./src/channel.js"; +export { setMSTeamsRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "msteams", name: "Microsoft Teams", diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index e337566e483..00430996001 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,5 +1,6 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionName, ChannelPlugin, @@ -14,7 +15,6 @@ import { MSTeamsConfigSchema, PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk/msteams"; -import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; import type { ProbeMSTeamsResult } from "./probe.js"; import { @@ -57,11 +57,9 @@ const TEAMS_GRAPH_PERMISSION_HINTS: Record = { "Files.Read.All": "files (OneDrive)", }; -type MSTeamsChannelRuntime = typeof import("./channel.runtime.js").msTeamsChannelRuntime; - -const loadMSTeamsChannelRuntime = createLazyRuntimeSurface( +const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), - ({ msTeamsChannelRuntime }) => msTeamsChannelRuntime, + "msTeamsChannelRuntime", ); export const msteamsPlugin: ChannelPlugin = { diff --git a/extensions/nextcloud-talk/api.ts b/extensions/nextcloud-talk/api.ts new file mode 100644 index 00000000000..05701614b9e --- /dev/null +++ b/extensions/nextcloud-talk/api.ts @@ -0,0 +1 @@ +export { nextcloudTalkPlugin } from "./src/channel.js"; diff --git a/extensions/nextcloud-talk/index.ts b/extensions/nextcloud-talk/index.ts index 2057bd435e8..56a398d705b 100644 --- a/extensions/nextcloud-talk/index.ts +++ b/extensions/nextcloud-talk/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { nextcloudTalkPlugin } from "./src/channel.js"; import { setNextcloudTalkRuntime } from "./src/runtime.js"; +export { nextcloudTalkPlugin } from "./src/channel.js"; +export { setNextcloudTalkRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "nextcloud-talk", name: "Nextcloud Talk", diff --git a/extensions/nostr/api.ts b/extensions/nostr/api.ts new file mode 100644 index 00000000000..7c705aec6e5 --- /dev/null +++ b/extensions/nostr/api.ts @@ -0,0 +1 @@ +export * from "./src/setup-surface.js"; diff --git a/extensions/nostr/index.ts b/extensions/nostr/index.ts index cdabf64c322..2b891c4f0f2 100644 --- a/extensions/nostr/index.ts +++ b/extensions/nostr/index.ts @@ -5,6 +5,9 @@ import { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js"; import { getNostrRuntime, setNostrRuntime } from "./src/runtime.js"; import { resolveNostrAccount } from "./src/types.js"; +export { nostrPlugin } from "./src/channel.js"; +export { setNostrRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "nostr", name: "Nostr", diff --git a/extensions/signal/api.ts b/extensions/signal/api.ts new file mode 100644 index 00000000000..feaaa1c5835 --- /dev/null +++ b/extensions/signal/api.ts @@ -0,0 +1 @@ +export * from "./src/accounts.js"; diff --git a/extensions/signal/index.ts b/extensions/signal/index.ts index 6b20777f842..f18a7041b53 100644 --- a/extensions/signal/index.ts +++ b/extensions/signal/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { signalPlugin } from "./src/channel.js"; import { setSignalRuntime } from "./src/runtime.js"; +export { signalPlugin } from "./src/channel.js"; +export { setSignalRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "signal", name: "Signal", diff --git a/extensions/signal/runtime-api.ts b/extensions/signal/runtime-api.ts new file mode 100644 index 00000000000..e258df15c9c --- /dev/null +++ b/extensions/signal/runtime-api.ts @@ -0,0 +1 @@ +export * from "./src/index.js"; diff --git a/extensions/signal/setup-entry.ts b/extensions/signal/setup-entry.ts index 63f6d95e8fc..11930cbba37 100644 --- a/extensions/signal/setup-entry.ts +++ b/extensions/signal/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { signalSetupPlugin } from "./src/channel.setup.js"; +export { signalSetupPlugin } from "./src/channel.setup.js"; + export default defineSetupPluginEntry(signalSetupPlugin); diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts index 6fa8add4405..752fcfcc241 100644 --- a/extensions/signal/src/channel.setup.ts +++ b/extensions/signal/src/channel.setup.ts @@ -1,10 +1,15 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/signal"; +import { + buildChannelConfigSchema, + SignalConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/signal"; import { type ResolvedSignalAccount } from "./accounts.js"; import { signalSetupAdapter } from "./setup-core.js"; import { createSignalPluginBase, signalSetupWizard } from "./shared.js"; export const signalSetupPlugin: ChannelPlugin = { ...createSignalPluginBase({ + configSchema: buildChannelConfigSchema(SignalConfigSchema), setupWizard: signalSetupWizard, setup: signalSetupAdapter, }), diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 17b97c96f25..0a58c29bfe7 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -11,6 +11,7 @@ import { type RoutePeer } from "openclaw/plugin-sdk/routing"; import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, + buildChannelConfigSchema, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, @@ -19,6 +20,7 @@ import { normalizeSignalMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, + SignalConfigSchema, type ChannelMessageActionAdapter, type ChannelPlugin, } from "openclaw/plugin-sdk/signal"; @@ -277,6 +279,7 @@ async function sendFormattedSignalMedia(ctx: { export const signalPlugin: ChannelPlugin = { ...createSignalPluginBase({ + configSchema: buildChannelConfigSchema(SignalConfigSchema), setupWizard: signalSetupWizard, setup: signalSetupAdapter, }), diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index e0c4d5ec0a3..a89f25dc268 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -1,7 +1,5 @@ import { createPatchedAccountSetupAdapter, - formatCliCommand, - formatDocsLink, normalizeE164, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, @@ -16,6 +14,7 @@ import type { ChannelSetupWizard, ChannelSetupWizardTextInput, } from "openclaw/plugin-sdk/setup"; +import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { listSignalAccountIds, resolveDefaultSignalAccountId, diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 01ded866785..88d4d07a212 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,9 +1,5 @@ -import { - detectBinary, - installSignalCli, - setSetupChannelEnabled, - type ChannelSetupWizard, -} from "openclaw/plugin-sdk/setup"; +import { setSetupChannelEnabled, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { detectBinary, installSignalCli } from "openclaw/plugin-sdk/setup-tools"; import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js"; import { createSignalCliPathTextInput, diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index 3de5af7d57a..f03ecd847e2 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -12,7 +12,7 @@ import { setAccountEnabledInConfigSection, SignalConfigSchema, type ChannelPlugin, -} from "openclaw/plugin-sdk/signal"; +} from "openclaw/plugin-sdk/signal-core"; import { listSignalAccountIds, resolveDefaultSignalAccountId, diff --git a/extensions/slack/api.ts b/extensions/slack/api.ts new file mode 100644 index 00000000000..37aaf02b027 --- /dev/null +++ b/extensions/slack/api.ts @@ -0,0 +1,11 @@ +export * from "./src/account-inspect.js"; +export * from "./src/accounts.js"; +export * from "./src/actions.js"; +export * from "./src/blocks-input.js"; +export * from "./src/blocks-render.js"; +export * from "./src/http/index.js"; +export * from "./src/interactive-replies.js"; +export * from "./src/message-actions.js"; +export * from "./src/sent-thread-cache.js"; +export * from "./src/targets.js"; +export * from "./src/threading-tool-context.js"; diff --git a/extensions/slack/index.ts b/extensions/slack/index.ts index 44abfa36b0d..f59b28f1f94 100644 --- a/extensions/slack/index.ts +++ b/extensions/slack/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { slackPlugin } from "./src/channel.js"; import { setSlackRuntime } from "./src/runtime.js"; +export { slackPlugin } from "./src/channel.js"; +export { setSlackRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "slack", name: "Slack", diff --git a/extensions/slack/runtime-api.ts b/extensions/slack/runtime-api.ts new file mode 100644 index 00000000000..b40f24e4177 --- /dev/null +++ b/extensions/slack/runtime-api.ts @@ -0,0 +1,4 @@ +export * from "./src/directory-live.js"; +export * from "./src/index.js"; +export * from "./src/resolve-channels.js"; +export * from "./src/resolve-users.js"; diff --git a/extensions/slack/setup-entry.ts b/extensions/slack/setup-entry.ts index 5a80ca2128b..2600e593267 100644 --- a/extensions/slack/setup-entry.ts +++ b/extensions/slack/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { slackSetupPlugin } from "./src/channel.setup.js"; +export { slackSetupPlugin } from "./src/channel.setup.js"; + export default defineSetupPluginEntry(slackSetupPlugin); diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index 854e1782315..519f6eabe7b 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -1,4 +1,8 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/slack"; +import { + buildChannelConfigSchema, + SlackConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/slack"; import { type ResolvedSlackAccount } from "./accounts.js"; import { slackSetupAdapter } from "./setup-core.js"; import { slackSetupWizard } from "./setup-surface.js"; @@ -6,6 +10,7 @@ import { createSlackPluginBase } from "./shared.js"; export const slackSetupPlugin: ChannelPlugin = { ...createSlackPluginBase({ + configSchema: buildChannelConfigSchema(SlackConfigSchema), setupWizard: slackSetupWizard, setup: slackSetupAdapter, }), diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 5e25f0187b1..8a82a3577b8 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -11,6 +11,7 @@ import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { + buildChannelConfigSchema, buildComputedAccountStatusSnapshot, DEFAULT_ACCOUNT_ID, listSlackDirectoryGroupsFromConfig, @@ -22,6 +23,7 @@ import { resolveConfiguredFromRequiredCredentialStatuses, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, + SlackConfigSchema, createSlackActions, type ChannelPlugin, type OpenClawConfig, @@ -307,6 +309,7 @@ async function resolveSlackAllowlistNames(params: { export const slackPlugin: ChannelPlugin = { ...createSlackPluginBase({ + configSchema: buildChannelConfigSchema(SlackConfigSchema), setupWizard: slackSetupWizard, setup: slackSetupAdapter, }), diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index cc352284ca3..65f6203a57e 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -12,7 +12,7 @@ import { } from "openclaw/plugin-sdk/reply-runtime"; import { isSilentReplyText } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { loadWebMedia } from "../../whatsapp/src/media.js"; +import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 2a3aad980fa..5a8fe1feab4 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -2,7 +2,6 @@ import { createAllowlistSetupWizardProxy, DEFAULT_ACCOUNT_ID, createEnvPatchedAccountSetupAdapter, - formatDocsLink, hasConfiguredSecretInput, type OpenClawConfig, noteChannelLookupFailure, @@ -19,6 +18,7 @@ import { type ChannelSetupWizard, type ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js"; import { diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index d103a329c50..6731ddff84b 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,5 +1,4 @@ import { - formatDocsLink, noteChannelLookupFailure, noteChannelLookupSummary, type OpenClawConfig, @@ -12,6 +11,7 @@ import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js"; import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index 58dfae35c90..0d4fd0a3481 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -14,7 +14,7 @@ import { SlackConfigSchema, type ChannelPlugin, type OpenClawConfig, -} from "openclaw/plugin-sdk/slack"; +} from "openclaw/plugin-sdk/slack-core"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, diff --git a/extensions/synology-chat/api.ts b/extensions/synology-chat/api.ts new file mode 100644 index 00000000000..7c705aec6e5 --- /dev/null +++ b/extensions/synology-chat/api.ts @@ -0,0 +1 @@ +export * from "./src/setup-surface.js"; diff --git a/extensions/synology-chat/index.ts b/extensions/synology-chat/index.ts index 79e3f49d513..1e51c8f68aa 100644 --- a/extensions/synology-chat/index.ts +++ b/extensions/synology-chat/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { synologyChatPlugin } from "./src/channel.js"; import { setSynologyRuntime } from "./src/runtime.js"; +export { synologyChatPlugin } from "./src/channel.js"; +export { setSynologyRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "synology-chat", name: "Synology Chat", diff --git a/extensions/talk-voice/api.ts b/extensions/talk-voice/api.ts new file mode 100644 index 00000000000..a5ae821e944 --- /dev/null +++ b/extensions/talk-voice/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/talk-voice"; diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index 5448c3425b0..d0916ea6b99 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -1,6 +1,6 @@ import { resolveActiveTalkProviderConfig } from "openclaw/plugin-sdk/config-runtime"; import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech"; -import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/talk-voice"; +import { definePluginEntry, type OpenClawPluginApi } from "./api.js"; function mask(s: string, keep: number = 6): string { const trimmed = s.trim(); diff --git a/extensions/telegram/api.ts b/extensions/telegram/api.ts new file mode 100644 index 00000000000..d5960350c39 --- /dev/null +++ b/extensions/telegram/api.ts @@ -0,0 +1,17 @@ +export * from "./src/account-inspect.js"; +export * from "./src/accounts.js"; +export * from "./src/allow-from.js"; +export * from "./src/api-fetch.js"; +export * from "./src/exec-approvals.js"; +export * from "./src/inline-buttons.js"; +export * from "./src/model-buttons.js"; +export * from "./src/normalize.js"; +export * from "./src/outbound-adapter.js"; +export * from "./src/outbound-params.js"; +export * from "./src/reaction-level.js"; +export * from "./src/sticker-cache.js"; +export * from "./src/status-issues.js"; +export * from "./src/targets.js"; +export * from "./src/update-offset-store.js"; +export type { TelegramButtonStyle, TelegramInlineButtons } from "./src/button-types.js"; +export type { StickerMetadata } from "./src/bot/types.js"; diff --git a/extensions/telegram/index.ts b/extensions/telegram/index.ts index 89413373c5a..ec6290914fe 100644 --- a/extensions/telegram/index.ts +++ b/extensions/telegram/index.ts @@ -3,6 +3,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { telegramPlugin } from "./src/channel.js"; import { setTelegramRuntime } from "./src/runtime.js"; +export { telegramPlugin } from "./src/channel.js"; +export { setTelegramRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "telegram", name: "Telegram", diff --git a/extensions/telegram/runtime-api.ts b/extensions/telegram/runtime-api.ts new file mode 100644 index 00000000000..e704dc007a3 --- /dev/null +++ b/extensions/telegram/runtime-api.ts @@ -0,0 +1,7 @@ +export * from "./src/audit.js"; +export * from "./src/channel-actions.js"; +export * from "./src/monitor.js"; +export * from "./src/probe.js"; +export * from "./src/send.js"; +export * from "./src/thread-bindings.js"; +export * from "./src/token.js"; diff --git a/extensions/telegram/setup-entry.ts b/extensions/telegram/setup-entry.ts index c44a073e80b..7b2c02399fa 100644 --- a/extensions/telegram/setup-entry.ts +++ b/extensions/telegram/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { telegramSetupPlugin } from "./src/channel.setup.js"; +export { telegramSetupPlugin } from "./src/channel.setup.js"; + export default defineSetupPluginEntry(telegramSetupPlugin); diff --git a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts index 1f9adb41a72..44aa89a7623 100644 --- a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts +++ b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts @@ -1,14 +1,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn()); -const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn()); +const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn()); +const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn()); -vi.mock("../../../src/acp/persistent-bindings.js", () => ({ - ensureConfiguredAcpBindingSession: (...args: unknown[]) => - ensureConfiguredAcpBindingSessionMock(...args), - resolveConfiguredAcpBindingRecord: (...args: unknown[]) => - resolveConfiguredAcpBindingRecordMock(...args), -})); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureConfiguredBindingRouteReady: (...args: unknown[]) => + ensureConfiguredBindingRouteReadyMock(...args), + resolveConfiguredBindingRoute: (...args: unknown[]) => + resolveConfiguredBindingRouteMock(...args), + }; +}); import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; @@ -43,15 +47,92 @@ function createConfiguredTelegramBinding() { } as const; } +function createConfiguredTelegramRoute() { + const configuredBinding = createConfiguredTelegramBinding(); + return { + bindingResolution: { + conversation: { + channel: "telegram", + accountId: "work", + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }, + compiledBinding: { + channel: "telegram", + accountPattern: "work", + binding: { + type: "acp", + agentId: "codex", + match: { + channel: "telegram", + accountId: "work", + peer: { + kind: "group", + id: "-1001234567890:topic:42", + }, + }, + }, + bindingConversationId: "-1001234567890:topic:42", + target: { + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }, + agentId: "codex", + provider: { + compileConfiguredBinding: () => ({ + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }), + matchInboundConversation: () => ({ + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }), + }, + targetFactory: { + driverId: "acp", + materialize: () => ({ + record: configuredBinding.record, + statefulTarget: { + kind: "stateful", + driverId: "acp", + sessionKey: configuredBinding.record.targetSessionKey, + agentId: configuredBinding.spec.agentId, + }, + }), + }, + }, + match: { + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }, + record: configuredBinding.record, + statefulTarget: { + kind: "stateful", + driverId: "acp", + sessionKey: configuredBinding.record.targetSessionKey, + agentId: configuredBinding.spec.agentId, + }, + }, + configuredBinding, + boundSessionKey: configuredBinding.record.targetSessionKey, + route: { + agentId: "codex", + accountId: "work", + channel: "telegram", + sessionKey: configuredBinding.record.targetSessionKey, + mainSessionKey: "agent:codex:main", + matchedBy: "binding.channel", + lastRoutePolicy: "bound", + }, + } as const; +} + describe("buildTelegramMessageContext ACP configured bindings", () => { beforeEach(() => { - ensureConfiguredAcpBindingSessionMock.mockReset(); - resolveConfiguredAcpBindingRecordMock.mockReset(); - resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredTelegramBinding()); - ensureConfiguredAcpBindingSessionMock.mockResolvedValue({ - ok: true, - sessionKey: "agent:codex:acp:binding:telegram:work:abc123", - }); + ensureConfiguredBindingRouteReadyMock.mockReset(); + resolveConfiguredBindingRouteMock.mockReset(); + resolveConfiguredBindingRouteMock.mockReturnValue(createConfiguredTelegramRoute()); + ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true }); }); it("treats configured topic bindings as explicit route matches on non-default accounts", async () => { @@ -68,7 +149,7 @@ describe("buildTelegramMessageContext ACP configured bindings", () => { expect(ctx?.route.accountId).toBe("work"); expect(ctx?.route.matchedBy).toBe("binding.channel"); expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123"); - expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1); + expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); }); it("skips ACP session initialization when topic access is denied", async () => { @@ -86,8 +167,8 @@ describe("buildTelegramMessageContext ACP configured bindings", () => { }); expect(ctx).toBeNull(); - expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1); - expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled(); + expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1); + expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled(); }); it("defers ACP session initialization for unauthorized control commands", async () => { @@ -109,14 +190,13 @@ describe("buildTelegramMessageContext ACP configured bindings", () => { }); expect(ctx).toBeNull(); - expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1); - expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled(); + expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1); + expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled(); }); it("drops inbound processing when configured ACP binding initialization fails", async () => { - ensureConfiguredAcpBindingSessionMock.mockResolvedValue({ + ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: false, - sessionKey: "agent:codex:acp:binding:telegram:work:abc123", error: "gateway unavailable", }); @@ -130,7 +210,7 @@ describe("buildTelegramMessageContext ACP configured bindings", () => { }); expect(ctx).toBeNull(); - expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1); - expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1); + expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1); + expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index b569b1aeb1e..78ba9f02492 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -7,7 +7,7 @@ import { } from "openclaw/plugin-sdk/channel-runtime"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramDirectConfig, TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime"; -import { ensureConfiguredAcpRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; +import { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; @@ -201,24 +201,24 @@ export const buildTelegramMessageContext = async ({ if (!configuredBinding) { return true; } - const ensured = await ensureConfiguredAcpRouteReady({ + const ensured = await ensureConfiguredBindingRouteReady({ cfg: freshCfg, - configuredBinding, + bindingResolution: configuredBinding, }); if (ensured.ok) { logVerbose( - `telegram: using configured ACP binding for ${configuredBinding.spec.conversationId} -> ${configuredBindingSessionKey}`, + `telegram: using configured ACP binding for ${configuredBinding.record.conversation.conversationId} -> ${configuredBindingSessionKey}`, ); return true; } logVerbose( - `telegram: configured ACP binding unavailable for ${configuredBinding.spec.conversationId}: ${ensured.error}`, + `telegram: configured ACP binding unavailable for ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`, ); logInboundDrop({ log: logVerbose, channel: "telegram", reason: "configured ACP binding unavailable", - target: configuredBinding.spec.conversationId, + target: configuredBinding.record.conversation.conversationId, }); return false; }; diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 0a75b12fc1a..7540f22b1ac 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { ResolvedAgentRoute } from "../../../src/routing/resolve-route.js"; import { createDeferred, createNativeCommandTestParams, @@ -14,10 +15,10 @@ import { // All mocks scoped to this file only — does not affect bot-native-commands.test.ts -type ResolveConfiguredAcpBindingRecordFn = - typeof import("../../../src/acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord; -type EnsureConfiguredAcpBindingSessionFn = - typeof import("../../../src/acp/persistent-bindings.js").ensureConfiguredAcpBindingSession; +type ResolveConfiguredBindingRouteFn = + typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute; +type EnsureConfiguredBindingRouteReadyFn = + typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady; type DispatchReplyWithBufferedBlockDispatcherFn = typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher; type DispatchReplyWithBufferedBlockDispatcherParams = @@ -34,10 +35,12 @@ const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { }; const persistentBindingMocks = vi.hoisted(() => ({ - resolveConfiguredAcpBindingRecord: vi.fn(() => null), - ensureConfiguredAcpBindingSession: vi.fn(async () => ({ + resolveConfiguredBindingRoute: vi.fn(({ route }) => ({ + bindingResolution: null, + route, + })), + ensureConfiguredBindingRouteReady: vi.fn(async () => ({ ok: true, - sessionKey: "agent:codex:acp:binding:telegram:default:seed", })), })); const sessionMocks = vi.hoisted(() => ({ @@ -59,12 +62,58 @@ const sessionBindingMocks = vi.hoisted(() => ({ touch: vi.fn(), })); -vi.mock("../../../src/acp/persistent-bindings.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord, - ensureConfiguredAcpBindingSession: persistentBindingMocks.ensureConfiguredAcpBindingSession, + resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute, + ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady, + getSessionBindingService: () => ({ + bind: vi.fn(), + getCapabilities: vi.fn(), + listBySession: vi.fn(), + resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref), + touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at), + unbind: vi.fn(), + }), + }; +}); +vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })), + recordInboundSessionMetaSafe: vi.fn( + async (params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; + ctx: unknown; + onError?: (error: unknown) => void; + }) => { + const storePath = sessionMocks.resolveStorePath(params.cfg.session?.store, { + agentId: params.agentId, + }); + try { + await sessionMocks.recordSessionMetaFromInbound({ + storePath, + sessionKey: params.sessionKey, + ctx: params.ctx, + }); + } catch (error) { + params.onError?.(error); + } + }, + ), + }; +}); +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + finalizeInboundContext: vi.fn((ctx: unknown) => ctx), + dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher, + listSkillCommandsForAgents: vi.fn(() => []), }; }); vi.mock("../../../src/config/sessions.js", () => ({ @@ -74,15 +123,6 @@ vi.mock("../../../src/config/sessions.js", () => ({ vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: vi.fn(async () => []), })); -vi.mock("../../../src/auto-reply/reply/inbound-context.js", () => ({ - finalizeInboundContext: vi.fn((ctx: unknown) => ctx), -})); -vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ - dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher, -})); -vi.mock("../../../src/channels/reply-prefix.js", () => ({ - createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })), -})); vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({ getSessionBindingService: () => ({ bind: vi.fn(), @@ -93,10 +133,6 @@ vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({ unbind: vi.fn(), }), })); -vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, listSkillCommandsForAgents: vi.fn(() => []) }; -}); vi.mock("../../../src/plugins/commands.js", () => ({ getPluginCommandSpecs: vi.fn(() => []), matchPluginCommand: vi.fn(() => null), @@ -233,13 +269,93 @@ function createConfiguredAcpTopicBinding(boundSessionKey: string) { status: "active", boundAt: 0, }, - } satisfies import("../../../src/acp/persistent-bindings.js").ResolvedConfiguredAcpBinding; + } as const; +} + +function createConfiguredBindingRoute( + route: ResolvedAgentRoute, + binding: ReturnType | null, +) { + return { + bindingResolution: binding + ? { + conversation: binding.record.conversation, + compiledBinding: { + channel: "telegram" as const, + binding: { + type: "acp" as const, + agentId: binding.spec.agentId, + match: { + channel: "telegram", + accountId: binding.spec.accountId, + peer: { + kind: "group" as const, + id: binding.spec.conversationId, + }, + }, + acp: { + mode: binding.spec.mode, + }, + }, + bindingConversationId: binding.spec.conversationId, + target: { + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }, + agentId: binding.spec.agentId, + provider: { + compileConfiguredBinding: () => ({ + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }), + matchInboundConversation: () => ({ + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }), + }, + targetFactory: { + driverId: "acp" as const, + materialize: () => ({ + record: binding.record, + statefulTarget: { + kind: "stateful" as const, + driverId: "acp" as const, + sessionKey: binding.record.targetSessionKey, + agentId: binding.spec.agentId, + }, + }), + }, + }, + match: { + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }, + record: binding.record, + statefulTarget: { + kind: "stateful" as const, + driverId: "acp" as const, + sessionKey: binding.record.targetSessionKey, + agentId: binding.spec.agentId, + }, + } + : null, + ...(binding ? { boundSessionKey: binding.record.targetSessionKey } : {}), + route, + }; } function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType) { expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled(); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled(); + expect(persistentBindingMocks.resolveConfiguredBindingRoute).not.toHaveBeenCalled(); + expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).not.toHaveBeenCalled(); expect(sendMessage).toHaveBeenCalledWith( -1001234567890, "You are not authorized to use this command.", @@ -249,13 +365,12 @@ function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType { beforeEach(() => { - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockClear(); - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockClear(); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ - ok: true, - sessionKey: "agent:codex:acp:binding:telegram:default:seed", - }); + persistentBindingMocks.resolveConfiguredBindingRoute.mockClear(); + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute(route, null), + ); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear(); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json"); replyMocks.dispatchReplyWithBufferedBlockDispatcher @@ -403,13 +518,18 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("routes Telegram native commands through configured ACP topic bindings", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( - createConfiguredAcpTopicBinding(boundSessionKey), + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute( + { + ...route, + sessionKey: boundSessionKey, + agentId: "codex", + matchedBy: "binding.channel", + }, + createConfiguredAcpTopicBinding(boundSessionKey), + ), ); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ - ok: true, - sessionKey: boundSessionKey, - }); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); const { handler } = registerAndResolveStatusHandler({ cfg: {}, @@ -418,8 +538,8 @@ describe("registerTelegramNativeCommands — session metadata", () => { }); await handler(createTelegramTopicCommandContext()); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1); + expect(persistentBindingMocks.resolveConfiguredBindingRoute).toHaveBeenCalledTimes(1); + expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1); const dispatchCall = ( replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< [{ ctx?: { CommandTargetSessionKey?: string } }] @@ -488,12 +608,19 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( - createConfiguredAcpTopicBinding(boundSessionKey), + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute( + { + ...route, + sessionKey: boundSessionKey, + agentId: "codex", + matchedBy: "binding.channel", + }, + createConfiguredAcpTopicBinding(boundSessionKey), + ), ); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: false, - sessionKey: boundSessionKey, error: "gateway unavailable", }); @@ -514,13 +641,18 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( - createConfiguredAcpTopicBinding(boundSessionKey), + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute( + { + ...route, + sessionKey: boundSessionKey, + agentId: "codex", + matchedBy: "binding.channel", + }, + createConfiguredAcpTopicBinding(boundSessionKey), + ), ); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ - ok: true, - sessionKey: boundSessionKey, - }); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); const { handler, sendMessage } = registerAndResolveCommandHandler({ commandName: "new", @@ -535,7 +667,9 @@ describe("registerTelegramNativeCommands — session metadata", () => { }); it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => { - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null); + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute(route, null), + ); const { handler, sendMessage } = registerAndResolveCommandHandler({ commandName: "new", diff --git a/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts index 29540bb9011..5a2b2552739 100644 --- a/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts +++ b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts @@ -4,16 +4,16 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { writeSkill } from "../../../src/agents/skills.e2e-test-helpers.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; +import { + pluginCommandMocks, + resetPluginCommandMocks, +} from "../../../test/helpers/extensions/telegram-plugin-command.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; import { createNativeCommandTestParams, resetNativeCommandMenuMocks, waitForRegisteredCommands, } from "./bot-native-commands.menu-test-support.js"; -import { - pluginCommandMocks, - resetPluginCommandMocks, -} from "./bot-native-commands.plugin-command-test-support.js"; const tempDirs: string[] = []; diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index a3f5ab3a9ce..e20806b11e4 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -8,7 +8,7 @@ import type { RuntimeEnv } from "../../../src/runtime.js"; import { pluginCommandMocks, resetPluginCommandMocks, -} from "./bot-native-commands.plugin-command-test-support.js"; +} from "../../../test/helpers/extensions/telegram-plugin-command.js"; const skillCommandMocks = vi.hoisted(() => ({ listSkillCommandsForAgents: vi.fn(() => []), })); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index c496c1b97f6..0e513131133 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -18,7 +18,7 @@ import type { TelegramGroupConfig, TelegramTopicConfig, } from "openclaw/plugin-sdk/config-runtime"; -import { ensureConfiguredAcpRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; +import { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { executePluginCommand, @@ -490,13 +490,13 @@ export const registerTelegramNativeCommands = ({ topicAgentId, }); if (configuredBinding) { - const ensured = await ensureConfiguredAcpRouteReady({ + const ensured = await ensureConfiguredBindingRouteReady({ cfg, - configuredBinding, + bindingResolution: configuredBinding, }); if (!ensured.ok) { logVerbose( - `telegram native command: configured ACP binding unavailable for topic ${configuredBinding.spec.conversationId}: ${ensured.error}`, + `telegram native command: configured ACP binding unavailable for topic ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`, ); await withTelegramApiErrorLogging({ operation: "sendMessage", diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index 9d015e770a5..f8573fecadd 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -20,7 +20,7 @@ export function getLoadWebMediaMock(): AnyMock { return loadWebMedia; } -vi.mock("../../whatsapp/src/media.js", () => ({ +vi.mock("openclaw/plugin-sdk/web-media", () => ({ loadWebMedia, })); diff --git a/extensions/telegram/src/bot.ts b/extensions/telegram/src/bot.ts index 6d1d7bc24b2..450c68b4aad 100644 --- a/extensions/telegram/src/bot.ts +++ b/extensions/telegram/src/bot.ts @@ -38,7 +38,7 @@ import { type TelegramUpdateKeyContext, } from "./bot-updates.js"; import { buildTelegramGroupPeerId, resolveTelegramStreamMode } from "./bot/helpers.js"; -import { resolveTelegramTransport } from "./fetch.js"; +import { resolveTelegramTransport, type TelegramTransport } from "./fetch.js"; import { tagTelegramNetworkError } from "./network-errors.js"; import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js"; import { getTelegramSequentialKey } from "./sequential-key.js"; @@ -65,6 +65,8 @@ export type TelegramBotOptions = { mediaGroupFlushMs?: number; textFragmentGapMs?: number; }; + /** Pre-resolved Telegram transport to reuse across bot instances. If not provided, creates a new one. */ + telegramTransport?: TelegramTransport; }; export { getTelegramSequentialKey }; @@ -132,9 +134,11 @@ export function createTelegramBot(opts: TelegramBotOptions) { : null; const telegramCfg = account.config; - const telegramTransport = resolveTelegramTransport(opts.proxyFetch, { - network: telegramCfg.network, - }); + const telegramTransport = + opts.telegramTransport ?? + resolveTelegramTransport(opts.proxyFetch, { + network: telegramCfg.network, + }); const shouldProvideFetch = Boolean(telegramTransport.fetch); // grammY's ApiClientOptions types still track `node-fetch` types; Node 22+ global fetch // (undici) is structurally compatible at runtime but not assignable in TS. diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index d0a2d0fd610..41dec78c70d 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -17,7 +17,7 @@ import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/r import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { loadWebMedia } from "../../../whatsapp/src/media.js"; +import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import type { TelegramInlineButtons } from "../button-types.js"; import { splitTelegramCaption } from "../caption.js"; import { diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index 55fec660a82..54dcf963997 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -6,31 +6,32 @@ import type { TelegramContext } from "./types.js"; const saveMediaBuffer = vi.fn(); const fetchRemoteMedia = vi.fn(); -vi.mock("../../../../src/media/store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args), + fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args), }; }); -vi.mock("../../../../src/media/fetch.js", () => ({ - fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args), -})); - -vi.mock("../../../../src/globals.js", () => ({ - danger: (s: string) => s, - warn: (s: string) => s, - logVerbose: () => {}, -})); +vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logVerbose: () => {}, + warn: (s: string) => s, + danger: (s: string) => s, + }; +}); vi.mock("../sticker-cache.js", () => ({ cacheSticker: () => {}, getCachedSticker: () => null, })); -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -const { resolveMedia } = await import("./delivery.js"); +let resolveMedia: typeof import("./delivery.js").resolveMedia; + const MAX_MEDIA_BYTES = 10_000_000; const BOT_TOKEN = "tok123"; @@ -164,10 +165,12 @@ async function flushRetryTimers() { } describe("resolveMedia getFile retry", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resolveMedia } = await import("./delivery.js")); vi.useFakeTimers(); - fetchRemoteMedia.mockClear(); - saveMediaBuffer.mockClear(); + fetchRemoteMedia.mockReset(); + saveMediaBuffer.mockReset(); }); afterEach(() => { diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts index 36b3bb50be9..52f6eef966c 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -4,7 +4,7 @@ import { retryAsync } from "openclaw/plugin-sdk/infra-runtime"; import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; import { logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; -import { shouldRetryTelegramIpv4Fallback, type TelegramTransport } from "../fetch.js"; +import { shouldRetryTelegramTransportFallback, type TelegramTransport } from "../fetch.js"; import { cacheSticker, getCachedSticker } from "../sticker-cache.js"; import { resolveTelegramMediaPlaceholder } from "./helpers.js"; import type { StickerMetadata, TelegramContext } from "./types.js"; @@ -129,9 +129,8 @@ async function downloadAndSaveTelegramFile(params: { const fetched = await fetchRemoteMedia({ url, fetchImpl: params.transport.sourceFetch, - dispatcherPolicy: params.transport.pinnedDispatcherPolicy, - fallbackDispatcherPolicy: params.transport.fallbackPinnedDispatcherPolicy, - shouldRetryFetchError: shouldRetryTelegramIpv4Fallback, + dispatcherAttempts: params.transport.dispatcherAttempts, + shouldRetryFetchError: shouldRetryTelegramTransportFallback, filePathHint: params.filePath, maxBytes: params.maxBytes, readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS, diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index 4879ef96c09..bdee67aa41d 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -1,4 +1,8 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/telegram"; +import { + buildChannelConfigSchema, + TelegramConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/telegram"; import { type ResolvedTelegramAccount } from "./accounts.js"; import type { TelegramProbe } from "./probe.js"; import { telegramSetupAdapter } from "./setup-core.js"; @@ -7,6 +11,7 @@ import { createTelegramPluginBase } from "./shared.js"; export const telegramSetupPlugin: ChannelPlugin = { ...createTelegramPluginBase({ + configSchema: buildChannelConfigSchema(TelegramConfigSchema), setupWizard: telegramSetupWizard, setup: telegramSetupAdapter, }), diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index bac2de59f0b..6c1f4da5e73 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -13,6 +13,36 @@ import * as monitorModule from "./monitor.js"; import * as probeModule from "./probe.js"; import { setTelegramRuntime } from "./runtime.js"; +const probeTelegramMock = vi.hoisted(() => vi.fn()); +const collectTelegramUnmentionedGroupIdsMock = vi.hoisted(() => vi.fn()); +const auditTelegramGroupMembershipMock = vi.hoisted(() => vi.fn()); +const monitorTelegramProviderMock = vi.hoisted(() => vi.fn()); + +vi.mock("./probe.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + probeTelegram: probeTelegramMock, + }; +}); + +vi.mock("./audit.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + collectTelegramUnmentionedGroupIds: collectTelegramUnmentionedGroupIdsMock, + auditTelegramGroupMembership: auditTelegramGroupMembershipMock, + }; +}); + +vi.mock("./monitor.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + monitorTelegramProvider: monitorTelegramProviderMock, + }; +}); + function createCfg(): OpenClawConfig { return { channels: { @@ -156,7 +186,9 @@ describe("telegramPlugin duplicate token guard", () => { }); it("blocks startup for duplicate token accounts before polling starts", async () => { - const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({ probeOk: true }); + const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({ + probeOk: true, + }); await expect( telegramPlugin.gateway!.startAccount!( @@ -168,15 +200,23 @@ describe("telegramPlugin duplicate token guard", () => { ), ).rejects.toThrow("Duplicate Telegram bot token"); + expect(probeTelegramMock).not.toHaveBeenCalled(); + expect(monitorTelegramProviderMock).not.toHaveBeenCalled(); expect(probeTelegram).not.toHaveBeenCalled(); expect(monitorTelegramProvider).not.toHaveBeenCalled(); }); it("passes webhookPort through to monitor startup options", async () => { - const { monitorTelegramProvider } = installGatewayRuntime({ + const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({ probeOk: true, botUsername: "opsbot", }); + probeTelegramMock.mockResolvedValue({ + ok: true, + bot: { username: "opsbot" }, + elapsedMs: 1, + }); + monitorTelegramProviderMock.mockResolvedValue(undefined); const cfg = createCfg(); cfg.channels!.telegram!.accounts!.ops = { @@ -194,18 +234,39 @@ describe("telegramPlugin duplicate token guard", () => { }), ); - expect(monitorTelegramProvider).toHaveBeenCalledWith( + expect(probeTelegramMock).toHaveBeenCalledWith("token-ops", 2500, { + accountId: "ops", + proxyUrl: undefined, + network: undefined, + }); + expect(monitorTelegramProviderMock).toHaveBeenCalledWith( expect.objectContaining({ useWebhook: true, webhookPort: 9876, }), ); + expect(probeTelegram).toHaveBeenCalled(); + expect(monitorTelegramProvider).toHaveBeenCalled(); }); it("passes account proxy and network settings into Telegram probes", async () => { - const { probeTelegram } = installGatewayRuntime({ - probeOk: true, - botUsername: "opsbot", + const runtimeProbeTelegram = vi.fn(async () => { + throw new Error("runtime probe should not be used"); + }); + setTelegramRuntime({ + channel: { + telegram: { + probeTelegram: runtimeProbeTelegram, + }, + }, + logging: { + shouldLogVerbose: () => false, + }, + } as unknown as PluginRuntime); + probeTelegramMock.mockResolvedValue({ + ok: true, + bot: { username: "opsbot" }, + elapsedMs: 1, }); const cfg = createCfg(); @@ -218,7 +279,7 @@ describe("telegramPlugin duplicate token guard", () => { cfg, }); - expect(probeTelegram).toHaveBeenCalledWith("token-ops", 5000, { + expect(probeTelegramMock).toHaveBeenCalledWith("token-ops", 5000, { accountId: "ops", proxyUrl: "http://127.0.0.1:8888", network: { @@ -226,19 +287,40 @@ describe("telegramPlugin duplicate token guard", () => { dnsResultOrder: "ipv4first", }, }); + expect(runtimeProbeTelegram).not.toHaveBeenCalled(); }); it("passes account proxy and network settings into Telegram membership audits", async () => { - const { collectUnmentionedGroupIds, auditGroupMembership } = installGatewayRuntime({ - probeOk: true, - botUsername: "opsbot", + const runtimeCollectUnmentionedGroupIds = vi.fn(() => { + throw new Error("runtime audit helper should not be used"); }); - - collectUnmentionedGroupIds.mockReturnValue({ + const runtimeAuditGroupMembership = vi.fn(async () => { + throw new Error("runtime audit helper should not be used"); + }); + setTelegramRuntime({ + channel: { + telegram: { + collectUnmentionedGroupIds: runtimeCollectUnmentionedGroupIds, + auditGroupMembership: runtimeAuditGroupMembership, + }, + }, + logging: { + shouldLogVerbose: () => false, + }, + } as unknown as PluginRuntime); + collectTelegramUnmentionedGroupIdsMock.mockReturnValue({ groupIds: ["-100123"], unresolvedGroups: 0, hasWildcardUnmentionedGroups: false, }); + auditTelegramGroupMembershipMock.mockResolvedValue({ + ok: true, + checkedGroups: 1, + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + groups: [], + elapsedMs: 1, + }); const cfg = createCfg(); configureOpsProxyNetwork(cfg); @@ -257,7 +339,10 @@ describe("telegramPlugin duplicate token guard", () => { cfg, }); - expect(auditGroupMembership).toHaveBeenCalledWith({ + expect(collectTelegramUnmentionedGroupIdsMock).toHaveBeenCalledWith({ + "-100123": { requireMention: false }, + }); + expect(auditTelegramGroupMembershipMock).toHaveBeenCalledWith({ token: "token-ops", botId: 123, groupIds: ["-100123"], @@ -268,6 +353,8 @@ describe("telegramPlugin duplicate token guard", () => { }, timeoutMs: 5000, }); + expect(runtimeCollectUnmentionedGroupIds).not.toHaveBeenCalled(); + expect(runtimeAuditGroupMembership).not.toHaveBeenCalled(); }); it("forwards mediaLocalRoots to sendMessageTelegram for outbound media sends", async () => { @@ -391,7 +478,11 @@ describe("telegramPlugin duplicate token guard", () => { }); it("does not crash startup when a resolved account token is undefined", async () => { - const { monitorTelegramProvider } = installGatewayRuntime({ probeOk: false }); + const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({ + probeOk: false, + }); + probeTelegramMock.mockResolvedValue({ ok: false, elapsedMs: 1 }); + monitorTelegramProviderMock.mockResolvedValue(undefined); const cfg = createCfg(); const ctx = createStartAccountCtx({ @@ -405,11 +496,18 @@ describe("telegramPlugin duplicate token guard", () => { } as ResolvedTelegramAccount; await expect(telegramPlugin.gateway!.startAccount!(ctx)).resolves.toBeUndefined(); - expect(monitorTelegramProvider).toHaveBeenCalledWith( + expect(probeTelegramMock).toHaveBeenCalledWith("", 2500, { + accountId: "ops", + proxyUrl: undefined, + network: undefined, + }); + expect(monitorTelegramProviderMock).toHaveBeenCalledWith( expect.objectContaining({ token: "", }), ); + expect(probeTelegram).toHaveBeenCalled(); + expect(monitorTelegramProvider).toHaveBeenCalled(); }); }); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index c05c926d52f..0e2ce964b95 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -12,6 +12,7 @@ import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra- import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { parseTelegramTopicConversation } from "openclaw/plugin-sdk/telegram"; import { + buildChannelConfigSchema, buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, @@ -20,6 +21,7 @@ import { PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, + TelegramConfigSchema, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, type ChannelPlugin, @@ -296,6 +298,7 @@ function readTelegramAllowlistConfig(account: ResolvedTelegramAccount) { export const telegramPlugin: ChannelPlugin = { ...createTelegramPluginBase({ + configSchema: buildChannelConfigSchema(TelegramConfigSchema), setupWizard: telegramSetupWizard, setup: telegramSetupAdapter, }), @@ -330,11 +333,15 @@ export const telegramPlugin: ChannelPlugin + bindings: { + compileConfiguredBinding: ({ conversationId }) => normalizeTelegramAcpConversationId(conversationId), - matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) => - matchTelegramAcpConversation({ bindingConversationId, conversationId, parentConversationId }), + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => + matchTelegramAcpConversation({ + bindingConversationId: compiledBinding.conversationId, + conversationId, + parentConversationId, + }), }, security: { resolveDmPolicy: resolveTelegramDmPolicy, diff --git a/extensions/telegram/src/conversation-route.ts b/extensions/telegram/src/conversation-route.ts index 26c3b039312..5d777763cde 100644 --- a/extensions/telegram/src/conversation-route.ts +++ b/extensions/telegram/src/conversation-route.ts @@ -1,5 +1,8 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { resolveConfiguredAcpRoute } from "openclaw/plugin-sdk/conversation-runtime"; +import { + resolveConfiguredBindingRoute, + type ConfiguredBindingRouteResult, +} from "openclaw/plugin-sdk/conversation-runtime"; import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime"; import { isPluginOwnedSessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime"; import { @@ -31,7 +34,7 @@ export function resolveTelegramConversationRoute(params: { topicAgentId?: string | null; }): { route: ReturnType; - configuredBinding: ReturnType["configuredBinding"]; + configuredBinding: ConfiguredBindingRouteResult["bindingResolution"]; configuredBindingSessionKey: string; } { const peerId = params.isGroup @@ -94,15 +97,17 @@ export function resolveTelegramConversationRoute(params: { ); } - const configuredRoute = resolveConfiguredAcpRoute({ + const configuredRoute = resolveConfiguredBindingRoute({ cfg: params.cfg, route, - channel: "telegram", - accountId: params.accountId, - conversationId: peerId, - parentConversationId: params.isGroup ? String(params.chatId) : undefined, + conversation: { + channel: "telegram", + accountId: params.accountId, + conversationId: peerId, + parentConversationId: params.isGroup ? String(params.chatId) : undefined, + }, }); - let configuredBinding = configuredRoute.configuredBinding; + let configuredBinding = configuredRoute.bindingResolution; let configuredBindingSessionKey = configuredRoute.boundSessionKey ?? ""; route = configuredRoute.route; diff --git a/extensions/telegram/src/fetch.test.ts b/extensions/telegram/src/fetch.test.ts index 7681d0c8701..4afdacf0568 100644 --- a/extensions/telegram/src/fetch.test.ts +++ b/extensions/telegram/src/fetch.test.ts @@ -1,6 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveFetch } from "../../../src/infra/fetch.js"; -import { resolveTelegramFetch, resolveTelegramTransport } from "./fetch.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const setDefaultResultOrder = vi.hoisted(() => vi.fn()); const setDefaultAutoSelectFamily = vi.hoisted(() => vi.fn()); @@ -56,6 +54,16 @@ vi.mock("undici", () => ({ setGlobalDispatcher, })); +let resolveFetch: typeof import("../../../src/infra/fetch.js").resolveFetch; +let resolveTelegramFetch: typeof import("./fetch.js").resolveTelegramFetch; +let resolveTelegramTransport: typeof import("./fetch.js").resolveTelegramTransport; + +beforeEach(async () => { + vi.resetModules(); + ({ resolveFetch } = await import("../../../src/infra/fetch.js")); + ({ resolveTelegramFetch, resolveTelegramTransport } = await import("./fetch.js")); +}); + function resolveTelegramFetchOrThrow( proxyFetch?: typeof fetch, options?: { network?: { autoSelectFamily?: boolean; dnsResultOrder?: "ipv4first" | "verbatim" } }, @@ -152,6 +160,24 @@ function expectPinnedIpv4ConnectDispatcher(args: { } } +function expectPinnedFallbackIpDispatcher(callIndex: number) { + const dispatcher = getDispatcherFromUndiciCall(callIndex); + expect(dispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + lookup: expect.any(Function), + }), + ); + const callback = vi.fn(); + ( + dispatcher?.options?.connect?.lookup as + | ((hostname: string, callback: (err: null, address: string, family: number) => void) => void) + | undefined + )?.("api.telegram.org", callback); + expect(callback).toHaveBeenCalledWith(null, "149.154.167.220", 4); +} + function expectCallerDispatcherPreserved(callIndexes: number[], dispatcher: unknown) { for (const callIndex of callIndexes) { const callInit = undiciFetch.mock.calls[callIndex - 1]?.[1] as @@ -395,7 +421,7 @@ describe("resolveTelegramFetch", () => { pinnedCall: 2, followupCall: 3, }); - expect(transport.pinnedDispatcherPolicy).toEqual( + expect(transport.dispatcherAttempts?.[0]?.dispatcherPolicy).toEqual( expect.objectContaining({ mode: "direct", }), @@ -533,6 +559,34 @@ describe("resolveTelegramFetch", () => { ); }); + it("escalates from IPv4 fallback to pinned Telegram IP and keeps it sticky", async () => { + undiciFetch + .mockRejectedValueOnce(buildFetchFallbackError("ETIMEDOUT")) + .mockRejectedValueOnce(buildFetchFallbackError("EHOSTUNREACH")) + .mockResolvedValueOnce({ ok: true } as Response) + .mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + await resolved("https://api.telegram.org/botx/sendMessage"); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(undiciFetch).toHaveBeenCalledTimes(4); + + const secondDispatcher = getDispatcherFromUndiciCall(2); + const thirdDispatcher = getDispatcherFromUndiciCall(3); + const fourthDispatcher = getDispatcherFromUndiciCall(4); + + expect(secondDispatcher).not.toBe(thirdDispatcher); + expect(thirdDispatcher).toBe(fourthDispatcher); + expectPinnedFallbackIpDispatcher(3); + }); + it("preserves caller-provided dispatcher across fallback retry", async () => { const fetchError = buildFetchFallbackError("EHOSTUNREACH"); undiciFetch.mockRejectedValueOnce(fetchError).mockResolvedValueOnce({ ok: true } as Response); diff --git a/extensions/telegram/src/fetch.ts b/extensions/telegram/src/fetch.ts index 962d0256af1..ad60faab13b 100644 --- a/extensions/telegram/src/fetch.ts +++ b/extensions/telegram/src/fetch.ts @@ -1,8 +1,11 @@ import * as dns from "node:dns"; import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-runtime"; -import { resolveFetch } from "openclaw/plugin-sdk/infra-runtime"; -import { hasEnvHttpProxyConfigured } from "openclaw/plugin-sdk/infra-runtime"; -import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/infra-runtime"; +import { + createPinnedLookup, + hasEnvHttpProxyConfigured, + resolveFetch, + type PinnedDispatcherPolicy, +} from "openclaw/plugin-sdk/infra-runtime"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici"; import { @@ -15,6 +18,7 @@ const log = createSubsystemLogger("telegram/network"); const TELEGRAM_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 300; const TELEGRAM_API_HOSTNAME = "api.telegram.org"; +const TELEGRAM_FALLBACK_IPS: readonly string[] = ["149.154.167.220"]; type RequestInitWithDispatcher = RequestInit & { dispatcher?: unknown; @@ -24,6 +28,16 @@ type TelegramDispatcher = Agent | EnvHttpProxyAgent | ProxyAgent; type TelegramDispatcherMode = "direct" | "env-proxy" | "explicit-proxy"; +type TelegramDispatcherAttempt = { + dispatcherPolicy?: PinnedDispatcherPolicy; +}; + +type TelegramTransportAttempt = { + createDispatcher: () => TelegramDispatcher; + exportAttempt: TelegramDispatcherAttempt; + logMessage?: string; +}; + type TelegramDnsResultOrder = "ipv4first" | "verbatim"; type LookupCallback = @@ -49,17 +63,17 @@ const FALLBACK_RETRY_ERROR_CODES = [ "UND_ERR_SOCKET", ] as const; -type Ipv4FallbackContext = { +type TelegramTransportFallbackContext = { message: string; codes: Set; }; -type Ipv4FallbackRule = { +type TelegramTransportFallbackRule = { name: string; - matches: (ctx: Ipv4FallbackContext) => boolean; + matches: (ctx: TelegramTransportFallbackContext) => boolean; }; -const IPV4_FALLBACK_RULES: readonly Ipv4FallbackRule[] = [ +const TELEGRAM_TRANSPORT_FALLBACK_RULES: readonly TelegramTransportFallbackRule[] = [ { name: "fetch-failed-envelope", matches: ({ message }) => message.includes("fetch failed"), @@ -98,7 +112,6 @@ function createDnsResultOrderLookup( const lookupOptions: LookupOptions = { ...baseOptions, order, - // Keep `verbatim` for compatibility with Node runtimes that ignore `order`. verbatim: order === "verbatim", }; lookup(hostname, lookupOptions, callback); @@ -139,14 +152,6 @@ function buildTelegramConnectOptions(params: { } function shouldBypassEnvProxyForTelegramApi(env: NodeJS.ProcessEnv = process.env): boolean { - // We need this classification before dispatch to decide whether sticky IPv4 fallback - // can safely arm. EnvHttpProxyAgent does not expose route decisions (proxy vs direct - // NO_PROXY bypass), so we mirror undici's parsing/matching behavior for this host. - // Match EnvHttpProxyAgent behavior (undici): - // - lower-case no_proxy takes precedence over NO_PROXY - // - entries split by comma or whitespace - // - wildcard handling is exact-string "*" only - // - leading "." and "*." are normalized the same way const noProxyValue = env.no_proxy ?? env.NO_PROXY ?? ""; if (!noProxyValue) { return false; @@ -228,16 +233,32 @@ function resolveTelegramDispatcherPolicy(params: { }; } +function withPinnedLookup( + options: Record | undefined, + pinnedHostname: PinnedDispatcherPolicy["pinnedHostname"], +): Record | undefined { + if (!pinnedHostname) { + return options ? { ...options } : undefined; + } + const lookup = createPinnedLookup({ + hostname: pinnedHostname.hostname, + addresses: [...pinnedHostname.addresses], + fallback: dns.lookup, + }); + return options ? { ...options, lookup } : { lookup }; +} + function createTelegramDispatcher(policy: PinnedDispatcherPolicy): { dispatcher: TelegramDispatcher; mode: TelegramDispatcherMode; effectivePolicy: PinnedDispatcherPolicy; } { if (policy.mode === "explicit-proxy") { - const proxyOptions = policy.proxyTls + const proxyTlsOptions = withPinnedLookup(policy.proxyTls, policy.pinnedHostname); + const proxyOptions = proxyTlsOptions ? ({ uri: policy.proxyUrl, - proxyTls: { ...policy.proxyTls }, + proxyTls: proxyTlsOptions, } satisfies ConstructorParameters[0]) : policy.proxyUrl; try { @@ -253,13 +274,13 @@ function createTelegramDispatcher(policy: PinnedDispatcherPolicy): { } if (policy.mode === "env-proxy") { + const connectOptions = withPinnedLookup(policy.connect, policy.pinnedHostname); + const proxyTlsOptions = withPinnedLookup(policy.proxyTls, policy.pinnedHostname); const proxyOptions = - policy.connect || policy.proxyTls + connectOptions || proxyTlsOptions ? ({ - ...(policy.connect ? { connect: { ...policy.connect } } : {}), - // undici's EnvHttpProxyAgent passes `connect` only to the no-proxy Agent. - // Real proxied HTTPS traffic reads transport settings from ProxyAgent.proxyTls. - ...(policy.proxyTls ? { proxyTls: { ...policy.proxyTls } } : {}), + ...(connectOptions ? { connect: connectOptions } : {}), + ...(proxyTlsOptions ? { proxyTls: proxyTlsOptions } : {}), } satisfies ConstructorParameters[0]) : undefined; try { @@ -276,14 +297,12 @@ function createTelegramDispatcher(policy: PinnedDispatcherPolicy): { ); const directPolicy: PinnedDispatcherPolicy = { mode: "direct", - ...(policy.connect ? { connect: { ...policy.connect } } : {}), + ...(connectOptions ? { connect: connectOptions } : {}), }; return { dispatcher: new Agent( directPolicy.connect - ? ({ - connect: { ...directPolicy.connect }, - } satisfies ConstructorParameters[0]) + ? ({ connect: directPolicy.connect } satisfies ConstructorParameters[0]) : undefined, ), mode: "direct", @@ -292,11 +311,12 @@ function createTelegramDispatcher(policy: PinnedDispatcherPolicy): { } } + const connectOptions = withPinnedLookup(policy.connect, policy.pinnedHostname); return { dispatcher: new Agent( - policy.connect + connectOptions ? ({ - connect: { ...policy.connect }, + connect: connectOptions, } satisfies ConstructorParameters[0]) : undefined, ), @@ -375,13 +395,13 @@ function formatErrorCodes(err: unknown): string { return codes.length > 0 ? codes.join(",") : "none"; } -function shouldRetryWithIpv4Fallback(err: unknown): boolean { - const ctx: Ipv4FallbackContext = { +function shouldUseTelegramTransportFallback(err: unknown): boolean { + const ctx: TelegramTransportFallbackContext = { message: err && typeof err === "object" && "message" in err ? String(err.message).toLowerCase() : "", codes: collectErrorCodes(err), }; - for (const rule of IPV4_FALLBACK_RULES) { + for (const rule of TELEGRAM_TRANSPORT_FALLBACK_RULES) { if (!rule.matches(ctx)) { return false; } @@ -389,18 +409,71 @@ function shouldRetryWithIpv4Fallback(err: unknown): boolean { return true; } -export function shouldRetryTelegramIpv4Fallback(err: unknown): boolean { - return shouldRetryWithIpv4Fallback(err); +export function shouldRetryTelegramTransportFallback(err: unknown): boolean { + return shouldUseTelegramTransportFallback(err); } -// Prefer wrapped fetch when available to normalize AbortSignal across runtimes. export type TelegramTransport = { fetch: typeof fetch; sourceFetch: typeof fetch; - pinnedDispatcherPolicy?: PinnedDispatcherPolicy; - fallbackPinnedDispatcherPolicy?: PinnedDispatcherPolicy; + dispatcherAttempts?: TelegramDispatcherAttempt[]; }; +function createTelegramTransportAttempts(params: { + defaultDispatcher: ReturnType; + allowFallback: boolean; + fallbackPolicy?: PinnedDispatcherPolicy; +}): TelegramTransportAttempt[] { + const attempts: TelegramTransportAttempt[] = [ + { + createDispatcher: () => params.defaultDispatcher.dispatcher, + exportAttempt: { dispatcherPolicy: params.defaultDispatcher.effectivePolicy }, + }, + ]; + + if (!params.allowFallback || !params.fallbackPolicy) { + return attempts; + } + const fallbackPolicy = params.fallbackPolicy; + + let ipv4Dispatcher: TelegramDispatcher | null = null; + attempts.push({ + createDispatcher: () => { + if (!ipv4Dispatcher) { + ipv4Dispatcher = createTelegramDispatcher(fallbackPolicy).dispatcher; + } + return ipv4Dispatcher; + }, + exportAttempt: { dispatcherPolicy: fallbackPolicy }, + logMessage: "fetch fallback: enabling sticky IPv4-only dispatcher", + }); + + if (TELEGRAM_FALLBACK_IPS.length === 0) { + return attempts; + } + + const fallbackIpPolicy: PinnedDispatcherPolicy = { + ...fallbackPolicy, + pinnedHostname: { + hostname: TELEGRAM_API_HOSTNAME, + addresses: [...TELEGRAM_FALLBACK_IPS], + }, + }; + let fallbackIpDispatcher: TelegramDispatcher | null = null; + attempts.push({ + createDispatcher: () => { + if (!fallbackIpDispatcher) { + fallbackIpDispatcher = createTelegramDispatcher(fallbackIpPolicy).dispatcher; + } + return fallbackIpDispatcher; + }, + exportAttempt: { dispatcherPolicy: fallbackIpPolicy }, + logMessage: "fetch fallback: DNS-resolved IP unreachable; trying alternative Telegram API IP", + }); + + return attempts; +} + export function resolveTelegramTransport( proxyFetch?: typeof fetch, options?: { network?: TelegramNetworkConfig }, @@ -424,7 +497,6 @@ export function resolveTelegramTransport( ? resolveWrappedFetch(proxyFetch) : undiciSourceFetch; const dnsResultOrder = normalizeDnsResultOrder(dnsDecision.value); - // Preserve fully caller-owned custom fetch implementations. if (proxyFetch && !explicitProxyUrl) { return { fetch: sourceFetch, sourceFetch }; } @@ -439,70 +511,75 @@ export function resolveTelegramTransport( }); const defaultDispatcher = createTelegramDispatcher(defaultDispatcherResolution.policy); const shouldBypassEnvProxy = shouldBypassEnvProxyForTelegramApi(); - const allowStickyIpv4Fallback = + const allowStickyFallback = defaultDispatcher.mode === "direct" || (defaultDispatcher.mode === "env-proxy" && shouldBypassEnvProxy); - const stickyShouldUseEnvProxy = defaultDispatcher.mode === "env-proxy"; - const fallbackPinnedDispatcherPolicy = allowStickyIpv4Fallback + const fallbackDispatcherPolicy = allowStickyFallback ? resolveTelegramDispatcherPolicy({ autoSelectFamily: false, dnsResultOrder: "ipv4first", - useEnvProxy: stickyShouldUseEnvProxy, + useEnvProxy: defaultDispatcher.mode === "env-proxy", forceIpv4: true, proxyUrl: explicitProxyUrl, }).policy : undefined; + const transportAttempts = createTelegramTransportAttempts({ + defaultDispatcher, + allowFallback: allowStickyFallback, + fallbackPolicy: fallbackDispatcherPolicy, + }); - let stickyIpv4FallbackEnabled = false; - let stickyIpv4Dispatcher: TelegramDispatcher | null = null; - const resolveStickyIpv4Dispatcher = () => { - if (!stickyIpv4Dispatcher) { - if (!fallbackPinnedDispatcherPolicy) { - return defaultDispatcher.dispatcher; - } - stickyIpv4Dispatcher = createTelegramDispatcher(fallbackPinnedDispatcherPolicy).dispatcher; - } - return stickyIpv4Dispatcher; - }; - + let stickyAttemptIndex = 0; const resolvedFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const callerProvidedDispatcher = Boolean( (init as RequestInitWithDispatcher | undefined)?.dispatcher, ); - const initialInit = withDispatcherIfMissing( - init, - stickyIpv4FallbackEnabled ? resolveStickyIpv4Dispatcher() : defaultDispatcher.dispatcher, - ); + const startIndex = Math.min(stickyAttemptIndex, transportAttempts.length - 1); + let err: unknown; + try { - return await sourceFetch(input, initialInit); - } catch (err) { - if (shouldRetryWithIpv4Fallback(err)) { - // Preserve caller-owned dispatchers on retry. - if (callerProvidedDispatcher) { - return sourceFetch(input, init ?? {}); - } - // Proxy routes should not arm sticky IPv4 mode; `family=4` would constrain - // proxy-connect behavior instead of Telegram endpoint selection. - if (!allowStickyIpv4Fallback) { - throw err; - } - if (!stickyIpv4FallbackEnabled) { - stickyIpv4FallbackEnabled = true; - log.warn( - `fetch fallback: enabling sticky IPv4-only dispatcher (codes=${formatErrorCodes(err)})`, - ); - } - return sourceFetch(input, withDispatcherIfMissing(init, resolveStickyIpv4Dispatcher())); - } + return await sourceFetch( + input, + withDispatcherIfMissing(init, transportAttempts[startIndex].createDispatcher()), + ); + } catch (caught) { + err = caught; + } + + if (!shouldUseTelegramTransportFallback(err)) { throw err; } + if (callerProvidedDispatcher) { + return sourceFetch(input, init ?? {}); + } + + for (let nextIndex = startIndex + 1; nextIndex < transportAttempts.length; nextIndex += 1) { + const nextAttempt = transportAttempts[nextIndex]; + if (nextAttempt.logMessage) { + log.warn(`${nextAttempt.logMessage} (codes=${formatErrorCodes(err)})`); + } + try { + const response = await sourceFetch( + input, + withDispatcherIfMissing(init, nextAttempt.createDispatcher()), + ); + stickyAttemptIndex = nextIndex; + return response; + } catch (caught) { + err = caught; + if (!shouldUseTelegramTransportFallback(err)) { + throw err; + } + } + } + + throw err; }) as typeof fetch; return { fetch: resolvedFetch, sourceFetch, - pinnedDispatcherPolicy: defaultDispatcher.effectivePolicy, - fallbackPinnedDispatcherPolicy, + dispatcherAttempts: transportAttempts.map((attempt) => attempt.exportAttempt), }; } diff --git a/extensions/telegram/src/monitor.test.ts b/extensions/telegram/src/monitor.test.ts index c4a898c5a6d..d75b01c4608 100644 --- a/extensions/telegram/src/monitor.test.ts +++ b/extensions/telegram/src/monitor.test.ts @@ -82,6 +82,12 @@ const { readTelegramUpdateOffsetSpy } = vi.hoisted(() => ({ const { startTelegramWebhookSpy } = vi.hoisted(() => ({ startTelegramWebhookSpy: vi.fn(async () => ({ server: { close: vi.fn() }, stop: vi.fn() })), })); +const { resolveTelegramTransportSpy } = vi.hoisted(() => ({ + resolveTelegramTransportSpy: vi.fn(() => ({ + fetch: globalThis.fetch, + sourceFetch: globalThis.fetch, + })), +})); type RunnerStub = { task: () => Promise; @@ -267,6 +273,10 @@ vi.mock("./webhook.js", () => ({ startTelegramWebhook: startTelegramWebhookSpy, })); +vi.mock("./fetch.js", () => ({ + resolveTelegramTransport: resolveTelegramTransportSpy, +})); + vi.mock("./update-offset-store.js", () => ({ readTelegramUpdateOffset: readTelegramUpdateOffsetSpy, writeTelegramUpdateOffset: vi.fn(async () => undefined), @@ -298,6 +308,10 @@ describe("monitorTelegramProvider (grammY)", () => { computeBackoff.mockClear(); sleepWithAbort.mockClear(); startTelegramWebhookSpy.mockClear(); + resolveTelegramTransportSpy.mockReset().mockImplementation(() => ({ + fetch: globalThis.fetch, + sourceFetch: globalThis.fetch, + })); registerUnhandledRejectionHandlerMock.mockClear(); resetUnhandledRejection(); createTelegramBotErrors.length = 0; @@ -499,6 +513,34 @@ describe("monitorTelegramProvider (grammY)", () => { expect(runSpy).toHaveBeenCalledTimes(2); }); + it("reuses the resolved transport across polling restarts", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const telegramTransport = { + fetch: globalThis.fetch, + sourceFetch: globalThis.fetch, + }; + resolveTelegramTransportSpy.mockReturnValueOnce(telegramTransport); + + const abort = new AbortController(); + mockRunOnceWithStalledPollingRunner(); + mockRunOnceAndAbort(abort); + + const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); + await vi.waitFor(() => expect(createTelegramBotCalls.length).toBeGreaterThanOrEqual(1)); + + vi.advanceTimersByTime(120_000); + await monitor; + + expect(resolveTelegramTransportSpy).toHaveBeenCalledTimes(1); + expect(createTelegramBotCalls).toHaveLength(2); + expect(createTelegramBotCalls[0]?.telegramTransport).toBe(telegramTransport); + expect(createTelegramBotCalls[1]?.telegramTransport).toBe(telegramTransport); + } finally { + vi.useRealTimers(); + } + }); + it("aborts the active Telegram fetch when unhandled network rejection forces restart", async () => { const abort = new AbortController(); const { stop } = mockRunOnceWithStalledPollingRunner(); diff --git a/extensions/telegram/src/monitor.ts b/extensions/telegram/src/monitor.ts index 11530ad66ef..e703266f0f0 100644 --- a/extensions/telegram/src/monitor.ts +++ b/extensions/telegram/src/monitor.ts @@ -9,6 +9,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js"; +import { resolveTelegramTransport } from "./fetch.js"; import { isRecoverableTelegramNetworkError, isTelegramPollingNetworkError, @@ -178,6 +179,11 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { return; } + // Create transport once to preserve sticky IPv4 fallback state across polling restarts + const telegramTransport = resolveTelegramTransport(proxyFetch, { + network: account.config.network, + }); + pollingSession = new TelegramPollingSession({ token, config: cfg, @@ -189,6 +195,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { getLastUpdateId: () => lastUpdateId, persistUpdateId, log, + telegramTransport, }); await pollingSession.runUntilAbort(); } finally { diff --git a/extensions/telegram/src/polling-session.ts b/extensions/telegram/src/polling-session.ts index 89342994387..59cbec7d589 100644 --- a/extensions/telegram/src/polling-session.ts +++ b/extensions/telegram/src/polling-session.ts @@ -4,6 +4,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; import { formatDurationPrecise } from "openclaw/plugin-sdk/infra-runtime"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { createTelegramBot } from "./bot.js"; +import { type TelegramTransport } from "./fetch.js"; import { isRecoverableTelegramNetworkError } from "./network-errors.js"; const TELEGRAM_POLL_RESTART_POLICY = { @@ -47,6 +48,8 @@ type TelegramPollingSessionOpts = { getLastUpdateId: () => number | null; persistUpdateId: (updateId: number) => Promise; log: (line: string) => void; + /** Pre-resolved Telegram transport to reuse across bot instances */ + telegramTransport?: TelegramTransport; }; export class TelegramPollingSession { @@ -135,6 +138,7 @@ export class TelegramPollingSession { lastUpdateId: this.opts.getLastUpdateId(), onUpdateId: this.opts.persistUpdateId, }, + telegramTransport: this.opts.telegramTransport, }); } catch (err) { await this.#waitBeforeRetryOnRecoverableSetupError(err, "Telegram setup network error"); diff --git a/extensions/telegram/src/send.test-harness.ts b/extensions/telegram/src/send.test-harness.ts index f313141dab0..28ad1e6bb0a 100644 --- a/extensions/telegram/src/send.test-harness.ts +++ b/extensions/telegram/src/send.test-harness.ts @@ -44,7 +44,7 @@ type TelegramSendTestMocks = { maybePersistResolvedTelegramTarget: MockFn; }; -vi.mock("../../whatsapp/src/media.js", () => ({ +vi.mock("openclaw/plugin-sdk/web-media", () => ({ loadWebMedia, })); @@ -61,6 +61,14 @@ vi.mock("grammy", () => ({ botCtorSpy(token, options); } }, + HttpError: class HttpError extends Error { + constructor( + message = "HttpError", + public error?: unknown, + ) { + super(message); + } + }, InputFile: class {}, })); @@ -94,5 +102,6 @@ export function installTelegramSendTestHooks() { } export async function importTelegramSendModule() { + vi.resetModules(); return await import("./send.js"); } diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index 0682fda6786..ec824d88ec7 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -19,7 +19,7 @@ import { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/media-ru import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { redactSensitiveText } from "openclaw/plugin-sdk/text-runtime"; -import { loadWebMedia } from "../../whatsapp/src/media.js"; +import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js"; diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index 7e73898f8b1..afc302500bf 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -1,8 +1,6 @@ import { createEnvPatchedAccountSetupAdapter, DEFAULT_ACCOUNT_ID, - formatCliCommand, - formatDocsLink, patchChannelConfigForAccount, promptResolvedAllowFrom, splitSetupEntries, @@ -10,6 +8,7 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; +import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; import { fetchTelegramChatId } from "./api-fetch.js"; diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts index 644869dbc60..2a6fbf41d0b 100644 --- a/extensions/telegram/src/shared.ts +++ b/extensions/telegram/src/shared.ts @@ -10,7 +10,7 @@ import { TelegramConfigSchema, type ChannelPlugin, type OpenClawConfig, -} from "openclaw/plugin-sdk/telegram"; +} from "openclaw/plugin-sdk/telegram-core"; import { inspectTelegramAccount } from "./account-inspect.js"; import { listTelegramAccountIds, diff --git a/extensions/telegram/src/target-writeback.test.ts b/extensions/telegram/src/target-writeback.test.ts index bb8b2129924..8403f7e1b0f 100644 --- a/extensions/telegram/src/target-writeback.test.ts +++ b/extensions/telegram/src/target-writeback.test.ts @@ -7,29 +7,24 @@ const loadCronStore = vi.fn(); const resolveCronStorePath = vi.fn(); const saveCronStore = vi.fn(); -vi.mock("../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, readConfigFileSnapshotForWrite, writeConfigFile, - }; -}); - -vi.mock("../../../src/cron/store.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, loadCronStore, resolveCronStorePath, saveCronStore, }; }); -const { maybePersistResolvedTelegramTarget } = await import("./target-writeback.js"); - describe("maybePersistResolvedTelegramTarget", () => { - beforeEach(() => { + let maybePersistResolvedTelegramTarget: typeof import("./target-writeback.js").maybePersistResolvedTelegramTarget; + + beforeEach(async () => { + vi.resetModules(); + ({ maybePersistResolvedTelegramTarget } = await import("./target-writeback.js")); readConfigFileSnapshotForWrite.mockReset(); writeConfigFile.mockReset(); loadCronStore.mockReset(); diff --git a/extensions/thread-ownership/api.ts b/extensions/thread-ownership/api.ts new file mode 100644 index 00000000000..d94a5fd68e1 --- /dev/null +++ b/extensions/thread-ownership/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/thread-ownership"; diff --git a/extensions/thread-ownership/index.ts b/extensions/thread-ownership/index.ts index 8e6e5c1d020..603b064bc68 100644 --- a/extensions/thread-ownership/index.ts +++ b/extensions/thread-ownership/index.ts @@ -1,8 +1,4 @@ -import { - definePluginEntry, - type OpenClawConfig, - type OpenClawPluginApi, -} from "openclaw/plugin-sdk/thread-ownership"; +import { definePluginEntry, type OpenClawConfig, type OpenClawPluginApi } from "./api.js"; type ThreadOwnershipConfig = { forwarderUrl?: string; diff --git a/extensions/tlon/api.ts b/extensions/tlon/api.ts new file mode 100644 index 00000000000..8f7fe4d268b --- /dev/null +++ b/extensions/tlon/api.ts @@ -0,0 +1,2 @@ +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/tlon/index.ts b/extensions/tlon/index.ts index 9ae569fea03..a59c7bcb9f2 100644 --- a/extensions/tlon/index.ts +++ b/extensions/tlon/index.ts @@ -6,6 +6,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { tlonPlugin } from "./src/channel.js"; import { setTlonRuntime } from "./src/runtime.js"; +export { tlonPlugin } from "./src/channel.js"; +export { setTlonRuntime } from "./src/runtime.js"; + const __dirname = dirname(fileURLToPath(import.meta.url)); const ALLOWED_TLON_COMMANDS = new Set([ diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 5f754201ac1..daea0d8a52e 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,3 +1,4 @@ +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/tlon"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { @@ -17,12 +18,7 @@ import { validateUrbitBaseUrl } from "./urbit/base-url.js"; const TLON_CHANNEL_ID = "tlon" as const; -let tlonChannelRuntimePromise: Promise | null = null; - -async function loadTlonChannelRuntime() { - tlonChannelRuntimePromise ??= import("./channel.runtime.js"); - return tlonChannelRuntimePromise; -} +const loadTlonChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js")); const tlonSetupWizardProxy = createTlonSetupWizardBase({ resolveConfigured: async ({ cfg }) => diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 1ea42902aaf..19c9ec5b841 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -36,6 +36,7 @@ import { stripBotMention, isDmAllowed, isSummarizationRequest, + resolveAuthorizedMessageText, type ParsedCite, } from "./utils.js"; @@ -1245,9 +1246,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise normalizeShip(ship)).some((ship) => ship === normalizedInviter); } +/** + * Resolve quoted/cited content only after the caller has passed authorization. + * Unauthorized paths must keep raw text and must not trigger cross-channel cite fetches. + */ +export async function resolveAuthorizedMessageText(params: { + rawText: string; + content: unknown; + authorizedForCites: boolean; + resolveAllCites: (content: unknown) => Promise; +}): Promise { + const { rawText, content, authorizedForCites, resolveAllCites } = params; + if (!authorizedForCites) { + return rawText; + } + const citedContent = await resolveAllCites(content); + return citedContent + rawText; +} + // Helper to recursively extract text from inline content function renderInlineItem( item: any, diff --git a/extensions/tlon/src/security.test.ts b/extensions/tlon/src/security.test.ts index 04fad337b14..2733f2e3780 100644 --- a/extensions/tlon/src/security.test.ts +++ b/extensions/tlon/src/security.test.ts @@ -8,12 +8,14 @@ * - Bot mention detection boundaries */ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + extractCites, isDmAllowed, isGroupInviteAllowed, isBotMentioned, extractMessageText, + resolveAuthorizedMessageText, } from "./monitor/utils.js"; import { normalizeShip } from "./targets.js"; @@ -340,6 +342,186 @@ describe("Security: Authorization Edge Cases", () => { }); }); +describe("Security: Cite Resolution Authorization Ordering", () => { + async function resolveAllCitesForPoC( + content: unknown, + api: { scry: (path: string) => Promise }, + ): Promise { + const cites = extractCites(content); + if (cites.length === 0) { + return ""; + } + + const resolved: string[] = []; + for (const cite of cites) { + if (cite.type !== "chan" || !cite.nest || !cite.postId) { + continue; + } + const data = (await api.scry(`/channels/v4/${cite.nest}/posts/post/${cite.postId}.json`)) as { + essay?: { content?: unknown }; + }; + const text = data?.essay?.content ? extractMessageText(data.essay.content) : ""; + if (text) { + resolved.push(`> ${cite.author || "unknown"} wrote: ${text}`); + } + } + + return resolved.length > 0 ? resolved.join("\n") + "\n\n" : ""; + } + + function buildCitedMessage( + secretNest = "chat/~private-ship/ops", + postId = "1701411845077995094", + ) { + return [ + { + block: { + cite: { + chan: { + nest: secretNest, + where: `/msg/~victim-ship/${postId}`, + }, + }, + }, + }, + { inline: ["~bot-ship please summarize this"] }, + ]; + } + + it("does not resolve channel cites for unauthorized senders", async () => { + const content = buildCitedMessage(); + const rawText = extractMessageText(content); + const api = { + scry: vi.fn(async () => ({ + essay: { content: [{ inline: ["TOP-SECRET"] }] }, + })), + }; + + const messageText = await resolveAuthorizedMessageText({ + rawText, + content, + authorizedForCites: false, + resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api), + }); + + expect(messageText).toBe(rawText); + expect(api.scry).not.toHaveBeenCalled(); + }); + + it("resolves channel cites after sender authorization passes", async () => { + const secretNest = "chat/~private-ship/ops"; + const postId = "170141184507799509469114119040828178432"; + const content = buildCitedMessage(secretNest, postId); + const rawText = extractMessageText(content); + const api = { + scry: vi.fn(async (path: string) => { + expect(path).toBe(`/channels/v4/${secretNest}/posts/post/${postId}.json`); + return { + essay: { content: [{ inline: ["TOP-SECRET: migration key is rotate-me"] }] }, + }; + }), + }; + + const messageText = await resolveAuthorizedMessageText({ + rawText, + content, + authorizedForCites: true, + resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api), + }); + + expect(api.scry).toHaveBeenCalledTimes(1); + expect(messageText).toContain("TOP-SECRET: migration key is rotate-me"); + expect(messageText).toContain("> ~victim-ship wrote: TOP-SECRET: migration key is rotate-me"); + }); + + it("does not resolve DM cites before a deny path", async () => { + const content = buildCitedMessage("chat/~secret-dm/ops", "1701411845077995095"); + const rawText = extractMessageText(content); + const senderShip = "~attacker-ship"; + const allowlist = ["~trusted-ship"]; + const api = { + scry: vi.fn(async () => ({ + essay: { content: [{ inline: ["DM-SECRET"] }] }, + })), + }; + + const senderAllowed = allowlist + .map((ship) => normalizeShip(ship)) + .includes(normalizeShip(senderShip)); + expect(senderAllowed).toBe(false); + + const messageText = await resolveAuthorizedMessageText({ + rawText, + content, + authorizedForCites: senderAllowed, + resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api), + }); + + expect(messageText).toBe(rawText); + expect(api.scry).not.toHaveBeenCalled(); + }); + + it("does not resolve DM cites before owner approval command handling", async () => { + const content = [ + { + block: { + cite: { + chan: { + nest: "chat/~private-ship/admin", + where: "/msg/~victim-ship/1701411845077995096", + }, + }, + }, + }, + { inline: ["/approve 1"] }, + ]; + const rawText = extractMessageText(content); + const api = { + scry: vi.fn(async () => ({ + essay: { content: [{ inline: ["ADMIN-SECRET"] }] }, + })), + }; + + const messageText = await resolveAuthorizedMessageText({ + rawText, + content, + authorizedForCites: false, + resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api), + }); + + expect(rawText).toContain("/approve 1"); + expect(messageText).toBe(rawText); + expect(messageText).not.toContain("ADMIN-SECRET"); + expect(api.scry).not.toHaveBeenCalled(); + }); + + it("resolves DM cites for allowed senders after authorization passes", async () => { + const secretNest = "chat/~private-ship/dm"; + const postId = "1701411845077995097"; + const content = buildCitedMessage(secretNest, postId); + const rawText = extractMessageText(content); + const api = { + scry: vi.fn(async (path: string) => { + expect(path).toBe(`/channels/v4/${secretNest}/posts/post/${postId}.json`); + return { + essay: { content: [{ inline: ["ALLOWED-DM-SECRET"] }] }, + }; + }), + }; + + const messageText = await resolveAuthorizedMessageText({ + rawText, + content, + authorizedForCites: true, + resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api), + }); + + expect(api.scry).toHaveBeenCalledTimes(1); + expect(messageText).toContain("ALLOWED-DM-SECRET"); + expect(messageText).toContain("> ~victim-ship wrote: ALLOWED-DM-SECRET"); + }); +}); + describe("Security: Sender Role Identification", () => { /** * Tests for sender role identification (owner vs user). diff --git a/extensions/twitch/api.ts b/extensions/twitch/api.ts new file mode 100644 index 00000000000..7c705aec6e5 --- /dev/null +++ b/extensions/twitch/api.ts @@ -0,0 +1 @@ +export * from "./src/setup-surface.js"; diff --git a/extensions/twitch/src/send.test.ts b/extensions/twitch/src/send.test.ts index b45321229a4..4607670e3bf 100644 --- a/extensions/twitch/src/send.test.ts +++ b/extensions/twitch/src/send.test.ts @@ -11,12 +11,16 @@ */ import { describe, expect, it, vi } from "vitest"; +import { getClientManager } from "./client-manager-registry.js"; +import { getAccountConfig } from "./config.js"; import { sendMessageTwitchInternal } from "./send.js"; import { BASE_TWITCH_TEST_ACCOUNT, installTwitchTestHooks, makeTwitchTestConfig, } from "./test-fixtures.js"; +import { stripMarkdownForTwitch } from "./utils/markdown.js"; +import { isAccountConfigured } from "./utils/twitch.js"; // Mock dependencies vi.mock("./config.js", () => ({ @@ -55,15 +59,16 @@ describe("send", () => { installTwitchTestHooks(); describe("sendMessageTwitchInternal", () => { + function setupBaseAccount(params?: { configured?: boolean }) { + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(isAccountConfigured).mockReturnValue(params?.configured ?? true); + } + async function mockSuccessfulSend(params: { messageId: string; stripMarkdown?: (text: string) => string; }) { - const { getAccountConfig } = await import("./config.js"); - const { getClientManager } = await import("./client-manager-registry.js"); - const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); - - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + setupBaseAccount(); vi.mocked(getClientManager).mockReturnValue({ sendMessage: vi.fn().mockResolvedValue({ ok: true, @@ -112,8 +117,6 @@ describe("send", () => { }); it("should return error when account not found", async () => { - const { getAccountConfig } = await import("./config.js"); - vi.mocked(getAccountConfig).mockReturnValue(null); const result = await sendMessageTwitchInternal( @@ -130,11 +133,7 @@ describe("send", () => { }); it("should return error when account not configured", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(false); + setupBaseAccount({ configured: false }); const result = await sendMessageTwitchInternal( "#testchannel", @@ -150,9 +149,6 @@ describe("send", () => { }); it("should return error when no channel specified", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - // Set channel to undefined to trigger the error (bypassing type check) const accountWithoutChannel = { ...mockAccount, @@ -175,12 +171,7 @@ describe("send", () => { }); it("should skip sending empty message after markdown stripping", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); - - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(true); + setupBaseAccount(); vi.mocked(stripMarkdownForTwitch).mockReturnValue(""); const result = await sendMessageTwitchInternal( @@ -197,12 +188,7 @@ describe("send", () => { }); it("should return error when client manager not found", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - const { getClientManager } = await import("./client-manager-registry.js"); - - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(true); + setupBaseAccount(); vi.mocked(getClientManager).mockReturnValue(undefined); const result = await sendMessageTwitchInternal( @@ -219,12 +205,7 @@ describe("send", () => { }); it("should handle send errors gracefully", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - const { getClientManager } = await import("./client-manager-registry.js"); - - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(true); + setupBaseAccount(); vi.mocked(getClientManager).mockReturnValue({ sendMessage: vi.fn().mockRejectedValue(new Error("Connection lost")), } as unknown as ReturnType); @@ -244,12 +225,7 @@ describe("send", () => { }); it("should use account channel when channel parameter is empty", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - const { getClientManager } = await import("./client-manager-registry.js"); - - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(true); + setupBaseAccount(); const mockSend = vi.fn().mockResolvedValue({ ok: true, messageId: "twitch-msg-789", diff --git a/extensions/voice-call/api.ts b/extensions/voice-call/api.ts new file mode 100644 index 00000000000..ef9f7d7a3c0 --- /dev/null +++ b/extensions/voice-call/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/voice-call"; diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 9f976881a11..ad63cf1f52a 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -3,7 +3,7 @@ import { definePluginEntry, type GatewayRequestHandlerOptions, type OpenClawPluginApi, -} from "openclaw/plugin-sdk/voice-call"; +} from "./api.js"; import { registerVoiceCallCli } from "./src/cli.js"; import { VoiceCallConfigSchema, diff --git a/extensions/voice-call/src/cli.ts b/extensions/voice-call/src/cli.ts index c1abc9a1f0e..322a9dae355 100644 --- a/extensions/voice-call/src/cli.ts +++ b/extensions/voice-call/src/cli.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { Command } from "commander"; -import { sleep } from "openclaw/plugin-sdk/voice-call"; +import { sleep } from "../api.js"; import type { VoiceCallConfig } from "./config.js"; import type { VoiceCallRuntime } from "./runtime.js"; import { resolveUserPath } from "./utils.js"; diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 2d1494c7876..5ecd4f01bd3 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -1,10 +1,5 @@ -import { - TtsAutoSchema, - TtsConfigSchema, - TtsModeSchema, - TtsProviderSchema, -} from "openclaw/plugin-sdk/voice-call"; import { z } from "zod"; +import { TtsAutoSchema, TtsConfigSchema, TtsModeSchema, TtsProviderSchema } from "../api.js"; import { deepMergeDefined } from "./deep-merge.js"; // ----------------------------------------------------------------------------- diff --git a/extensions/voice-call/src/core-bridge.ts b/extensions/voice-call/src/core-bridge.ts index 13ed56302fe..8c3981db346 100644 --- a/extensions/voice-call/src/core-bridge.ts +++ b/extensions/voice-call/src/core-bridge.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/voice-call"; +import type { OpenClawPluginApi } from "../api.js"; import type { VoiceCallTtsConfig } from "./config.js"; export type CoreConfig = { diff --git a/extensions/voice-call/src/providers/shared/guarded-json-api.ts b/extensions/voice-call/src/providers/shared/guarded-json-api.ts index cc8d1f33e03..625ad0f833a 100644 --- a/extensions/voice-call/src/providers/shared/guarded-json-api.ts +++ b/extensions/voice-call/src/providers/shared/guarded-json-api.ts @@ -1,4 +1,4 @@ -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/voice-call"; +import { fetchWithSsrFGuard } from "../../../api.js"; type GuardedJsonApiRequestParams = { url: string; diff --git a/extensions/voice-call/src/providers/tts-openai.ts b/extensions/voice-call/src/providers/tts-openai.ts index 0a7c74d90ac..c16b20c0a66 100644 --- a/extensions/voice-call/src/providers/tts-openai.ts +++ b/extensions/voice-call/src/providers/tts-openai.ts @@ -1,4 +1,4 @@ -import { resolveOpenAITtsInstructions } from "openclaw/plugin-sdk/voice-call"; +import { resolveOpenAITtsInstructions } from "../../api.js"; import { pcmToMulaw } from "../telephony-audio.js"; /** diff --git a/extensions/voice-call/src/response-generator.ts b/extensions/voice-call/src/response-generator.ts index 3c8a45eadfb..d1903410f86 100644 --- a/extensions/voice-call/src/response-generator.ts +++ b/extensions/voice-call/src/response-generator.ts @@ -4,7 +4,7 @@ */ import crypto from "node:crypto"; -import type { SessionEntry } from "openclaw/plugin-sdk/voice-call"; +import type { SessionEntry } from "../api.js"; import type { VoiceCallConfig } from "./config.js"; import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js"; diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 2b79309c9f0..fe015727e73 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -4,7 +4,7 @@ import { isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, -} from "openclaw/plugin-sdk/voice-call"; +} from "../api.js"; import { normalizeVoiceCallConfig, type VoiceCallConfig } from "./config.js"; import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js"; import type { CallManager } from "./manager.js"; diff --git a/extensions/whatsapp/api.ts b/extensions/whatsapp/api.ts new file mode 100644 index 00000000000..feaaa1c5835 --- /dev/null +++ b/extensions/whatsapp/api.ts @@ -0,0 +1 @@ +export * from "./src/accounts.js"; diff --git a/extensions/whatsapp/index.ts b/extensions/whatsapp/index.ts index da16917fa43..de3e6c92706 100644 --- a/extensions/whatsapp/index.ts +++ b/extensions/whatsapp/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { whatsappPlugin } from "./src/channel.js"; import { setWhatsAppRuntime } from "./src/runtime.js"; +export { whatsappPlugin } from "./src/channel.js"; +export { setWhatsAppRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "whatsapp", name: "WhatsApp", diff --git a/extensions/whatsapp/login-qr-api.ts b/extensions/whatsapp/login-qr-api.ts new file mode 100644 index 00000000000..a8af0fc64b2 --- /dev/null +++ b/extensions/whatsapp/login-qr-api.ts @@ -0,0 +1 @@ +export * from "./src/login-qr.js"; diff --git a/extensions/whatsapp/runtime-api.ts b/extensions/whatsapp/runtime-api.ts new file mode 100644 index 00000000000..24e269ad62f --- /dev/null +++ b/extensions/whatsapp/runtime-api.ts @@ -0,0 +1,9 @@ +export * from "./src/active-listener.js"; +export * from "./src/agent-tools-login.js"; +export * from "./src/auth-store.js"; +export * from "./src/auto-reply.js"; +export * from "./src/inbound.js"; +export * from "./src/login.js"; +export * from "./src/media.js"; +export * from "./src/send.js"; +export * from "./src/session.js"; diff --git a/extensions/whatsapp/setup-entry.ts b/extensions/whatsapp/setup-entry.ts index a01efecdc36..16471e34e0f 100644 --- a/extensions/whatsapp/setup-entry.ts +++ b/extensions/whatsapp/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { whatsappSetupPlugin } from "./src/channel.setup.js"; +export { whatsappSetupPlugin } from "./src/channel.setup.js"; + export default defineSetupPluginEntry(whatsappSetupPlugin); diff --git a/extensions/whatsapp/src/channel.runtime.ts b/extensions/whatsapp/src/channel.runtime.ts index de2203db2ad..0d944b3cb17 100644 --- a/extensions/whatsapp/src/channel.runtime.ts +++ b/extensions/whatsapp/src/channel.runtime.ts @@ -7,10 +7,6 @@ import { readWebSelfId as readWebSelfIdImpl, webAuthExists as webAuthExistsImpl, } from "./auth-store.js"; -import { - startWebLoginWithQr as startWebLoginWithQrImpl, - waitForWebLogin as waitForWebLoginImpl, -} from "./login-qr.js"; import { loginWeb as loginWebImpl } from "./login.js"; import { whatsappSetupWizard as whatsappSetupWizardImpl } from "./setup-surface.js"; @@ -26,6 +22,13 @@ type WaitForWebLogin = typeof import("./login-qr.js").waitForWebLogin; type WhatsAppSetupWizard = typeof import("./setup-surface.js").whatsappSetupWizard; type MonitorWebChannel = typeof import("openclaw/plugin-sdk/whatsapp").monitorWebChannel; +let loginQrPromise: Promise | null = null; + +function loadWhatsAppLoginQr() { + loginQrPromise ??= import("./login-qr.js"); + return loginQrPromise; +} + export function getActiveWebListener( ...args: Parameters ): ReturnType { @@ -56,14 +59,18 @@ export function loginWeb(...args: Parameters): ReturnType { return loginWebImpl(...args); } -export function startWebLoginWithQr( +export async function startWebLoginWithQr( ...args: Parameters ): ReturnType { - return startWebLoginWithQrImpl(...args); + const { startWebLoginWithQr } = await loadWhatsAppLoginQr(); + return await startWebLoginWithQr(...args); } -export function waitForWebLogin(...args: Parameters): ReturnType { - return waitForWebLoginImpl(...args); +export async function waitForWebLogin( + ...args: Parameters +): ReturnType { + const { waitForWebLogin } = await loadWhatsAppLoginQr(); + return await waitForWebLogin(...args); } export const whatsappSetupWizard: WhatsAppSetupWizard = { ...whatsappSetupWizardImpl }; diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index ebe4deb5789..3b4ecacce26 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -1,4 +1,11 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/whatsapp"; +import { + buildChannelConfigSchema, + resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, + WhatsAppConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/whatsapp"; import { type ResolvedWhatsAppAccount } from "./accounts.js"; import { webAuthExists } from "./auth-store.js"; import { whatsappSetupAdapter } from "./setup-core.js"; @@ -6,6 +13,12 @@ import { createWhatsAppPluginBase, whatsappSetupWizardProxy } from "./shared.js" export const whatsappSetupPlugin: ChannelPlugin = { ...createWhatsAppPluginBase({ + configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, + }, setupWizard: whatsappSetupWizardProxy, setup: whatsappSetupAdapter, isConfigured: async (account) => await webAuthExists(account.authDir), diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index e7f79ad5f2a..d69dd480a4a 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,5 +1,6 @@ import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { + buildChannelConfigSchema, createActionGate, createWhatsAppOutboundBase, DEFAULT_ACCOUNT_ID, @@ -7,9 +8,13 @@ import { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, readStringParam, + resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, resolveWhatsAppOutboundTarget, resolveWhatsAppHeartbeatRecipients, resolveWhatsAppMentionStripRegexes, + WhatsAppConfigSchema, type ChannelMessageActionName, type ChannelPlugin, } from "openclaw/plugin-sdk/whatsapp"; @@ -44,6 +49,12 @@ function parseWhatsAppExplicitTarget(raw: string) { export const whatsappPlugin: ChannelPlugin = { ...createWhatsAppPluginBase({ + configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, + }, setupWizard: whatsappSetupWizardProxy, setup: whatsappSetupAdapter, isConfigured: async (account) => diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 4a87ce4d0f8..e836362bca5 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -1,8 +1,6 @@ import path from "node:path"; import { DEFAULT_ACCOUNT_ID, - formatCliCommand, - formatDocsLink, normalizeAccountId, normalizeAllowFromEntries, normalizeE164, @@ -13,6 +11,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; import { loginWeb } from "./login.js"; import { whatsappSetupAdapter } from "./setup-core.js"; diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 6616ac6911f..1777de07736 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -16,7 +16,7 @@ import { resolveWhatsAppGroupToolPolicy, WhatsAppConfigSchema, type ChannelPlugin, -} from "openclaw/plugin-sdk/whatsapp"; +} from "openclaw/plugin-sdk/whatsapp-core"; import { listWhatsAppAccountIds, resolveDefaultWhatsAppAccountId, diff --git a/extensions/zalo/api.ts b/extensions/zalo/api.ts new file mode 100644 index 00000000000..8f7fe4d268b --- /dev/null +++ b/extensions/zalo/api.ts @@ -0,0 +1,2 @@ +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/zalo/index.ts b/extensions/zalo/index.ts index c5091444450..b1391b68c01 100644 --- a/extensions/zalo/index.ts +++ b/extensions/zalo/index.ts @@ -2,6 +2,9 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { zaloPlugin } from "./src/channel.js"; import { setZaloRuntime } from "./src/runtime.js"; +export { zaloPlugin } from "./src/channel.js"; +export { setZaloRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "zalo", name: "Zalo", diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index b492a57a6dc..201838f0b04 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -1,17 +1,15 @@ +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageActionName, OpenClawConfig, } from "openclaw/plugin-sdk/zalo"; import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk/zalo"; -import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; import { listEnabledZaloAccounts } from "./accounts.js"; -type ZaloActionsRuntime = typeof import("./actions.runtime.js").zaloActionsRuntime; - -const loadZaloActionsRuntime = createLazyRuntimeSurface( +const loadZaloActionsRuntime = createLazyRuntimeNamedExport( () => import("./actions.runtime.js"), - ({ zaloActionsRuntime }) => zaloActionsRuntime, + "zaloActionsRuntime", ); const providerId = "zalo"; diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 16e4560cd14..80b03ea00c5 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -5,6 +5,7 @@ import { buildOpenGroupPolicyWarning, collectOpenProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-policy"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelAccountSnapshot, ChannelPlugin, @@ -56,12 +57,7 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined { return trimmed.replace(/^(zalo|zl):/i, ""); } -let zaloChannelRuntimePromise: Promise | null = null; - -async function loadZaloChannelRuntime() { - zaloChannelRuntimePromise ??= import("./channel.runtime.js"); - return zaloChannelRuntimePromise; -} +const loadZaloChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js")); export const zaloPlugin: ChannelPlugin = { id: "zalo", diff --git a/extensions/zalouser/api.ts b/extensions/zalouser/api.ts new file mode 100644 index 00000000000..8f7fe4d268b --- /dev/null +++ b/extensions/zalouser/api.ts @@ -0,0 +1,2 @@ +export * from "./src/setup-core.js"; +export * from "./src/setup-surface.js"; diff --git a/extensions/zalouser/index.ts b/extensions/zalouser/index.ts index 2199567cff8..c5d4cc2ba24 100644 --- a/extensions/zalouser/index.ts +++ b/extensions/zalouser/index.ts @@ -4,6 +4,9 @@ import { zalouserPlugin } from "./src/channel.js"; import { setZalouserRuntime } from "./src/runtime.js"; import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js"; +export { zalouserPlugin } from "./src/channel.js"; +export { setZalouserRuntime } from "./src/runtime.js"; + export default defineChannelPluginEntry({ id: "zalouser", name: "Zalo Personal", diff --git a/package.json b/package.json index c6ed4cab402..473a4fcfefe 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,10 @@ "types": "./dist/plugin-sdk/setup.d.ts", "default": "./dist/plugin-sdk/setup.js" }, + "./plugin-sdk/setup-tools": { + "types": "./dist/plugin-sdk/setup-tools.d.ts", + "default": "./dist/plugin-sdk/setup-tools.js" + }, "./plugin-sdk/config-runtime": { "types": "./dist/plugin-sdk/config-runtime.d.ts", "default": "./dist/plugin-sdk/config-runtime.js" @@ -158,26 +162,50 @@ "types": "./dist/plugin-sdk/telegram.d.ts", "default": "./dist/plugin-sdk/telegram.js" }, + "./plugin-sdk/telegram-core": { + "types": "./dist/plugin-sdk/telegram-core.d.ts", + "default": "./dist/plugin-sdk/telegram-core.js" + }, "./plugin-sdk/discord": { "types": "./dist/plugin-sdk/discord.d.ts", "default": "./dist/plugin-sdk/discord.js" }, + "./plugin-sdk/discord-core": { + "types": "./dist/plugin-sdk/discord-core.d.ts", + "default": "./dist/plugin-sdk/discord-core.js" + }, "./plugin-sdk/slack": { "types": "./dist/plugin-sdk/slack.d.ts", "default": "./dist/plugin-sdk/slack.js" }, + "./plugin-sdk/slack-core": { + "types": "./dist/plugin-sdk/slack-core.d.ts", + "default": "./dist/plugin-sdk/slack-core.js" + }, "./plugin-sdk/signal": { "types": "./dist/plugin-sdk/signal.d.ts", "default": "./dist/plugin-sdk/signal.js" }, + "./plugin-sdk/signal-core": { + "types": "./dist/plugin-sdk/signal-core.d.ts", + "default": "./dist/plugin-sdk/signal-core.js" + }, "./plugin-sdk/imessage": { "types": "./dist/plugin-sdk/imessage.d.ts", "default": "./dist/plugin-sdk/imessage.js" }, + "./plugin-sdk/imessage-core": { + "types": "./dist/plugin-sdk/imessage-core.d.ts", + "default": "./dist/plugin-sdk/imessage-core.js" + }, "./plugin-sdk/whatsapp": { "types": "./dist/plugin-sdk/whatsapp.d.ts", "default": "./dist/plugin-sdk/whatsapp.js" }, + "./plugin-sdk/whatsapp-core": { + "types": "./dist/plugin-sdk/whatsapp-core.d.ts", + "default": "./dist/plugin-sdk/whatsapp-core.js" + }, "./plugin-sdk/line": { "types": "./dist/plugin-sdk/line.d.ts", "default": "./dist/plugin-sdk/line.js" @@ -230,6 +258,10 @@ "types": "./dist/plugin-sdk/lobster.d.ts", "default": "./dist/plugin-sdk/lobster.js" }, + "./plugin-sdk/lazy-runtime": { + "types": "./dist/plugin-sdk/lazy-runtime.d.ts", + "default": "./dist/plugin-sdk/lazy-runtime.js" + }, "./plugin-sdk/matrix": { "types": "./dist/plugin-sdk/matrix.d.ts", "default": "./dist/plugin-sdk/matrix.js" @@ -398,10 +430,6 @@ "types": "./dist/plugin-sdk/image-generation.d.ts", "default": "./dist/plugin-sdk/image-generation.js" }, - "./plugin-sdk/image-generation-runtime": { - "types": "./dist/plugin-sdk/image-generation-runtime.d.ts", - "default": "./dist/plugin-sdk/image-generation-runtime.js" - }, "./plugin-sdk/reply-history": { "types": "./dist/plugin-sdk/reply-history.d.ts", "default": "./dist/plugin-sdk/reply-history.js" @@ -422,6 +450,10 @@ "types": "./dist/plugin-sdk/runtime-store.d.ts", "default": "./dist/plugin-sdk/runtime-store.js" }, + "./plugin-sdk/web-media": { + "types": "./dist/plugin-sdk/web-media.d.ts", + "default": "./dist/plugin-sdk/web-media.js" + }, "./plugin-sdk/speech": { "types": "./dist/plugin-sdk/speech.d.ts", "default": "./dist/plugin-sdk/speech.js" @@ -553,6 +585,7 @@ "test:fast": "vitest run --config vitest.unit.config.ts", "test:force": "node --import tsx scripts/test-force.ts", "test:gateway": "vitest run --config vitest.gateway.config.ts --pool=forks", + "test:gateway:watch-regression": "node scripts/check-gateway-watch-regression.mjs", "test:install:e2e": "bash scripts/test-install-sh-e2e-docker.sh", "test:install:e2e:anthropic": "OPENCLAW_E2E_MODELS=anthropic CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh", "test:install:e2e:openai": "OPENCLAW_E2E_MODELS=openai CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh", diff --git a/scripts/check-gateway-watch-regression.mjs b/scripts/check-gateway-watch-regression.mjs new file mode 100644 index 00000000000..238bc68e742 --- /dev/null +++ b/scripts/check-gateway-watch-regression.mjs @@ -0,0 +1,464 @@ +#!/usr/bin/env node + +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; + +const DEFAULTS = { + outputDir: path.join(process.cwd(), ".local", "gateway-watch-regression"), + windowMs: 10_000, + sigkillGraceMs: 10_000, + cpuWarnMs: 1_000, + cpuFailMs: 8_000, + distRuntimeFileGrowthMax: 200, + distRuntimeByteGrowthMax: 2 * 1024 * 1024, + keepLogs: true, + skipBuild: false, +}; + +function parseArgs(argv) { + const options = { ...DEFAULTS }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = argv[i + 1]; + const readValue = () => { + if (!next) { + throw new Error(`Missing value for ${arg}`); + } + i += 1; + return next; + }; + switch (arg) { + case "--output-dir": + options.outputDir = path.resolve(readValue()); + break; + case "--window-ms": + options.windowMs = Number(readValue()); + break; + case "--sigkill-grace-ms": + options.sigkillGraceMs = Number(readValue()); + break; + case "--cpu-warn-ms": + options.cpuWarnMs = Number(readValue()); + break; + case "--cpu-fail-ms": + options.cpuFailMs = Number(readValue()); + break; + case "--dist-runtime-file-growth-max": + options.distRuntimeFileGrowthMax = Number(readValue()); + break; + case "--dist-runtime-byte-growth-max": + options.distRuntimeByteGrowthMax = Number(readValue()); + break; + case "--skip-build": + options.skipBuild = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + return options; +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function normalizePath(filePath) { + return filePath.replaceAll("\\", "/"); +} + +function listTreeEntries(rootName) { + const rootPath = path.join(process.cwd(), rootName); + if (!fs.existsSync(rootPath)) { + return [`${rootName} (missing)`]; + } + + const entries = [rootName]; + const queue = [rootPath]; + while (queue.length > 0) { + const current = queue.pop(); + if (!current) { + continue; + } + const dirents = fs.readdirSync(current, { withFileTypes: true }); + for (const dirent of dirents) { + const fullPath = path.join(current, dirent.name); + const relativePath = normalizePath(path.relative(process.cwd(), fullPath)); + entries.push(relativePath); + if (dirent.isDirectory()) { + queue.push(fullPath); + } + } + } + return entries.toSorted((a, b) => a.localeCompare(b)); +} + +function humanBytes(bytes) { + if (bytes < 1024) { + return `${bytes}B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)}K`; + } + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)}M`; + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`; +} + +function snapshotTree(rootName) { + const rootPath = path.join(process.cwd(), rootName); + const stats = { + exists: fs.existsSync(rootPath), + files: 0, + directories: 0, + symlinks: 0, + entries: 0, + apparentBytes: 0, + }; + + if (!stats.exists) { + return stats; + } + + const queue = [rootPath]; + while (queue.length > 0) { + const current = queue.pop(); + if (!current) { + continue; + } + const currentStats = fs.lstatSync(current); + stats.entries += 1; + if (currentStats.isDirectory()) { + stats.directories += 1; + for (const dirent of fs.readdirSync(current, { withFileTypes: true })) { + queue.push(path.join(current, dirent.name)); + } + continue; + } + if (currentStats.isSymbolicLink()) { + stats.symlinks += 1; + continue; + } + if (currentStats.isFile()) { + stats.files += 1; + stats.apparentBytes += currentStats.size; + } + } + + return stats; +} + +function writeSnapshot(snapshotDir) { + ensureDir(snapshotDir); + const pathEntries = [...listTreeEntries("dist"), ...listTreeEntries("dist-runtime")]; + fs.writeFileSync(path.join(snapshotDir, "paths.txt"), `${pathEntries.join("\n")}\n`, "utf8"); + + const dist = snapshotTree("dist"); + const distRuntime = snapshotTree("dist-runtime"); + const snapshot = { + generatedAt: new Date().toISOString(), + dist, + distRuntime, + }; + fs.writeFileSync( + path.join(snapshotDir, "snapshot.json"), + `${JSON.stringify(snapshot, null, 2)}\n`, + ); + fs.writeFileSync( + path.join(snapshotDir, "stats.txt"), + [ + `generated_at: ${snapshot.generatedAt}`, + "", + "[dist]", + `files: ${dist.files}`, + `directories: ${dist.directories}`, + `symlinks: ${dist.symlinks}`, + `entries: ${dist.entries}`, + `apparent_bytes: ${dist.apparentBytes}`, + `apparent_human: ${humanBytes(dist.apparentBytes)}`, + "", + "[dist-runtime]", + `files: ${distRuntime.files}`, + `directories: ${distRuntime.directories}`, + `symlinks: ${distRuntime.symlinks}`, + `entries: ${distRuntime.entries}`, + `apparent_bytes: ${distRuntime.apparentBytes}`, + `apparent_human: ${humanBytes(distRuntime.apparentBytes)}`, + "", + ].join("\n"), + "utf8", + ); + return snapshot; +} + +function runCheckedCommand(command, args) { + const result = spawnSync(command, args, { + cwd: process.cwd(), + stdio: "inherit", + env: process.env, + }); + if (typeof result.status === "number" && result.status === 0) { + return; + } + throw new Error(`${command} ${args.join(" ")} failed with status ${result.status ?? "unknown"}`); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function buildTimedWatchCommand(pidFilePath, timeFilePath, isolatedHomeDir) { + const shellSource = [ + 'echo "$$" > "$OPENCLAW_WATCH_PID_FILE"', + "exec node scripts/watch-node.mjs gateway --force --allow-unconfigured", + ].join("\n"); + const env = { + OPENCLAW_WATCH_PID_FILE: pidFilePath, + HOME: isolatedHomeDir, + OPENCLAW_HOME: isolatedHomeDir, + }; + + if (process.platform === "darwin") { + return { + command: "/usr/bin/time", + args: ["-lp", "-o", timeFilePath, "/bin/sh", "-lc", shellSource], + env, + }; + } + + return { + command: "/usr/bin/time", + args: [ + "-f", + "__TIMING__ user=%U sys=%S elapsed=%e", + "-o", + timeFilePath, + "/bin/sh", + "-lc", + shellSource, + ], + env, + }; +} + +function parseTimingFile(timeFilePath) { + const text = fs.readFileSync(timeFilePath, "utf8"); + if (process.platform === "darwin") { + const user = Number(text.match(/^user\s+([0-9.]+)/m)?.[1] ?? "NaN"); + const sys = Number(text.match(/^sys\s+([0-9.]+)/m)?.[1] ?? "NaN"); + const elapsed = Number(text.match(/^real\s+([0-9.]+)/m)?.[1] ?? "NaN"); + return { + userSeconds: user, + sysSeconds: sys, + elapsedSeconds: elapsed, + }; + } + + const match = text.match(/__TIMING__ user=([0-9.]+) sys=([0-9.]+) elapsed=([0-9.]+)/); + return { + userSeconds: Number(match?.[1] ?? "NaN"), + sysSeconds: Number(match?.[2] ?? "NaN"), + elapsedSeconds: Number(match?.[3] ?? "NaN"), + }; +} + +async function runTimedWatch(options, outputDir) { + const pidFilePath = path.join(outputDir, "watch.pid"); + const timeFilePath = path.join(outputDir, "watch.time.log"); + const isolatedHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gateway-watch-")); + fs.writeFileSync(path.join(outputDir, "watch.home.txt"), `${isolatedHomeDir}\n`, "utf8"); + const stdoutPath = path.join(outputDir, "watch.stdout.log"); + const stderrPath = path.join(outputDir, "watch.stderr.log"); + const { command, args, env } = buildTimedWatchCommand(pidFilePath, timeFilePath, isolatedHomeDir); + const child = spawn(command, args, { + cwd: process.cwd(), + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + + const exitPromise = new Promise((resolve) => { + child.on("exit", (code, signal) => resolve({ code, signal })); + }); + + let watchPid = null; + for (let attempt = 0; attempt < 50; attempt += 1) { + if (fs.existsSync(pidFilePath)) { + watchPid = Number(fs.readFileSync(pidFilePath, "utf8").trim()); + break; + } + await sleep(100); + } + + await sleep(options.windowMs); + + if (watchPid) { + try { + process.kill(watchPid, "SIGTERM"); + } catch { + // ignore + } + } + + const gracefulExit = await Promise.race([ + exitPromise, + sleep(options.sigkillGraceMs).then(() => null), + ]); + + if (gracefulExit === null) { + if (watchPid) { + try { + process.kill(watchPid, "SIGKILL"); + } catch { + // ignore + } + } + } + + const exit = (await exitPromise) ?? { code: null, signal: null }; + fs.writeFileSync(stdoutPath, stdout, "utf8"); + fs.writeFileSync(stderrPath, stderr, "utf8"); + const timing = fs.existsSync(timeFilePath) + ? parseTimingFile(timeFilePath) + : { userSeconds: Number.NaN, sysSeconds: Number.NaN, elapsedSeconds: Number.NaN }; + + return { + exit, + timing, + stdoutPath, + stderrPath, + timeFilePath, + }; +} + +function parsePathFile(filePath) { + return fs + .readFileSync(filePath, "utf8") + .split("\n") + .map((line) => line.trimEnd()) + .filter(Boolean); +} + +function writeDiffArtifacts(outputDir, preDir, postDir) { + const diffDir = path.join(outputDir, "diff"); + ensureDir(diffDir); + const prePaths = parsePathFile(path.join(preDir, "paths.txt")); + const postPaths = parsePathFile(path.join(postDir, "paths.txt")); + const preSet = new Set(prePaths); + const postSet = new Set(postPaths); + const added = postPaths.filter((entry) => !preSet.has(entry)); + const removed = prePaths.filter((entry) => !postSet.has(entry)); + + fs.writeFileSync(path.join(diffDir, "added-paths.txt"), `${added.join("\n")}\n`, "utf8"); + fs.writeFileSync(path.join(diffDir, "removed-paths.txt"), `${removed.join("\n")}\n`, "utf8"); + return { added, removed }; +} + +function fail(message) { + console.error(`FAIL: ${message}`); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + ensureDir(options.outputDir); + if (!options.skipBuild) { + runCheckedCommand("pnpm", ["build"]); + } + + const preDir = path.join(options.outputDir, "pre"); + const pre = writeSnapshot(preDir); + + const watchDir = path.join(options.outputDir, "watch"); + ensureDir(watchDir); + const watchResult = await runTimedWatch(options, watchDir); + + const postDir = path.join(options.outputDir, "post"); + const post = writeSnapshot(postDir); + const diff = writeDiffArtifacts(options.outputDir, preDir, postDir); + + const distRuntimeFileGrowth = post.distRuntime.files - pre.distRuntime.files; + const distRuntimeByteGrowth = post.distRuntime.apparentBytes - pre.distRuntime.apparentBytes; + const distRuntimeAddedPaths = diff.added.filter((entry) => + entry.startsWith("dist-runtime/"), + ).length; + const cpuMs = Math.round((watchResult.timing.userSeconds + watchResult.timing.sysSeconds) * 1000); + const watchTriggeredBuild = + fs + .readFileSync(watchResult.stderrPath, "utf8") + .includes("Building TypeScript (dist is stale).") || + fs + .readFileSync(watchResult.stdoutPath, "utf8") + .includes("Building TypeScript (dist is stale)."); + + const summary = { + windowMs: options.windowMs, + watchTriggeredBuild, + cpuMs, + cpuWarnMs: options.cpuWarnMs, + cpuFailMs: options.cpuFailMs, + distRuntimeFileGrowth, + distRuntimeFileGrowthMax: options.distRuntimeFileGrowthMax, + distRuntimeByteGrowth, + distRuntimeByteGrowthMax: options.distRuntimeByteGrowthMax, + distRuntimeAddedPaths, + addedPaths: diff.added.length, + removedPaths: diff.removed.length, + watchExit: watchResult.exit, + timing: watchResult.timing, + }; + fs.writeFileSync( + path.join(options.outputDir, "summary.json"), + `${JSON.stringify(summary, null, 2)}\n`, + ); + + console.log(JSON.stringify(summary, null, 2)); + + const failures = []; + if (distRuntimeFileGrowth > options.distRuntimeFileGrowthMax) { + failures.push( + `dist-runtime file growth ${distRuntimeFileGrowth} exceeded max ${options.distRuntimeFileGrowthMax}`, + ); + } + if (distRuntimeByteGrowth > options.distRuntimeByteGrowthMax) { + failures.push( + `dist-runtime apparent byte growth ${distRuntimeByteGrowth} exceeded max ${options.distRuntimeByteGrowthMax}`, + ); + } + if (!Number.isFinite(cpuMs)) { + failures.push("failed to parse CPU timing from the bounded gateway:watch run"); + } else if (cpuMs > options.cpuFailMs) { + failures.push( + `LOUD ALARM: gateway:watch used ${cpuMs}ms CPU in ${options.windowMs}ms window, above loud-alarm threshold ${options.cpuFailMs}ms`, + ); + } else if (cpuMs > options.cpuWarnMs) { + failures.push( + `gateway:watch used ${cpuMs}ms CPU in ${options.windowMs}ms window, above target ${options.cpuWarnMs}ms`, + ); + } + + if (failures.length > 0) { + for (const message of failures) { + fail(message); + } + fail( + "Possible duplicate dist-runtime graph regression: this can reintroduce split runtime personalities where plugins and core observe different global state, including Telegram missing /voice, /phone, or /pair.", + ); + process.exit(1); + } + + process.exit(0); +} + +await main(); diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 6e41a759867..72de88ed3ca 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -10,6 +10,7 @@ "runtime", "runtime-env", "setup", + "setup-tools", "config-runtime", "reply-runtime", "channel-runtime", @@ -29,11 +30,17 @@ "acp-runtime", "zai", "telegram", + "telegram-core", "discord", + "discord-core", "slack", + "slack-core", "signal", + "signal-core", "imessage", + "imessage-core", "whatsapp", + "whatsapp-core", "line", "msteams", "acpx", @@ -47,6 +54,7 @@ "irc", "llm-task", "lobster", + "lazy-runtime", "matrix", "mattermost", "memory-core", @@ -89,12 +97,12 @@ "provider-usage", "provider-web-search", "image-generation", - "image-generation-runtime", "reply-history", "media-understanding", "google", "request-url", "runtime-store", + "web-media", "speech", "state-paths", "tool-send" diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index b15aa3bd72e..58f74b72918 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -603,137 +603,164 @@ export class AcpSessionManager { } await this.evictIdleRuntimeHandles({ cfg: input.cfg }); await this.withSessionActor(sessionKey, async () => { - const resolution = this.resolveSession({ - cfg: input.cfg, - sessionKey, - }); - const resolvedMeta = requireReadySessionMeta(resolution); - - const { - runtime, - handle: ensuredHandle, - meta: ensuredMeta, - } = await this.ensureRuntimeHandle({ - cfg: input.cfg, - sessionKey, - meta: resolvedMeta, - }); - let handle = ensuredHandle; - const meta = ensuredMeta; - await this.applyRuntimeControls({ - sessionKey, - runtime, - handle, - meta, - }); const turnStartedAt = Date.now(); const actorKey = normalizeActorKey(sessionKey); - - await this.setSessionState({ - cfg: input.cfg, - sessionKey, - state: "running", - clearLastError: true, - }); - - const internalAbortController = new AbortController(); - const onCallerAbort = () => { - internalAbortController.abort(); - }; - if (input.signal?.aborted) { - internalAbortController.abort(); - } else if (input.signal) { - input.signal.addEventListener("abort", onCallerAbort, { once: true }); - } - - const activeTurn: ActiveTurnState = { - runtime, - handle, - abortController: internalAbortController, - }; - this.activeTurnBySession.set(actorKey, activeTurn); - - let streamError: AcpRuntimeError | null = null; - try { - const combinedSignal = - input.signal && typeof AbortSignal.any === "function" - ? AbortSignal.any([input.signal, internalAbortController.signal]) - : internalAbortController.signal; - for await (const event of runtime.runTurn({ - handle, - text: input.text, - attachments: input.attachments, - mode: input.mode, - requestId: input.requestId, - signal: combinedSignal, - })) { - if (event.type === "error") { - streamError = new AcpRuntimeError( - normalizeAcpErrorCode(event.code), - event.message?.trim() || "ACP turn failed before completion.", - ); - } - if (input.onEvent) { - await input.onEvent(event); - } - } - if (streamError) { - throw streamError; - } - this.recordTurnCompletion({ - startedAt: turnStartedAt, - }); - await this.setSessionState({ + for (let attempt = 0; attempt < 2; attempt += 1) { + const resolution = this.resolveSession({ cfg: input.cfg, sessionKey, - state: "idle", - clearLastError: true, }); - } catch (error) { - const acpError = toAcpRuntimeError({ - error, - fallbackCode: "ACP_TURN_FAILED", - fallbackMessage: "ACP turn failed before completion.", - }); - this.recordTurnCompletion({ - startedAt: turnStartedAt, - errorCode: acpError.code, - }); - await this.setSessionState({ - cfg: input.cfg, - sessionKey, - state: "error", - lastError: acpError.message, - }); - throw acpError; - } finally { - if (input.signal) { - input.signal.removeEventListener("abort", onCallerAbort); - } - if (this.activeTurnBySession.get(actorKey) === activeTurn) { - this.activeTurnBySession.delete(actorKey); - } - if (meta.mode !== "oneshot") { - ({ handle } = await this.reconcileRuntimeSessionIdentifiers({ + const resolvedMeta = requireReadySessionMeta(resolution); + let runtime: AcpRuntime | undefined; + let handle: AcpRuntimeHandle | undefined; + let meta: SessionAcpMeta | undefined; + let activeTurn: ActiveTurnState | undefined; + let internalAbortController: AbortController | undefined; + let onCallerAbort: (() => void) | undefined; + let activeTurnStarted = false; + let sawTurnOutput = false; + let retryFreshHandle = false; + try { + const ensured = await this.ensureRuntimeHandle({ cfg: input.cfg, + sessionKey, + meta: resolvedMeta, + }); + runtime = ensured.runtime; + handle = ensured.handle; + meta = ensured.meta; + await this.applyRuntimeControls({ sessionKey, runtime, handle, meta, - failOnStatusError: false, - })); - } - if (meta.mode === "oneshot") { - try { - await runtime.close({ - handle, - reason: "oneshot-complete", - }); - } catch (error) { - logVerbose(`acp-manager: ACP oneshot close failed for ${sessionKey}: ${String(error)}`); - } finally { - this.clearCachedRuntimeState(sessionKey); + }); + + await this.setSessionState({ + cfg: input.cfg, + sessionKey, + state: "running", + clearLastError: true, + }); + + internalAbortController = new AbortController(); + onCallerAbort = () => { + internalAbortController?.abort(); + }; + if (input.signal?.aborted) { + internalAbortController.abort(); + } else if (input.signal) { + input.signal.addEventListener("abort", onCallerAbort, { once: true }); } + + activeTurn = { + runtime, + handle, + abortController: internalAbortController, + }; + this.activeTurnBySession.set(actorKey, activeTurn); + activeTurnStarted = true; + + let streamError: AcpRuntimeError | null = null; + const combinedSignal = + input.signal && typeof AbortSignal.any === "function" + ? AbortSignal.any([input.signal, internalAbortController.signal]) + : internalAbortController.signal; + for await (const event of runtime.runTurn({ + handle, + text: input.text, + attachments: input.attachments, + mode: input.mode, + requestId: input.requestId, + signal: combinedSignal, + })) { + if (event.type === "error") { + streamError = new AcpRuntimeError( + normalizeAcpErrorCode(event.code), + event.message?.trim() || "ACP turn failed before completion.", + ); + } else if (event.type === "text_delta" || event.type === "tool_call") { + sawTurnOutput = true; + } + if (input.onEvent) { + await input.onEvent(event); + } + } + if (streamError) { + throw streamError; + } + this.recordTurnCompletion({ + startedAt: turnStartedAt, + }); + await this.setSessionState({ + cfg: input.cfg, + sessionKey, + state: "idle", + clearLastError: true, + }); + return; + } catch (error) { + const acpError = toAcpRuntimeError({ + error, + fallbackCode: activeTurnStarted ? "ACP_TURN_FAILED" : "ACP_SESSION_INIT_FAILED", + fallbackMessage: activeTurnStarted + ? "ACP turn failed before completion." + : "Could not initialize ACP session runtime.", + }); + retryFreshHandle = this.shouldRetryTurnWithFreshHandle({ + attempt, + sessionKey, + error: acpError, + sawTurnOutput, + }); + if (retryFreshHandle) { + continue; + } + this.recordTurnCompletion({ + startedAt: turnStartedAt, + errorCode: acpError.code, + }); + await this.setSessionState({ + cfg: input.cfg, + sessionKey, + state: "error", + lastError: acpError.message, + }); + throw acpError; + } finally { + if (input.signal && onCallerAbort) { + input.signal.removeEventListener("abort", onCallerAbort); + } + if (activeTurn && this.activeTurnBySession.get(actorKey) === activeTurn) { + this.activeTurnBySession.delete(actorKey); + } + if (!retryFreshHandle && runtime && handle && meta && meta.mode !== "oneshot") { + ({ handle } = await this.reconcileRuntimeSessionIdentifiers({ + cfg: input.cfg, + sessionKey, + runtime, + handle, + meta, + failOnStatusError: false, + })); + } + if (!retryFreshHandle && runtime && handle && meta && meta.mode === "oneshot") { + try { + await runtime.close({ + handle, + reason: "oneshot-complete", + }); + } catch (error) { + logVerbose( + `acp-manager: ACP oneshot close failed for ${sessionKey}: ${String(error)}`, + ); + } finally { + this.clearCachedRuntimeState(sessionKey); + } + } + } + if (retryFreshHandle) { + continue; } } }); @@ -864,7 +891,9 @@ export class AcpSessionManager { }); if ( input.allowBackendUnavailable && - (acpError.code === "ACP_BACKEND_MISSING" || acpError.code === "ACP_BACKEND_UNAVAILABLE") + (acpError.code === "ACP_BACKEND_MISSING" || + acpError.code === "ACP_BACKEND_UNAVAILABLE" || + this.isRecoverableAcpxExitError(acpError.message)) ) { // Treat unavailable backends as terminal for this cached handle so it // cannot continue counting against maxConcurrentSessions. @@ -916,7 +945,17 @@ export class AcpSessionManager { const agentMatches = cached.agent === agent; const modeMatches = cached.mode === mode; const cwdMatches = (cached.cwd ?? "") === (cwd ?? ""); - if (backendMatches && agentMatches && modeMatches && cwdMatches) { + if ( + backendMatches && + agentMatches && + modeMatches && + cwdMatches && + (await this.isCachedRuntimeHandleReusable({ + sessionKey: params.sessionKey, + runtime: cached.runtime, + handle: cached.handle, + })) + ) { return { runtime: cached.runtime, handle: cached.handle, @@ -1020,6 +1059,49 @@ export class AcpSessionManager { }; } + private async isCachedRuntimeHandleReusable(params: { + sessionKey: string; + runtime: AcpRuntime; + handle: AcpRuntimeHandle; + }): Promise { + if (!params.runtime.getStatus) { + return true; + } + try { + const status = await params.runtime.getStatus({ + handle: params.handle, + }); + if (this.isRuntimeStatusUnavailable(status)) { + this.clearCachedRuntimeState(params.sessionKey); + logVerbose( + `acp-manager: evicting cached runtime handle for ${params.sessionKey} after unhealthy status probe: ${status.summary ?? "status unavailable"}`, + ); + return false; + } + return true; + } catch (error) { + this.clearCachedRuntimeState(params.sessionKey); + logVerbose( + `acp-manager: evicting cached runtime handle for ${params.sessionKey} after status probe failed: ${String(error)}`, + ); + return false; + } + } + + private isRuntimeStatusUnavailable(status: AcpRuntimeStatus | undefined): boolean { + if (!status) { + return false; + } + const detailsStatus = + typeof status.details?.status === "string" ? status.details.status.trim().toLowerCase() : ""; + if (detailsStatus === "dead" || detailsStatus === "no-session") { + return true; + } + const summaryMatch = status.summary?.match(/\bstatus=([^\s]+)/i); + const summaryStatus = summaryMatch?.[1]?.trim().toLowerCase() ?? ""; + return summaryStatus === "dead" || summaryStatus === "no-session"; + } + private async persistRuntimeOptions(params: { cfg: OpenClawConfig; sessionKey: string; @@ -1103,6 +1185,29 @@ export class AcpSessionManager { this.errorCountsByCode.set(normalized, (this.errorCountsByCode.get(normalized) ?? 0) + 1); } + private shouldRetryTurnWithFreshHandle(params: { + attempt: number; + sessionKey: string; + error: AcpRuntimeError; + sawTurnOutput: boolean; + }): boolean { + if (params.attempt > 0 || params.sawTurnOutput) { + return false; + } + if (!this.isRecoverableAcpxExitError(params.error.message)) { + return false; + } + this.clearCachedRuntimeState(params.sessionKey); + logVerbose( + `acp-manager: retrying ${params.sessionKey} with a fresh runtime handle after early turn failure: ${params.error.message}`, + ); + return true; + } + + private isRecoverableAcpxExitError(message: string): boolean { + return /^acpx exited with code \d+/i.test(message.trim()); + } + private async evictIdleRuntimeHandles(params: { cfg: OpenClawConfig }): Promise { const idleTtlMs = resolveRuntimeIdleTtlMs(params.cfg); if (idleTtlMs <= 0 || this.runtimeCache.size() === 0) { diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 66faa84b1d3..7229e34914d 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -354,6 +354,52 @@ describe("AcpSessionManager", () => { expect(runtimeState.runTurn).toHaveBeenCalledTimes(2); }); + it("re-ensures cached runtime handles when the backend reports the session is dead", async () => { + const runtimeState = createRuntime(); + runtimeState.getStatus + .mockResolvedValueOnce({ + summary: "status=alive", + details: { status: "alive" }, + }) + .mockResolvedValueOnce({ + summary: "status=dead", + details: { status: "dead" }, + }) + .mockResolvedValueOnce({ + summary: "status=alive", + details: { status: "alive" }, + }); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "first", + mode: "prompt", + requestId: "r1", + }); + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "second", + mode: "prompt", + requestId: "r2", + }); + + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); + expect(runtimeState.getStatus).toHaveBeenCalledTimes(3); + expect(runtimeState.runTurn).toHaveBeenCalledTimes(2); + }); + it("rehydrates runtime handles after a manager restart", async () => { const runtimeState = createRuntime(); hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ @@ -531,6 +577,61 @@ describe("AcpSessionManager", () => { expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); }); + it("drops cached runtime handles when close sees a stale acpx process-exit error", async () => { + const runtimeState = createRuntime(); + runtimeState.close.mockRejectedValueOnce(new Error("acpx exited with code 1")); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? ""; + return { + sessionKey, + storeSessionKey: sessionKey, + acp: { + ...readySessionMeta(), + runtimeSessionName: `runtime:${sessionKey}`, + }, + }; + }); + const limitedCfg = { + acp: { + ...baseCfg.acp, + maxConcurrentSessions: 1, + }, + } as OpenClawConfig; + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: limitedCfg, + sessionKey: "agent:codex:acp:session-a", + text: "first", + mode: "prompt", + requestId: "r1", + }); + + const closeResult = await manager.closeSession({ + cfg: limitedCfg, + sessionKey: "agent:codex:acp:session-a", + reason: "manual-close", + allowBackendUnavailable: true, + }); + expect(closeResult.runtimeClosed).toBe(false); + expect(closeResult.runtimeNotice).toBe("acpx exited with code 1"); + + await expect( + manager.runTurn({ + cfg: limitedCfg, + sessionKey: "agent:codex:acp:session-b", + text: "second", + mode: "prompt", + requestId: "r2", + }), + ).resolves.toBeUndefined(); + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); + }); + it("evicts idle cached runtimes before enforcing max concurrent limits", async () => { vi.useFakeTimers(); try { @@ -807,6 +908,82 @@ describe("AcpSessionManager", () => { expect(states.at(-1)).toBe("error"); }); + it("marks the session as errored when runtime ensure fails before turn start", async () => { + const runtimeState = createRuntime(); + runtimeState.ensureSession.mockRejectedValue(new Error("acpx exited with code 1")); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: { + ...readySessionMeta(), + state: "running", + }, + }); + + const manager = new AcpSessionManager(); + await expect( + manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "do work", + mode: "prompt", + requestId: "run-1", + }), + ).rejects.toMatchObject({ + code: "ACP_SESSION_INIT_FAILED", + message: "acpx exited with code 1", + }); + + const states = extractStatesFromUpserts(); + expect(states).not.toContain("running"); + expect(states.at(-1)).toBe("error"); + }); + + it("retries once with a fresh runtime handle after an early acpx exit", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + runtimeState.runTurn + .mockImplementationOnce(async function* () { + yield { + type: "error" as const, + message: "acpx exited with code 1", + }; + }) + .mockImplementationOnce(async function* () { + yield { type: "done" as const }; + }); + + const manager = new AcpSessionManager(); + await expect( + manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "do work", + mode: "prompt", + requestId: "run-1", + }), + ).resolves.toBeUndefined(); + + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); + expect(runtimeState.runTurn).toHaveBeenCalledTimes(2); + const states = extractStatesFromUpserts(); + expect(states).toContain("running"); + expect(states).toContain("idle"); + expect(states).not.toContain("error"); + }); + it("persists runtime mode changes through setSessionRuntimeMode", async () => { const runtimeState = createRuntime(); hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ diff --git a/src/acp/persistent-bindings.lifecycle.test.ts b/src/acp/persistent-bindings.lifecycle.test.ts new file mode 100644 index 00000000000..44e159d887f --- /dev/null +++ b/src/acp/persistent-bindings.lifecycle.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { importFreshModule } from "../../test/helpers/import-fresh.js"; +import type { OpenClawConfig } from "../config/config.js"; + +const managerMocks = vi.hoisted(() => ({ + closeSession: vi.fn(), + initializeSession: vi.fn(), + updateSessionRuntimeOptions: vi.fn(), +})); + +const sessionMetaMocks = vi.hoisted(() => ({ + readAcpSessionEntry: vi.fn(), +})); + +const resolveMocks = vi.hoisted(() => ({ + resolveConfiguredAcpBindingSpecBySessionKey: vi.fn(), +})); + +vi.mock("./control-plane/manager.js", () => ({ + getAcpSessionManager: () => ({ + closeSession: managerMocks.closeSession, + initializeSession: managerMocks.initializeSession, + updateSessionRuntimeOptions: managerMocks.updateSessionRuntimeOptions, + }), +})); + +vi.mock("./runtime/session-meta.js", () => ({ + readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry, +})); + +vi.mock("./persistent-bindings.resolve.js", () => ({ + resolveConfiguredAcpBindingSpecBySessionKey: + resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey, +})); +type BindingTargetsModule = typeof import("../channels/plugins/binding-targets.js"); +let bindingTargets: BindingTargetsModule; +let bindingTargetsImportScope = 0; + +const baseCfg = { + session: { mainKey: "main", scope: "per-sender" }, + agents: { + list: [{ id: "codex" }, { id: "claude" }], + }, +} satisfies OpenClawConfig; + +beforeEach(async () => { + vi.resetModules(); + bindingTargetsImportScope += 1; + bindingTargets = await importFreshModule( + import.meta.url, + `../channels/plugins/binding-targets.js?scope=${bindingTargetsImportScope}`, + ); + managerMocks.closeSession.mockReset().mockResolvedValue({ + runtimeClosed: true, + metaCleared: false, + }); + managerMocks.initializeSession.mockReset().mockResolvedValue(undefined); + managerMocks.updateSessionRuntimeOptions.mockReset().mockResolvedValue(undefined); + sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined); + resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey.mockReset().mockReturnValue(null); +}); + +describe("resetConfiguredBindingTargetInPlace", () => { + it("does not resolve configured bindings when ACP metadata already exists", async () => { + const sessionKey = "agent:claude:acp:binding:discord:default:9373ab192b2317f4"; + sessionMetaMocks.readAcpSessionEntry.mockReturnValue({ + acp: { + agent: "claude", + mode: "persistent", + backend: "acpx", + runtimeOptions: { cwd: "/home/bob/clawd" }, + }, + }); + resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey.mockImplementation(() => { + throw new Error("configured binding resolution should be skipped"); + }); + + const result = await bindingTargets.resetConfiguredBindingTargetInPlace({ + cfg: baseCfg, + sessionKey, + reason: "reset", + }); + + expect(result).toEqual({ ok: true }); + expect(resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey).not.toHaveBeenCalled(); + expect(managerMocks.closeSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + clearMeta: false, + }), + ); + expect(managerMocks.initializeSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + agent: "claude", + backendId: "acpx", + }), + ); + }); +}); diff --git a/src/acp/persistent-bindings.lifecycle.ts b/src/acp/persistent-bindings.lifecycle.ts index 2a2cf6b9c20..9f43b584da3 100644 --- a/src/acp/persistent-bindings.lifecycle.ts +++ b/src/acp/persistent-bindings.lifecycle.ts @@ -8,6 +8,7 @@ import { buildConfiguredAcpSessionKey, normalizeText, type ConfiguredAcpBindingSpec, + type ResolvedConfiguredAcpBinding, } from "./persistent-bindings.types.js"; import { readAcpSessionEntry } from "./runtime/session-meta.js"; @@ -96,7 +97,7 @@ export async function ensureConfiguredAcpBindingSession(params: { } catch (error) { const message = error instanceof Error ? error.message : String(error); logVerbose( - `acp-persistent-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`, + `acp-configured-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`, ); return { ok: false, @@ -106,6 +107,26 @@ export async function ensureConfiguredAcpBindingSession(params: { } } +export async function ensureConfiguredAcpBindingReady(params: { + cfg: OpenClawConfig; + configuredBinding: ResolvedConfiguredAcpBinding | null; +}): Promise<{ ok: true } | { ok: false; error: string }> { + if (!params.configuredBinding) { + return { ok: true }; + } + const ensured = await ensureConfiguredAcpBindingSession({ + cfg: params.cfg, + spec: params.configuredBinding.spec, + }); + if (ensured.ok) { + return { ok: true }; + } + return { + ok: false, + error: ensured.error ?? "unknown error", + }; +} + export async function resetAcpSessionInPlace(params: { cfg: OpenClawConfig; sessionKey: string; @@ -119,14 +140,17 @@ export async function resetAcpSessionInPlace(params: { }; } - const configuredBinding = resolveConfiguredAcpBindingSpecBySessionKey({ - cfg: params.cfg, - sessionKey, - }); const meta = readAcpSessionEntry({ cfg: params.cfg, sessionKey, })?.acp; + const configuredBinding = + !meta || !normalizeText(meta.agent) + ? resolveConfiguredAcpBindingSpecBySessionKey({ + cfg: params.cfg, + sessionKey, + }) + : null; if (!meta) { if (configuredBinding) { const ensured = await ensureConfiguredAcpBindingSession({ @@ -189,7 +213,7 @@ export async function resetAcpSessionInPlace(params: { return { ok: true }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - logVerbose(`acp-persistent-binding: failed reset for ${sessionKey}: ${message}`); + logVerbose(`acp-configured-binding: failed reset for ${sessionKey}: ${message}`); return { ok: false, error: message, diff --git a/src/acp/persistent-bindings.resolve.ts b/src/acp/persistent-bindings.resolve.ts index d0039078378..068b89f8891 100644 --- a/src/acp/persistent-bindings.resolve.ts +++ b/src/acp/persistent-bindings.resolve.ts @@ -1,275 +1,17 @@ -import { getChannelPlugin } from "../channels/plugins/index.js"; -import { listAcpBindings } from "../config/bindings.js"; +import { + resolveConfiguredBindingRecord, + resolveConfiguredBindingRecordBySessionKey, + resolveConfiguredBindingRecordForConversation, +} from "../channels/plugins/binding-registry.js"; import type { OpenClawConfig } from "../config/config.js"; -import type { AgentAcpBinding } from "../config/types.js"; -import { pickFirstExistingAgentId } from "../routing/resolve-route.js"; +import type { ConversationRef } from "../infra/outbound/session-binding-service.js"; import { - DEFAULT_ACCOUNT_ID, - normalizeAccountId, - parseAgentSessionKey, -} from "../routing/session-key.js"; -import { - buildConfiguredAcpSessionKey, - normalizeBindingConfig, - normalizeMode, - normalizeText, - toConfiguredAcpBindingRecord, - type ConfiguredAcpBindingChannel, + resolveConfiguredAcpBindingSpecFromRecord, + toResolvedConfiguredAcpBinding, type ConfiguredAcpBindingSpec, type ResolvedConfiguredAcpBinding, } from "./persistent-bindings.types.js"; -function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null { - const normalized = (value ?? "").trim().toLowerCase(); - if (!normalized) { - return null; - } - const plugin = getChannelPlugin(normalized); - return plugin?.acpBindings ? plugin.id : null; -} - -function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 { - const trimmed = (match ?? "").trim(); - if (!trimmed) { - return actual === DEFAULT_ACCOUNT_ID ? 2 : 0; - } - if (trimmed === "*") { - return 1; - } - return normalizeAccountId(trimmed) === actual ? 2 : 0; -} - -function resolveBindingConversationId(binding: AgentAcpBinding): string | null { - const id = binding.match.peer?.id?.trim(); - return id ? id : null; -} - -function parseConfiguredBindingSessionKey(params: { - sessionKey: string; -}): { channel: ConfiguredAcpBindingChannel; accountId: string } | null { - const parsed = parseAgentSessionKey(params.sessionKey); - const rest = parsed?.rest?.trim().toLowerCase() ?? ""; - if (!rest) { - return null; - } - const tokens = rest.split(":"); - if (tokens.length !== 5 || tokens[0] !== "acp" || tokens[1] !== "binding") { - return null; - } - const channel = normalizeBindingChannel(tokens[2]); - if (!channel) { - return null; - } - return { - channel, - accountId: normalizeAccountId(tokens[3]), - }; -} - -function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): { - acpAgentId?: string; - mode?: string; - cwd?: string; - backend?: string; -} { - const agent = params.cfg.agents?.list?.find( - (entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(), - ); - if (!agent || agent.runtime?.type !== "acp") { - return {}; - } - return { - acpAgentId: normalizeText(agent.runtime.acp?.agent), - mode: normalizeText(agent.runtime.acp?.mode), - cwd: normalizeText(agent.runtime.acp?.cwd), - backend: normalizeText(agent.runtime.acp?.backend), - }; -} - -function toConfiguredBindingSpec(params: { - cfg: OpenClawConfig; - channel: ConfiguredAcpBindingChannel; - accountId: string; - conversationId: string; - parentConversationId?: string; - binding: AgentAcpBinding; -}): ConfiguredAcpBindingSpec { - const accountId = normalizeAccountId(params.accountId); - const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main"); - const runtimeDefaults = resolveAgentRuntimeAcpDefaults({ - cfg: params.cfg, - ownerAgentId: agentId, - }); - const bindingOverrides = normalizeBindingConfig(params.binding.acp); - const acpAgentId = normalizeText(runtimeDefaults.acpAgentId); - const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode); - return { - channel: params.channel, - accountId, - conversationId: params.conversationId, - parentConversationId: params.parentConversationId, - agentId, - acpAgentId, - mode, - cwd: bindingOverrides.cwd ?? runtimeDefaults.cwd, - backend: bindingOverrides.backend ?? runtimeDefaults.backend, - label: bindingOverrides.label, - }; -} - -function resolveConfiguredBindingRecord(params: { - cfg: OpenClawConfig; - bindings: AgentAcpBinding[]; - channel: ConfiguredAcpBindingChannel; - accountId: string; - selectConversation: (binding: AgentAcpBinding) => { - conversationId: string; - parentConversationId?: string; - matchPriority?: number; - } | null; -}): ResolvedConfiguredAcpBinding | null { - let wildcardMatch: { - binding: AgentAcpBinding; - conversationId: string; - parentConversationId?: string; - matchPriority: number; - } | null = null; - let exactMatch: { - binding: AgentAcpBinding; - conversationId: string; - parentConversationId?: string; - matchPriority: number; - } | null = null; - for (const binding of params.bindings) { - if (normalizeBindingChannel(binding.match.channel) !== params.channel) { - continue; - } - const accountMatchPriority = resolveAccountMatchPriority( - binding.match.accountId, - params.accountId, - ); - if (accountMatchPriority === 0) { - continue; - } - const conversation = params.selectConversation(binding); - if (!conversation) { - continue; - } - const matchPriority = conversation.matchPriority ?? 0; - if (accountMatchPriority === 2) { - if (!exactMatch || matchPriority > exactMatch.matchPriority) { - exactMatch = { - binding, - conversationId: conversation.conversationId, - parentConversationId: conversation.parentConversationId, - matchPriority, - }; - } - continue; - } - if (!wildcardMatch || matchPriority > wildcardMatch.matchPriority) { - wildcardMatch = { - binding, - conversationId: conversation.conversationId, - parentConversationId: conversation.parentConversationId, - matchPriority, - }; - } - } - if (exactMatch) { - const spec = toConfiguredBindingSpec({ - cfg: params.cfg, - channel: params.channel, - accountId: params.accountId, - conversationId: exactMatch.conversationId, - parentConversationId: exactMatch.parentConversationId, - binding: exactMatch.binding, - }); - return { - spec, - record: toConfiguredAcpBindingRecord(spec), - }; - } - if (!wildcardMatch) { - return null; - } - const spec = toConfiguredBindingSpec({ - cfg: params.cfg, - channel: params.channel, - accountId: params.accountId, - conversationId: wildcardMatch.conversationId, - parentConversationId: wildcardMatch.parentConversationId, - binding: wildcardMatch.binding, - }); - return { - spec, - record: toConfiguredAcpBindingRecord(spec), - }; -} - -export function resolveConfiguredAcpBindingSpecBySessionKey(params: { - cfg: OpenClawConfig; - sessionKey: string; -}): ConfiguredAcpBindingSpec | null { - const sessionKey = params.sessionKey.trim(); - if (!sessionKey) { - return null; - } - const parsedSessionKey = parseConfiguredBindingSessionKey({ sessionKey }); - 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); - if (!channel || channel !== parsedSessionKey.channel) { - continue; - } - const accountMatchPriority = resolveAccountMatchPriority( - binding.match.accountId, - parsedSessionKey.accountId, - ); - if (accountMatchPriority === 0) { - continue; - } - const targetConversationId = resolveBindingConversationId(binding); - if (!targetConversationId) { - continue; - } - const target = acpBindings.normalizeConfiguredBindingTarget({ - binding, - conversationId: targetConversationId, - }); - if (!target) { - continue; - } - const spec = toConfiguredBindingSpec({ - cfg: params.cfg, - channel, - accountId: parsedSessionKey.accountId, - conversationId: target.conversationId, - parentConversationId: target.parentConversationId, - binding, - }); - if (buildConfiguredAcpSessionKey(spec) !== sessionKey) { - continue; - } - if (accountMatchPriority === 2) { - return spec; - } - if (!wildcardMatch) { - wildcardMatch = spec; - } - } - return wildcardMatch; -} - export function resolveConfiguredAcpBindingRecord(params: { cfg: OpenClawConfig; channel: string; @@ -277,36 +19,22 @@ export function resolveConfiguredAcpBindingRecord(params: { conversationId: string; parentConversationId?: string; }): ResolvedConfiguredAcpBinding | null { - const channel = normalizeBindingChannel(params.channel); - const accountId = normalizeAccountId(params.accountId); - const conversationId = params.conversationId.trim(); - const parentConversationId = params.parentConversationId?.trim() || undefined; - if (!channel || !conversationId) { - return null; - } - const plugin = getChannelPlugin(channel); - const acpBindings = plugin?.acpBindings; - if (!acpBindings?.matchConfiguredBinding) { - return null; - } - const matchConfiguredBinding = acpBindings.matchConfiguredBinding; - - return resolveConfiguredBindingRecord({ - cfg: params.cfg, - bindings: listAcpBindings(params.cfg), - channel, - accountId, - selectConversation: (binding) => { - const bindingConversationId = resolveBindingConversationId(binding); - if (!bindingConversationId) { - return null; - } - return matchConfiguredBinding({ - binding, - bindingConversationId, - conversationId, - parentConversationId, - }); - }, - }); + const resolved = resolveConfiguredBindingRecord(params); + return resolved ? toResolvedConfiguredAcpBinding(resolved.record) : null; +} + +export function resolveConfiguredAcpBindingRecordForConversation(params: { + cfg: OpenClawConfig; + conversation: ConversationRef; +}): ResolvedConfiguredAcpBinding | null { + const resolved = resolveConfiguredBindingRecordForConversation(params); + return resolved ? toResolvedConfiguredAcpBinding(resolved.record) : null; +} + +export function resolveConfiguredAcpBindingSpecBySessionKey(params: { + cfg: OpenClawConfig; + sessionKey: string; +}): ConfiguredAcpBindingSpec | null { + const resolved = resolveConfiguredBindingRecordBySessionKey(params); + return resolved ? resolveConfiguredAcpBindingSpecFromRecord(resolved.record) : null; } diff --git a/src/acp/persistent-bindings.route.ts b/src/acp/persistent-bindings.route.ts deleted file mode 100644 index d11d46d423d..00000000000 --- a/src/acp/persistent-bindings.route.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { ResolvedAgentRoute } from "../routing/resolve-route.js"; -import { deriveLastRoutePolicy } from "../routing/resolve-route.js"; -import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; -import { - ensureConfiguredAcpBindingSession, - resolveConfiguredAcpBindingRecord, - type ConfiguredAcpBindingChannel, - type ResolvedConfiguredAcpBinding, -} from "./persistent-bindings.js"; - -export function resolveConfiguredAcpRoute(params: { - cfg: OpenClawConfig; - route: ResolvedAgentRoute; - channel: ConfiguredAcpBindingChannel; - accountId: string; - conversationId: string; - parentConversationId?: string; -}): { - configuredBinding: ResolvedConfiguredAcpBinding | null; - route: ResolvedAgentRoute; - boundSessionKey?: string; - boundAgentId?: string; -} { - const configuredBinding = resolveConfiguredAcpBindingRecord({ - cfg: params.cfg, - channel: params.channel, - accountId: params.accountId, - conversationId: params.conversationId, - parentConversationId: params.parentConversationId, - }); - if (!configuredBinding) { - return { - configuredBinding: null, - route: params.route, - }; - } - const boundSessionKey = configuredBinding.record.targetSessionKey?.trim() ?? ""; - if (!boundSessionKey) { - return { - configuredBinding, - route: params.route, - }; - } - const boundAgentId = resolveAgentIdFromSessionKey(boundSessionKey) || params.route.agentId; - return { - configuredBinding, - boundSessionKey, - boundAgentId, - route: { - ...params.route, - sessionKey: boundSessionKey, - agentId: boundAgentId, - lastRoutePolicy: deriveLastRoutePolicy({ - sessionKey: boundSessionKey, - mainSessionKey: params.route.mainSessionKey, - }), - matchedBy: "binding.channel", - }, - }; -} - -export async function ensureConfiguredAcpRouteReady(params: { - cfg: OpenClawConfig; - configuredBinding: ResolvedConfiguredAcpBinding | null; -}): Promise<{ ok: true } | { ok: false; error: string }> { - if (!params.configuredBinding) { - return { ok: true }; - } - const ensured = await ensureConfiguredAcpBindingSession({ - cfg: params.cfg, - spec: params.configuredBinding.spec, - }); - if (ensured.ok) { - return { ok: true }; - } - return { - ok: false, - error: ensured.error ?? "unknown error", - }; -} diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts index cb815b9d948..27b0e59733c 100644 --- a/src/acp/persistent-bindings.test.ts +++ b/src/acp/persistent-bindings.test.ts @@ -2,9 +2,12 @@ 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 { importFreshModule } from "../../test/helpers/import-fresh.js"; +import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js"; const managerMocks = vi.hoisted(() => ({ resolveSession: vi.fn(), closeSession: vi.fn(), @@ -27,17 +30,24 @@ vi.mock("./runtime/session-meta.js", () => ({ readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry, })); -type PersistentBindingsModule = typeof import("./persistent-bindings.js"); - -let buildConfiguredAcpSessionKey: PersistentBindingsModule["buildConfiguredAcpSessionKey"]; -let ensureConfiguredAcpBindingSession: PersistentBindingsModule["ensureConfiguredAcpBindingSession"]; -let resetAcpSessionInPlace: PersistentBindingsModule["resetAcpSessionInPlace"]; -let resolveConfiguredAcpBindingRecord: PersistentBindingsModule["resolveConfiguredAcpBindingRecord"]; -let resolveConfiguredAcpBindingSpecBySessionKey: PersistentBindingsModule["resolveConfiguredAcpBindingSpecBySessionKey"]; +type PersistentBindingsModule = Pick< + typeof import("./persistent-bindings.resolve.js"), + "resolveConfiguredAcpBindingRecord" | "resolveConfiguredAcpBindingSpecBySessionKey" +> & + Pick< + typeof import("./persistent-bindings.lifecycle.js"), + "ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace" + >; +let persistentBindings: PersistentBindingsModule; +let persistentBindingsImportScope = 0; type ConfiguredBinding = NonNullable[number]; -type BindingRecordInput = Parameters[0]; -type BindingSpec = Parameters[0]["spec"]; +type BindingRecordInput = Parameters< + PersistentBindingsModule["resolveConfiguredAcpBindingRecord"] +>[0]; +type BindingSpec = Parameters< + PersistentBindingsModule["ensureConfiguredAcpBindingSession"] +>[0]["spec"]; const baseCfg = { session: { mainKey: "main", scope: "per-sender" }, @@ -117,7 +127,7 @@ function createFeishuBinding(params: { } function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial = {}) { - return resolveConfiguredAcpBindingRecord({ + return persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "discord", accountId: defaultDiscordAccountId, @@ -131,7 +141,7 @@ function resolveDiscordBindingSpecBySession( conversationId = defaultDiscordConversationId, ) { const resolved = resolveBindingRecord(cfg, { conversationId }); - return resolveConfiguredAcpBindingSpecBySessionKey({ + return persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({ cfg, sessionKey: resolved?.record.targetSessionKey ?? "", }); @@ -148,7 +158,11 @@ function createDiscordPersistentSpec(overrides: Partial = {}): Bind } as BindingSpec; } -function mockReadySession(params: { spec: BindingSpec; cwd: string }) { +function mockReadySession(params: { + spec: BindingSpec; + cwd: string; + state?: "idle" | "running" | "error"; +}) { const sessionKey = buildConfiguredAcpSessionKey(params.spec); managerMocks.resolveSession.mockReturnValue({ kind: "ready", @@ -159,14 +173,33 @@ function mockReadySession(params: { spec: BindingSpec; cwd: string }) { runtimeSessionName: "existing", mode: params.spec.mode, runtimeOptions: { cwd: params.cwd }, - state: "idle", + state: params.state ?? "idle", lastActivityAt: Date.now(), }, }); return sessionKey; } -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); + persistentBindingsImportScope += 1; + const [resolveModule, lifecycleModule] = await Promise.all([ + importFreshModule( + import.meta.url, + `./persistent-bindings.resolve.js?scope=${persistentBindingsImportScope}`, + ), + importFreshModule( + import.meta.url, + `./persistent-bindings.lifecycle.js?scope=${persistentBindingsImportScope}`, + ), + ]); + persistentBindings = { + resolveConfiguredAcpBindingRecord: resolveModule.resolveConfiguredAcpBindingRecord, + resolveConfiguredAcpBindingSpecBySessionKey: + resolveModule.resolveConfiguredAcpBindingSpecBySessionKey, + ensureConfiguredAcpBindingSession: lifecycleModule.ensureConfiguredAcpBindingSession, + resetAcpSessionInPlace: lifecycleModule.resetAcpSessionInPlace, + }; setActivePluginRegistry( createTestRegistry([ { pluginId: "discord", plugin: discordPlugin, source: "test" }, @@ -184,17 +217,6 @@ beforeEach(() => { sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined); }); -beforeEach(async () => { - vi.resetModules(); - ({ - buildConfiguredAcpSessionKey, - ensureConfiguredAcpBindingSession, - resetAcpSessionInPlace, - resolveConfiguredAcpBindingRecord, - resolveConfiguredAcpBindingSpecBySessionKey, - } = await import("./persistent-bindings.js")); -}); - describe("resolveConfiguredAcpBindingRecord", () => { it("resolves discord channel ACP binding from top-level typed bindings", () => { const cfg = createCfgWithBindings([ @@ -263,7 +285,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "work", @@ -318,13 +340,13 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const canonical = resolveConfiguredAcpBindingRecord({ + const canonical = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "telegram", accountId: "default", conversationId: "-1001234567890:topic:42", }); - const splitIds = resolveConfiguredAcpBindingRecord({ + const splitIds = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "telegram", accountId: "default", @@ -347,7 +369,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "telegram", accountId: "default", @@ -364,7 +386,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -384,7 +406,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -405,7 +427,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -427,7 +449,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -449,7 +471,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -468,7 +490,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -514,6 +536,25 @@ describe("resolveConfiguredAcpBindingRecord", () => { expect(resolved?.spec.cwd).toBe("/workspace/repo-a"); expect(resolved?.spec.backend).toBe("acpx"); }); + + it("derives configured binding cwd from an explicit agent workspace", () => { + const cfg = createCfgWithBindings( + [ + createDiscordBinding({ + agentId: "codex", + conversationId: defaultDiscordConversationId, + }), + ], + { + agents: { + list: [{ id: "codex", workspace: "/workspace/openclaw" }, { id: "claude" }], + }, + }, + ); + const resolved = resolveBindingRecord(cfg); + + expect(resolved?.spec.cwd).toBe(resolveAgentWorkspaceDir(cfg, "codex")); + }); }); describe("resolveConfiguredAcpBindingSpecBySessionKey", () => { @@ -534,7 +575,7 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => { }); it("returns null for unknown session keys", () => { - const spec = resolveConfiguredAcpBindingSpecBySessionKey({ + const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({ cfg: baseCfg, sessionKey: "agent:main:acp:binding:discord:default:notfound", }); @@ -568,13 +609,13 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => { acp: { backend: "acpx" }, }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", conversationId: "user_123", }); - const spec = resolveConfiguredAcpBindingSpecBySessionKey({ + const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({ cfg, sessionKey: resolved?.record.targetSessionKey ?? "", }); @@ -614,7 +655,7 @@ describe("ensureConfiguredAcpBindingSession", () => { cwd: "/workspace/openclaw", }); - const ensured = await ensureConfiguredAcpBindingSession({ + const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({ cfg: baseCfg, spec, }); @@ -633,7 +674,7 @@ describe("ensureConfiguredAcpBindingSession", () => { cwd: "/workspace/other-repo", }); - const ensured = await ensureConfiguredAcpBindingSession({ + const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({ cfg: baseCfg, spec, }); @@ -649,6 +690,26 @@ describe("ensureConfiguredAcpBindingSession", () => { expect(managerMocks.initializeSession).toHaveBeenCalledTimes(1); }); + it("keeps a matching ready session even when the stored ACP session is in error state", async () => { + const spec = createDiscordPersistentSpec({ + cwd: "/home/bob/clawd", + }); + const sessionKey = mockReadySession({ + spec, + cwd: "/home/bob/clawd", + state: "error", + }); + + const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({ + cfg: baseCfg, + spec, + }); + + expect(ensured).toEqual({ ok: true, sessionKey }); + expect(managerMocks.closeSession).not.toHaveBeenCalled(); + expect(managerMocks.initializeSession).not.toHaveBeenCalled(); + }); + it("initializes ACP session with runtime agent override when provided", async () => { const spec = createDiscordPersistentSpec({ agentId: "coding", @@ -656,7 +717,7 @@ describe("ensureConfiguredAcpBindingSession", () => { }); managerMocks.resolveSession.mockReturnValue({ kind: "none" }); - const ensured = await ensureConfiguredAcpBindingSession({ + const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({ cfg: baseCfg, spec, }); @@ -692,7 +753,7 @@ describe("resetAcpSessionInPlace", () => { }); managerMocks.resolveSession.mockReturnValue({ kind: "none" }); - const result = await resetAcpSessionInPlace({ + const result = await persistentBindings.resetAcpSessionInPlace({ cfg, sessionKey, reason: "new", @@ -721,7 +782,7 @@ describe("resetAcpSessionInPlace", () => { }); managerMocks.initializeSession.mockRejectedValueOnce(new Error("backend unavailable")); - const result = await resetAcpSessionInPlace({ + const result = await persistentBindings.resetAcpSessionInPlace({ cfg: baseCfg, sessionKey, reason: "reset", @@ -752,7 +813,7 @@ describe("resetAcpSessionInPlace", () => { }, }); - const result = await resetAcpSessionInPlace({ + const result = await persistentBindings.resetAcpSessionInPlace({ cfg, sessionKey, reason: "reset", @@ -766,4 +827,64 @@ describe("resetAcpSessionInPlace", () => { }), ); }); + + it("preserves configured ACP agent overrides during in-place reset when metadata omits the agent", async () => { + const cfg = createCfgWithBindings( + [ + createDiscordBinding({ + agentId: "coding", + conversationId: "1478844424791396446", + }), + ], + { + agents: { + list: [ + { id: "main" }, + { + id: "coding", + runtime: { + type: "acp", + acp: { + agent: "codex", + backend: "acpx", + mode: "persistent", + }, + }, + }, + { id: "claude" }, + ], + }, + }, + ); + const sessionKey = buildConfiguredAcpSessionKey({ + channel: "discord", + accountId: "default", + conversationId: "1478844424791396446", + agentId: "coding", + acpAgentId: "codex", + mode: "persistent", + backend: "acpx", + }); + sessionMetaMocks.readAcpSessionEntry.mockReturnValue({ + acp: { + mode: "persistent", + backend: "acpx", + }, + }); + + const result = await persistentBindings.resetAcpSessionInPlace({ + cfg, + sessionKey, + reason: "reset", + }); + + expect(result).toEqual({ ok: true }); + expect(managerMocks.initializeSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + agent: "codex", + backendId: "acpx", + }), + ); + }); }); diff --git a/src/acp/persistent-bindings.ts b/src/acp/persistent-bindings.ts deleted file mode 100644 index d5b1f4ce729..00000000000 --- a/src/acp/persistent-bindings.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { - buildConfiguredAcpSessionKey, - normalizeBindingConfig, - normalizeMode, - normalizeText, - toConfiguredAcpBindingRecord, - type AcpBindingConfigShape, - type ConfiguredAcpBindingChannel, - type ConfiguredAcpBindingSpec, - type ResolvedConfiguredAcpBinding, -} from "./persistent-bindings.types.js"; -export { - ensureConfiguredAcpBindingSession, - resetAcpSessionInPlace, -} from "./persistent-bindings.lifecycle.js"; -export { - resolveConfiguredAcpBindingRecord, - resolveConfiguredAcpBindingSpecBySessionKey, -} from "./persistent-bindings.resolve.js"; diff --git a/src/acp/persistent-bindings.types.ts b/src/acp/persistent-bindings.types.ts index 3583fc4cd9f..3b5a0335a59 100644 --- a/src/acp/persistent-bindings.types.ts +++ b/src/acp/persistent-bindings.types.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import type { ChannelId } from "../channels/plugins/types.js"; import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js"; +import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { sanitizeAgentId } from "../routing/session-key.js"; import type { AcpRuntimeSessionMode } from "./runtime/types.js"; @@ -104,3 +105,72 @@ export function toConfiguredAcpBindingRecord(spec: ConfiguredAcpBindingSpec): Se }, }; } + +export function parseConfiguredAcpSessionKey( + sessionKey: string, +): { channel: ConfiguredAcpBindingChannel; accountId: string } | null { + const trimmed = sessionKey.trim(); + if (!trimmed.startsWith("agent:")) { + return null; + } + const rest = trimmed.slice(trimmed.indexOf(":") + 1); + const nextSeparator = rest.indexOf(":"); + if (nextSeparator === -1) { + return null; + } + const tokens = rest.slice(nextSeparator + 1).split(":"); + if (tokens.length !== 5 || tokens[0] !== "acp" || tokens[1] !== "binding") { + return null; + } + const channel = tokens[2]?.trim().toLowerCase(); + if (!channel) { + return null; + } + return { + channel: channel as ConfiguredAcpBindingChannel, + accountId: normalizeAccountId(tokens[3] ?? "default"), + }; +} + +export function resolveConfiguredAcpBindingSpecFromRecord( + record: SessionBindingRecord, +): ConfiguredAcpBindingSpec | null { + if (record.targetKind !== "session") { + return null; + } + const conversationId = record.conversation.conversationId.trim(); + if (!conversationId) { + return null; + } + const agentId = + normalizeText(record.metadata?.agentId) ?? + resolveAgentIdFromSessionKey(record.targetSessionKey); + if (!agentId) { + return null; + } + return { + channel: record.conversation.channel as ConfiguredAcpBindingChannel, + accountId: normalizeAccountId(record.conversation.accountId), + conversationId, + parentConversationId: normalizeText(record.conversation.parentConversationId), + agentId, + acpAgentId: normalizeText(record.metadata?.acpAgentId), + mode: normalizeMode(record.metadata?.mode), + cwd: normalizeText(record.metadata?.cwd), + backend: normalizeText(record.metadata?.backend), + label: normalizeText(record.metadata?.label), + }; +} + +export function toResolvedConfiguredAcpBinding( + record: SessionBindingRecord, +): ResolvedConfiguredAcpBinding | null { + const spec = resolveConfiguredAcpBindingSpecFromRecord(record); + if (!spec) { + return null; + } + return { + spec, + record, + }; +} diff --git a/src/acp/runtime/session-meta.ts b/src/acp/runtime/session-meta.ts index ff48d1e1ce6..fc94a1f0c05 100644 --- a/src/acp/runtime/session-meta.ts +++ b/src/acp/runtime/session-meta.ts @@ -165,6 +165,7 @@ export async function upsertAcpSessionMeta(params: { }, { activeSessionKey: sessionKey.toLowerCase(), + allowDropAcpMetaSessionKeys: [sessionKey], }, ); } diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 5ed69abd71f..5db40b13a27 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -51,6 +51,7 @@ import { applyVerboseOverride } from "../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; import { resolveSendPolicy } from "../sessions/send-policy.js"; import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; +import { sanitizeForLog } from "../terminal/ansi.js"; import { resolveMessageChannel } from "../utils/message-channel.js"; import { listAgentIds, @@ -82,6 +83,7 @@ import { modelKey, normalizeModelRef, normalizeProviderId, + parseModelRef, resolveConfiguredModelRef, resolveDefaultModelForAgent, resolveThinkingDefault, @@ -124,6 +126,36 @@ const OVERRIDE_FIELDS_CLEARED_BY_DELETE: OverrideFieldClearedByDelete[] = [ "claudeCliSessionId", ]; +const OVERRIDE_VALUE_MAX_LENGTH = 256; + +function containsControlCharacters(value: string): boolean { + for (const char of value) { + const code = char.codePointAt(0); + if (code === undefined) { + continue; + } + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) { + return true; + } + } + return false; +} + +function normalizeExplicitOverrideInput(raw: string, kind: "provider" | "model"): string { + const trimmed = raw.trim(); + const label = kind === "provider" ? "Provider" : "Model"; + if (!trimmed) { + throw new Error(`${label} override must be non-empty.`); + } + if (trimmed.length > OVERRIDE_VALUE_MAX_LENGTH) { + throw new Error(`${label} override exceeds ${String(OVERRIDE_VALUE_MAX_LENGTH)} characters.`); + } + if (containsControlCharacters(trimmed)) { + throw new Error(`${label} override contains invalid control characters.`); + } + return trimmed; +} + async function persistSessionEntry(params: PersistSessionEntryParams): Promise { const persisted = await updateSessionStore(params.storePath, (store) => { const merged = mergeSessionEntry(store[params.sessionKey], params.entry); @@ -340,7 +372,7 @@ function runAgentAttempt(params: { resolvedVerboseLevel: VerboseLevel | undefined; agentDir: string; onAgentEvent: (evt: { stream: string; data?: Record }) => void; - primaryProvider: string; + authProfileProvider: string; sessionStore?: Record; storePath?: string; allowTransientCooldownProbe?: boolean; @@ -388,7 +420,7 @@ function runAgentAttempt(params: { params.storePath ) { log.warn( - `CLI session expired, clearing from session store: provider=${params.providerOverride} sessionKey=${params.sessionKey}`, + `CLI session expired, clearing from session store: provider=${sanitizeForLog(params.providerOverride)} sessionKey=${params.sessionKey}`, ); // Clear the expired session ID from the session store @@ -452,7 +484,7 @@ function runAgentAttempt(params: { } const authProfileId = - params.providerOverride === params.primaryProvider + params.providerOverride === params.authProfileProvider ? params.sessionEntry?.authProfileOverride : undefined; return runEmbeddedPiAgent({ @@ -937,7 +969,19 @@ async function agentCommandInternal( const hasStoredOverride = Boolean( sessionEntry?.modelOverride || sessionEntry?.providerOverride, ); - const needsModelCatalog = hasAllowlist || hasStoredOverride; + const explicitProviderOverride = + typeof opts.provider === "string" + ? normalizeExplicitOverrideInput(opts.provider, "provider") + : undefined; + const explicitModelOverride = + typeof opts.model === "string" + ? normalizeExplicitOverrideInput(opts.model, "model") + : undefined; + const hasExplicitRunOverride = Boolean(explicitProviderOverride || explicitModelOverride); + if (hasExplicitRunOverride && opts.allowModelOverride !== true) { + throw new Error("Model override is not authorized for this caller."); + } + const needsModelCatalog = hasAllowlist || hasStoredOverride || hasExplicitRunOverride; let allowedModelKeys = new Set(); let allowedModelCatalog: Awaited> = []; let modelCatalog: Awaited> | null = null; @@ -1000,13 +1044,38 @@ async function agentCommandInternal( model = normalizedStored.model; } } + const providerForAuthProfileValidation = provider; + if (hasExplicitRunOverride) { + const explicitRef = explicitModelOverride + ? explicitProviderOverride + ? normalizeModelRef(explicitProviderOverride, explicitModelOverride) + : parseModelRef(explicitModelOverride, provider) + : explicitProviderOverride + ? normalizeModelRef(explicitProviderOverride, model) + : null; + if (!explicitRef) { + throw new Error("Invalid model override."); + } + const explicitKey = modelKey(explicitRef.provider, explicitRef.model); + if ( + !isCliProvider(explicitRef.provider, cfg) && + !allowAnyModel && + !allowedModelKeys.has(explicitKey) + ) { + throw new Error( + `Model override "${sanitizeForLog(explicitRef.provider)}/${sanitizeForLog(explicitRef.model)}" is not allowed for agent "${sessionAgentId}".`, + ); + } + provider = explicitRef.provider; + model = explicitRef.model; + } if (sessionEntry) { const authProfileId = sessionEntry.authProfileOverride; if (authProfileId) { const entry = sessionEntry; const store = ensureAuthProfileStore(); const profile = store.profiles[authProfileId]; - if (!profile || profile.provider !== provider) { + if (!profile || profile.provider !== providerForAuthProfileValidation) { if (sessionStore && sessionKey) { await clearSessionAuthProfileOverride({ sessionEntry: entry, @@ -1068,6 +1137,7 @@ async function agentCommandInternal( const resolvedSessionFile = await resolveSessionTranscriptFile({ sessionId, sessionKey: sessionKey ?? sessionId, + storePath, sessionEntry, agentId: sessionAgentId, threadId: opts.threadId, @@ -1132,7 +1202,7 @@ async function agentCommandInternal( skillsSnapshot, resolvedVerboseLevel, agentDir, - primaryProvider: provider, + authProfileProvider: providerForAuthProfileValidation, sessionStore, storePath, allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe, @@ -1230,6 +1300,8 @@ export async function agentCommand( // Ingress callers must opt into owner semantics explicitly via // agentCommandFromIngress so network-facing paths cannot inherit this default by accident. senderIsOwner: opts.senderIsOwner ?? true, + // Local/CLI callers are trusted by default for per-run model overrides. + allowModelOverride: opts.allowModelOverride ?? true, }, runtime, deps, @@ -1246,10 +1318,14 @@ export async function agentCommandFromIngress( // This keeps network-facing callers from silently picking up the local trusted default. throw new Error("senderIsOwner must be explicitly set for ingress agent runs."); } + if (typeof opts.allowModelOverride !== "boolean") { + throw new Error("allowModelOverride must be explicitly set for ingress agent runs."); + } return await agentCommandInternal( { ...opts, senderIsOwner: opts.senderIsOwner, + allowModelOverride: opts.allowModelOverride, }, runtime, deps, diff --git a/src/agents/chutes-models.test.ts b/src/agents/chutes-models.test.ts new file mode 100644 index 00000000000..66bafde50ad --- /dev/null +++ b/src/agents/chutes-models.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + buildChutesModelDefinition, + CHUTES_MODEL_CATALOG, + discoverChutesModels, + clearChutesModelCache, +} from "./chutes-models.js"; + +describe("chutes-models", () => { + beforeEach(() => { + clearChutesModelCache(); + }); + + it("buildChutesModelDefinition returns config with required fields", () => { + const entry = CHUTES_MODEL_CATALOG[0]; + const def = buildChutesModelDefinition(entry); + expect(def.id).toBe(entry.id); + expect(def.name).toBe(entry.name); + expect(def.reasoning).toBe(entry.reasoning); + expect(def.input).toEqual(entry.input); + expect(def.cost).toEqual(entry.cost); + expect(def.contextWindow).toBe(entry.contextWindow); + expect(def.maxTokens).toBe(entry.maxTokens); + expect(def.compat?.supportsUsageInStreaming).toBe(false); + }); + + it("discoverChutesModels returns static catalog when accessToken is empty", async () => { + const models = await discoverChutesModels(""); + expect(models).toHaveLength(CHUTES_MODEL_CATALOG.length); + expect(models.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id)); + }); + + it("discoverChutesModels returns static catalog in test env by default", async () => { + const models = await discoverChutesModels("test-token"); + expect(models).toHaveLength(CHUTES_MODEL_CATALOG.length); + expect(models[0]?.id).toBe("Qwen/Qwen3-32B"); + }); + + it("discoverChutesModels correctly maps API response when not in test env", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { id: "zai-org/GLM-4.7-TEE" }, + { + id: "new-provider/new-model-r1", + supported_features: ["reasoning"], + input_modalities: ["text", "image"], + context_length: 200000, + max_output_length: 16384, + pricing: { prompt: 0.1, completion: 0.2 }, + }, + { id: "new-provider/simple-model" }, + ], + }), + }); + vi.stubGlobal("fetch", mockFetch); + + try { + const models = await discoverChutesModels("test-token-real-fetch"); + expect(models.length).toBeGreaterThan(0); + if (models.length === 3) { + expect(models[0]?.id).toBe("zai-org/GLM-4.7-TEE"); + expect(models[1]?.reasoning).toBe(true); + expect(models[1]?.compat?.supportsUsageInStreaming).toBe(false); + } + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("discoverChutesModels retries without auth on 401", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi.fn().mockImplementation((url, init) => { + if (init?.headers?.Authorization === "Bearer test-token-error") { + // pragma: allowlist secret + return Promise.resolve({ + ok: false, + status: 401, + }); + } + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [ + { + id: "Qwen/Qwen3-32B", + name: "Qwen/Qwen3-32B", + supported_features: ["reasoning"], + input_modalities: ["text"], + context_length: 40960, + max_output_length: 40960, + pricing: { prompt: 0.08, completion: 0.24 }, + }, + { + id: "unsloth/Mistral-Nemo-Instruct-2407", + name: "unsloth/Mistral-Nemo-Instruct-2407", + input_modalities: ["text"], + context_length: 131072, + max_output_length: 131072, + pricing: { prompt: 0.02, completion: 0.04 }, + }, + { + id: "deepseek-ai/DeepSeek-V3-0324-TEE", + name: "deepseek-ai/DeepSeek-V3-0324-TEE", + supported_features: ["reasoning"], + input_modalities: ["text"], + context_length: 131072, + max_output_length: 65536, + pricing: { prompt: 0.28, completion: 0.42 }, + }, + ], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + const models = await discoverChutesModels("test-token-error"); + expect(models.length).toBeGreaterThan(0); + expect(mockFetch).toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("caches fallback static catalog for non-OK responses", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + }); + vi.stubGlobal("fetch", mockFetch); + + try { + const first = await discoverChutesModels("chutes-fallback-token"); + const second = await discoverChutesModels("chutes-fallback-token"); + expect(first.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id)); + expect(second.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id)); + expect(mockFetch).toHaveBeenCalledTimes(1); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("scopes discovery cache by access token", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi + .fn() + .mockImplementation((_url, init?: { headers?: Record }) => { + const auth = init?.headers?.Authorization; + if (auth === "Bearer chutes-token-a") { + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: "private/model-a" }], + }), + }); + } + if (auth === "Bearer chutes-token-b") { + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: "private/model-b" }], + }), + }); + } + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: "public/model" }], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + const modelsA = await discoverChutesModels("chutes-token-a"); + const modelsB = await discoverChutesModels("chutes-token-b"); + const modelsASecond = await discoverChutesModels("chutes-token-a"); + expect(modelsA[0]?.id).toBe("private/model-a"); + expect(modelsB[0]?.id).toBe("private/model-b"); + expect(modelsASecond[0]?.id).toBe("private/model-a"); + // One request per token, then cache hit for the repeated token-a call. + expect(mockFetch).toHaveBeenCalledTimes(2); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("evicts oldest token entries when cache reaches max size", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi + .fn() + .mockImplementation((_url, init?: { headers?: Record }) => { + const auth = init?.headers?.Authorization ?? ""; + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: auth ? `${auth}-model` : "public-model" }], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + for (let i = 0; i < 150; i += 1) { + await discoverChutesModels(`cache-token-${i}`); + } + + // The oldest key should have been evicted once we exceed the cap. + await discoverChutesModels("cache-token-0"); + expect(mockFetch).toHaveBeenCalledTimes(151); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("prunes expired token cache entries during subsequent discovery", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-01T00:00:00.000Z")); + + const mockFetch = vi + .fn() + .mockImplementation((_url, init?: { headers?: Record }) => { + const auth = init?.headers?.Authorization ?? ""; + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: auth ? `${auth}-model` : "public-model" }], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + await discoverChutesModels("token-a"); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + await discoverChutesModels("token-b"); + await discoverChutesModels("token-a"); + expect(mockFetch).toHaveBeenCalledTimes(3); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("does not cache 401 fallback under the failed token key", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi + .fn() + .mockImplementation((_url, init?: { headers?: Record }) => { + if (init?.headers?.Authorization === "Bearer failed-token") { + return Promise.resolve({ + ok: false, + status: 401, + }); + } + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: "public/model" }], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + await discoverChutesModels("failed-token"); + await discoverChutesModels("failed-token"); + // Two calls each perform: authenticated attempt (401) + public fallback. + expect(mockFetch).toHaveBeenCalledTimes(4); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/agents/chutes-models.ts b/src/agents/chutes-models.ts new file mode 100644 index 00000000000..585723e3adc --- /dev/null +++ b/src/agents/chutes-models.ts @@ -0,0 +1,639 @@ +import type { ModelDefinitionConfig } from "../config/types.models.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; + +const log = createSubsystemLogger("chutes-models"); + +/** Chutes.ai OpenAI-compatible API base URL. */ +export const CHUTES_BASE_URL = "https://llm.chutes.ai/v1"; + +export const CHUTES_DEFAULT_MODEL_ID = "zai-org/GLM-4.7-TEE"; +export const CHUTES_DEFAULT_MODEL_REF = `chutes/${CHUTES_DEFAULT_MODEL_ID}`; + +/** Default cost for Chutes models (actual cost varies by model and compute). */ +export const CHUTES_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +/** Default context window and max tokens for discovered models. */ +const CHUTES_DEFAULT_CONTEXT_WINDOW = 128000; +const CHUTES_DEFAULT_MAX_TOKENS = 4096; + +/** + * Static catalog of popular Chutes models. + * Used as a fallback and for initial onboarding allowlisting. + */ +export const CHUTES_MODEL_CATALOG: ModelDefinitionConfig[] = [ + { + id: "Qwen/Qwen3-32B", + name: "Qwen/Qwen3-32B", + reasoning: true, + input: ["text"], + contextWindow: 40960, + maxTokens: 40960, + cost: { input: 0.08, output: 0.24, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/Mistral-Nemo-Instruct-2407", + name: "unsloth/Mistral-Nemo-Instruct-2407", + reasoning: false, + input: ["text"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.02, output: 0.04, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3-0324-TEE", + name: "deepseek-ai/DeepSeek-V3-0324-TEE", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 65536, + cost: { input: 0.25, output: 1, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-235B-A22B-Instruct-2507-TEE", + name: "Qwen/Qwen3-235B-A22B-Instruct-2507-TEE", + reasoning: true, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + cost: { input: 0.08, output: 0.55, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "openai/gpt-oss-120b-TEE", + name: "openai/gpt-oss-120b-TEE", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.05, output: 0.45, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "chutesai/Mistral-Small-3.1-24B-Instruct-2503", + name: "chutesai/Mistral-Small-3.1-24B-Instruct-2503", + reasoning: false, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.03, output: 0.11, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3.2-TEE", + name: "deepseek-ai/DeepSeek-V3.2-TEE", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.28, output: 0.42, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.7-TEE", + name: "zai-org/GLM-4.7-TEE", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65535, + cost: { input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "moonshotai/Kimi-K2.5-TEE", + name: "moonshotai/Kimi-K2.5-TEE", + reasoning: true, + input: ["text", "image"], + contextWindow: 262144, + maxTokens: 65535, + cost: { input: 0.45, output: 2.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/gemma-3-27b-it", + name: "unsloth/gemma-3-27b-it", + reasoning: false, + input: ["text", "image"], + contextWindow: 128000, + maxTokens: 65536, + cost: { input: 0.04, output: 0.15, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "XiaomiMiMo/MiMo-V2-Flash-TEE", + name: "XiaomiMiMo/MiMo-V2-Flash-TEE", + reasoning: true, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + cost: { input: 0.09, output: 0.29, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "chutesai/Mistral-Small-3.2-24B-Instruct-2506", + name: "chutesai/Mistral-Small-3.2-24B-Instruct-2506", + reasoning: false, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.06, output: 0.18, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-R1-0528-TEE", + name: "deepseek-ai/DeepSeek-R1-0528-TEE", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 65536, + cost: { input: 0.45, output: 2.15, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-5-TEE", + name: "zai-org/GLM-5-TEE", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65535, + cost: { input: 0.95, output: 3.15, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3.1-TEE", + name: "deepseek-ai/DeepSeek-V3.1-TEE", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 65536, + cost: { input: 0.2, output: 0.8, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3.1-Terminus-TEE", + name: "deepseek-ai/DeepSeek-V3.1-Terminus-TEE", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 65536, + cost: { input: 0.23, output: 0.9, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/gemma-3-4b-it", + name: "unsloth/gemma-3-4b-it", + reasoning: false, + input: ["text", "image"], + contextWindow: 96000, + maxTokens: 96000, + cost: { input: 0.01, output: 0.03, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "MiniMaxAI/MiniMax-M2.5-TEE", + name: "MiniMaxAI/MiniMax-M2.5-TEE", + reasoning: true, + input: ["text"], + contextWindow: 196608, + maxTokens: 65536, + cost: { input: 0.3, output: 1.1, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "tngtech/DeepSeek-TNG-R1T2-Chimera", + name: "tngtech/DeepSeek-TNG-R1T2-Chimera", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 163840, + cost: { input: 0.25, output: 0.85, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-Coder-Next-TEE", + name: "Qwen/Qwen3-Coder-Next-TEE", + reasoning: true, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + cost: { input: 0.12, output: 0.75, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "NousResearch/Hermes-4-405B-FP8-TEE", + name: "NousResearch/Hermes-4-405B-FP8-TEE", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3", + name: "deepseek-ai/DeepSeek-V3", + reasoning: false, + input: ["text"], + contextWindow: 163840, + maxTokens: 163840, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "openai/gpt-oss-20b", + name: "openai/gpt-oss-20b", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.04, output: 0.15, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/Llama-3.2-3B-Instruct", + name: "unsloth/Llama-3.2-3B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 4096, + cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/Mistral-Small-24B-Instruct-2501", + name: "unsloth/Mistral-Small-24B-Instruct-2501", + reasoning: false, + input: ["text", "image"], + contextWindow: 32768, + maxTokens: 32768, + cost: { input: 0.07, output: 0.3, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.7-FP8", + name: "zai-org/GLM-4.7-FP8", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65535, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.6-TEE", + name: "zai-org/GLM-4.6-TEE", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65536, + cost: { input: 0.4, output: 1.7, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3.5-397B-A17B-TEE", + name: "Qwen/Qwen3.5-397B-A17B-TEE", + reasoning: true, + input: ["text", "image"], + contextWindow: 262144, + maxTokens: 65536, + cost: { input: 0.55, output: 3.5, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen2.5-72B-Instruct", + name: "Qwen/Qwen2.5-72B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 32768, + maxTokens: 32768, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "NousResearch/DeepHermes-3-Mistral-24B-Preview", + name: "NousResearch/DeepHermes-3-Mistral-24B-Preview", + reasoning: false, + input: ["text"], + contextWindow: 32768, + maxTokens: 32768, + cost: { input: 0.02, output: 0.1, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-Next-80B-A3B-Instruct", + name: "Qwen/Qwen3-Next-80B-A3B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 262144, + maxTokens: 262144, + cost: { input: 0.1, output: 0.8, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.6-FP8", + name: "zai-org/GLM-4.6-FP8", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65535, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-235B-A22B-Thinking-2507", + name: "Qwen/Qwen3-235B-A22B-Thinking-2507", + reasoning: true, + input: ["text"], + contextWindow: 262144, + maxTokens: 262144, + cost: { input: 0.11, output: 0.6, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", + name: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.03, output: 0.11, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "tngtech/R1T2-Chimera-Speed", + name: "tngtech/R1T2-Chimera-Speed", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.22, output: 0.6, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.6V", + name: "zai-org/GLM-4.6V", + reasoning: true, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.3, output: 0.9, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen2.5-VL-32B-Instruct", + name: "Qwen/Qwen2.5-VL-32B-Instruct", + reasoning: false, + input: ["text", "image"], + contextWindow: 16384, + maxTokens: 16384, + cost: { input: 0.05, output: 0.22, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-VL-235B-A22B-Instruct", + name: "Qwen/Qwen3-VL-235B-A22B-Instruct", + reasoning: false, + input: ["text", "image"], + contextWindow: 262144, + maxTokens: 262144, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-14B", + name: "Qwen/Qwen3-14B", + reasoning: true, + input: ["text"], + contextWindow: 40960, + maxTokens: 40960, + cost: { input: 0.05, output: 0.22, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen2.5-Coder-32B-Instruct", + name: "Qwen/Qwen2.5-Coder-32B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 32768, + maxTokens: 32768, + cost: { input: 0.03, output: 0.11, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-30B-A3B", + name: "Qwen/Qwen3-30B-A3B", + reasoning: true, + input: ["text"], + contextWindow: 40960, + maxTokens: 40960, + cost: { input: 0.06, output: 0.22, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/gemma-3-12b-it", + name: "unsloth/gemma-3-12b-it", + reasoning: false, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.03, output: 0.1, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/Llama-3.2-1B-Instruct", + name: "unsloth/Llama-3.2-1B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 4096, + cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16-TEE", + name: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16-TEE", + reasoning: true, + input: ["text"], + contextWindow: 128000, + maxTokens: 4096, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "NousResearch/Hermes-4-14B", + name: "NousResearch/Hermes-4-14B", + reasoning: true, + input: ["text"], + contextWindow: 40960, + maxTokens: 40960, + cost: { input: 0.01, output: 0.05, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3Guard-Gen-0.6B", + name: "Qwen/Qwen3Guard-Gen-0.6B", + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 4096, + cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "rednote-hilab/dots.ocr", + name: "rednote-hilab/dots.ocr", + reasoning: false, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 }, + }, +]; + +export function buildChutesModelDefinition( + model: (typeof CHUTES_MODEL_CATALOG)[number], +): ModelDefinitionConfig { + return { + ...model, + // Avoid usage-only streaming chunks that can break OpenAI-compatible parsers. + compat: { + supportsUsageInStreaming: false, + }, + }; +} + +interface ChutesModelEntry { + id: string; + name?: string; + supported_features?: string[]; + input_modalities?: string[]; + context_length?: number; + max_output_length?: number; + pricing?: { + prompt?: number; + completion?: number; + }; + [key: string]: unknown; +} + +interface OpenAIListModelsResponse { + data?: ChutesModelEntry[]; +} + +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const CACHE_MAX_ENTRIES = 100; + +interface CacheEntry { + models: ModelDefinitionConfig[]; + time: number; +} + +// Keyed by trimmed access token (empty string = unauthenticated). +// Prevents a public unauthenticated result from suppressing authenticated +// discovery for users with token-scoped private models. +const modelCache = new Map(); + +/** @internal - For testing only */ +export function clearChutesModelCache() { + modelCache.clear(); +} + +function pruneExpiredCacheEntries(now: number = Date.now()): void { + for (const [key, entry] of modelCache.entries()) { + if (now - entry.time >= CACHE_TTL) { + modelCache.delete(key); + } + } +} + +/** Cache the result for the given token key and return it. */ +function cacheAndReturn( + tokenKey: string, + models: ModelDefinitionConfig[], +): ModelDefinitionConfig[] { + const now = Date.now(); + pruneExpiredCacheEntries(now); + + if (!modelCache.has(tokenKey) && modelCache.size >= CACHE_MAX_ENTRIES) { + const oldest = modelCache.keys().next(); + if (!oldest.done) { + modelCache.delete(oldest.value); + } + } + + modelCache.set(tokenKey, { models, time: now }); + return models; +} + +/** + * Discover models from Chutes.ai API with fallback to static catalog. + * Mimics the logic in Chutes init script. + */ +export async function discoverChutesModels(accessToken?: string): Promise { + const trimmedKey = accessToken?.trim() ?? ""; + + // Return cached result for this token if still within TTL + const now = Date.now(); + pruneExpiredCacheEntries(now); + const cached = modelCache.get(trimmedKey); + if (cached) { + return cached.models; + } + + // Skip API discovery in test environment + if (process.env.NODE_ENV === "test" || process.env.VITEST === "true") { + return CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition); + } + + // If auth fails the result comes from the public endpoint — cache it under "" + // so the original token key stays uncached and retries cleanly next TTL window. + let effectiveKey = trimmedKey; + const staticCatalog = () => + cacheAndReturn(effectiveKey, CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition)); + + const headers: Record = {}; + if (trimmedKey) { + headers.Authorization = `Bearer ${trimmedKey}`; + } + + try { + let response = await fetch(`${CHUTES_BASE_URL}/models`, { + signal: AbortSignal.timeout(10_000), + headers, + }); + + if (response.status === 401 && trimmedKey) { + // Auth failed — fall back to the public (unauthenticated) endpoint. + // Cache the result under "" so the bad token stays uncached and can + // be retried with a refreshed credential after the TTL expires. + effectiveKey = ""; + response = await fetch(`${CHUTES_BASE_URL}/models`, { + signal: AbortSignal.timeout(10_000), + }); + } + + if (!response.ok) { + // Only log if it's not a common auth/overload error that we have a fallback for + if (response.status !== 401 && response.status !== 503) { + log.warn(`GET /v1/models failed: HTTP ${response.status}, using static catalog`); + } + return staticCatalog(); + } + + const body = (await response.json()) as OpenAIListModelsResponse; + const data = body?.data; + if (!Array.isArray(data) || data.length === 0) { + log.warn("No models in response, using static catalog"); + return staticCatalog(); + } + + const seen = new Set(); + const models: ModelDefinitionConfig[] = []; + + for (const entry of data) { + const id = typeof entry?.id === "string" ? entry.id.trim() : ""; + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + + const isReasoning = + entry.supported_features?.includes("reasoning") || + id.toLowerCase().includes("r1") || + id.toLowerCase().includes("thinking") || + id.toLowerCase().includes("reason") || + id.toLowerCase().includes("tee"); + + const input: Array<"text" | "image"> = (entry.input_modalities || ["text"]).filter( + (i): i is "text" | "image" => i === "text" || i === "image", + ); + + models.push({ + id, + name: id, // Mirror init.sh: uses id for name + reasoning: isReasoning, + input, + cost: { + input: entry.pricing?.prompt || 0, + output: entry.pricing?.completion || 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: entry.context_length || CHUTES_DEFAULT_CONTEXT_WINDOW, + maxTokens: entry.max_output_length || CHUTES_DEFAULT_MAX_TOKENS, + compat: { + supportsUsageInStreaming: false, + }, + }); + } + + return cacheAndReturn( + effectiveKey, + models.length > 0 ? models : CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition), + ); + } catch (error) { + log.warn(`Discovery failed: ${String(error)}, using static catalog`); + return staticCatalog(); + } +} diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts index 66d0209bdfb..a85157bb191 100644 --- a/src/agents/command/types.ts +++ b/src/agents/command/types.ts @@ -39,6 +39,10 @@ export type AgentCommandOpts = { clientTools?: ClientToolDefinition[]; /** Agent id override (must exist in config). */ agentId?: string; + /** Per-run provider override. */ + provider?: string; + /** Per-run model override. */ + model?: string; to?: string; sessionId?: string; sessionKey?: string; @@ -65,6 +69,8 @@ export type AgentCommandOpts = { runContext?: AgentRunContext; /** Whether this caller is authorized for owner-only tools (defaults true for local CLI calls). */ senderIsOwner?: boolean; + /** Whether this caller is authorized to use provider/model per-run overrides. */ + allowModelOverride?: boolean; /** Group/spawn metadata for subagent policy inheritance and routing context. */ groupId?: SpawnedRunMetadata["groupId"]; groupChannel?: SpawnedRunMetadata["groupChannel"]; @@ -84,7 +90,12 @@ export type AgentCommandOpts = { workspaceDir?: SpawnedRunMetadata["workspaceDir"]; }; -export type AgentCommandIngressOpts = Omit & { - /** Ingress callsites must always pass explicit owner authorization state. */ +export type AgentCommandIngressOpts = Omit< + AgentCommandOpts, + "senderIsOwner" | "allowModelOverride" +> & { + /** Ingress callsites must always pass explicit owner-tool authorization state. */ senderIsOwner: boolean; + /** Ingress callsites must always pass explicit model-override authorization state. */ + allowModelOverride: boolean; }; diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index 0f33ada0d1b..df0e67e6c68 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -50,9 +50,13 @@ function createContextOverrideConfig(provider: string, model: string, contextWin }; } +async function flushAsyncWarmup() { + await new Promise((r) => setTimeout(r, 0)); +} + async function importResolveContextTokensForModel() { const { resolveContextTokensForModel } = await import("./context.js"); - await new Promise((r) => setTimeout(r, 0)); + await flushAsyncWarmup(); return resolveContextTokensForModel; } @@ -76,57 +80,34 @@ describe("lookupContextTokens", () => { expect(lookupContextTokens("openrouter/claude-sonnet")).toBe(321_000); }); - it("does not skip eager warmup when --profile is followed by -- terminator", async () => { - const loadConfigMock = vi.fn(() => ({ models: {} })); - mockContextModuleDeps(loadConfigMock); - + it("only warms eagerly for startup commands that need model metadata", async () => { const argvSnapshot = process.argv; - process.argv = ["node", "openclaw", "--profile", "--", "config", "validate"]; try { - await import("./context.js"); - expect(loadConfigMock).toHaveBeenCalledTimes(1); - } finally { - process.argv = argvSnapshot; - } - }); - - it("skips eager warmup for logs commands that do not need model metadata at startup", async () => { - const loadConfigMock = vi.fn(() => ({ models: {} })); - mockContextModuleDeps(loadConfigMock); - - const argvSnapshot = process.argv; - process.argv = ["node", "openclaw", "logs", "--limit", "5"]; - try { - await import("./context.js"); - expect(loadConfigMock).not.toHaveBeenCalled(); - } finally { - process.argv = argvSnapshot; - } - }); - - it("skips eager warmup for status commands that only read model metadata opportunistically", async () => { - const loadConfigMock = vi.fn(() => ({ models: {} })); - mockContextModuleDeps(loadConfigMock); - - const argvSnapshot = process.argv; - process.argv = ["node", "openclaw", "status", "--json"]; - try { - await import("./context.js"); - expect(loadConfigMock).not.toHaveBeenCalled(); - } finally { - process.argv = argvSnapshot; - } - }); - - it("skips eager warmup for gateway commands that do not need model metadata at startup", async () => { - const loadConfigMock = vi.fn(() => ({ models: {} })); - mockContextModuleDeps(loadConfigMock); - - const argvSnapshot = process.argv; - process.argv = ["node", "openclaw", "gateway", "status", "--json"]; - try { - await import("./context.js"); - expect(loadConfigMock).not.toHaveBeenCalled(); + for (const scenario of [ + { + argv: ["node", "openclaw", "--profile", "--", "config", "validate"], + expectedCalls: 1, + }, + { + argv: ["node", "openclaw", "logs", "--limit", "5"], + expectedCalls: 0, + }, + { + argv: ["node", "openclaw", "status", "--json"], + expectedCalls: 0, + }, + { + argv: ["node", "openclaw", "gateway", "status", "--json"], + expectedCalls: 0, + }, + ]) { + vi.resetModules(); + const loadConfigMock = vi.fn(() => ({ models: {} })); + mockContextModuleDeps(loadConfigMock); + process.argv = scenario.argv; + await import("./context.js"); + expect(loadConfigMock).toHaveBeenCalledTimes(scenario.expectedCalls); + } } finally { process.argv = argvSnapshot; } @@ -176,7 +157,7 @@ describe("lookupContextTokens", () => { const { lookupContextTokens } = await import("./context.js"); // Trigger async cache population. - await new Promise((r) => setTimeout(r, 0)); + await flushAsyncWarmup(); // Conservative minimum: bare-id cache feeds runtime flush/compaction paths. expect(lookupContextTokens("gemini-3.1-pro-preview")).toBe(128_000); }); @@ -191,7 +172,7 @@ describe("lookupContextTokens", () => { ]); const { resolveContextTokensForModel } = await import("./context.js"); - await new Promise((r) => setTimeout(r, 0)); + await flushAsyncWarmup(); // With provider specified and no config override, bare lookup finds the // provider-qualified discovery entry. @@ -277,7 +258,7 @@ describe("lookupContextTokens", () => { }; const { resolveContextTokensForModel } = await import("./context.js"); - await new Promise((r) => setTimeout(r, 0)); + await flushAsyncWarmup(); // Exact key "qwen" wins over the alias-normalized match "qwen-portal". const qwenResult = resolveContextTokensForModel({ diff --git a/src/agents/model-auth-markers.test.ts b/src/agents/model-auth-markers.test.ts index b90f1fd9ffa..960a648675b 100644 --- a/src/agents/model-auth-markers.test.ts +++ b/src/agents/model-auth-markers.test.ts @@ -4,12 +4,14 @@ import { isKnownEnvApiKeyMarker, isNonSecretApiKeyMarker, NON_ENV_SECRETREF_MARKER, + resolveOAuthApiKeyMarker, } from "./model-auth-markers.js"; describe("model auth markers", () => { it("recognizes explicit non-secret markers", () => { expect(isNonSecretApiKeyMarker(NON_ENV_SECRETREF_MARKER)).toBe(true); expect(isNonSecretApiKeyMarker("qwen-oauth")).toBe(true); + expect(isNonSecretApiKeyMarker(resolveOAuthApiKeyMarker("chutes"))).toBe(true); expect(isNonSecretApiKeyMarker("ollama-local")).toBe(true); }); diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts index 8a890d3a694..37ec67ba2c0 100644 --- a/src/agents/model-auth-markers.ts +++ b/src/agents/model-auth-markers.ts @@ -2,6 +2,7 @@ import type { SecretRefSource } from "../config/types.secrets.js"; import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js"; export const MINIMAX_OAUTH_MARKER = "minimax-oauth"; +export const OAUTH_API_KEY_MARKER_PREFIX = "oauth:"; export const QWEN_OAUTH_MARKER = "qwen-oauth"; export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local"; export const CUSTOM_LOCAL_AUTH_MARKER = "custom-local"; @@ -41,6 +42,14 @@ export function isKnownEnvApiKeyMarker(value: string): boolean { return KNOWN_ENV_API_KEY_MARKERS.has(trimmed) && !isAwsSdkAuthMarker(trimmed); } +export function resolveOAuthApiKeyMarker(providerId: string): string { + return `${OAUTH_API_KEY_MARKER_PREFIX}${providerId.trim()}`; +} + +export function isOAuthApiKeyMarker(value: string): boolean { + return value.trim().startsWith(OAUTH_API_KEY_MARKER_PREFIX); +} + export function resolveNonEnvSecretRefApiKeyMarker(_source: SecretRefSource): string { return NON_ENV_SECRETREF_MARKER; } @@ -71,6 +80,7 @@ export function isNonSecretApiKeyMarker( const isKnownMarker = trimmed === MINIMAX_OAUTH_MARKER || trimmed === QWEN_OAUTH_MARKER || + isOAuthApiKeyMarker(trimmed) || trimmed === OLLAMA_LOCAL_AUTH_MARKER || trimmed === CUSTOM_LOCAL_AUTH_MARKER || trimmed === NON_ENV_SECRETREF_MARKER || diff --git a/src/agents/models-config.providers.chutes.test.ts b/src/agents/models-config.providers.chutes.test.ts new file mode 100644 index 00000000000..a47ee57fcb3 --- /dev/null +++ b/src/agents/models-config.providers.chutes.test.ts @@ -0,0 +1,212 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { CHUTES_BASE_URL } from "./chutes-models.js"; +import { resolveOAuthApiKeyMarker } from "./model-auth-markers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; + +const CHUTES_OAUTH_MARKER = resolveOAuthApiKeyMarker("chutes"); +const ORIGINAL_VITEST_ENV = process.env.VITEST; +const ORIGINAL_NODE_ENV = process.env.NODE_ENV; + +describe("chutes implicit provider auth mode", () => { + beforeEach(() => { + process.env.VITEST = "true"; + process.env.NODE_ENV = "test"; + }); + + afterAll(() => { + process.env.VITEST = ORIGINAL_VITEST_ENV; + process.env.NODE_ENV = ORIGINAL_NODE_ENV; + }); + + it("auto-loads bundled chutes discovery for env api keys", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const providers = await resolveImplicitProviders({ + agentDir, + env: { + CHUTES_API_KEY: "env-chutes-api-key", + } as NodeJS.ProcessEnv, + }); + + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe("CHUTES_API_KEY"); + }); + + it("keeps api_key-backed chutes profiles on the api-key loader path", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "chutes:default": { + type: "api_key", + provider: "chutes", + key: "chutes-live-api-key", // pragma: allowlist secret + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key"); + expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER); + }); + + it("keeps api_key precedence when oauth profile is inserted first", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "chutes:oauth": { + type: "oauth", + provider: "chutes", + access: "oauth-access-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }, + "chutes:default": { + type: "api_key", + provider: "chutes", + key: "chutes-live-api-key", // pragma: allowlist secret + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key"); + expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER); + }); + + it("keeps api_key precedence when api_key profile is inserted first", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "chutes:default": { + type: "api_key", + provider: "chutes", + key: "chutes-live-api-key", // pragma: allowlist secret + }, + "chutes:oauth": { + type: "oauth", + provider: "chutes", + access: "oauth-access-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key"); + expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER); + }); + + it("forwards oauth access token to chutes model discovery", async () => { + // Enable real discovery so fetch is actually called. + const originalVitest = process.env.VITEST; + const originalNodeEnv = process.env.NODE_ENV; + const originalFetch = globalThis.fetch; + delete process.env.VITEST; + delete process.env.NODE_ENV; + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: "chutes/private-model" }] }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "chutes:default": { + type: "oauth", + provider: "chutes", + access: "my-chutes-access-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); + expect(providers?.chutes?.apiKey).toBe(CHUTES_OAUTH_MARKER); + + const chutesCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes("chutes.ai")); + expect(chutesCalls.length).toBeGreaterThan(0); + const request = chutesCalls[0]?.[1] as { headers?: Record } | undefined; + expect(request?.headers?.Authorization).toBe("Bearer my-chutes-access-token"); + } finally { + process.env.VITEST = originalVitest; + process.env.NODE_ENV = originalNodeEnv; + globalThis.fetch = originalFetch; + } + }); + + it("uses CHUTES_OAUTH_MARKER only for oauth-backed chutes profiles", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "chutes:default": { + type: "oauth", + provider: "chutes", + access: "oauth-access-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe(CHUTES_OAUTH_MARKER); + }); +}); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 19ce478b2f4..af9c3d6e34a 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -617,10 +617,22 @@ type ProviderApiKeyResolver = (provider: string) => { discoveryApiKey?: string; }; +type ProviderAuthResolver = ( + provider: string, + options?: { oauthMarker?: string }, +) => { + apiKey: string | undefined; + discoveryApiKey?: string; + mode: "api_key" | "oauth" | "token" | "none"; + source: "env" | "profile" | "none"; + profileId?: string; +}; + type ImplicitProviderContext = ImplicitProviderParams & { authStore: ReturnType; env: NodeJS.ProcessEnv; resolveProviderApiKey: ProviderApiKeyResolver; + resolveProviderAuth: ProviderAuthResolver; }; function mergeImplicitProviderSet( @@ -668,6 +680,8 @@ async function resolvePluginImplicitProviders( env: ctx.env, resolveProviderApiKey: (providerId) => ctx.resolveProviderApiKey(providerId?.trim() || provider.id), + resolveProviderAuth: (providerId, options) => + ctx.resolveProviderAuth(providerId?.trim() || provider.id, options), }); mergeImplicitProviderSet( discovered, @@ -704,11 +718,74 @@ export async function resolveImplicitProviders( discoveryApiKey: fromProfiles?.discoveryApiKey, }; }; + const resolveProviderAuth: ProviderAuthResolver = ( + provider: string, + options?: { oauthMarker?: string }, + ) => { + const envVar = resolveEnvApiKeyVarName(provider, env); + if (envVar) { + return { + apiKey: envVar, + discoveryApiKey: toDiscoveryApiKey(env[envVar]), + mode: "api_key", + source: "env", + }; + } + + const ids = listProfilesForProvider(authStore, provider); + let oauthCandidate: + | { + apiKey: string | undefined; + discoveryApiKey?: string; + mode: "oauth"; + source: "profile"; + profileId: string; + } + | undefined; + for (const id of ids) { + const cred = authStore.profiles[id]; + if (!cred) { + continue; + } + if (cred.type === "oauth") { + oauthCandidate ??= { + apiKey: options?.oauthMarker, + discoveryApiKey: toDiscoveryApiKey(cred.access), + mode: "oauth", + source: "profile", + profileId: id, + }; + continue; + } + const resolved = resolveApiKeyFromCredential(cred, env); + if (!resolved) { + continue; + } + return { + apiKey: resolved.apiKey, + discoveryApiKey: resolved.discoveryApiKey, + mode: cred.type, + source: "profile", + profileId: id, + }; + } + if (oauthCandidate) { + return oauthCandidate; + } + + return { + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }; + }; const context: ImplicitProviderContext = { ...params, authStore, env, resolveProviderApiKey, + resolveProviderAuth, }; mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, "simple")); diff --git a/src/agents/openclaw-tools.image-generation.test.ts b/src/agents/openclaw-tools.image-generation.test.ts index dd237115ab7..9ad49f66371 100644 --- a/src/agents/openclaw-tools.image-generation.test.ts +++ b/src/agents/openclaw-tools.image-generation.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import * as imageGenerationRuntime from "../image-generation/runtime.js"; import { createOpenClawTools } from "./openclaw-tools.js"; vi.mock("../plugins/tools.js", () => ({ @@ -10,7 +11,33 @@ function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } +function stubImageGenerationProviders() { + vi.spyOn(imageGenerationRuntime, "listRuntimeImageGenerationProviders").mockReturnValue([ + { + id: "openai", + defaultModel: "gpt-image-1", + models: ["gpt-image-1"], + supportedSizes: ["1024x1024"], + generateImage: vi.fn(async () => { + throw new Error("not used"); + }), + }, + ]); +} + describe("openclaw tools image generation registration", () => { + beforeEach(() => { + vi.stubEnv("OPENAI_API_KEY", ""); + vi.stubEnv("OPENAI_API_KEYS", ""); + vi.stubEnv("GEMINI_API_KEY", ""); + vi.stubEnv("GEMINI_API_KEYS", ""); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + it("registers image_generate when image-generation config is present", () => { const tools = createOpenClawTools({ config: asConfig({ @@ -28,7 +55,21 @@ describe("openclaw tools image generation registration", () => { expect(tools.map((tool) => tool.name)).toContain("image_generate"); }); - it("omits image_generate when image-generation config is absent", () => { + it("registers image_generate when a compatible provider has env-backed auth", () => { + stubImageGenerationProviders(); + vi.stubEnv("OPENAI_API_KEY", "openai-test"); + + const tools = createOpenClawTools({ + config: asConfig({}), + agentDir: "/tmp/openclaw-agent-main", + }); + + expect(tools.map((tool) => tool.name)).toContain("image_generate"); + }); + + it("omits image_generate when config is absent and no compatible provider auth exists", () => { + stubImageGenerationProviders(); + const tools = createOpenClawTools({ config: asConfig({}), agentDir: "/tmp/openclaw-agent-main", diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 54ad50539e3..72b16ad003f 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -529,6 +529,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { messageCount: 1, tokenCount: 10, compactedCount: 1, + sessionFile: "/tmp/session.jsonl", }, expect.objectContaining({ sessionKey: "agent:main:session-1", messageProvider: "telegram" }), ); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 98a3b438d21..4e967730667 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -1039,6 +1039,7 @@ export async function compactEmbeddedPiSessionDirect( messageCount: messageCountAfter, tokenCount: tokensAfter, compactedCount, + sessionFile: params.sessionFile, }, { sessionId: params.sessionId, diff --git a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts index 7b7ce460826..e5f02cecf0c 100644 --- a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts +++ b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { waitForCompactionRetryWithAggregateTimeout } from "./compaction-retry-aggregate-timeout.js"; type AggregateTimeoutParams = Parameters[0]; +type TimeoutCallback = NonNullable; +type TimeoutCallbackMock = ReturnType>; async function withFakeTimers(run: () => Promise) { vi.useFakeTimers(); @@ -13,7 +15,7 @@ async function withFakeTimers(run: () => Promise) { } } -function expectClearedTimeoutState(onTimeout: ReturnType, timedOut: boolean) { +function expectClearedTimeoutState(onTimeout: TimeoutCallbackMock, timedOut: boolean) { if (timedOut) { expect(onTimeout).toHaveBeenCalledTimes(1); } else { @@ -25,18 +27,15 @@ function expectClearedTimeoutState(onTimeout: ReturnType, timedOut function buildAggregateTimeoutParams( overrides: Partial & Pick, -): { params: AggregateTimeoutParams; onTimeoutSpy: ReturnType } { - const onTimeoutSpy = vi.fn(); - const onTimeout = overrides.onTimeout ?? (() => onTimeoutSpy()); +): AggregateTimeoutParams & { onTimeout: TimeoutCallbackMock } { + const onTimeout = + (overrides.onTimeout as TimeoutCallbackMock | undefined) ?? vi.fn(); return { - params: { - waitForCompactionRetry: overrides.waitForCompactionRetry, - abortable: overrides.abortable ?? (async (promise) => await promise), - aggregateTimeoutMs: overrides.aggregateTimeoutMs ?? 60_000, - isCompactionStillInFlight: overrides.isCompactionStillInFlight, - onTimeout, - }, - onTimeoutSpy, + waitForCompactionRetry: overrides.waitForCompactionRetry, + abortable: overrides.abortable ?? (async (promise) => await promise), + aggregateTimeoutMs: overrides.aggregateTimeoutMs ?? 60_000, + isCompactionStillInFlight: overrides.isCompactionStillInFlight, + onTimeout, }; } @@ -44,7 +43,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { it("times out and fires callback when compaction retry never resolves", async () => { await withFakeTimers(async () => { const waitForCompactionRetry = vi.fn(async () => await new Promise(() => {})); - const { params, onTimeoutSpy } = buildAggregateTimeoutParams({ waitForCompactionRetry }); + const params = buildAggregateTimeoutParams({ waitForCompactionRetry }); const resultPromise = waitForCompactionRetryWithAggregateTimeout(params); @@ -52,7 +51,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { const result = await resultPromise; expect(result.timedOut).toBe(true); - expectClearedTimeoutState(onTimeoutSpy, true); + expectClearedTimeoutState(params.onTimeout, true); }); }); @@ -72,15 +71,14 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { waitForCompactionRetry, isCompactionStillInFlight: () => compactionInFlight, }); - const { params: aggregateTimeoutParams, onTimeoutSpy } = params; - const resultPromise = waitForCompactionRetryWithAggregateTimeout(aggregateTimeoutParams); + const resultPromise = waitForCompactionRetryWithAggregateTimeout(params); await vi.advanceTimersByTimeAsync(170_000); const result = await resultPromise; expect(result.timedOut).toBe(false); - expectClearedTimeoutState(onTimeoutSpy, false); + expectClearedTimeoutState(params.onTimeout, false); }); }); @@ -91,7 +89,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { setTimeout(() => { compactionInFlight = false; }, 90_000); - const { params, onTimeoutSpy } = buildAggregateTimeoutParams({ + const params = buildAggregateTimeoutParams({ waitForCompactionRetry, isCompactionStillInFlight: () => compactionInFlight, }); @@ -102,19 +100,19 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { const result = await resultPromise; expect(result.timedOut).toBe(true); - expectClearedTimeoutState(onTimeoutSpy, true); + expectClearedTimeoutState(params.onTimeout, true); }); }); it("does not time out when compaction retry resolves", async () => { await withFakeTimers(async () => { const waitForCompactionRetry = vi.fn(async () => {}); - const { params, onTimeoutSpy } = buildAggregateTimeoutParams({ waitForCompactionRetry }); + const params = buildAggregateTimeoutParams({ waitForCompactionRetry }); const result = await waitForCompactionRetryWithAggregateTimeout(params); expect(result.timedOut).toBe(false); - expectClearedTimeoutState(onTimeoutSpy, false); + expectClearedTimeoutState(params.onTimeout, false); }); }); @@ -123,7 +121,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { const abortError = new Error("aborted"); abortError.name = "AbortError"; const waitForCompactionRetry = vi.fn(async () => await new Promise(() => {})); - const { params, onTimeoutSpy } = buildAggregateTimeoutParams({ + const params = buildAggregateTimeoutParams({ waitForCompactionRetry, abortable: async () => { throw abortError; @@ -132,7 +130,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { await expect(waitForCompactionRetryWithAggregateTimeout(params)).rejects.toThrow("aborted"); - expectClearedTimeoutState(onTimeoutSpy, false); + expectClearedTimeoutState(params.onTimeout, false); }); }); }); diff --git a/src/agents/pi-embedded-runner/system-prompt.test.ts b/src/agents/pi-embedded-runner/system-prompt.test.ts index 0ba4ee66d0f..b50565eb738 100644 --- a/src/agents/pi-embedded-runner/system-prompt.test.ts +++ b/src/agents/pi-embedded-runner/system-prompt.test.ts @@ -2,16 +2,25 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; import { applySystemPromptOverrideToSession, createSystemPromptOverride } from "./system-prompt.js"; -type MutableSystemPromptFields = { +type MutableSession = { _baseSystemPrompt?: string; _rebuildSystemPrompt?: (toolNames: string[]) => string; }; -function createMockSession() { - const setSystemPrompt = vi.fn(); +type MockSession = MutableSession & { + agent: { + setSystemPrompt: ReturnType; + }; +}; + +function createMockSession(): { + session: MockSession; + setSystemPrompt: ReturnType; +} { + const setSystemPrompt = vi.fn<(prompt: string) => void>(); const session = { agent: { setSystemPrompt }, - } as unknown as AgentSession; + } as MockSession; return { session, setSystemPrompt }; } @@ -19,9 +28,9 @@ function applyAndGetMutableSession( prompt: Parameters[1], ) { const { session, setSystemPrompt } = createMockSession(); - applySystemPromptOverrideToSession(session, prompt); + applySystemPromptOverrideToSession(session as unknown as AgentSession, prompt); return { - mutable: session as unknown as MutableSystemPromptFields, + mutable: session, setSystemPrompt, }; } diff --git a/src/agents/pi-embedded-subscribe.handlers.compaction.ts b/src/agents/pi-embedded-subscribe.handlers.compaction.ts index 7b9c4499eff..f0717f140cf 100644 --- a/src/agents/pi-embedded-subscribe.handlers.compaction.ts +++ b/src/agents/pi-embedded-subscribe.handlers.compaction.ts @@ -80,8 +80,9 @@ export function handleAutoCompactionEnd( { messageCount: ctx.params.session.messages?.length ?? 0, compactedCount: ctx.getCompactionCount(), + sessionFile: ctx.params.session.sessionFile, }, - {}, + { sessionKey: ctx.params.sessionKey }, ) .catch((err) => { ctx.log.warn(`after_compaction hook failed: ${String(err)}`); diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index 882099f3569..509bbdd25b2 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -138,10 +138,31 @@ async function runCompactionScenario(params: { }); const result = (await compactionHandler(params.event, mockContext)) as { cancel?: boolean; + compaction?: { + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + }; }; return { result, getApiKeyMock }; } +function expectCompactionResult(result: { + cancel?: boolean; + compaction?: { + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + }; +}) { + expect(result.cancel).not.toBe(true); + expect(result.compaction).toBeDefined(); + if (!result.compaction) { + throw new Error("Expected compaction result"); + } + return result.compaction; +} + describe("compaction-safeguard tool failures", () => { it("formats tool failures with meta and summary", () => { const messages: AgentMessage[] = [ @@ -1524,10 +1545,117 @@ describe("compaction-safeguard double-compaction guard", () => { event: mockEvent, apiKey: "sk-test", // pragma: allowlist secret }); - expect(result).toEqual({ cancel: true }); + const compaction = expectCompactionResult(result); + // After fix for #41981: returns a compaction result (not cancel) to write + // a boundary entry and break the re-trigger loop. + // buildStructuredFallbackSummary(undefined) produces a minimal structured summary + expect(compaction.summary).toContain("## Decisions"); + expect(compaction.summary).toContain("No prior history."); + expect(compaction.summary).toContain("## Open TODOs"); + expect(compaction.firstKeptEntryId).toBe("entry-1"); + expect(compaction.tokensBefore).toBe(1500); expect(getApiKeyMock).not.toHaveBeenCalled(); }); + it("returns compaction result with structured fallback summary sections", async () => { + const sessionManager = stubSessionManager(); + const model = createAnthropicModelFixture(); + setCompactionSafeguardRuntime(sessionManager, { model }); + + const mockEvent = { + preparation: { + messagesToSummarize: [] as AgentMessage[], + turnPrefixMessages: [] as AgentMessage[], + firstKeptEntryId: "entry-2", + tokensBefore: 2000, + previousSummary: "## Decisions\nUsed approach A.", + fileOps: { read: [], edited: [], written: [] }, + settings: { reserveTokens: 16384 }, + }, + customInstructions: "", + signal: new AbortController().signal, + }; + const { result } = await runCompactionScenario({ + sessionManager, + event: mockEvent, + apiKey: "sk-test", // pragma: allowlist secret + }); + const compaction = expectCompactionResult(result); + // Fallback preserves previous summary when it has required sections + expect(compaction.summary).toContain("## Decisions"); + expect(compaction.summary).toContain("## Open TODOs"); + expect(compaction.firstKeptEntryId).toBe("entry-2"); + }); + + it("writes boundary again on repeated empty preparation (no cancel loop after new assistant message)", async () => { + const sessionManager = stubSessionManager(); + const model = createAnthropicModelFixture(); + setCompactionSafeguardRuntime(sessionManager, { model }); + + const mockEvent = { + preparation: { + messagesToSummarize: [] as AgentMessage[], + turnPrefixMessages: [] as AgentMessage[], + firstKeptEntryId: "entry-3", + tokensBefore: 1000, + fileOps: { read: [], edited: [], written: [] }, + }, + customInstructions: "", + signal: new AbortController().signal, + }; + + // First call — writes boundary + const { result: result1 } = await runCompactionScenario({ + sessionManager, + event: mockEvent, + apiKey: "sk-test", // pragma: allowlist secret + }); + const compaction1 = expectCompactionResult(result1); + expect(compaction1.summary).toContain("## Decisions"); + + // Simulate: after the boundary, a new assistant message arrives, SDK + // triggers compaction again with another empty preparation. The safeguard + // must write another boundary (not cancel) to avoid re-entering the + // cancel loop described in the maintainer review. + const { result: result2 } = await runCompactionScenario({ + sessionManager, + event: mockEvent, + apiKey: "sk-test", // pragma: allowlist secret + }); + const compaction2 = expectCompactionResult(result2); + expect(compaction2.summary).toContain("## Decisions"); + expect(compaction2.firstKeptEntryId).toBe("entry-3"); + }); + + it("does not write boundary when turnPrefixMessages has real content (split-turn)", async () => { + const sessionManager = stubSessionManager(); + const model = createAnthropicModelFixture(); + setCompactionSafeguardRuntime(sessionManager, { model }); + + const mockEvent = { + preparation: { + messagesToSummarize: [] as AgentMessage[], + turnPrefixMessages: [ + { role: "user" as const, content: "real turn prefix content" }, + ] as AgentMessage[], + firstKeptEntryId: "entry-4", + tokensBefore: 2000, + fileOps: { read: [], edited: [], written: [] }, + isSplitTurn: true, + }, + customInstructions: "", + signal: new AbortController().signal, + }; + const { result } = await runCompactionScenario({ + sessionManager, + event: mockEvent, + apiKey: null, + }); + // Should NOT take the boundary fast-path — falls through to normal compaction + // (which cancels due to no API key, but that's the expected normal path) + expect(result).toEqual({ cancel: true }); + }); + it("continues when messages include real conversation content", async () => { const sessionManager = stubSessionManager(); const model = createAnthropicModelFixture(); diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index 4461b97d3e0..92332140656 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -702,11 +702,32 @@ async function readWorkspaceContextForSummary(): Promise { export default function compactionSafeguardExtension(api: ExtensionAPI): void { api.on("session_before_compact", async (event, ctx) => { const { preparation, customInstructions: eventInstructions, signal } = event; - if (!preparation.messagesToSummarize.some(isRealConversationMessage)) { - log.warn( - "Compaction safeguard: cancelling compaction with no real conversation messages to summarize.", + const hasRealSummarizable = preparation.messagesToSummarize.some(isRealConversationMessage); + const hasRealTurnPrefix = preparation.turnPrefixMessages.some(isRealConversationMessage); + if (!hasRealSummarizable && !hasRealTurnPrefix) { + // When there are no summarizable messages AND no real turn-prefix content, + // cancelling compaction leaves context unchanged but the SDK re-triggers + // _checkCompaction after every assistant response — creating a cancel loop + // that blocks cron lanes (#41981). + // + // Strategy: always return a minimal compaction result so the SDK writes a + // boundary entry. The SDK's prepareCompaction() returns undefined when the + // last entry is a compaction, which blocks immediate re-triggering within + // the same turn. After a new assistant message arrives, if the SDK triggers + // compaction again with an empty preparation, we write another boundary — + // this is bounded to at most one boundary per LLM round-trip, not a tight + // loop. + log.info( + "Compaction safeguard: no real conversation messages to summarize; writing compaction boundary to suppress re-trigger loop.", ); - return { cancel: true }; + const fallbackSummary = buildStructuredFallbackSummary(preparation.previousSummary); + return { + compaction: { + summary: fallbackSummary, + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + }, + }; } const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps); const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles); diff --git a/src/agents/subagent-orphan-recovery.test.ts b/src/agents/subagent-orphan-recovery.test.ts index 66b8097154c..287d2c714f3 100644 --- a/src/agents/subagent-orphan-recovery.test.ts +++ b/src/agents/subagent-orphan-recovery.test.ts @@ -1,4 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as sessions from "../config/sessions.js"; +import * as gateway from "../gateway/call.js"; +import * as sessionUtils from "../gateway/session-utils.fs.js"; +import { recoverOrphanedSubagentSessions } from "./subagent-orphan-recovery.js"; +import * as subagentRegistry from "./subagent-registry.js"; import type { SubagentRunRecord } from "./subagent-registry.types.js"; // Mock dependencies before importing the module under test @@ -51,10 +56,6 @@ 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", updatedAt: Date.now(), @@ -69,8 +70,6 @@ describe("subagent-orphan-recovery", () => { const activeRuns = new Map(); activeRuns.set("run-1", run); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -98,9 +97,6 @@ describe("subagent-orphan-recovery", () => { }); 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", @@ -112,8 +108,6 @@ describe("subagent-orphan-recovery", () => { const activeRuns = new Map(); activeRuns.set("run-1", createTestRunRecord()); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -124,8 +118,6 @@ describe("subagent-orphan-recovery", () => { }); it("skips runs that have already ended", async () => { - const gateway = await import("../gateway/call.js"); - const activeRuns = new Map(); activeRuns.set( "run-1", @@ -134,8 +126,6 @@ describe("subagent-orphan-recovery", () => { }), ); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -145,9 +135,6 @@ describe("subagent-orphan-recovery", () => { }); 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", @@ -192,8 +179,6 @@ describe("subagent-orphan-recovery", () => { }), ); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -204,9 +189,6 @@ describe("subagent-orphan-recovery", () => { }); it("handles callGateway failure gracefully and preserves abortedLastRun flag", 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", @@ -220,8 +202,6 @@ describe("subagent-orphan-recovery", () => { const activeRuns = new Map(); activeRuns.set("run-1", createTestRunRecord()); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -235,8 +215,6 @@ describe("subagent-orphan-recovery", () => { }); 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(), }); @@ -247,17 +225,12 @@ describe("subagent-orphan-recovery", () => { }); 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, }); @@ -268,9 +241,6 @@ describe("subagent-orphan-recovery", () => { }); 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); @@ -285,8 +255,6 @@ describe("subagent-orphan-recovery", () => { const activeRuns = new Map(); activeRuns.set("run-1", createTestRunRecord()); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -309,9 +277,6 @@ describe("subagent-orphan-recovery", () => { }); 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", @@ -324,8 +289,6 @@ describe("subagent-orphan-recovery", () => { const activeRuns = new Map(); activeRuns.set("run-1", createTestRunRecord({ task: longTask })); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); - await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns, }); @@ -340,10 +303,6 @@ describe("subagent-orphan-recovery", () => { }); 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", @@ -363,7 +322,6 @@ describe("subagent-orphan-recovery", () => { 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]; @@ -374,10 +332,6 @@ describe("subagent-orphan-recovery", () => { }); 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", @@ -394,7 +348,6 @@ describe("subagent-orphan-recovery", () => { 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]; @@ -404,9 +357,6 @@ describe("subagent-orphan-recovery", () => { }); 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")); @@ -427,7 +377,6 @@ describe("subagent-orphan-recovery", () => { }), ); - const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns }); expect(result.recovered).toBe(1); diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index 97f324921e3..86f5aaf07d9 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -1,19 +1,89 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as imageGenerationRuntime from "../../image-generation/runtime.js"; import * as imageOps from "../../media/image-ops.js"; import * as mediaStore from "../../media/store.js"; import * as webMedia from "../../plugin-sdk/web-media.js"; -import { createImageGenerateTool } from "./image-generate-tool.js"; +import { + createImageGenerateTool, + resolveImageGenerationModelConfigForTool, +} from "./image-generate-tool.js"; + +function stubImageGenerationProviders() { + vi.spyOn(imageGenerationRuntime, "listRuntimeImageGenerationProviders").mockReturnValue([ + { + id: "google", + defaultModel: "gemini-3.1-flash-image-preview", + models: ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview"], + supportedResolutions: ["1K", "2K", "4K"], + supportsImageEditing: true, + generateImage: vi.fn(async () => { + throw new Error("not used"); + }), + }, + { + id: "openai", + defaultModel: "gpt-image-1", + models: ["gpt-image-1"], + supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], + supportsImageEditing: false, + generateImage: vi.fn(async () => { + throw new Error("not used"); + }), + }, + ]); +} describe("createImageGenerateTool", () => { - afterEach(() => { - vi.restoreAllMocks(); + beforeEach(() => { + vi.stubEnv("OPENAI_API_KEY", ""); + vi.stubEnv("OPENAI_API_KEYS", ""); + vi.stubEnv("GEMINI_API_KEY", ""); + vi.stubEnv("GEMINI_API_KEYS", ""); }); - it("returns null when image-generation model is not configured", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("returns null when no image-generation model can be inferred", () => { + stubImageGenerationProviders(); expect(createImageGenerateTool({ config: {} })).toBeNull(); }); + it("infers an OpenAI image-generation model from env-backed auth", () => { + stubImageGenerationProviders(); + vi.stubEnv("OPENAI_API_KEY", "openai-test"); + + expect(resolveImageGenerationModelConfigForTool({ cfg: {} })).toEqual({ + primary: "openai/gpt-image-1", + }); + expect(createImageGenerateTool({ config: {} })).not.toBeNull(); + }); + + it("prefers the primary model provider when multiple image providers have auth", () => { + stubImageGenerationProviders(); + vi.stubEnv("OPENAI_API_KEY", "openai-test"); + vi.stubEnv("GEMINI_API_KEY", "gemini-test"); + + expect( + resolveImageGenerationModelConfigForTool({ + cfg: { + agents: { + defaults: { + model: { + primary: "google/gemini-3.1-pro-preview", + }, + }, + }, + }, + }), + ).toEqual({ + primary: "google/gemini-3.1-flash-image-preview", + fallbacks: ["openai/gpt-image-1"], + }); + }); + it("generates images and returns MEDIA paths", async () => { const generateImage = vi.spyOn(imageGenerationRuntime, "generateImage").mockResolvedValue({ provider: "openai", @@ -215,28 +285,7 @@ describe("createImageGenerateTool", () => { }); it("lists registered provider and model options", async () => { - vi.spyOn(imageGenerationRuntime, "listRuntimeImageGenerationProviders").mockReturnValue([ - { - id: "google", - defaultModel: "gemini-3.1-flash-image-preview", - models: ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview"], - supportedResolutions: ["1K", "2K", "4K"], - supportsImageEditing: true, - generateImage: vi.fn(async () => { - throw new Error("not used"); - }), - }, - { - id: "openai", - defaultModel: "gpt-image-1", - models: ["gpt-image-1"], - supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], - supportsImageEditing: false, - generateImage: vi.fn(async () => { - throw new Error("not used"); - }), - }, - ]); + stubImageGenerationProviders(); const tool = createImageGenerateTool({ config: { diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts index 810bfe3ba6f..057b9013100 100644 --- a/src/agents/tools/image-generate-tool.ts +++ b/src/agents/tools/image-generate-tool.ts @@ -15,7 +15,17 @@ import { loadWebMedia } from "../../plugin-sdk/web-media.js"; import { resolveUserPath } from "../../utils.js"; import { ToolInputError, readNumberParam, readStringParam } from "./common.js"; import { decodeDataUrl } from "./image-tool.helpers.js"; -import { resolveMediaToolLocalRoots } from "./media-tool-shared.js"; +import { + applyImageGenerationModelConfigDefaults, + resolveMediaToolLocalRoots, +} from "./media-tool-shared.js"; +import { + buildToolModelConfigFromCandidates, + coerceToolModelConfig, + hasToolModelConfig, + resolveDefaultModelRef, + type ToolModelConfig, +} from "./model-config.helpers.js"; import { createSandboxBridgeReadFile, resolveSandboxedBridgeMediaPath, @@ -71,15 +81,51 @@ const ImageGenerateToolSchema = Type.Object({ ), }); -function hasConfiguredImageGenerationModel(cfg: OpenClawConfig): boolean { - const configured = cfg.agents?.defaults?.imageGenerationModel; - if (typeof configured === "string") { - return configured.trim().length > 0; +function resolveImageGenerationModelCandidates( + cfg: OpenClawConfig | undefined, +): Array { + const providerDefaults = new Map(); + for (const provider of listRuntimeImageGenerationProviders({ config: cfg })) { + const providerId = provider.id.trim(); + const modelId = provider.defaultModel?.trim(); + if (!providerId || !modelId || providerDefaults.has(providerId)) { + continue; + } + providerDefaults.set(providerId, `${providerId}/${modelId}`); } - if (configured?.primary?.trim()) { - return true; + + const orderedProviders = [ + resolveDefaultModelRef(cfg).provider, + "openai", + "google", + ...providerDefaults.keys(), + ]; + const orderedRefs: string[] = []; + const seen = new Set(); + for (const providerId of orderedProviders) { + const ref = providerDefaults.get(providerId); + if (!ref || seen.has(ref)) { + continue; + } + seen.add(ref); + orderedRefs.push(ref); } - return (configured?.fallbacks ?? []).some((entry) => entry.trim().length > 0); + return orderedRefs; +} + +export function resolveImageGenerationModelConfigForTool(params: { + cfg?: OpenClawConfig; + agentDir?: string; +}): ToolModelConfig | null { + const explicit = coerceToolModelConfig(params.cfg?.agents?.defaults?.imageGenerationModel); + if (hasToolModelConfig(explicit)) { + return explicit; + } + return buildToolModelConfigFromCandidates({ + explicit, + agentDir: params.agentDir, + candidates: resolveImageGenerationModelCandidates(params.cfg), + }); } function resolveAction(args: Record): "generate" | "list" { @@ -274,9 +320,15 @@ export function createImageGenerateTool(options?: { fsPolicy?: ToolFsPolicy; }): AnyAgentTool | null { const cfg = options?.config ?? loadConfig(); - if (!hasConfiguredImageGenerationModel(cfg)) { + const imageGenerationModelConfig = resolveImageGenerationModelConfigForTool({ + cfg, + agentDir: options?.agentDir, + }); + if (!imageGenerationModelConfig) { return null; } + const effectiveCfg = + applyImageGenerationModelConfigDefaults(cfg, imageGenerationModelConfig) ?? cfg; const localRoots = resolveMediaToolLocalRoots(options?.workspaceDir, { workspaceOnly: options?.fsPolicy?.workspaceOnly === true, }); @@ -293,25 +345,27 @@ export function createImageGenerateTool(options?: { label: "Image Generation", name: "image_generate", description: - 'Generate new images or edit reference images with the configured image-generation model. Use action="list" to inspect available providers/models. Generated images are delivered automatically from the tool result as MEDIA paths.', + 'Generate new images or edit reference images with the configured or inferred image-generation model. Use action="list" to inspect available providers/models. Generated images are delivered automatically from the tool result as MEDIA paths.', parameters: ImageGenerateToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; const action = resolveAction(params); if (action === "list") { - const providers = listRuntimeImageGenerationProviders({ config: cfg }).map((provider) => ({ - id: provider.id, - ...(provider.label ? { label: provider.label } : {}), - ...(provider.defaultModel ? { defaultModel: provider.defaultModel } : {}), - models: provider.models ?? (provider.defaultModel ? [provider.defaultModel] : []), - ...(provider.supportedSizes ? { supportedSizes: [...provider.supportedSizes] } : {}), - ...(provider.supportedResolutions - ? { supportedResolutions: [...provider.supportedResolutions] } - : {}), - ...(typeof provider.supportsImageEditing === "boolean" - ? { supportsImageEditing: provider.supportsImageEditing } - : {}), - })); + const providers = listRuntimeImageGenerationProviders({ config: effectiveCfg }).map( + (provider) => ({ + id: provider.id, + ...(provider.label ? { label: provider.label } : {}), + ...(provider.defaultModel ? { defaultModel: provider.defaultModel } : {}), + models: provider.models ?? (provider.defaultModel ? [provider.defaultModel] : []), + ...(provider.supportedSizes ? { supportedSizes: [...provider.supportedSizes] } : {}), + ...(provider.supportedResolutions + ? { supportedResolutions: [...provider.supportedResolutions] } + : {}), + ...(typeof provider.supportsImageEditing === "boolean" + ? { supportsImageEditing: provider.supportsImageEditing } + : {}), + }), + ); const lines = providers.flatMap((provider) => { const caps: string[] = []; if (provider.supportsImageEditing) { @@ -360,7 +414,7 @@ export function createImageGenerateTool(options?: { : undefined); const result = await generateImage({ - cfg, + cfg: effectiveCfg, prompt, agentDir: options?.agentDir, modelOverride: model, diff --git a/src/agents/tools/image-tool.helpers.ts b/src/agents/tools/image-tool.helpers.ts index a1581cb2b94..f0e088b4092 100644 --- a/src/agents/tools/image-tool.helpers.ts +++ b/src/agents/tools/image-tool.helpers.ts @@ -1,12 +1,9 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { OpenClawConfig } from "../../config/config.js"; -import { - resolveAgentModelFallbackValues, - resolveAgentModelPrimaryValue, -} from "../../config/model-input.js"; import { extractAssistantText } from "../pi-embedded-utils.js"; +import { coerceToolModelConfig, type ToolModelConfig } from "./model-config.helpers.js"; -export type ImageModelConfig = { primary?: string; fallbacks?: string[] }; +export type ImageModelConfig = ToolModelConfig; export function decodeDataUrl(dataUrl: string): { buffer: Buffer; @@ -55,12 +52,7 @@ export function coerceImageAssistantText(params: { } export function coerceImageModelConfig(cfg?: OpenClawConfig): ImageModelConfig { - const primary = resolveAgentModelPrimaryValue(cfg?.agents?.defaults?.imageModel); - const fallbacks = resolveAgentModelFallbackValues(cfg?.agents?.defaults?.imageModel); - return { - ...(primary?.trim() ? { primary: primary.trim() } : {}), - ...(fallbacks.length > 0 ? { fallbacks } : {}), - }; + return coerceToolModelConfig(cfg?.agents?.defaults?.imageModel); } export function resolveProviderVisionModelFromConfig(params: { diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 8dd471b8a7d..39f755fdffd 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -18,7 +18,11 @@ import { resolveMediaToolLocalRoots, resolvePromptAndModelOverride, } from "./media-tool-shared.js"; -import { hasAuthForProvider, resolveDefaultModelRef } from "./model-config.helpers.js"; +import { + buildToolModelConfigFromCandidates, + hasToolModelConfig, + resolveDefaultModelRef, +} from "./model-config.helpers.js"; import { createSandboxBridgeReadFile, resolveSandboxedBridgeMediaPath, @@ -68,89 +72,40 @@ export function resolveImageModelConfigForTool(params: { // because images are auto-injected into prompts (see attempt.ts detectAndLoadPromptImages). // The tool description is adjusted via modelHasVision to discourage redundant usage. const explicit = coerceImageModelConfig(params.cfg); - if (explicit.primary?.trim() || (explicit.fallbacks?.length ?? 0) > 0) { + if (hasToolModelConfig(explicit)) { return explicit; } const primary = resolveDefaultModelRef(params.cfg); - const openaiOk = hasAuthForProvider({ - provider: "openai", - agentDir: params.agentDir, - }); - const anthropicOk = hasAuthForProvider({ - provider: "anthropic", - agentDir: params.agentDir, - }); - - const fallbacks: string[] = []; - const addFallback = (modelRef: string | null) => { - const ref = (modelRef ?? "").trim(); - if (!ref) { - return; - } - if (fallbacks.includes(ref)) { - return; - } - fallbacks.push(ref); - }; const providerVisionFromConfig = resolveProviderVisionModelFromConfig({ cfg: params.cfg, provider: primary.provider, }); - const providerOk = hasAuthForProvider({ - provider: primary.provider, + const primaryCandidates = (() => { + if (isMinimaxVlmProvider(primary.provider)) { + return [`${primary.provider}/MiniMax-VL-01`]; + } + if (providerVisionFromConfig) { + return [providerVisionFromConfig]; + } + if (primary.provider === "zai") { + return ["zai/glm-4.6v"]; + } + if (primary.provider === "openai") { + return ["openai/gpt-5-mini"]; + } + if (primary.provider === "anthropic") { + return [ANTHROPIC_IMAGE_PRIMARY]; + } + return []; + })(); + + return buildToolModelConfigFromCandidates({ + explicit, agentDir: params.agentDir, + candidates: [...primaryCandidates, "openai/gpt-5-mini", ANTHROPIC_IMAGE_FALLBACK], }); - - let preferred: string | null = null; - - // MiniMax users: always try the canonical vision model first when auth exists. - if (isMinimaxVlmProvider(primary.provider) && providerOk) { - preferred = `${primary.provider}/MiniMax-VL-01`; - } else if (providerOk && providerVisionFromConfig) { - preferred = providerVisionFromConfig; - } else if (primary.provider === "zai" && providerOk) { - preferred = "zai/glm-4.6v"; - } else if (primary.provider === "openai" && openaiOk) { - preferred = "openai/gpt-5-mini"; - } else if (primary.provider === "anthropic" && anthropicOk) { - preferred = ANTHROPIC_IMAGE_PRIMARY; - } - - if (preferred?.trim()) { - if (openaiOk) { - addFallback("openai/gpt-5-mini"); - } - if (anthropicOk) { - addFallback(ANTHROPIC_IMAGE_FALLBACK); - } - // Don't duplicate primary in fallbacks. - const pruned = fallbacks.filter((ref) => ref !== preferred); - return { - primary: preferred, - ...(pruned.length > 0 ? { fallbacks: pruned } : {}), - }; - } - - // Cross-provider fallback when we can't pair with the primary provider. - if (openaiOk) { - if (anthropicOk) { - addFallback(ANTHROPIC_IMAGE_FALLBACK); - } - return { - primary: "openai/gpt-5-mini", - ...(fallbacks.length ? { fallbacks } : {}), - }; - } - if (anthropicOk) { - return { - primary: ANTHROPIC_IMAGE_PRIMARY, - fallbacks: [ANTHROPIC_IMAGE_FALLBACK], - }; - } - - return null; } function pickMaxBytes(cfg?: OpenClawConfig, maxBytesMb?: number): number | undefined { @@ -279,7 +234,7 @@ export function createImageTool(options?: { const agentDir = options?.agentDir?.trim(); if (!agentDir) { const explicit = coerceImageModelConfig(options?.config); - if (explicit.primary?.trim() || (explicit.fallbacks?.length ?? 0) > 0) { + if (hasToolModelConfig(explicit)) { throw new Error("createImageTool requires agentDir when enabled"); } return null; diff --git a/src/agents/tools/media-tool-shared.ts b/src/agents/tools/media-tool-shared.ts index 56f4a92ca97..9326935b72f 100644 --- a/src/agents/tools/media-tool-shared.ts +++ b/src/agents/tools/media-tool-shared.ts @@ -2,6 +2,7 @@ import { type Api, type Model } from "@mariozechner/pi-ai"; import type { OpenClawConfig } from "../../config/config.js"; import { getDefaultLocalRoots } from "../../plugin-sdk/web-media.js"; import type { ImageModelConfig } from "./image-tool.helpers.js"; +import type { ToolModelConfig } from "./model-config.helpers.js"; import { getApiKeyForModel, normalizeWorkspaceDir, requireApiKey } from "./tool-runtime.helpers.js"; type TextToolAttempt = { @@ -20,6 +21,21 @@ type TextToolResult = { export function applyImageModelConfigDefaults( cfg: OpenClawConfig | undefined, imageModelConfig: ImageModelConfig, +): OpenClawConfig | undefined { + return applyAgentDefaultModelConfig(cfg, "imageModel", imageModelConfig); +} + +export function applyImageGenerationModelConfigDefaults( + cfg: OpenClawConfig | undefined, + imageGenerationModelConfig: ToolModelConfig, +): OpenClawConfig | undefined { + return applyAgentDefaultModelConfig(cfg, "imageGenerationModel", imageGenerationModelConfig); +} + +function applyAgentDefaultModelConfig( + cfg: OpenClawConfig | undefined, + key: "imageModel" | "imageGenerationModel", + modelConfig: ToolModelConfig, ): OpenClawConfig | undefined { if (!cfg) { return undefined; @@ -30,7 +46,7 @@ export function applyImageModelConfigDefaults( ...cfg.agents, defaults: { ...cfg.agents?.defaults, - imageModel: imageModelConfig, + [key]: modelConfig, }, }, }; diff --git a/src/agents/tools/model-config.helpers.ts b/src/agents/tools/model-config.helpers.ts index 6f002238d88..3d6700c90f7 100644 --- a/src/agents/tools/model-config.helpers.ts +++ b/src/agents/tools/model-config.helpers.ts @@ -1,9 +1,22 @@ import type { OpenClawConfig } from "../../config/config.js"; +import { + resolveAgentModelFallbackValues, + resolveAgentModelPrimaryValue, +} from "../../config/model-input.js"; +import type { AgentModelConfig } from "../../config/types.agents-shared.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "../auth-profiles.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { resolveEnvApiKey } from "../model-auth.js"; import { resolveConfiguredModelRef } from "../model-selection.js"; +export type ToolModelConfig = { primary?: string; fallbacks?: string[] }; + +export function hasToolModelConfig(model: ToolModelConfig | undefined): boolean { + return Boolean( + model?.primary?.trim() || (model?.fallbacks ?? []).some((entry) => entry.trim().length > 0), + ); +} + export function resolveDefaultModelRef(cfg?: OpenClawConfig): { provider: string; model: string } { if (cfg) { const resolved = resolveConfiguredModelRef({ @@ -16,12 +29,59 @@ export function resolveDefaultModelRef(cfg?: OpenClawConfig): { provider: string return { provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL }; } -export function hasAuthForProvider(params: { provider: string; agentDir: string }): boolean { +export function hasAuthForProvider(params: { provider: string; agentDir?: string }): boolean { if (resolveEnvApiKey(params.provider)?.apiKey) { return true; } - const store = ensureAuthProfileStore(params.agentDir, { + const agentDir = params.agentDir?.trim(); + if (!agentDir) { + return false; + } + const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false, }); return listProfilesForProvider(store, params.provider).length > 0; } + +export function coerceToolModelConfig(model?: AgentModelConfig): ToolModelConfig { + const primary = resolveAgentModelPrimaryValue(model); + const fallbacks = resolveAgentModelFallbackValues(model); + return { + ...(primary?.trim() ? { primary: primary.trim() } : {}), + ...(fallbacks.length > 0 ? { fallbacks } : {}), + }; +} + +export function buildToolModelConfigFromCandidates(params: { + explicit: ToolModelConfig; + agentDir?: string; + candidates: Array; +}): ToolModelConfig | null { + if (hasToolModelConfig(params.explicit)) { + return params.explicit; + } + + const deduped: string[] = []; + for (const candidate of params.candidates) { + const trimmed = candidate?.trim(); + if (!trimmed || !trimmed.includes("/")) { + continue; + } + const provider = trimmed.slice(0, trimmed.indexOf("/")).trim(); + if (!provider || !hasAuthForProvider({ provider, agentDir: params.agentDir })) { + continue; + } + if (!deduped.includes(trimmed)) { + deduped.push(trimmed); + } + } + + if (deduped.length === 0) { + return null; + } + + return { + primary: deduped[0], + ...(deduped.length > 1 ? { fallbacks: deduped.slice(1) } : {}), + }; +} diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index a9c9539d61d..2ff557b3dca 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -10,15 +10,24 @@ import { providerSupportsNativePdf, resolvePdfToolMaxTokens, } from "./pdf-tool.helpers.js"; -import { createPdfTool, resolvePdfModelConfigForTool } from "./pdf-tool.js"; -vi.mock("@mariozechner/pi-ai", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - complete: vi.fn(), - }; -}); +const completeMock = vi.hoisted(() => vi.fn()); + +type PdfToolModule = typeof import("./pdf-tool.js"); +let createPdfTool: PdfToolModule["createPdfTool"]; +let resolvePdfModelConfigForTool: PdfToolModule["resolvePdfModelConfigForTool"]; + +async function importPdfToolModule(): Promise { + vi.resetModules(); + vi.doMock("@mariozechner/pi-ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + complete: completeMock, + }; + }); + return import("./pdf-tool.js"); +} async function withTempAgentDir(run: (agentDir: string) => Promise): Promise { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pdf-")); @@ -242,8 +251,10 @@ describe("providerSupportsNativePdf", () => { describe("resolvePdfModelConfigForTool", () => { const priorFetch = global.fetch; - beforeEach(() => { + beforeEach(async () => { resetAuthEnv(); + completeMock.mockReset(); + ({ resolvePdfModelConfigForTool } = await importPdfToolModule()); }); afterEach(() => { @@ -321,8 +332,10 @@ describe("resolvePdfModelConfigForTool", () => { describe("createPdfTool", () => { const priorFetch = global.fetch; - beforeEach(() => { + beforeEach(async () => { resetAuthEnv(); + completeMock.mockReset(); + ({ createPdfTool } = await importPdfToolModule()); }); afterEach(() => { @@ -484,8 +497,7 @@ describe("createPdfTool", () => { images: [], }); - const piAi = await import("@mariozechner/pi-ai"); - vi.mocked(piAi.complete).mockResolvedValue({ + completeMock.mockResolvedValue({ role: "assistant", stopReason: "stop", content: [{ type: "text", text: "fallback summary" }], diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index 9e0390bc887..9a831dde795 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -101,7 +101,7 @@ export function getWebSessionMocks(): AnyMocks { return webSessionMocks; } -vi.mock("../../extensions/whatsapp/src/session.js", () => webSessionMocks); +vi.mock("../../extensions/whatsapp/runtime-api.js", () => webSessionMocks); export const MAIN_SESSION_KEY = "agent:main:main"; diff --git a/src/auto-reply/reply/acp-reset-target.ts b/src/auto-reply/reply/acp-reset-target.ts index cf8952cdc4a..b77d0f320cc 100644 --- a/src/auto-reply/reply/acp-reset-target.ts +++ b/src/auto-reply/reply/acp-reset-target.ts @@ -1,4 +1,4 @@ -import { resolveConfiguredAcpBindingRecord } from "../../acp/persistent-bindings.js"; +import { resolveConfiguredBindingRecord } from "../../channels/plugins/binding-registry.js"; import type { OpenClawConfig } from "../../config/config.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js"; @@ -51,7 +51,7 @@ export function resolveEffectiveResetTargetSessionKey(params: { return undefined; } - const configuredBinding = resolveConfiguredAcpBindingRecord({ + const configuredBinding = resolveConfiguredBindingRecord({ cfg: params.cfg, channel, accountId, diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index ed3e61e58bb..c3425161773 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -1,5 +1,5 @@ import fs from "node:fs/promises"; -import { resetAcpSessionInPlace } from "../../acp/persistent-bindings.js"; +import { resetConfiguredBindingTargetInPlace } from "../../channels/plugins/binding-targets.js"; import { logVerbose } from "../../globals.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; @@ -228,7 +228,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise { await workspaceHarness.cleanupWorkspaces(); }); - it("lists discovered plugins and shows plugin details", async () => { + it("lists discovered plugins and inspects plugin details", async () => { await withTempHome("openclaw-command-plugins-home-", async () => { const workspaceDir = await workspaceHarness.createWorkspace(); await createClaudeBundlePlugin({ workspaceDir, pluginId: "superpowers" }); @@ -49,13 +49,33 @@ describe("handleCommands /plugins", () => { expect(listResult.reply?.text).toContain("superpowers"); expect(listResult.reply?.text).toContain("[disabled]"); - const showParams = buildCommandTestParams("/plugin show superpowers", buildCfg(), undefined, { - workspaceDir, - }); + const showParams = buildCommandTestParams( + "/plugins inspect superpowers", + buildCfg(), + undefined, + { + workspaceDir, + }, + ); showParams.command.senderIsOwner = true; const showResult = await handleCommands(showParams); expect(showResult.reply?.text).toContain('"id": "superpowers"'); expect(showResult.reply?.text).toContain('"bundleFormat": "claude"'); + expect(showResult.reply?.text).toContain('"shape":'); + + const inspectAllParams = buildCommandTestParams( + "/plugins inspect all", + buildCfg(), + undefined, + { + workspaceDir, + }, + ); + inspectAllParams.command.senderIsOwner = true; + const inspectAllResult = await handleCommands(inspectAllParams); + expect(inspectAllResult.reply?.text).toContain("```json"); + expect(inspectAllResult.reply?.text).toContain('"plugin"'); + expect(inspectAllResult.reply?.text).toContain('"superpowers"'); }); }); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index ea2c4fbf4b9..1adbf57e717 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -4,8 +4,14 @@ import { writeConfigFile, } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { PluginInstallRecord } from "../../config/types.plugins.js"; import type { PluginRecord } from "../../plugins/registry.js"; -import { buildPluginStatusReport, type PluginStatusReport } from "../../plugins/status.js"; +import { + buildAllPluginInspectReports, + buildPluginInspectReport, + buildPluginStatusReport, + type PluginStatusReport, +} from "../../plugins/status.js"; import { setPluginEnabledInConfig } from "../../plugins/toggle-config.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { @@ -21,6 +27,44 @@ function renderJsonBlock(label: string, value: unknown): string { return `${label}\n\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``; } +function buildPluginInspectJson(params: { + id: string; + config: OpenClawConfig; + report: PluginStatusReport; +}): { + inspect: NonNullable>; + install: PluginInstallRecord | null; +} | null { + const inspect = buildPluginInspectReport({ + id: params.id, + config: params.config, + report: params.report, + }); + if (!inspect) { + return null; + } + return { + inspect, + install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null, + }; +} + +function buildAllPluginInspectJson(params: { + config: OpenClawConfig; + report: PluginStatusReport; +}): Array<{ + inspect: ReturnType[number]; + install: PluginInstallRecord | null; +}> { + return buildAllPluginInspectReports({ + config: params.config, + report: params.report, + }).map((inspect) => ({ + inspect, + install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null, + })); +} + function formatPluginLabel(plugin: PluginRecord): string { if (!plugin.name || plugin.name === plugin.id) { return plugin.id; @@ -95,7 +139,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm return unauthorized; } const allowInternalReadOnly = - (pluginsCommand.action === "list" || pluginsCommand.action === "show") && + (pluginsCommand.action === "list" || pluginsCommand.action === "inspect") && isInternalMessageChannel(params.command.channel); const nonOwner = allowInternalReadOnly ? null : rejectNonOwnerCommand(params, "/plugins"); if (nonOwner) { @@ -130,27 +174,38 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm }; } - if (pluginsCommand.action === "show") { + if (pluginsCommand.action === "inspect") { if (!pluginsCommand.name) { return { shouldContinue: false, reply: { text: formatPluginsList(loaded.report) }, }; } - const plugin = findPlugin(loaded.report, pluginsCommand.name); - if (!plugin) { + if (pluginsCommand.name.toLowerCase() === "all") { + return { + shouldContinue: false, + reply: { + text: renderJsonBlock("🔌 Plugins", buildAllPluginInspectJson(loaded)), + }, + }; + } + const payload = buildPluginInspectJson({ + id: pluginsCommand.name, + config: loaded.config, + report: loaded.report, + }); + if (!payload) { return { shouldContinue: false, reply: { text: `🔌 No plugin named "${pluginsCommand.name}" found.` }, }; } - const install = loaded.config.plugins?.installs?.[plugin.id] ?? null; return { shouldContinue: false, reply: { - text: renderJsonBlock(`🔌 Plugin "${plugin.id}"`, { - plugin, - install, + text: renderJsonBlock(`🔌 Plugin "${payload.inspect.plugin.id}"`, { + ...payload.inspect, + install: payload.install, }), }, }; diff --git a/src/auto-reply/reply/commands-subagents.test-mocks.ts b/src/auto-reply/reply/commands-subagents.test-mocks.ts index 99c34fbf35c..b9934928372 100644 --- a/src/auto-reply/reply/commands-subagents.test-mocks.ts +++ b/src/auto-reply/reply/commands-subagents.test-mocks.ts @@ -10,7 +10,7 @@ export function installSubagentsCommandCoreMocks() { }); // Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent. - vi.mock("../../../extensions/discord/src/monitor/gateway-plugin.js", () => ({ + vi.mock("../../../extensions/discord/runtime-api.js", () => ({ createDiscordGatewayPlugin: () => ({}), })); } diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 5ed9919b7e8..4e0a332910e 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -2,28 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { abortEmbeddedPiRun, compactEmbeddedPiSession } from "../../agents/pi-embedded.js"; -import { - addSubagentRunForTests, - 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"; -import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js"; import { typedCases } from "../../test-utils/typed-cases.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import type { MsgContext } from "../templating.js"; -import { resetBashChatCommandForTests } from "./bash-command.js"; -import { handleCompactCommand } from "./commands-compact.js"; -import { buildCommandsPaginationKeyboard } from "./commands-info.js"; -import { extractMessageText } from "./commands-subagents.js"; -import { buildCommandTestParams } from "./commands.test-harness.js"; -import { parseConfigCommand } from "./config-commands.js"; -import { parseDebugCommand } from "./debug-commands.js"; -import { parseInlineDirectives } from "./directive-handling.js"; const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn()); @@ -101,13 +84,12 @@ vi.mock("./session-updates.js", () => ({ incrementCompactionCount: vi.fn(), })); -const callGatewayMock = vi.fn(); +const callGatewayMock = vi.hoisted(() => vi.fn()); vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), + callGateway: callGatewayMock, })); import type { HandleCommandsParams } from "./commands-types.js"; -import { buildCommandContext, handleCommands } from "./commands.js"; // Avoid expensive workspace scans during /context tests. vi.mock("./commands-context-report.js", () => ({ @@ -123,6 +105,26 @@ vi.mock("./commands-context-report.js", () => ({ }, })); +vi.resetModules(); + +const { addSubagentRunForTests, listSubagentRunsForRequester, resetSubagentRegistryForTests } = + await import("../../agents/subagent-registry.js"); +const { setDefaultChannelPluginRegistryForTests } = + await import("../../commands/channel-test-helpers.js"); +const internalHooks = await import("../../hooks/internal-hooks.js"); +const { clearPluginCommands, registerPluginCommand } = await import("../../plugins/commands.js"); +const { abortEmbeddedPiRun, compactEmbeddedPiSession } = + await import("../../agents/pi-embedded.js"); +const { resetBashChatCommandForTests } = await import("./bash-command.js"); +const { handleCompactCommand } = await import("./commands-compact.js"); +const { buildCommandsPaginationKeyboard } = await import("./commands-info.js"); +const { extractMessageText } = await import("./commands-subagents.js"); +const { buildCommandTestParams } = await import("./commands.test-harness.js"); +const { parseConfigCommand } = await import("./config-commands.js"); +const { parseDebugCommand } = await import("./debug-commands.js"); +const { parseInlineDirectives } = await import("./directive-handling.js"); +const { buildCommandContext, handleCommands } = await import("./commands.js"); + let testWorkspaceDir = os.tmpdir(); beforeAll(async () => { @@ -323,6 +325,24 @@ describe("/approve command", () => { vi.clearAllMocks(); }); + function createTelegramApproveCfg( + execApprovals: { + enabled: true; + approvers: string[]; + target: "dm"; + } | null = { enabled: true, approvers: ["123"], target: "dm" }, + ): OpenClawConfig { + return { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + ...(execApprovals ? { execApprovals } : {}), + }, + }, + } as OpenClawConfig; + } + it("rejects invalid usage", async () => { const cfg = { commands: { text: true }, @@ -355,15 +375,7 @@ describe("/approve command", () => { }); it("accepts Telegram command mentions for /approve", async () => { - const cfg = { - commands: { text: true }, - channels: { - telegram: { - allowFrom: ["*"], - execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, - }, - }, - } as OpenClawConfig; + const cfg = createTelegramApproveCfg(); const params = buildParams("/approve@bot abc12345 allow-once", cfg, { BotUsername: "bot", Provider: "telegram", @@ -384,132 +396,117 @@ describe("/approve command", () => { ); }); - it("rejects Telegram /approve mentions targeting a different bot", async () => { - const cfg = { - commands: { text: true }, - channels: { - telegram: { - allowFrom: ["*"], - execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + it("rejects unauthorized or invalid Telegram /approve variants", async () => { + for (const testCase of [ + { + name: "different bot mention", + cfg: createTelegramApproveCfg(), + commandBody: "/approve@otherbot abc12345 allow-once", + ctx: { + BotUsername: "bot", + Provider: "telegram", + Surface: "telegram", + SenderId: "123", }, + setup: undefined, + expectedText: "targets a different Telegram bot", + expectGatewayCalls: 0, }, - } as OpenClawConfig; - const params = buildParams("/approve@otherbot abc12345 allow-once", cfg, { - BotUsername: "bot", - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); - - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("targets a different Telegram bot"); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("surfaces unknown or expired approval id errors", async () => { - const cfg = { - commands: { text: true }, - channels: { - telegram: { - allowFrom: ["*"], - execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + { + name: "unknown approval id", + cfg: createTelegramApproveCfg(), + commandBody: "/approve abc12345 allow-once", + ctx: { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", }, + setup: () => callGatewayMock.mockRejectedValue(new Error("unknown or expired approval id")), + expectedText: "unknown or expired approval id", + expectGatewayCalls: 1, }, - } as OpenClawConfig; - const params = buildParams("/approve abc12345 allow-once", cfg, { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); - - callGatewayMock.mockRejectedValue(new Error("unknown or expired approval id")); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("unknown or expired approval id"); - }); - - it("rejects Telegram /approve when telegram exec approvals are disabled", async () => { - const cfg = { - commands: { text: true }, - channels: { telegram: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/approve abc12345 allow-once", cfg, { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Telegram exec approvals are not enabled"); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("rejects Telegram /approve from non-approvers", async () => { - const cfg = { - commands: { text: true }, - channels: { - telegram: { - allowFrom: ["*"], - execApprovals: { enabled: true, approvers: ["999"], target: "dm" }, + { + name: "telegram approvals disabled", + cfg: createTelegramApproveCfg(null), + commandBody: "/approve abc12345 allow-once", + ctx: { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", }, + setup: undefined, + expectedText: "Telegram exec approvals are not enabled", + expectGatewayCalls: 0, }, - } as OpenClawConfig; - const params = buildParams("/approve abc12345 allow-once", cfg, { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); + { + name: "non approver", + cfg: createTelegramApproveCfg({ enabled: true, approvers: ["999"], target: "dm" }), + commandBody: "/approve abc12345 allow-once", + ctx: { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }, + setup: undefined, + expectedText: "not authorized to approve", + expectGatewayCalls: 0, + }, + ] as const) { + callGatewayMock.mockReset(); + testCase.setup?.(); + const params = buildParams(testCase.commandBody, testCase.cfg, testCase.ctx); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("not authorized to approve"); - expect(callGatewayMock).not.toHaveBeenCalled(); + const result = await handleCommands(params); + expect(result.shouldContinue, testCase.name).toBe(false); + expect(result.reply?.text, testCase.name).toContain(testCase.expectedText); + expect(callGatewayMock, testCase.name).toHaveBeenCalledTimes(testCase.expectGatewayCalls); + } }); - it("rejects gateway clients without approvals scope", async () => { + it("enforces gateway approval scopes", async () => { const cfg = { commands: { text: true }, } as OpenClawConfig; - const params = buildParams("/approve abc allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - GatewayClientScopes: ["operator.write"], - }); - - callGatewayMock.mockResolvedValue({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("requires operator.approvals"); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("allows gateway clients with approvals or admin scopes", async () => { - const cfg = { - commands: { text: true }, - } as OpenClawConfig; - const scopeCases = [["operator.approvals"], ["operator.admin"]]; - for (const scopes of scopeCases) { + const cases = [ + { + scopes: ["operator.write"], + expectedText: "requires operator.approvals", + expectedGatewayCalls: 0, + }, + { + scopes: ["operator.approvals"], + expectedText: "Exec approval allow-once submitted", + expectedGatewayCalls: 1, + }, + { + scopes: ["operator.admin"], + expectedText: "Exec approval allow-once submitted", + expectedGatewayCalls: 1, + }, + ] as const; + for (const testCase of cases) { + callGatewayMock.mockReset(); callGatewayMock.mockResolvedValue({ ok: true }); const params = buildParams("/approve abc allow-once", cfg, { Provider: "webchat", Surface: "webchat", - GatewayClientScopes: scopes, + GatewayClientScopes: [...testCase.scopes], }); const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Exec approval allow-once submitted"); - expect(callGatewayMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), + expect(result.shouldContinue, String(testCase.scopes)).toBe(false); + expect(result.reply?.text, String(testCase.scopes)).toContain(testCase.expectedText); + expect(callGatewayMock, String(testCase.scopes)).toHaveBeenCalledTimes( + testCase.expectedGatewayCalls, ); + if (testCase.expectedGatewayCalls > 0) { + expect(callGatewayMock, String(testCase.scopes)).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + } } }); }); @@ -735,232 +732,274 @@ describe("extractMessageText", () => { }); }); -describe("handleCommands /config owner gating", () => { - it("blocks /config show from authorized non-owner senders", async () => { - const cfg = { - commands: { config: true, text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/config show", cfg); - params.command.senderIsOwner = false; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply).toBeUndefined(); - }); +describe("handleCommands owner gating for privileged show commands", () => { + it("enforces owner gating for /config show and /debug show", async () => { + const cases = [ + { + name: "/config show blocks authorized non-owner senders", + build: () => { + const params = buildParams("/config show", { + commands: { config: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig); + params.command.senderIsOwner = false; + return params; + }, + assert: (result: Awaited>) => { + expect(result.shouldContinue).toBe(false); + expect(result.reply).toBeUndefined(); + }, + }, + { + name: "/config show stays available for owners", + build: () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { messages: { ackReaction: ":)" } }, + }); + const params = buildParams("/config show messages.ackReaction", { + commands: { config: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig); + params.command.senderIsOwner = true; + return params; + }, + assert: (result: Awaited>) => { + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config messages.ackReaction"); + }, + }, + { + name: "/debug show blocks authorized non-owner senders", + build: () => { + const params = buildParams("/debug show", { + commands: { debug: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig); + params.command.senderIsOwner = false; + return params; + }, + assert: (result: Awaited>) => { + expect(result.shouldContinue).toBe(false); + expect(result.reply).toBeUndefined(); + }, + }, + { + name: "/debug show stays available for owners", + build: () => { + const params = buildParams("/debug show", { + commands: { debug: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig); + params.command.senderIsOwner = true; + return params; + }, + assert: (result: Awaited>) => { + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Debug overrides"); + }, + }, + ] as const; - it("keeps /config show working for owners", async () => { - const cfg = { - commands: { config: true, text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { messages: { ackReaction: ":)" } }, - }); - const params = buildParams("/config show messages.ackReaction", cfg); - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config messages.ackReaction"); + for (const testCase of cases) { + const result = await handleCommands(testCase.build()); + testCase.assert(result); + } }); }); describe("handleCommands /config configWrites gating", () => { - it("blocks /config set when channel config writes are disabled", async () => { - const cfg = { - commands: { config: true, text: true }, - channels: { whatsapp: { allowFrom: ["*"], configWrites: false } }, - } as OpenClawConfig; - const params = buildParams('/config set messages.ackReaction=":)"', cfg); - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config writes are disabled"); - }); - - it("blocks /config set when the target account disables writes", async () => { - const previousWriteCount = writeConfigFileMock.mock.calls.length; - const cfg = { - commands: { config: true, text: true }, - channels: { - telegram: { - configWrites: true, - accounts: { - work: { configWrites: false, enabled: true }, - }, - }, - }, - } as OpenClawConfig; - const params = buildPolicyParams( - "/config set channels.telegram.accounts.work.enabled=false", - cfg, + it("blocks disallowed /config set writes", async () => { + const cases = [ { - AccountId: "default", - Provider: "telegram", - Surface: "telegram", + name: "channel config writes disabled", + params: (() => { + const params = buildParams('/config set messages.ackReaction=":)"', { + commands: { config: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"], configWrites: false } }, + } as OpenClawConfig); + params.command.senderIsOwner = true; + return params; + })(), + expectedText: "Config writes are disabled", }, - ); - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true"); - expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); + { + name: "target account disables writes", + params: (() => { + const params = buildPolicyParams( + "/config set channels.telegram.accounts.work.enabled=false", + { + commands: { config: true, text: true }, + channels: { + telegram: { + configWrites: true, + accounts: { + work: { configWrites: false, enabled: true }, + }, + }, + }, + } as OpenClawConfig, + { + AccountId: "default", + Provider: "telegram", + Surface: "telegram", + }, + ); + params.command.senderIsOwner = true; + return params; + })(), + expectedText: "channels.telegram.accounts.work.configWrites=true", + }, + { + name: "ambiguous channel-root write", + params: (() => { + const params = buildPolicyParams( + '/config set channels.telegram={"enabled":false}', + { + commands: { config: true, text: true }, + channels: { telegram: { configWrites: true } }, + } as OpenClawConfig, + { + Provider: "telegram", + Surface: "telegram", + }, + ); + params.command.senderIsOwner = true; + return params; + })(), + expectedText: "cannot replace channels, channel roots, or accounts collections", + }, + ] as const; + + for (const testCase of cases) { + const previousWriteCount = writeConfigFileMock.mock.calls.length; + const result = await handleCommands(testCase.params); + expect(result.shouldContinue, testCase.name).toBe(false); + expect(result.reply?.text, testCase.name).toContain(testCase.expectedText); + expect(writeConfigFileMock.mock.calls.length, testCase.name).toBe(previousWriteCount); + } }); - it("blocks ambiguous channel-root /config writes from channel commands", async () => { - const previousWriteCount = writeConfigFileMock.mock.calls.length; - const cfg = { - commands: { config: true, text: true }, - channels: { telegram: { configWrites: true } }, - } as OpenClawConfig; - const params = buildPolicyParams('/config set channels.telegram={"enabled":false}', cfg, { - Provider: "telegram", - Surface: "telegram", - }); - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain( - "cannot replace channels, channel roots, or accounts collections", - ); - expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); - }); - - it("blocks /config set from gateway clients without operator.admin", async () => { - const cfg = { + it("enforces gateway client permissions for /config commands", async () => { + const baseCfg = { commands: { config: true, text: true }, } as OpenClawConfig; - const params = buildParams('/config set messages.ackReaction=":)"', cfg, { - Provider: INTERNAL_MESSAGE_CHANNEL, - Surface: INTERNAL_MESSAGE_CHANNEL, - GatewayClientScopes: ["operator.write"], - }); - params.command.channel = INTERNAL_MESSAGE_CHANNEL; - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("requires operator.admin"); - }); - - it("keeps /config show available to gateway operator.write clients", async () => { - const cfg = { - commands: { config: true, text: true }, - } as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { messages: { ackReaction: ":)" } }, - }); - const params = buildParams("/config show messages.ackReaction", cfg, { - Provider: INTERNAL_MESSAGE_CHANNEL, - Surface: INTERNAL_MESSAGE_CHANNEL, - GatewayClientScopes: ["operator.write"], - }); - params.command.channel = INTERNAL_MESSAGE_CHANNEL; - params.command.senderIsOwner = false; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config messages.ackReaction"); - }); - - it("keeps /config set working for gateway operator.admin clients", async () => { - await withTempConfigPath({ messages: { ackReaction: ":)" } }, async (configPath) => { - const cfg = { - commands: { config: true, text: true }, - } as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { messages: { ackReaction: ":)" } }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - const params = buildParams('/config set messages.ackReaction=":D"', cfg, { - Provider: INTERNAL_MESSAGE_CHANNEL, - Surface: INTERNAL_MESSAGE_CHANNEL, - GatewayClientScopes: ["operator.write", "operator.admin"], - }); - params.command.channel = INTERNAL_MESSAGE_CHANNEL; - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config updated"); - const written = await readJsonFile(configPath); - expect(written.messages?.ackReaction).toBe(":D"); - }); - }); - - it("keeps /config set working for gateway operator.admin on protected account paths", async () => { - const initialConfig = { - channels: { - telegram: { - accounts: { - work: { enabled: true, configWrites: false }, - }, + const cases = [ + { + name: "blocks /config set from gateway clients without operator.admin", + run: async () => { + const params = buildParams('/config set messages.ackReaction=":)"', baseCfg, { + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + GatewayClientScopes: ["operator.write"], + }); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("requires operator.admin"); }, }, - }; - await withTempConfigPath(initialConfig, async (configPath) => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: structuredClone(initialConfig), - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - const params = buildParams( - "/config set channels.telegram.accounts.work.enabled=false", - { - commands: { config: true, text: true }, - channels: { - telegram: { - accounts: { - work: { enabled: true, configWrites: false }, + { + name: "keeps /config show available to gateway operator.write clients", + run: async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { messages: { ackReaction: ":)" } }, + }); + const params = buildParams("/config show messages.ackReaction", baseCfg, { + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + GatewayClientScopes: ["operator.write"], + }); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = false; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config messages.ackReaction"); + }, + }, + { + name: "keeps /config set working for gateway operator.admin clients", + run: async () => { + await withTempConfigPath({ messages: { ackReaction: ":)" } }, async (configPath) => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { messages: { ackReaction: ":)" } }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + const params = buildParams('/config set messages.ackReaction=":D"', baseCfg, { + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + GatewayClientScopes: ["operator.write", "operator.admin"], + }); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config updated"); + const written = await readJsonFile(configPath); + expect(written.messages?.ackReaction).toBe(":D"); + }); + }, + }, + { + name: "keeps /config set working for gateway operator.admin on protected account paths", + run: async () => { + const initialConfig = { + channels: { + telegram: { + accounts: { + work: { enabled: true, configWrites: false }, + }, }, }, - }, - } as OpenClawConfig, - { - Provider: INTERNAL_MESSAGE_CHANNEL, - Surface: INTERNAL_MESSAGE_CHANNEL, - GatewayClientScopes: ["operator.write", "operator.admin"], + }; + await withTempConfigPath(initialConfig, async (configPath) => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: structuredClone(initialConfig), + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + const params = buildParams( + "/config set channels.telegram.accounts.work.enabled=false", + { + commands: { config: true, text: true }, + channels: { + telegram: { + accounts: { + work: { enabled: true, configWrites: false }, + }, + }, + }, + } as OpenClawConfig, + { + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + GatewayClientScopes: ["operator.write", "operator.admin"], + }, + ); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config updated"); + const written = await readJsonFile(configPath); + expect(written.channels?.telegram?.accounts?.work?.enabled).toBe(false); + }); }, - ); - params.command.channel = INTERNAL_MESSAGE_CHANNEL; - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config updated"); - const written = await readJsonFile(configPath); - expect(written.channels?.telegram?.accounts?.work?.enabled).toBe(false); - }); - }); -}); + }, + ] as const; -describe("handleCommands /debug owner gating", () => { - it("blocks /debug show from authorized non-owner senders", async () => { - const cfg = { - commands: { debug: true, text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/debug show", cfg); - params.command.senderIsOwner = false; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply).toBeUndefined(); - }); - - it("keeps /debug show working for owners", async () => { - const cfg = { - commands: { debug: true, text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/debug show", cfg); - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Debug overrides"); + for (const testCase of cases) { + await testCase.run(); + } }); }); @@ -1045,78 +1084,92 @@ describe("handleCommands /allowlist", () => { expect(result.reply?.text).toContain("Paired allowFrom (store): 456"); }); - it("adds entries to config and pairing store", async () => { - await withTempConfigPath( - { - channels: { telegram: { allowFrom: ["123"] } }, - }, - async (configPath) => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { telegram: { allowFrom: ["123"] } }, - }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ - changed: true, - allowFrom: ["123", "789"], - }); - - const cfg = { - commands: { text: true, config: true }, - channels: { telegram: { allowFrom: ["123"] } }, - } as OpenClawConfig; - const params = buildPolicyParams("/allowlist add dm 789", cfg); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - const written = await readJsonFile(configPath); - expect(written.channels?.telegram?.allowFrom).toEqual(["123", "789"]); - expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ - channel: "telegram", - entry: "789", - accountId: "default", - }); - expect(result.reply?.text).toContain("DM allowlist added"); - }, - ); - }); - - it("writes store entries to the selected account scope", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, - }, - }); + it("adds allowlist entries to config and pairing stores", async () => { validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ ok: true, config, })); - addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ - changed: true, - allowFrom: ["123", "789"], - }); + const cases = [ + { + name: "default account", + run: async () => { + await withTempConfigPath( + { + channels: { telegram: { allowFrom: ["123"] } }, + }, + async (configPath) => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { telegram: { allowFrom: ["123"] } }, + }, + }); + addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ + changed: true, + allowFrom: ["123", "789"], + }); - const cfg = { - commands: { text: true, config: true }, - channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, - } as OpenClawConfig; - const params = buildPolicyParams("/allowlist add dm --account work 789", cfg, { - AccountId: "work", - }); - const result = await handleCommands(params); + const params = buildPolicyParams("/allowlist add dm 789", { + commands: { text: true, config: true }, + channels: { telegram: { allowFrom: ["123"] } }, + } as OpenClawConfig); + const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ - channel: "telegram", - entry: "789", - accountId: "work", - }); + expect(result.shouldContinue).toBe(false); + const written = await readJsonFile(configPath); + expect(written.channels?.telegram?.allowFrom, "default account").toEqual([ + "123", + "789", + ]); + expect(addChannelAllowFromStoreEntryMock, "default account").toHaveBeenCalledWith({ + channel: "telegram", + entry: "789", + accountId: "default", + }); + expect(result.reply?.text, "default account").toContain("DM allowlist added"); + }, + ); + }, + }, + { + name: "selected account scope", + run: async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, + }, + }); + addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ + changed: true, + allowFrom: ["123", "789"], + }); + + const params = buildPolicyParams( + "/allowlist add dm --account work 789", + { + commands: { text: true, config: true }, + channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, + } as OpenClawConfig, + { + AccountId: "work", + }, + ); + const result = await handleCommands(params); + + expect(result.shouldContinue, "selected account scope").toBe(false); + expect(addChannelAllowFromStoreEntryMock, "selected account scope").toHaveBeenCalledWith({ + channel: "telegram", + entry: "789", + accountId: "work", + }); + }, + }, + ] as const; + + for (const testCase of cases) { + await testCase.run(); + } }); it("blocks config-targeted /allowlist edits when the target account disables writes", async () => { @@ -1446,52 +1499,56 @@ describe("handleCommands identity", () => { }); describe("handleCommands hooks", () => { - it("triggers hooks for /new with arguments", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/new take notes", cfg); - const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); - - await handleCommands(params); - - expect(spy).toHaveBeenCalledWith(expect.objectContaining({ type: "command", action: "new" })); - spy.mockRestore(); - }); - - it("triggers hooks for native /new routed to target sessions", async () => { - const cfg = { - commands: { text: true }, - channels: { telegram: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/new", cfg, { - Provider: "telegram", - Surface: "telegram", - CommandSource: "native", - CommandTargetSessionKey: "agent:main:telegram:direct:123", - SessionKey: "telegram:slash:123", - SenderId: "123", - From: "telegram:123", - To: "slash:123", - CommandAuthorized: true, - }); - params.sessionKey = "agent:main:telegram:direct:123"; - const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); - - await handleCommands(params); - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - type: "command", - action: "new", - sessionKey: "agent:main:telegram:direct:123", - context: expect.objectContaining({ - workspaceDir: testWorkspaceDir, + it("triggers hooks for /new commands", async () => { + const cases = [ + { + name: "text command with arguments", + params: buildParams("/new take notes", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig), + expectedCall: expect.objectContaining({ type: "command", action: "new" }), + }, + { + name: "native command routed to target session", + params: (() => { + const params = buildParams( + "/new", + { + commands: { text: true }, + channels: { telegram: { allowFrom: ["*"] } }, + } as OpenClawConfig, + { + Provider: "telegram", + Surface: "telegram", + CommandSource: "native", + CommandTargetSessionKey: "agent:main:telegram:direct:123", + SessionKey: "telegram:slash:123", + SenderId: "123", + From: "telegram:123", + To: "slash:123", + CommandAuthorized: true, + }, + ); + params.sessionKey = "agent:main:telegram:direct:123"; + return params; + })(), + expectedCall: expect.objectContaining({ + type: "command", + action: "new", + sessionKey: "agent:main:telegram:direct:123", + context: expect.objectContaining({ + workspaceDir: testWorkspaceDir, + }), }), - }), - ); - spy.mockRestore(); + }, + ] as const; + for (const testCase of cases) { + const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); + await handleCommands(testCase.params); + expect(spy, testCase.name).toHaveBeenCalledWith(testCase.expectedCall); + spy.mockRestore(); + } }); }); diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 6d1604227bd..162f40613d1 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -1,11 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; -import { AcpRuntimeError } from "../../acp/runtime/errors.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; import type { PluginTargetedInboundClaimOutcome } from "../../plugins/hooks.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; import type { MsgContext } from "../templating.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; @@ -192,14 +193,16 @@ vi.mock("../../tts/tts.js", () => ({ resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg), })); -const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js"); -const { resetInboundDedupe } = await import("./inbound-dedupe.js"); -const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js"); -const { __testing: pluginBindingTesting } = await import("../../plugins/conversation-binding.js"); - const noAbortResult = { handled: false, aborted: false } as const; const emptyConfig = {} as OpenClawConfig; -type DispatchReplyArgs = Parameters[0]; +let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig; +let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe; +let acpManagerTesting: typeof import("../../acp/control-plane/manager.js").__testing; +let pluginBindingTesting: typeof import("../../plugins/conversation-binding.js").__testing; +let AcpRuntimeErrorClass: typeof import("../../acp/runtime/errors.js").AcpRuntimeError; +type DispatchReplyArgs = Parameters< + typeof import("./dispatch-from-config.js").dispatchReplyFromConfig +>[0]; function createDispatcher(): ReplyDispatcher { return { @@ -254,9 +257,39 @@ async function dispatchTwiceWithFreshDispatchers(params: Omit { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js")); + ({ resetInboundDedupe } = await import("./inbound-dedupe.js")); + ({ __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js")); + ({ __testing: pluginBindingTesting } = await import("../../plugins/conversation-binding.js")); + ({ AcpRuntimeError: AcpRuntimeErrorClass } = await import("../../acp/runtime/errors.js")); + const discordTestPlugin = { + ...createChannelTestPluginBase({ + id: "discord", + capabilities: { + chatTypes: ["direct"], + nativeCommands: true, + }, + }), + execApprovals: { + shouldSuppressLocalPrompt: ({ payload }: { payload: ReplyPayload }) => + Boolean( + payload.channelData && + typeof payload.channelData === "object" && + !Array.isArray(payload.channelData) && + payload.channelData.execApproval, + ), + }, + }; setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]), + createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: discordTestPlugin, + }, + ]), ); acpManagerTesting.resetAcpSessionManagerForTests(); resetInboundDedupe(); @@ -1733,7 +1766,7 @@ describe("dispatchReplyFromConfig", () => { }, }); acpMocks.requireAcpRuntimeBackend.mockImplementation(() => { - throw new AcpRuntimeError( + throw new AcpRuntimeErrorClass( "ACP_BACKEND_MISSING", "ACP runtime backend is not configured. Install and enable the acpx runtime plugin.", ); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 18a7eb7802d..34950c20950 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -1,4 +1,8 @@ import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { + resolveConversationBindingRecord, + touchConversationBindingRecord, +} from "../../bindings/records.js"; import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -20,7 +24,6 @@ import { toPluginMessageReceivedEvent, } from "../../hooks/message-hook-mappers.js"; import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js"; -import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import { logMessageProcessed, logMessageQueued, @@ -303,7 +306,7 @@ export async function dispatchReplyFromConfig(params: { const pluginOwnedBindingRecord = inboundClaimContext.conversationId && inboundClaimContext.channelId - ? getSessionBindingService().resolveByConversation({ + ? resolveConversationBindingRecord({ channel: inboundClaimContext.channelId, accountId: inboundClaimContext.accountId ?? "default", conversationId: inboundClaimContext.conversationId, @@ -320,7 +323,7 @@ export async function dispatchReplyFromConfig(params: { | undefined; if (pluginOwnedBinding) { - getSessionBindingService().touch(pluginOwnedBinding.bindingId); + touchConversationBindingRecord(pluginOwnedBinding.bindingId); logVerbose( `plugin-bound inbound routed to ${pluginOwnedBinding.pluginId} conversation=${pluginOwnedBinding.conversationId}`, ); diff --git a/src/auto-reply/reply/plugins-commands.ts b/src/auto-reply/reply/plugins-commands.ts index 2b5c0456849..95da9d8bc2b 100644 --- a/src/auto-reply/reply/plugins-commands.ts +++ b/src/auto-reply/reply/plugins-commands.ts @@ -1,6 +1,6 @@ export type PluginsCommand = | { action: "list" } - | { action: "show"; name?: string } + | { action: "inspect"; name?: string } | { action: "enable"; name: string } | { action: "disable"; name: string } | { action: "error"; message: string }; @@ -22,12 +22,15 @@ export function parsePluginsCommand(raw: string): PluginsCommand | null { if (action === "list") { return name - ? { action: "error", message: "Usage: /plugins list|show|get|enable|disable [plugin]" } + ? { + action: "error", + message: "Usage: /plugins list|inspect|show|get|enable|disable [plugin]", + } : { action: "list" }; } - if (action === "show" || action === "get") { - return { action: "show", name: name || undefined }; + if (action === "inspect" || action === "show" || action === "get") { + return { action: "inspect", name: name || undefined }; } if (action === "enable" || action === "disable") { @@ -42,6 +45,6 @@ export function parsePluginsCommand(raw: string): PluginsCommand | null { return { action: "error", - message: "Usage: /plugins list|show|get|enable|disable [plugin]", + message: "Usage: /plugins list|inspect|show|get|enable|disable [plugin]", }; } diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 98fd1144f77..515d71726fb 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -99,6 +99,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => httpRoutes: [], cliRegistrars: [], services: [], + conversationBindingResolvedHandlers: [], diagnostics: [], }); @@ -300,7 +301,7 @@ describe("routeReply", () => { }); it("passes thread id to Telegram sends", async () => { - mocks.sendMessageTelegram.mockClear(); + mocks.deliverOutboundPayloads.mockResolvedValue([]); await routeReply({ payload: { text: "hi" }, channel: "telegram", @@ -308,10 +309,12 @@ describe("routeReply", () => { threadId: 42, cfg: {} as never, }); - expect(mocks.sendMessageTelegram).toHaveBeenCalledWith( - "telegram:123", - "hi", - expect.objectContaining({ messageThreadId: 42 }), + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "telegram:123", + threadId: 42, + }), ); }); @@ -346,17 +349,19 @@ describe("routeReply", () => { }); it("passes replyToId to Telegram sends", async () => { - mocks.sendMessageTelegram.mockClear(); + mocks.deliverOutboundPayloads.mockResolvedValue([]); await routeReply({ payload: { text: "hi", replyToId: "123" }, channel: "telegram", to: "telegram:123", cfg: {} as never, }); - expect(mocks.sendMessageTelegram).toHaveBeenCalledWith( - "telegram:123", - "hi", - expect.objectContaining({ replyToMessageId: 123 }), + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "telegram:123", + replyToId: "123", + }), ); }); diff --git a/src/bindings/records.ts b/src/bindings/records.ts new file mode 100644 index 00000000000..d4c1909e023 --- /dev/null +++ b/src/bindings/records.ts @@ -0,0 +1,48 @@ +import { + getSessionBindingService, + type ConversationRef, + type SessionBindingBindInput, + type SessionBindingCapabilities, + type SessionBindingRecord, + type SessionBindingUnbindInput, +} from "../infra/outbound/session-binding-service.js"; + +// Shared binding record helpers used by both configured bindings and +// runtime-created plugin conversation bindings. +export async function createConversationBindingRecord( + input: SessionBindingBindInput, +): Promise { + return await getSessionBindingService().bind(input); +} + +export function getConversationBindingCapabilities(params: { + channel: string; + accountId: string; +}): SessionBindingCapabilities { + return getSessionBindingService().getCapabilities(params); +} + +export function listSessionBindingRecords(targetSessionKey: string): SessionBindingRecord[] { + return getSessionBindingService().listBySession(targetSessionKey); +} + +export function resolveConversationBindingRecord( + conversation: ConversationRef, +): SessionBindingRecord | null { + return getSessionBindingService().resolveByConversation(conversation); +} + +export function touchConversationBindingRecord(bindingId: string, at?: number): void { + const service = getSessionBindingService(); + if (typeof at === "number") { + service.touch(bindingId, at); + return; + } + service.touch(bindingId); +} + +export async function unbindConversationBindingRecord( + input: SessionBindingUnbindInput, +): Promise { + return await getSessionBindingService().unbind(input); +} diff --git a/src/channels/plugins/acp-bindings.test.ts b/src/channels/plugins/acp-bindings.test.ts new file mode 100644 index 00000000000..7d380c665a3 --- /dev/null +++ b/src/channels/plugins/acp-bindings.test.ts @@ -0,0 +1,252 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.types.js"; + +const resolveAgentConfigMock = vi.hoisted(() => vi.fn()); +const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn()); +const resolveAgentWorkspaceDirMock = vi.hoisted(() => vi.fn()); +const getChannelPluginMock = vi.hoisted(() => vi.fn()); +const getActivePluginRegistryMock = vi.hoisted(() => vi.fn()); +const getActivePluginRegistryVersionMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveAgentConfig: (...args: unknown[]) => resolveAgentConfigMock(...args), + resolveDefaultAgentId: (...args: unknown[]) => resolveDefaultAgentIdMock(...args), + resolveAgentWorkspaceDir: (...args: unknown[]) => resolveAgentWorkspaceDirMock(...args), +})); + +vi.mock("./index.js", () => ({ + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), +})); + +vi.mock("../../plugins/runtime.js", () => ({ + getActivePluginRegistry: (...args: unknown[]) => getActivePluginRegistryMock(...args), + getActivePluginRegistryVersion: (...args: unknown[]) => + getActivePluginRegistryVersionMock(...args), +})); + +async function importConfiguredBindings() { + const builtins = await import("./configured-binding-builtins.js"); + builtins.ensureConfiguredBindingBuiltinsRegistered(); + return await import("./configured-binding-registry.js"); +} + +function createConfig(options?: { bindingAgentId?: string; accountId?: string }) { + return { + agents: { + list: [{ id: "main" }, { id: "codex" }], + }, + bindings: [ + { + type: "acp", + agentId: options?.bindingAgentId ?? "codex", + match: { + channel: "discord", + accountId: options?.accountId ?? "default", + peer: { + kind: "channel", + id: "1479098716916023408", + }, + }, + acp: { + backend: "acpx", + }, + }, + ], + }; +} + +function createDiscordAcpPlugin(overrides?: { + compileConfiguredBinding?: ReturnType; + matchInboundConversation?: ReturnType; +}) { + const compileConfiguredBinding = + overrides?.compileConfiguredBinding ?? + vi.fn(({ conversationId }: { conversationId: string }) => ({ + conversationId, + })); + const matchInboundConversation = + overrides?.matchInboundConversation ?? + vi.fn( + ({ + compiledBinding, + conversationId, + parentConversationId, + }: { + compiledBinding: { conversationId: string }; + conversationId: string; + parentConversationId?: string; + }) => { + if (compiledBinding.conversationId === conversationId) { + return { conversationId, matchPriority: 2 }; + } + if (parentConversationId && compiledBinding.conversationId === parentConversationId) { + return { conversationId: parentConversationId, matchPriority: 1 }; + } + return null; + }, + ); + return { + id: "discord", + bindings: { + compileConfiguredBinding, + matchInboundConversation, + }, + }; +} + +describe("configured binding registry", () => { + beforeEach(() => { + vi.resetModules(); + resolveAgentConfigMock.mockReset().mockReturnValue(undefined); + resolveDefaultAgentIdMock.mockReset().mockReturnValue("main"); + resolveAgentWorkspaceDirMock.mockReset().mockReturnValue("/tmp/workspace"); + getChannelPluginMock.mockReset(); + getActivePluginRegistryMock.mockReset().mockReturnValue({ channels: [] }); + getActivePluginRegistryVersionMock.mockReset().mockReturnValue(1); + }); + + it("resolves configured ACP bindings from an already loaded channel plugin", async () => { + const plugin = createDiscordAcpPlugin(); + getChannelPluginMock.mockReturnValue(plugin); + const bindingRegistry = await importConfiguredBindings(); + + const resolved = bindingRegistry.resolveConfiguredBindingRecord({ + cfg: createConfig() as never, + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + + expect(resolved?.record.conversation.channel).toBe("discord"); + expect(resolved?.record.metadata?.backend).toBe("acpx"); + expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(1); + }); + + it("resolves configured ACP bindings from canonical conversation refs", async () => { + const plugin = createDiscordAcpPlugin(); + getChannelPluginMock.mockReturnValue(plugin); + const bindingRegistry = await importConfiguredBindings(); + + const resolved = bindingRegistry.resolveConfiguredBinding({ + cfg: createConfig() as never, + conversation: { + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }, + }); + + expect(resolved?.conversation).toEqual({ + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + expect(resolved?.record.conversation.channel).toBe("discord"); + expect(resolved?.statefulTarget).toEqual({ + kind: "stateful", + driverId: "acp", + sessionKey: resolved?.record.targetSessionKey, + agentId: "codex", + label: undefined, + }); + }); + + it("primes compiled ACP bindings from the already loaded active registry once", async () => { + const plugin = createDiscordAcpPlugin(); + const cfg = createConfig({ bindingAgentId: "codex" }); + getChannelPluginMock.mockReturnValue(undefined); + getActivePluginRegistryMock.mockReturnValue({ + channels: [{ plugin }], + }); + const bindingRegistry = await importConfiguredBindings(); + + const primed = bindingRegistry.primeConfiguredBindingRegistry({ + cfg: cfg as never, + }); + const resolved = bindingRegistry.resolveConfiguredBindingRecord({ + cfg: cfg as never, + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + + expect(primed).toEqual({ bindingCount: 1, channelCount: 1 }); + expect(resolved?.statefulTarget.agentId).toBe("codex"); + expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(1); + + const second = bindingRegistry.resolveConfiguredBindingRecord({ + cfg: cfg as never, + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + + expect(second?.statefulTarget.agentId).toBe("codex"); + }); + + it("resolves wildcard binding session keys from the compiled registry", async () => { + const plugin = createDiscordAcpPlugin(); + getChannelPluginMock.mockReturnValue(plugin); + const bindingRegistry = await importConfiguredBindings(); + + const resolved = bindingRegistry.resolveConfiguredBindingRecordBySessionKey({ + cfg: createConfig({ accountId: "*" }) as never, + sessionKey: buildConfiguredAcpSessionKey({ + channel: "discord", + accountId: "work", + conversationId: "1479098716916023408", + agentId: "codex", + mode: "persistent", + backend: "acpx", + }), + }); + + expect(resolved?.record.conversation.channel).toBe("discord"); + expect(resolved?.record.conversation.accountId).toBe("work"); + expect(resolved?.record.metadata?.backend).toBe("acpx"); + }); + + it("does not perform late plugin discovery when a channel plugin is unavailable", async () => { + const bindingRegistry = await importConfiguredBindings(); + + const resolved = bindingRegistry.resolveConfiguredBindingRecord({ + cfg: createConfig() as never, + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + + expect(resolved).toBeNull(); + }); + + it("rebuilds the compiled registry when the active plugin registry version changes", async () => { + const plugin = createDiscordAcpPlugin(); + getChannelPluginMock.mockReturnValue(plugin); + getActivePluginRegistryVersionMock.mockReturnValue(10); + const cfg = createConfig(); + const bindingRegistry = await importConfiguredBindings(); + + bindingRegistry.resolveConfiguredBindingRecord({ + cfg: cfg as never, + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + bindingRegistry.resolveConfiguredBindingRecord({ + cfg: cfg as never, + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + + getActivePluginRegistryVersionMock.mockReturnValue(11); + bindingRegistry.resolveConfiguredBindingRecord({ + cfg: cfg as never, + channel: "discord", + accountId: "default", + conversationId: "1479098716916023408", + }); + + expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/channels/plugins/acp-configured-binding-consumer.ts b/src/channels/plugins/acp-configured-binding-consumer.ts new file mode 100644 index 00000000000..d453726b357 --- /dev/null +++ b/src/channels/plugins/acp-configured-binding-consumer.ts @@ -0,0 +1,155 @@ +import { + buildConfiguredAcpSessionKey, + normalizeBindingConfig, + normalizeMode, + normalizeText, + parseConfiguredAcpSessionKey, + toConfiguredAcpBindingRecord, + type ConfiguredAcpBindingSpec, +} from "../../acp/persistent-bindings.types.js"; +import { + resolveAgentConfig, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../../agents/agent-scope.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { + ConfiguredBindingRuleConfig, + ConfiguredBindingTargetFactory, +} from "./binding-types.js"; +import type { ConfiguredBindingConsumer } from "./configured-binding-consumers.js"; +import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js"; + +function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): { + acpAgentId?: string; + mode?: string; + cwd?: string; + backend?: string; +} { + const agent = params.cfg.agents?.list?.find( + (entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(), + ); + if (!agent || agent.runtime?.type !== "acp") { + return {}; + } + return { + acpAgentId: normalizeText(agent.runtime.acp?.agent), + mode: normalizeText(agent.runtime.acp?.mode), + cwd: normalizeText(agent.runtime.acp?.cwd), + backend: normalizeText(agent.runtime.acp?.backend), + }; +} + +function resolveConfiguredBindingWorkspaceCwd(params: { + cfg: OpenClawConfig; + agentId: string; +}): string | undefined { + const explicitAgentWorkspace = normalizeText( + resolveAgentConfig(params.cfg, params.agentId)?.workspace, + ); + if (explicitAgentWorkspace) { + return resolveAgentWorkspaceDir(params.cfg, params.agentId); + } + if (params.agentId === resolveDefaultAgentId(params.cfg)) { + const defaultWorkspace = normalizeText(params.cfg.agents?.defaults?.workspace); + if (defaultWorkspace) { + return resolveAgentWorkspaceDir(params.cfg, params.agentId); + } + } + return undefined; +} + +function buildConfiguredAcpSpec(params: { + channel: string; + accountId: string; + conversation: ChannelConfiguredBindingConversationRef; + agentId: string; + acpAgentId?: string; + mode: "persistent" | "oneshot"; + cwd?: string; + backend?: string; + label?: string; +}): ConfiguredAcpBindingSpec { + return { + channel: params.channel as ConfiguredAcpBindingSpec["channel"], + accountId: params.accountId, + conversationId: params.conversation.conversationId, + parentConversationId: params.conversation.parentConversationId, + agentId: params.agentId, + acpAgentId: params.acpAgentId, + mode: params.mode, + cwd: params.cwd, + backend: params.backend, + label: params.label, + }; +} + +function buildAcpTargetFactory(params: { + cfg: OpenClawConfig; + binding: ConfiguredBindingRuleConfig; + channel: string; + agentId: string; +}): ConfiguredBindingTargetFactory | null { + if (params.binding.type !== "acp") { + return null; + } + const runtimeDefaults = resolveAgentRuntimeAcpDefaults({ + cfg: params.cfg, + ownerAgentId: params.agentId, + }); + const bindingOverrides = normalizeBindingConfig(params.binding.acp); + const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode); + const cwd = + bindingOverrides.cwd ?? + runtimeDefaults.cwd ?? + resolveConfiguredBindingWorkspaceCwd({ + cfg: params.cfg, + agentId: params.agentId, + }); + const backend = bindingOverrides.backend ?? runtimeDefaults.backend; + const label = bindingOverrides.label; + const acpAgentId = normalizeText(runtimeDefaults.acpAgentId); + + return { + driverId: "acp", + materialize: ({ accountId, conversation }) => { + const spec = buildConfiguredAcpSpec({ + channel: params.channel, + accountId, + conversation, + agentId: params.agentId, + acpAgentId, + mode, + cwd, + backend, + label, + }); + const record = toConfiguredAcpBindingRecord(spec); + return { + record, + statefulTarget: { + kind: "stateful", + driverId: "acp", + sessionKey: buildConfiguredAcpSessionKey(spec), + agentId: params.agentId, + ...(label ? { label } : {}), + }, + }; + }, + }; +} + +export const acpConfiguredBindingConsumer: ConfiguredBindingConsumer = { + id: "acp", + supports: (binding) => binding.type === "acp", + buildTargetFactory: (params) => + buildAcpTargetFactory({ + cfg: params.cfg, + binding: params.binding, + channel: params.channel, + agentId: params.agentId, + }), + parseSessionKey: ({ sessionKey }) => parseConfiguredAcpSessionKey(sessionKey), + matchesSessionKey: ({ sessionKey, materializedTarget }) => + materializedTarget.record.targetSessionKey === sessionKey, +}; diff --git a/src/channels/plugins/acp-stateful-target-driver.ts b/src/channels/plugins/acp-stateful-target-driver.ts new file mode 100644 index 00000000000..787013fc5b0 --- /dev/null +++ b/src/channels/plugins/acp-stateful-target-driver.ts @@ -0,0 +1,102 @@ +import { + ensureConfiguredAcpBindingReady, + ensureConfiguredAcpBindingSession, + resetAcpSessionInPlace, +} from "../../acp/persistent-bindings.lifecycle.js"; +import { resolveConfiguredAcpBindingSpecBySessionKey } from "../../acp/persistent-bindings.resolve.js"; +import { resolveConfiguredAcpBindingSpecFromRecord } from "../../acp/persistent-bindings.types.js"; +import { readAcpSessionEntry } from "../../acp/runtime/session-meta.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { + ConfiguredBindingResolution, + StatefulBindingTargetDescriptor, +} from "./binding-types.js"; +import type { + StatefulBindingTargetDriver, + StatefulBindingTargetResetResult, + StatefulBindingTargetReadyResult, + StatefulBindingTargetSessionResult, +} from "./stateful-target-drivers.js"; + +function toAcpStatefulBindingTargetDescriptor(params: { + cfg: OpenClawConfig; + sessionKey: string; +}): StatefulBindingTargetDescriptor | null { + const meta = readAcpSessionEntry(params)?.acp; + const metaAgentId = meta?.agent?.trim(); + if (metaAgentId) { + return { + kind: "stateful", + driverId: "acp", + sessionKey: params.sessionKey, + agentId: metaAgentId, + }; + } + const spec = resolveConfiguredAcpBindingSpecBySessionKey(params); + if (!spec) { + return null; + } + return { + kind: "stateful", + driverId: "acp", + sessionKey: params.sessionKey, + agentId: spec.agentId, + ...(spec.label ? { label: spec.label } : {}), + }; +} + +async function ensureAcpTargetReady(params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution; +}): Promise { + const configuredBinding = resolveConfiguredAcpBindingSpecFromRecord( + params.bindingResolution.record, + ); + if (!configuredBinding) { + return { + ok: false, + error: "Configured ACP binding unavailable", + }; + } + return await ensureConfiguredAcpBindingReady({ + cfg: params.cfg, + configuredBinding: { + spec: configuredBinding, + record: params.bindingResolution.record, + }, + }); +} + +async function ensureAcpTargetSession(params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution; +}): Promise { + const spec = resolveConfiguredAcpBindingSpecFromRecord(params.bindingResolution.record); + if (!spec) { + return { + ok: false, + sessionKey: params.bindingResolution.statefulTarget.sessionKey, + error: "Configured ACP binding unavailable", + }; + } + return await ensureConfiguredAcpBindingSession({ + cfg: params.cfg, + spec, + }); +} + +async function resetAcpTargetInPlace(params: { + cfg: OpenClawConfig; + sessionKey: string; + reason: "new" | "reset"; +}): Promise { + return await resetAcpSessionInPlace(params); +} + +export const acpStatefulBindingTargetDriver: StatefulBindingTargetDriver = { + id: "acp", + ensureReady: ensureAcpTargetReady, + ensureSession: ensureAcpTargetSession, + resolveTargetBySessionKey: toAcpStatefulBindingTargetDescriptor, + resetInPlace: resetAcpTargetInPlace, +}; diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 322e0f618f4..1692e0f0754 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -202,88 +202,103 @@ beforeEach(async () => { }); describe("discord message actions", () => { - it("lists channel and upload actions by default", async () => { - const cfg = { channels: { discord: { token: "d0" } } } as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).toContain("emoji-upload"); - expect(actions).toContain("sticker-upload"); - expect(actions).toContain("channel-create"); - }); - - it("respects disabled channel actions", async () => { - const cfg = { - channels: { discord: { token: "d0", actions: { channels: false } } }, - } as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).not.toContain("channel-create"); - }); - - it("lists moderation when at least one account enables it", () => { + it("derives discord action listings from channel and moderation gates", () => { const cases = [ { - channels: { - discord: { - accounts: { - vime: { token: "d1", actions: { moderation: true } }, - }, - }, - }, + name: "defaults", + cfg: { channels: { discord: { token: "d0" } } } as OpenClawConfig, + expectUploads: true, + expectChannelCreate: true, + expectModeration: false, }, { - channels: { - discord: { - accounts: { - ops: { token: "d1", actions: { moderation: true } }, - chat: { token: "d2" }, + name: "disabled channel actions", + cfg: { + channels: { discord: { token: "d0", actions: { channels: false } } }, + } as OpenClawConfig, + expectUploads: true, + expectChannelCreate: false, + expectModeration: false, + }, + { + name: "single account enables moderation", + cfg: { + channels: { + discord: { + accounts: { + vime: { token: "d1", actions: { moderation: true } }, + }, }, }, - }, + } as OpenClawConfig, + expectUploads: true, + expectChannelCreate: true, + expectModeration: true, + }, + { + name: "one of many accounts enables moderation", + cfg: { + channels: { + discord: { + accounts: { + ops: { token: "d1", actions: { moderation: true } }, + chat: { token: "d2" }, + }, + }, + }, + } as OpenClawConfig, + expectUploads: true, + expectChannelCreate: true, + expectModeration: true, + }, + { + name: "all accounts omit moderation", + cfg: { + channels: { + discord: { + accounts: { + ops: { token: "d1" }, + chat: { token: "d2" }, + }, + }, + }, + } as OpenClawConfig, + expectUploads: true, + expectChannelCreate: true, + expectModeration: false, + }, + { + name: "account moderation override inherits disabled top-level channels", + cfg: createDiscordModerationOverrideCfg(), + expectUploads: true, + expectChannelCreate: false, + expectModeration: true, + }, + { + name: "account override re-enables top-level disabled channels", + cfg: createDiscordModerationOverrideCfg({ channelsEnabled: true }), + expectUploads: true, + expectChannelCreate: true, + expectModeration: true, }, ] as const; - for (const channelConfig of cases) { - const cfg = channelConfig as unknown as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - expectModerationActions(actions); + for (const testCase of cases) { + const actions = discordMessageActions.listActions?.({ cfg: testCase.cfg }) ?? []; + if (testCase.expectUploads) { + expect(actions, testCase.name).toContain("emoji-upload"); + expect(actions, testCase.name).toContain("sticker-upload"); + } + expectChannelCreateAction(actions, testCase.expectChannelCreate); + if (testCase.expectModeration) { + expectModerationActions(actions); + } else { + expect(actions, testCase.name).not.toContain("timeout"); + expect(actions, testCase.name).not.toContain("kick"); + expect(actions, testCase.name).not.toContain("ban"); + } } }); - - it("omits moderation when all accounts omit it", () => { - const cfg = { - channels: { - discord: { - accounts: { - ops: { token: "d1" }, - chat: { token: "d2" }, - }, - }, - }, - } as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - // moderation defaults to false, so without explicit true it stays hidden - expect(actions).not.toContain("timeout"); - expect(actions).not.toContain("kick"); - expect(actions).not.toContain("ban"); - }); - - it("inherits top-level channel gate when account overrides moderation only", () => { - const cfg = createDiscordModerationOverrideCfg(); - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).toContain("timeout"); - expectChannelCreateAction(actions, false); - }); - - it("allows account to explicitly re-enable top-level disabled channels", () => { - const cfg = createDiscordModerationOverrideCfg({ channelsEnabled: true }); - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).toContain("timeout"); - expectChannelCreateAction(actions, true); - }); }); describe("handleDiscordMessageAction", () => { @@ -483,141 +498,149 @@ describe("handleDiscordMessageAction", () => { expect(call?.[1]).toEqual(expect.any(Object)); }); - it("forwards trusted mediaLocalRoots for send actions", async () => { - await handleDiscordMessageAction({ - action: "send", - params: { to: "channel:123", message: "hi", media: "/tmp/file.png" }, - cfg: {} as OpenClawConfig, - mediaLocalRoots: ["/tmp/agent-root"], - }); - - expect(handleDiscordAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "sendMessage", - mediaUrl: "/tmp/file.png", - }), - expect.any(Object), - expect.objectContaining({ mediaLocalRoots: ["/tmp/agent-root"] }), - ); - }); - - it("falls back to toolContext.currentMessageId for reactions when messageId is omitted", async () => { - await handleDiscordMessageAction({ - action: "react", - params: { - channelId: "123", - emoji: "ok", - }, - cfg: {} as OpenClawConfig, - toolContext: { currentMessageId: "9001" }, - }); - - const call = handleDiscordAction.mock.calls.at(-1); - expect(call?.[0]).toEqual( - expect.objectContaining({ - action: "react", - channelId: "123", - messageId: "9001", - emoji: "ok", - }), - ); - }); - - it("rejects reactions when neither messageId nor toolContext.currentMessageId is provided", async () => { - await expect( - handleDiscordMessageAction({ - action: "react", - params: { - channelId: "123", - emoji: "ok", + it("handles discord reaction messageId resolution", async () => { + const cases = [ + { + name: "falls back to toolContext.currentMessageId", + run: async () => { + await handleDiscordMessageAction({ + action: "react", + params: { + channelId: "123", + emoji: "ok", + }, + cfg: {} as OpenClawConfig, + toolContext: { currentMessageId: "9001" }, + }); }, - cfg: {} as OpenClawConfig, - }), - ).rejects.toThrow(/messageId required/i); + assert: () => { + const call = handleDiscordAction.mock.calls.at(-1); + expect(call?.[0]).toEqual( + expect.objectContaining({ + action: "react", + channelId: "123", + messageId: "9001", + emoji: "ok", + }), + ); + }, + }, + { + name: "rejects when no message id source is available", + run: async () => { + await expect( + handleDiscordMessageAction({ + action: "react", + params: { + channelId: "123", + emoji: "ok", + }, + cfg: {} as OpenClawConfig, + }), + ).rejects.toThrow(/messageId required/i); + }, + assert: () => { + expect(handleDiscordAction).not.toHaveBeenCalled(); + }, + }, + ] as const; - expect(handleDiscordAction).not.toHaveBeenCalled(); + for (const testCase of cases) { + handleDiscordAction.mockClear(); + await testCase.run(); + testCase.assert(); + } }); }); describe("telegramMessageActions", () => { - it("lists poll when telegram is configured", () => { - const actions = telegramMessageActions.listActions?.({ cfg: telegramCfg() }) ?? []; - - expect(actions).toContain("poll"); - }); - - it("lists topic-edit when telegram topic edits are enabled", () => { - const cfg = { - channels: { - telegram: { - botToken: "tok", - actions: { editForumTopic: true }, - }, + it("computes poll/topic action availability from telegram config gates", () => { + for (const testCase of [ + { + name: "configured telegram enables poll", + cfg: telegramCfg(), + expectPoll: true, + expectTopicEdit: true, }, - } as OpenClawConfig; - - const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).toContain("topic-edit"); - }); - - it("omits poll when sendMessage is disabled", () => { - const cfg = { - channels: { - telegram: { - botToken: "tok", - actions: { sendMessage: false }, - }, - }, - } as OpenClawConfig; - - const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).not.toContain("poll"); - }); - - it("omits poll when poll actions are disabled", () => { - const cfg = { - channels: { - telegram: { - botToken: "tok", - actions: { poll: false }, - }, - }, - } as OpenClawConfig; - - const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).not.toContain("poll"); - }); - - it("omits poll when sendMessage and poll are split across accounts", () => { - const cfg = { - channels: { - telegram: { - accounts: { - senderOnly: { - botToken: "tok-send", - actions: { - sendMessage: true, - poll: false, - }, + { + name: "topic edit gate enables topic-edit", + cfg: { + channels: { + telegram: { + botToken: "tok", + actions: { editForumTopic: true }, }, - pollOnly: { - botToken: "tok-poll", - actions: { - sendMessage: false, - poll: true, + }, + } as OpenClawConfig, + expectPoll: true, + expectTopicEdit: true, + }, + { + name: "sendMessage disabled hides poll", + cfg: { + channels: { + telegram: { + botToken: "tok", + actions: { sendMessage: false }, + }, + }, + } as OpenClawConfig, + expectPoll: false, + expectTopicEdit: true, + }, + { + name: "poll gate disabled hides poll", + cfg: { + channels: { + telegram: { + botToken: "tok", + actions: { poll: false }, + }, + }, + } as OpenClawConfig, + expectPoll: false, + expectTopicEdit: true, + }, + { + name: "split account gates do not expose poll", + cfg: { + channels: { + telegram: { + accounts: { + senderOnly: { + botToken: "tok-send", + actions: { + sendMessage: true, + poll: false, + }, + }, + pollOnly: { + botToken: "tok-poll", + actions: { + sendMessage: false, + poll: true, + }, + }, }, }, }, - }, + } as OpenClawConfig, + expectPoll: false, + expectTopicEdit: true, }, - } as OpenClawConfig; - - const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).not.toContain("poll"); + ]) { + const actions = telegramMessageActions.listActions?.({ cfg: testCase.cfg }) ?? []; + if (testCase.expectPoll) { + expect(actions, testCase.name).toContain("poll"); + } else { + expect(actions, testCase.name).not.toContain("poll"); + } + if (testCase.expectTopicEdit) { + expect(actions, testCase.name).toContain("topic-edit"); + } else { + expect(actions, testCase.name).not.toContain("topic-edit"); + } + } }); it("lists sticker actions only when enabled by config", () => { @@ -845,29 +868,6 @@ describe("telegramMessageActions", () => { } }); - it("forwards trusted mediaLocalRoots for send", async () => { - const cfg = telegramCfg(); - await telegramMessageActions.handleAction?.({ - channel: "telegram", - action: "send", - params: { - to: "123", - media: "/tmp/voice.ogg", - }, - cfg, - mediaLocalRoots: ["/tmp/agent-root"], - }); - - expect(handleTelegramAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "sendMessage", - mediaUrl: "/tmp/voice.ogg", - }), - cfg, - expect.objectContaining({ mediaLocalRoots: ["/tmp/agent-root"] }), - ); - }); - it("rejects non-integer messageId for edit before reaching telegram-actions", async () => { const cfg = telegramCfg(); const handleAction = telegramMessageActions.handleAction; @@ -910,111 +910,141 @@ describe("telegramMessageActions", () => { expect(actions).not.toContain("react"); }); - it("accepts numeric messageId and channelId for reactions", async () => { + it("normalizes telegram reaction message identifiers before dispatch", async () => { const cfg = telegramCfg(); - - await telegramMessageActions.handleAction?.({ - channel: "telegram", - action: "react", - params: { - channelId: 123, - messageId: 456, - emoji: "ok", + for (const testCase of [ + { + name: "numeric channelId/messageId", + params: { + channelId: 123, + messageId: 456, + emoji: "ok", + }, + toolContext: undefined, + expectedChatId: "123", + expectedMessageId: "456", }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledTimes(1); - const call = handleTelegramAction.mock.calls[0]?.[0]; - if (!call) { - throw new Error("missing telegram action call"); - } - const callPayload = call as Record; - expect(callPayload.action).toBe("react"); - expect(String(callPayload.chatId)).toBe("123"); - expect(String(callPayload.messageId)).toBe("456"); - expect(callPayload.emoji).toBe("ok"); - }); - - it("accepts snake_case message_id for reactions", async () => { - const cfg = telegramCfg(); - - await telegramMessageActions.handleAction?.({ - channel: "telegram", - action: "react", - params: { - channelId: 123, - message_id: "456", - emoji: "ok", + { + name: "snake_case message_id", + params: { + channelId: 123, + message_id: "456", + emoji: "ok", + }, + toolContext: undefined, + expectedChatId: "123", + expectedMessageId: "456", }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledTimes(1); - const call = handleTelegramAction.mock.calls[0]?.[0]; - if (!call) { - throw new Error("missing telegram action call"); - } - const callPayload = call as Record; - expect(callPayload.action).toBe("react"); - expect(String(callPayload.chatId)).toBe("123"); - expect(String(callPayload.messageId)).toBe("456"); - }); - - it("falls back to toolContext.currentMessageId for reactions when messageId is omitted", async () => { - const cfg = telegramCfg(); - - await telegramMessageActions.handleAction?.({ - channel: "telegram", - action: "react", - params: { - chatId: "123", - emoji: "ok", - }, - cfg, - accountId: undefined, - toolContext: { currentMessageId: "9001" }, - }); - - expect(handleTelegramAction).toHaveBeenCalledTimes(1); - const call = handleTelegramAction.mock.calls[0]?.[0]; - if (!call) { - throw new Error("missing telegram action call"); - } - const callPayload = call as Record; - expect(callPayload.action).toBe("react"); - expect(String(callPayload.messageId)).toBe("9001"); - }); - - it("forwards missing reaction messageId to telegram-actions for soft-fail handling", async () => { - const cfg = telegramCfg(); - - await expect( - telegramMessageActions.handleAction?.({ - channel: "telegram", - action: "react", + { + name: "toolContext fallback", params: { chatId: "123", emoji: "ok", }, - cfg, - accountId: undefined, - }), - ).resolves.toBeDefined(); + toolContext: { currentMessageId: "9001" }, + expectedChatId: "123", + expectedMessageId: "9001", + }, + { + name: "missing messageId soft-falls through to telegram-actions", + params: { + chatId: "123", + emoji: "ok", + }, + toolContext: undefined, + expectedChatId: "123", + expectedMessageId: undefined, + }, + ] as const) { + handleTelegramAction.mockClear(); + await expect( + telegramMessageActions.handleAction?.({ + channel: "telegram", + action: "react", + params: testCase.params, + cfg, + accountId: undefined, + toolContext: testCase.toolContext, + }), + ).resolves.toBeDefined(); - expect(handleTelegramAction).toHaveBeenCalledTimes(1); - const call = handleTelegramAction.mock.calls[0]?.[0]; - if (!call) { - throw new Error("missing telegram action call"); + expect(handleTelegramAction, testCase.name).toHaveBeenCalledTimes(1); + const call = handleTelegramAction.mock.calls[0]?.[0]; + if (!call) { + throw new Error("missing telegram action call"); + } + const callPayload = call as Record; + expect(callPayload.action, testCase.name).toBe("react"); + expect(String(callPayload.chatId), testCase.name).toBe(testCase.expectedChatId); + if (testCase.expectedMessageId === undefined) { + expect(callPayload.messageId, testCase.name).toBeUndefined(); + } else { + expect(String(callPayload.messageId), testCase.name).toBe(testCase.expectedMessageId); + } } - const callPayload = call as Record; - expect(callPayload.action).toBe("react"); - expect(callPayload.messageId).toBeUndefined(); }); }); +it("forwards trusted mediaLocalRoots for send actions", async () => { + const cases = [ + { + name: "discord", + run: async () => { + await handleDiscordMessageAction({ + action: "send", + params: { to: "channel:123", message: "hi", media: "/tmp/file.png" }, + cfg: {} as OpenClawConfig, + mediaLocalRoots: ["/tmp/agent-root"], + }); + }, + assert: () => { + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + mediaUrl: "/tmp/file.png", + }), + expect.any(Object), + expect.objectContaining({ mediaLocalRoots: ["/tmp/agent-root"] }), + ); + }, + clear: () => handleDiscordAction.mockClear(), + }, + { + name: "telegram", + run: async () => { + const cfg = telegramCfg(); + await telegramMessageActions.handleAction?.({ + channel: "telegram", + action: "send", + params: { + to: "123", + media: "/tmp/voice.ogg", + }, + cfg, + mediaLocalRoots: ["/tmp/agent-root"], + }); + }, + assert: () => { + expect(handleTelegramAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + mediaUrl: "/tmp/voice.ogg", + }), + expect.any(Object), + expect.objectContaining({ mediaLocalRoots: ["/tmp/agent-root"] }), + ); + }, + clear: () => handleTelegramAction.mockClear(), + }, + ] as const; + + for (const testCase of cases) { + testCase.clear(); + await testCase.run(); + testCase.assert(); + } +}); + describe("signalMessageActions", () => { it("lists actions based on account presence and reaction gates", () => { const cases = [ @@ -1104,6 +1134,18 @@ describe("signalMessageActions", () => { groupId: "group-id", targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", }, + toolContext: undefined, + }, + { + name: "falls back to toolContext.currentMessageId when messageId is omitted", + cfg: { channels: { signal: { account: "+15550001111" } } } as OpenClawConfig, + accountId: undefined, + params: { to: "+15559999999", emoji: "🔥" }, + expectedRecipient: "+15559999999", + expectedTimestamp: 1737630212345, + expectedEmoji: "🔥", + expectedOptions: {}, + toolContext: { currentMessageId: "1737630212345" }, }, ] as const; @@ -1112,6 +1154,7 @@ describe("signalMessageActions", () => { await runSignalAction("react", testCase.params, { cfg: testCase.cfg, accountId: testCase.accountId, + toolContext: "toolContext" in testCase ? testCase.toolContext : undefined, }); expect(sendReactionSignal, testCase.name).toHaveBeenCalledWith( testCase.expectedRecipient, @@ -1125,72 +1168,50 @@ describe("signalMessageActions", () => { } }); - it("falls back to toolContext.currentMessageId for reactions when messageId is omitted", async () => { - sendReactionSignal.mockClear(); - await runSignalAction( - "react", - { to: "+15559999999", emoji: "🔥" }, - { toolContext: { currentMessageId: "1737630212345" } }, - ); - expect(sendReactionSignal).toHaveBeenCalledTimes(1); - expect(sendReactionSignal).toHaveBeenCalledWith( - "+15559999999", - 1737630212345, - "🔥", - expect.objectContaining({}), - ); - }); - - it("rejects reaction when neither messageId nor toolContext.currentMessageId is provided", async () => { + it("rejects invalid signal reaction inputs before dispatch", async () => { const cfg = { channels: { signal: { account: "+15550001111" } }, } as OpenClawConfig; - await expectSignalActionRejected( - { to: "+15559999999", emoji: "✅" }, - /messageId.*required/, - cfg, - ); - }); - - it("requires targetAuthor for group reactions", async () => { - const cfg = { - channels: { signal: { account: "+15550001111" } }, - } as OpenClawConfig; - await expectSignalActionRejected( - { to: "signal:group:group-id", messageId: "123", emoji: "✅" }, - /targetAuthor/, - cfg, - ); + for (const testCase of [ + { + params: { to: "+15559999999", emoji: "✅" }, + error: /messageId.*required/, + }, + { + params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" }, + error: /targetAuthor/, + }, + ] as const) { + await expectSignalActionRejected(testCase.params, testCase.error, cfg); + } }); }); describe("slack actions adapter", () => { - it("forwards threadId for read", async () => { - await runSlackAction("read", { - channelId: "C1", - threadId: "171234.567", - }); - - expectFirstSlackAction({ - action: "readMessages", - channelId: "C1", - threadId: "171234.567", - }); - }); - - it("forwards normalized limit for emoji-list", async () => { - await runSlackAction("emoji-list", { - limit: "2.9", - }); - - expectFirstSlackAction({ - action: "emojiList", - limit: 2, - }); - }); - - it("forwards blocks for send/edit actions", async () => { + it("forwards slack action params", async () => { const cases = [ + { + action: "read" as const, + params: { + channelId: "C1", + threadId: "171234.567", + }, + expected: { + action: "readMessages", + channelId: "C1", + threadId: "171234.567", + }, + }, + { + action: "emoji-list" as const, + params: { + limit: "2.9", + }, + expected: { + action: "emojiList", + limit: 2, + }, + }, { action: "send" as const, params: { @@ -1251,19 +1272,40 @@ describe("slack actions adapter", () => { blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], }, }, + { + action: "send" as const, + params: { + to: "channel:C1", + message: "", + media: "https://example.com/image.png", + }, + expected: { + action: "sendMessage", + to: "channel:C1", + content: "", + mediaUrl: "https://example.com/image.png", + }, + absentKeys: ["blocks"], + }, ] as const; for (const testCase of cases) { handleSlackAction.mockClear(); await runSlackAction(testCase.action, testCase.params); expectFirstSlackAction(testCase.expected); + const [params] = handleSlackAction.mock.calls[0] ?? []; + const absentKeys = "absentKeys" in testCase ? testCase.absentKeys : undefined; + for (const key of absentKeys ?? []) { + expect(params).not.toHaveProperty(key); + } } }); - it("rejects invalid send block combinations before dispatch", async () => { + it("rejects invalid Slack payloads before dispatch", async () => { const cases = [ { name: "invalid JSON", + action: "send" as const, params: { to: "channel:C1", message: "", @@ -1273,6 +1315,7 @@ describe("slack actions adapter", () => { }, { name: "empty blocks", + action: "send" as const, params: { to: "channel:C1", message: "", @@ -1282,6 +1325,7 @@ describe("slack actions adapter", () => { }, { name: "blocks with media", + action: "send" as const, params: { to: "channel:C1", message: "", @@ -1290,48 +1334,34 @@ describe("slack actions adapter", () => { }, error: /does not support blocks with media/i, }, - ] as const; - - for (const testCase of cases) { - handleSlackAction.mockClear(); - await expectSlackSendRejected(testCase.params, testCase.error); - } - }); - - it("does not attach empty blocks to plain media sends", async () => { - handleSlackAction.mockClear(); - - await runSlackAction("send", { - to: "channel:C1", - message: "", - media: "https://example.com/image.png", - }); - - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject({ - action: "sendMessage", - to: "channel:C1", - content: "", - mediaUrl: "https://example.com/image.png", - }); - expect(params).not.toHaveProperty("blocks"); - }); - - it("rejects edit when both message and blocks are missing", async () => { - const { cfg, actions } = slackHarness(); - - await expect( - actions.handleAction?.({ - channel: "slack", - action: "edit", - cfg, + { + name: "edit missing message and blocks", + action: "edit" as const, params: { channelId: "C1", messageId: "171234.567", message: "", }, - }), - ).rejects.toThrow(/edit requires message or blocks/i); - expect(handleSlackAction).not.toHaveBeenCalled(); + error: /edit requires message or blocks/i, + }, + ] as const; + + for (const testCase of cases) { + handleSlackAction.mockClear(); + if (testCase.action === "send") { + await expectSlackSendRejected(testCase.params, testCase.error); + } else { + const { cfg, actions } = slackHarness(); + await expect( + actions.handleAction?.({ + channel: "slack", + action: "edit", + cfg, + params: testCase.params, + }), + ).rejects.toThrow(testCase.error); + } + expect(handleSlackAction, testCase.name).not.toHaveBeenCalled(); + } }); }); diff --git a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts index 3ba353b1f6e..c7375b6c1a7 100644 --- a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts +++ b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts @@ -1 +1 @@ -export * from "../../../../../extensions/discord/src/actions/handle-action.guild-admin.js"; +export * from "../../../../../extensions/discord/api.js"; diff --git a/src/channels/plugins/actions/discord/handle-action.ts b/src/channels/plugins/actions/discord/handle-action.ts index 4bd957ec624..c7375b6c1a7 100644 --- a/src/channels/plugins/actions/discord/handle-action.ts +++ b/src/channels/plugins/actions/discord/handle-action.ts @@ -1 +1 @@ -export * from "../../../../../extensions/discord/src/actions/handle-action.js"; +export * from "../../../../../extensions/discord/api.js"; diff --git a/src/channels/plugins/binding-provider.ts b/src/channels/plugins/binding-provider.ts new file mode 100644 index 00000000000..27dc5c49951 --- /dev/null +++ b/src/channels/plugins/binding-provider.ts @@ -0,0 +1,14 @@ +import type { ChannelConfiguredBindingProvider } from "./types.adapters.js"; +import type { ChannelPlugin } from "./types.plugin.js"; + +export function resolveChannelConfiguredBindingProvider( + plugin: + | Pick + | { + bindings?: ChannelConfiguredBindingProvider; + } + | null + | undefined, +): ChannelConfiguredBindingProvider | undefined { + return plugin?.bindings; +} diff --git a/src/channels/plugins/binding-registry.ts b/src/channels/plugins/binding-registry.ts new file mode 100644 index 00000000000..f4e95c19eba --- /dev/null +++ b/src/channels/plugins/binding-registry.ts @@ -0,0 +1,46 @@ +import { ensureConfiguredBindingBuiltinsRegistered } from "./configured-binding-builtins.js"; +import { + primeConfiguredBindingRegistry as primeConfiguredBindingRegistryRaw, + resolveConfiguredBinding as resolveConfiguredBindingRaw, + resolveConfiguredBindingRecord as resolveConfiguredBindingRecordRaw, + resolveConfiguredBindingRecordBySessionKey as resolveConfiguredBindingRecordBySessionKeyRaw, + resolveConfiguredBindingRecordForConversation as resolveConfiguredBindingRecordForConversationRaw, +} from "./configured-binding-registry.js"; + +// Thin public wrapper around the configured-binding registry. Runtime plugin +// conversation bindings use a separate approval-driven path in src/plugins/. + +export function primeConfiguredBindingRegistry( + ...args: Parameters +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return primeConfiguredBindingRegistryRaw(...args); +} + +export function resolveConfiguredBindingRecord( + ...args: Parameters +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return resolveConfiguredBindingRecordRaw(...args); +} + +export function resolveConfiguredBindingRecordForConversation( + ...args: Parameters +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return resolveConfiguredBindingRecordForConversationRaw(...args); +} + +export function resolveConfiguredBinding( + ...args: Parameters +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return resolveConfiguredBindingRaw(...args); +} + +export function resolveConfiguredBindingRecordBySessionKey( + ...args: Parameters +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return resolveConfiguredBindingRecordBySessionKeyRaw(...args); +} diff --git a/src/channels/plugins/binding-routing.ts b/src/channels/plugins/binding-routing.ts new file mode 100644 index 00000000000..6fe8b0c400b --- /dev/null +++ b/src/channels/plugins/binding-routing.ts @@ -0,0 +1,91 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { ConversationRef } from "../../infra/outbound/session-binding-service.js"; +import type { ResolvedAgentRoute } from "../../routing/resolve-route.js"; +import { deriveLastRoutePolicy } from "../../routing/resolve-route.js"; +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { resolveConfiguredBinding } from "./binding-registry.js"; +import { ensureConfiguredBindingTargetReady } from "./binding-targets.js"; +import type { ConfiguredBindingResolution } from "./binding-types.js"; + +export type ConfiguredBindingRouteResult = { + bindingResolution: ConfiguredBindingResolution | null; + route: ResolvedAgentRoute; + boundSessionKey?: string; + boundAgentId?: string; +}; + +type ConfiguredBindingRouteConversationInput = + | { + conversation: ConversationRef; + } + | { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + }; + +function resolveConfiguredBindingConversationRef( + params: ConfiguredBindingRouteConversationInput, +): ConversationRef { + if ("conversation" in params) { + return params.conversation; + } + return { + channel: params.channel, + accountId: params.accountId, + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }; +} + +export function resolveConfiguredBindingRoute( + params: { + cfg: OpenClawConfig; + route: ResolvedAgentRoute; + } & ConfiguredBindingRouteConversationInput, +): ConfiguredBindingRouteResult { + const bindingResolution = + resolveConfiguredBinding({ + cfg: params.cfg, + conversation: resolveConfiguredBindingConversationRef(params), + }) ?? null; + if (!bindingResolution) { + return { + bindingResolution: null, + route: params.route, + }; + } + + const boundSessionKey = bindingResolution.statefulTarget.sessionKey.trim(); + if (!boundSessionKey) { + return { + bindingResolution, + route: params.route, + }; + } + const boundAgentId = + resolveAgentIdFromSessionKey(boundSessionKey) || bindingResolution.statefulTarget.agentId; + return { + bindingResolution, + boundSessionKey, + boundAgentId, + route: { + ...params.route, + sessionKey: boundSessionKey, + agentId: boundAgentId, + lastRoutePolicy: deriveLastRoutePolicy({ + sessionKey: boundSessionKey, + mainSessionKey: params.route.mainSessionKey, + }), + matchedBy: "binding.channel", + }, + }; +} + +export async function ensureConfiguredBindingRouteReady(params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution | null; +}): Promise<{ ok: true } | { ok: false; error: string }> { + return await ensureConfiguredBindingTargetReady(params); +} diff --git a/src/channels/plugins/binding-targets.test.ts b/src/channels/plugins/binding-targets.test.ts new file mode 100644 index 00000000000..98503052b3f --- /dev/null +++ b/src/channels/plugins/binding-targets.test.ts @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ensureConfiguredBindingTargetReady, + ensureConfiguredBindingTargetSession, + resetConfiguredBindingTargetInPlace, +} from "./binding-targets.js"; +import type { ConfiguredBindingResolution } from "./binding-types.js"; +import { + registerStatefulBindingTargetDriver, + unregisterStatefulBindingTargetDriver, + type StatefulBindingTargetDriver, +} from "./stateful-target-drivers.js"; + +function createBindingResolution(driverId: string): ConfiguredBindingResolution { + return { + conversation: { + channel: "discord", + accountId: "default", + conversationId: "123", + }, + compiledBinding: { + channel: "discord", + binding: { + type: "acp" as const, + agentId: "codex", + match: { + channel: "discord", + peer: { + kind: "channel" as const, + id: "123", + }, + }, + acp: { + mode: "persistent", + }, + }, + bindingConversationId: "123", + target: { + conversationId: "123", + }, + agentId: "codex", + provider: { + compileConfiguredBinding: () => ({ + conversationId: "123", + }), + matchInboundConversation: () => ({ + conversationId: "123", + }), + }, + targetFactory: { + driverId, + materialize: () => ({ + record: { + bindingId: "binding:123", + targetSessionKey: `agent:codex:${driverId}`, + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "123", + }, + status: "active", + boundAt: 0, + }, + statefulTarget: { + kind: "stateful", + driverId, + sessionKey: `agent:codex:${driverId}`, + agentId: "codex", + }, + }), + }, + }, + match: { + conversationId: "123", + }, + record: { + bindingId: "binding:123", + targetSessionKey: `agent:codex:${driverId}`, + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "123", + }, + status: "active", + boundAt: 0, + }, + statefulTarget: { + kind: "stateful", + driverId, + sessionKey: `agent:codex:${driverId}`, + agentId: "codex", + }, + }; +} + +afterEach(() => { + unregisterStatefulBindingTargetDriver("test-driver"); +}); + +describe("binding target drivers", () => { + it("delegates ensureReady and ensureSession to the resolved driver", async () => { + const ensureReady = vi.fn(async () => ({ ok: true as const })); + const ensureSession = vi.fn(async () => ({ + ok: true as const, + sessionKey: "agent:codex:test-driver", + })); + const driver: StatefulBindingTargetDriver = { + id: "test-driver", + ensureReady, + ensureSession, + }; + registerStatefulBindingTargetDriver(driver); + + const bindingResolution = createBindingResolution("test-driver"); + await expect( + ensureConfiguredBindingTargetReady({ + cfg: {} as never, + bindingResolution, + }), + ).resolves.toEqual({ ok: true }); + await expect( + ensureConfiguredBindingTargetSession({ + cfg: {} as never, + bindingResolution, + }), + ).resolves.toEqual({ + ok: true, + sessionKey: "agent:codex:test-driver", + }); + + expect(ensureReady).toHaveBeenCalledTimes(1); + expect(ensureReady).toHaveBeenCalledWith({ + cfg: {} as never, + bindingResolution, + }); + expect(ensureSession).toHaveBeenCalledTimes(1); + expect(ensureSession).toHaveBeenCalledWith({ + cfg: {} as never, + bindingResolution, + }); + }); + + it("resolves resetInPlace through the driver session-key lookup", async () => { + const resetInPlace = vi.fn(async () => ({ ok: true as const })); + const driver: StatefulBindingTargetDriver = { + id: "test-driver", + ensureReady: async () => ({ ok: true }), + ensureSession: async () => ({ + ok: true, + sessionKey: "agent:codex:test-driver", + }), + resolveTargetBySessionKey: ({ sessionKey }) => ({ + kind: "stateful", + driverId: "test-driver", + sessionKey, + agentId: "codex", + }), + resetInPlace, + }; + registerStatefulBindingTargetDriver(driver); + + await expect( + resetConfiguredBindingTargetInPlace({ + cfg: {} as never, + sessionKey: "agent:codex:test-driver", + reason: "reset", + }), + ).resolves.toEqual({ ok: true }); + + expect(resetInPlace).toHaveBeenCalledTimes(1); + expect(resetInPlace).toHaveBeenCalledWith({ + cfg: {} as never, + sessionKey: "agent:codex:test-driver", + reason: "reset", + bindingTarget: { + kind: "stateful", + driverId: "test-driver", + sessionKey: "agent:codex:test-driver", + agentId: "codex", + }, + }); + }); + + it("returns a typed error when no driver is registered", async () => { + const bindingResolution = createBindingResolution("missing-driver"); + + await expect( + ensureConfiguredBindingTargetReady({ + cfg: {} as never, + bindingResolution, + }), + ).resolves.toEqual({ + ok: false, + error: "Configured binding target driver unavailable: missing-driver", + }); + await expect( + ensureConfiguredBindingTargetSession({ + cfg: {} as never, + bindingResolution, + }), + ).resolves.toEqual({ + ok: false, + sessionKey: "agent:codex:missing-driver", + error: "Configured binding target driver unavailable: missing-driver", + }); + }); +}); diff --git a/src/channels/plugins/binding-targets.ts b/src/channels/plugins/binding-targets.ts new file mode 100644 index 00000000000..2ca8fefea22 --- /dev/null +++ b/src/channels/plugins/binding-targets.ts @@ -0,0 +1,69 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { ConfiguredBindingResolution } from "./binding-types.js"; +import { ensureStatefulTargetBuiltinsRegistered } from "./stateful-target-builtins.js"; +import { + getStatefulBindingTargetDriver, + resolveStatefulBindingTargetBySessionKey, +} from "./stateful-target-drivers.js"; + +export async function ensureConfiguredBindingTargetReady(params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution | null; +}): Promise<{ ok: true } | { ok: false; error: string }> { + ensureStatefulTargetBuiltinsRegistered(); + if (!params.bindingResolution) { + return { ok: true }; + } + const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId); + if (!driver) { + return { + ok: false, + error: `Configured binding target driver unavailable: ${params.bindingResolution.statefulTarget.driverId}`, + }; + } + return await driver.ensureReady({ + cfg: params.cfg, + bindingResolution: params.bindingResolution, + }); +} + +export async function resetConfiguredBindingTargetInPlace(params: { + cfg: OpenClawConfig; + sessionKey: string; + reason: "new" | "reset"; +}): Promise<{ ok: true } | { ok: false; skipped?: boolean; error?: string }> { + ensureStatefulTargetBuiltinsRegistered(); + const resolved = resolveStatefulBindingTargetBySessionKey({ + cfg: params.cfg, + sessionKey: params.sessionKey, + }); + if (!resolved?.driver.resetInPlace) { + return { + ok: false, + skipped: true, + }; + } + return await resolved.driver.resetInPlace({ + ...params, + bindingTarget: resolved.bindingTarget, + }); +} + +export async function ensureConfiguredBindingTargetSession(params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution; +}): Promise<{ ok: true; sessionKey: string } | { ok: false; sessionKey: string; error: string }> { + ensureStatefulTargetBuiltinsRegistered(); + const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId); + if (!driver) { + return { + ok: false, + sessionKey: params.bindingResolution.statefulTarget.sessionKey, + error: `Configured binding target driver unavailable: ${params.bindingResolution.statefulTarget.driverId}`, + }; + } + return await driver.ensureSession({ + cfg: params.cfg, + bindingResolution: params.bindingResolution, + }); +} diff --git a/src/channels/plugins/binding-types.ts b/src/channels/plugins/binding-types.ts new file mode 100644 index 00000000000..81ca368bc2b --- /dev/null +++ b/src/channels/plugins/binding-types.ts @@ -0,0 +1,53 @@ +import type { AgentBinding } from "../../config/types.js"; +import type { + ConversationRef, + SessionBindingRecord, +} from "../../infra/outbound/session-binding-service.js"; +import type { + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingMatch, + ChannelConfiguredBindingProvider, +} from "./types.adapters.js"; +import type { ChannelId } from "./types.js"; + +export type ConfiguredBindingConversation = ConversationRef; +export type ConfiguredBindingChannel = ChannelId; +export type ConfiguredBindingRuleConfig = AgentBinding; + +export type StatefulBindingTargetDescriptor = { + kind: "stateful"; + driverId: string; + sessionKey: string; + agentId: string; + label?: string; +}; + +export type ConfiguredBindingRecordResolution = { + record: SessionBindingRecord; + statefulTarget: StatefulBindingTargetDescriptor; +}; + +export type ConfiguredBindingTargetFactory = { + driverId: string; + materialize: (params: { + accountId: string; + conversation: ChannelConfiguredBindingConversationRef; + }) => ConfiguredBindingRecordResolution; +}; + +export type CompiledConfiguredBinding = { + channel: ConfiguredBindingChannel; + accountPattern?: string; + binding: ConfiguredBindingRuleConfig; + bindingConversationId: string; + target: ChannelConfiguredBindingConversationRef; + agentId: string; + provider: ChannelConfiguredBindingProvider; + targetFactory: ConfiguredBindingTargetFactory; +}; + +export type ConfiguredBindingResolution = ConfiguredBindingRecordResolution & { + conversation: ConfiguredBindingConversation; + compiledBinding: CompiledConfiguredBinding; + match: ChannelConfiguredBindingMatch; +}; diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index c7cae53de20..5579ddfdf65 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -1,33 +1,30 @@ -import { bluebubblesPlugin } from "../../../extensions/bluebubbles/src/channel.js"; -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; -import { discordSetupPlugin } from "../../../extensions/discord/src/channel.setup.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 { imessageSetupPlugin } from "../../../extensions/imessage/src/channel.setup.js"; -import { ircPlugin } from "../../../extensions/irc/src/channel.js"; -import { linePlugin } from "../../../extensions/line/src/channel.js"; -import { lineSetupPlugin } from "../../../extensions/line/src/channel.setup.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 { signalSetupPlugin } from "../../../extensions/signal/src/channel.setup.js"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { slackSetupPlugin } from "../../../extensions/slack/src/channel.setup.js"; -import { synologyChatPlugin } from "../../../extensions/synology-chat/src/channel.js"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { telegramSetupPlugin } from "../../../extensions/telegram/src/channel.setup.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 { whatsappSetupPlugin } from "../../../extensions/whatsapp/src/channel.setup.js"; -import { zaloPlugin } from "../../../extensions/zalo/src/channel.js"; -import { zalouserPlugin } from "../../../extensions/zalouser/src/channel.js"; +import { bluebubblesPlugin } from "../../../extensions/bluebubbles/index.js"; +import { discordPlugin, setDiscordRuntime } from "../../../extensions/discord/index.js"; +import { discordSetupPlugin } from "../../../extensions/discord/setup-entry.js"; +import { feishuPlugin } from "../../../extensions/feishu/index.js"; +import { googlechatPlugin } from "../../../extensions/googlechat/index.js"; +import { imessagePlugin } from "../../../extensions/imessage/index.js"; +import { imessageSetupPlugin } from "../../../extensions/imessage/setup-entry.js"; +import { ircPlugin } from "../../../extensions/irc/index.js"; +import { linePlugin, setLineRuntime } from "../../../extensions/line/index.js"; +import { lineSetupPlugin } from "../../../extensions/line/setup-entry.js"; +import { matrixPlugin } from "../../../extensions/matrix/index.js"; +import { mattermostPlugin } from "../../../extensions/mattermost/index.js"; +import { msteamsPlugin } from "../../../extensions/msteams/index.js"; +import { nextcloudTalkPlugin } from "../../../extensions/nextcloud-talk/index.js"; +import { nostrPlugin } from "../../../extensions/nostr/index.js"; +import { signalPlugin } from "../../../extensions/signal/index.js"; +import { signalSetupPlugin } from "../../../extensions/signal/setup-entry.js"; +import { slackPlugin } from "../../../extensions/slack/index.js"; +import { slackSetupPlugin } from "../../../extensions/slack/setup-entry.js"; +import { synologyChatPlugin } from "../../../extensions/synology-chat/index.js"; +import { telegramPlugin, setTelegramRuntime } from "../../../extensions/telegram/index.js"; +import { telegramSetupPlugin } from "../../../extensions/telegram/setup-entry.js"; +import { tlonPlugin } from "../../../extensions/tlon/index.js"; +import { whatsappPlugin } from "../../../extensions/whatsapp/index.js"; +import { whatsappSetupPlugin } from "../../../extensions/whatsapp/setup-entry.js"; +import { zaloPlugin } from "../../../extensions/zalo/index.js"; +import { zalouserPlugin } from "../../../extensions/zalouser/index.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; export const bundledChannelPlugins = [ diff --git a/src/channels/plugins/configured-binding-builtins.ts b/src/channels/plugins/configured-binding-builtins.ts new file mode 100644 index 00000000000..2d27e9b5286 --- /dev/null +++ b/src/channels/plugins/configured-binding-builtins.ts @@ -0,0 +1,13 @@ +import { acpConfiguredBindingConsumer } from "./acp-configured-binding-consumer.js"; +import { + registerConfiguredBindingConsumer, + unregisterConfiguredBindingConsumer, +} from "./configured-binding-consumers.js"; + +export function ensureConfiguredBindingBuiltinsRegistered(): void { + registerConfiguredBindingConsumer(acpConfiguredBindingConsumer); +} + +export function resetConfiguredBindingBuiltinsForTesting(): void { + unregisterConfiguredBindingConsumer(acpConfiguredBindingConsumer.id); +} diff --git a/src/channels/plugins/configured-binding-compiler.ts b/src/channels/plugins/configured-binding-compiler.ts new file mode 100644 index 00000000000..ca5a88022d1 --- /dev/null +++ b/src/channels/plugins/configured-binding-compiler.ts @@ -0,0 +1,240 @@ +import { listConfiguredBindings } from "../../config/bindings.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { getActivePluginRegistry, getActivePluginRegistryVersion } from "../../plugins/runtime.js"; +import { pickFirstExistingAgentId } from "../../routing/resolve-route.js"; +import { resolveChannelConfiguredBindingProvider } from "./binding-provider.js"; +import type { CompiledConfiguredBinding, ConfiguredBindingChannel } from "./binding-types.js"; +import { resolveConfiguredBindingConsumer } from "./configured-binding-consumers.js"; +import { getChannelPlugin } from "./index.js"; +import type { + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingProvider, +} from "./types.adapters.js"; + +// Configured bindings are channel-owned rules compiled from config, separate +// from runtime plugin-owned conversation bindings. + +type ChannelPluginLike = NonNullable>; + +export type CompiledConfiguredBindingRegistry = { + rulesByChannel: Map; +}; + +type CachedCompiledConfiguredBindingRegistry = { + registryVersion: number; + registry: CompiledConfiguredBindingRegistry; +}; + +const compiledRegistryCache = new WeakMap< + OpenClawConfig, + CachedCompiledConfiguredBindingRegistry +>(); + +function findChannelPlugin(params: { + registry: + | { + channels?: Array<{ plugin?: ChannelPluginLike | null } | null> | null; + } + | null + | undefined; + channel: string; +}): ChannelPluginLike | undefined { + return ( + params.registry?.channels?.find((entry) => entry?.plugin?.id === params.channel)?.plugin ?? + undefined + ); +} + +function resolveLoadedChannelPlugin(channel: string) { + const normalized = channel.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + + const current = getChannelPlugin(normalized as ConfiguredBindingChannel); + if (current) { + return current; + } + + return findChannelPlugin({ + registry: getActivePluginRegistry(), + channel: normalized, + }); +} + +function resolveConfiguredBindingAdapter(channel: string): { + channel: ConfiguredBindingChannel; + provider: ChannelConfiguredBindingProvider; +} | null { + const normalized = channel.trim().toLowerCase(); + if (!normalized) { + return null; + } + const plugin = resolveLoadedChannelPlugin(normalized); + const provider = resolveChannelConfiguredBindingProvider(plugin); + if ( + !plugin || + !provider || + !provider.compileConfiguredBinding || + !provider.matchInboundConversation + ) { + return null; + } + return { + channel: plugin.id, + provider, + }; +} + +function resolveBindingConversationId(binding: { + match?: { peer?: { id?: string } }; +}): string | null { + const id = binding.match?.peer?.id?.trim(); + return id ? id : null; +} + +function compileConfiguredBindingTarget(params: { + provider: ChannelConfiguredBindingProvider; + binding: CompiledConfiguredBinding["binding"]; + conversationId: string; +}): ChannelConfiguredBindingConversationRef | null { + return params.provider.compileConfiguredBinding({ + binding: params.binding, + conversationId: params.conversationId, + }); +} + +function compileConfiguredBindingRule(params: { + cfg: OpenClawConfig; + channel: ConfiguredBindingChannel; + binding: CompiledConfiguredBinding["binding"]; + target: ChannelConfiguredBindingConversationRef; + bindingConversationId: string; + provider: ChannelConfiguredBindingProvider; +}): CompiledConfiguredBinding | null { + const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main"); + const consumer = resolveConfiguredBindingConsumer(params.binding); + if (!consumer) { + return null; + } + const targetFactory = consumer.buildTargetFactory({ + cfg: params.cfg, + binding: params.binding, + channel: params.channel, + agentId, + target: params.target, + bindingConversationId: params.bindingConversationId, + }); + if (!targetFactory) { + return null; + } + return { + channel: params.channel, + accountPattern: params.binding.match.accountId?.trim() || undefined, + binding: params.binding, + bindingConversationId: params.bindingConversationId, + target: params.target, + agentId, + provider: params.provider, + targetFactory, + }; +} + +function pushCompiledRule( + target: Map, + rule: CompiledConfiguredBinding, +) { + const existing = target.get(rule.channel); + if (existing) { + existing.push(rule); + return; + } + target.set(rule.channel, [rule]); +} + +function compileConfiguredBindingRegistry(params: { + cfg: OpenClawConfig; +}): CompiledConfiguredBindingRegistry { + const rulesByChannel = new Map(); + + for (const binding of listConfiguredBindings(params.cfg)) { + const bindingConversationId = resolveBindingConversationId(binding); + if (!bindingConversationId) { + continue; + } + + const resolvedChannel = resolveConfiguredBindingAdapter(binding.match.channel); + if (!resolvedChannel) { + continue; + } + + const target = compileConfiguredBindingTarget({ + provider: resolvedChannel.provider, + binding, + conversationId: bindingConversationId, + }); + if (!target) { + continue; + } + + const rule = compileConfiguredBindingRule({ + cfg: params.cfg, + channel: resolvedChannel.channel, + binding, + target, + bindingConversationId, + provider: resolvedChannel.provider, + }); + if (!rule) { + continue; + } + pushCompiledRule(rulesByChannel, rule); + } + + return { + rulesByChannel, + }; +} + +export function resolveCompiledBindingRegistry( + cfg: OpenClawConfig, +): CompiledConfiguredBindingRegistry { + const registryVersion = getActivePluginRegistryVersion(); + const cached = compiledRegistryCache.get(cfg); + if (cached?.registryVersion === registryVersion) { + return cached.registry; + } + + const registry = compileConfiguredBindingRegistry({ + cfg, + }); + compiledRegistryCache.set(cfg, { + registryVersion, + registry, + }); + return registry; +} + +export function primeCompiledBindingRegistry( + cfg: OpenClawConfig, +): CompiledConfiguredBindingRegistry { + const registry = compileConfiguredBindingRegistry({ cfg }); + compiledRegistryCache.set(cfg, { + registryVersion: getActivePluginRegistryVersion(), + registry, + }); + return registry; +} + +export function countCompiledBindingRegistry(registry: CompiledConfiguredBindingRegistry): { + bindingCount: number; + channelCount: number; +} { + return { + bindingCount: [...registry.rulesByChannel.values()].reduce( + (sum, rules) => sum + rules.length, + 0, + ), + channelCount: registry.rulesByChannel.size, + }; +} diff --git a/src/channels/plugins/configured-binding-consumers.ts b/src/channels/plugins/configured-binding-consumers.ts new file mode 100644 index 00000000000..dbe5dc8791c --- /dev/null +++ b/src/channels/plugins/configured-binding-consumers.ts @@ -0,0 +1,69 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { + CompiledConfiguredBinding, + ConfiguredBindingRecordResolution, + ConfiguredBindingRuleConfig, + ConfiguredBindingTargetFactory, +} from "./binding-types.js"; +import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js"; + +export type ParsedConfiguredBindingSessionKey = { + channel: string; + accountId: string; +}; + +export type ConfiguredBindingConsumer = { + id: string; + supports: (binding: ConfiguredBindingRuleConfig) => boolean; + buildTargetFactory: (params: { + cfg: OpenClawConfig; + binding: ConfiguredBindingRuleConfig; + channel: string; + agentId: string; + target: ChannelConfiguredBindingConversationRef; + bindingConversationId: string; + }) => ConfiguredBindingTargetFactory | null; + parseSessionKey?: (params: { sessionKey: string }) => ParsedConfiguredBindingSessionKey | null; + matchesSessionKey?: (params: { + sessionKey: string; + compiledBinding: CompiledConfiguredBinding; + accountId: string; + materializedTarget: ConfiguredBindingRecordResolution; + }) => boolean; +}; + +const registeredConfiguredBindingConsumers = new Map(); + +export function listConfiguredBindingConsumers(): ConfiguredBindingConsumer[] { + return [...registeredConfiguredBindingConsumers.values()]; +} + +export function resolveConfiguredBindingConsumer( + binding: ConfiguredBindingRuleConfig, +): ConfiguredBindingConsumer | null { + for (const consumer of listConfiguredBindingConsumers()) { + if (consumer.supports(binding)) { + return consumer; + } + } + return null; +} + +export function registerConfiguredBindingConsumer(consumer: ConfiguredBindingConsumer): void { + const id = consumer.id.trim(); + if (!id) { + throw new Error("Configured binding consumer id is required"); + } + const existing = registeredConfiguredBindingConsumers.get(id); + if (existing) { + return; + } + registeredConfiguredBindingConsumers.set(id, { + ...consumer, + id, + }); +} + +export function unregisterConfiguredBindingConsumer(id: string): void { + registeredConfiguredBindingConsumers.delete(id.trim()); +} diff --git a/src/channels/plugins/configured-binding-match.ts b/src/channels/plugins/configured-binding-match.ts new file mode 100644 index 00000000000..7e9ec4f4b09 --- /dev/null +++ b/src/channels/plugins/configured-binding-match.ts @@ -0,0 +1,116 @@ +import type { ConversationRef } from "../../infra/outbound/session-binding-service.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +import type { + CompiledConfiguredBinding, + ConfiguredBindingChannel, + ConfiguredBindingRecordResolution, +} from "./binding-types.js"; +import type { + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingMatch, +} from "./types.adapters.js"; + +export function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 { + const trimmed = (match ?? "").trim(); + if (!trimmed) { + return actual === DEFAULT_ACCOUNT_ID ? 2 : 0; + } + if (trimmed === "*") { + return 1; + } + return normalizeAccountId(trimmed) === actual ? 2 : 0; +} + +function matchCompiledBindingConversation(params: { + rule: CompiledConfiguredBinding; + conversationId: string; + parentConversationId?: string; +}): ChannelConfiguredBindingMatch | null { + return params.rule.provider.matchInboundConversation({ + binding: params.rule.binding, + compiledBinding: params.rule.target, + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }); +} + +export function resolveCompiledBindingChannel(raw: string): ConfiguredBindingChannel | null { + const normalized = raw.trim().toLowerCase(); + return normalized ? (normalized as ConfiguredBindingChannel) : null; +} + +export function toConfiguredBindingConversationRef(conversation: ConversationRef): { + channel: ConfiguredBindingChannel; + accountId: string; + conversationId: string; + parentConversationId?: string; +} | null { + const channel = resolveCompiledBindingChannel(conversation.channel); + const conversationId = conversation.conversationId.trim(); + if (!channel || !conversationId) { + return null; + } + return { + channel, + accountId: normalizeAccountId(conversation.accountId), + conversationId, + parentConversationId: conversation.parentConversationId?.trim() || undefined, + }; +} + +export function materializeConfiguredBindingRecord(params: { + rule: CompiledConfiguredBinding; + accountId: string; + conversation: ChannelConfiguredBindingConversationRef; +}): ConfiguredBindingRecordResolution { + return params.rule.targetFactory.materialize({ + accountId: normalizeAccountId(params.accountId), + conversation: params.conversation, + }); +} + +export function resolveMatchingConfiguredBinding(params: { + rules: CompiledConfiguredBinding[]; + conversation: ReturnType; +}): { rule: CompiledConfiguredBinding; match: ChannelConfiguredBindingMatch } | null { + if (!params.conversation) { + return null; + } + + let wildcardMatch: { + rule: CompiledConfiguredBinding; + match: ChannelConfiguredBindingMatch; + } | null = null; + let exactMatch: { rule: CompiledConfiguredBinding; match: ChannelConfiguredBindingMatch } | null = + null; + + for (const rule of params.rules) { + const accountMatchPriority = resolveAccountMatchPriority( + rule.accountPattern, + params.conversation.accountId, + ); + if (accountMatchPriority === 0) { + continue; + } + const match = matchCompiledBindingConversation({ + rule, + conversationId: params.conversation.conversationId, + parentConversationId: params.conversation.parentConversationId, + }); + if (!match) { + continue; + } + const matchPriority = match.matchPriority ?? 0; + if (accountMatchPriority === 2) { + if (!exactMatch || matchPriority > (exactMatch.match.matchPriority ?? 0)) { + exactMatch = { rule, match }; + } + continue; + } + if (!wildcardMatch || matchPriority > (wildcardMatch.match.matchPriority ?? 0)) { + wildcardMatch = { rule, match }; + } + } + + return exactMatch ?? wildcardMatch; +} diff --git a/src/channels/plugins/configured-binding-registry.ts b/src/channels/plugins/configured-binding-registry.ts new file mode 100644 index 00000000000..6a7aba3bdfb --- /dev/null +++ b/src/channels/plugins/configured-binding-registry.ts @@ -0,0 +1,116 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { ConversationRef } from "../../infra/outbound/session-binding-service.js"; +import type { + ConfiguredBindingRecordResolution, + ConfiguredBindingResolution, +} from "./binding-types.js"; +import { + countCompiledBindingRegistry, + primeCompiledBindingRegistry, + resolveCompiledBindingRegistry, +} from "./configured-binding-compiler.js"; +import { + materializeConfiguredBindingRecord, + resolveMatchingConfiguredBinding, + toConfiguredBindingConversationRef, +} from "./configured-binding-match.js"; +import { resolveConfiguredBindingRecordBySessionKeyFromRegistry } from "./configured-binding-session-lookup.js"; + +export function primeConfiguredBindingRegistry(params: { cfg: OpenClawConfig }): { + bindingCount: number; + channelCount: number; +} { + return countCompiledBindingRegistry(primeCompiledBindingRegistry(params.cfg)); +} + +export function resolveConfiguredBindingRecord(params: { + cfg: OpenClawConfig; + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; +}): ConfiguredBindingRecordResolution | null { + const conversation = toConfiguredBindingConversationRef({ + channel: params.channel, + accountId: params.accountId, + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }); + if (!conversation) { + return null; + } + return resolveConfiguredBindingRecordForConversation({ + cfg: params.cfg, + conversation, + }); +} + +export function resolveConfiguredBindingRecordForConversation(params: { + cfg: OpenClawConfig; + conversation: ConversationRef; +}): ConfiguredBindingRecordResolution | null { + const conversation = toConfiguredBindingConversationRef(params.conversation); + if (!conversation) { + return null; + } + const registry = resolveCompiledBindingRegistry(params.cfg); + const rules = registry.rulesByChannel.get(conversation.channel); + if (!rules || rules.length === 0) { + return null; + } + const resolved = resolveMatchingConfiguredBinding({ + rules, + conversation, + }); + if (!resolved) { + return null; + } + return materializeConfiguredBindingRecord({ + rule: resolved.rule, + accountId: conversation.accountId, + conversation: resolved.match, + }); +} + +export function resolveConfiguredBinding(params: { + cfg: OpenClawConfig; + conversation: ConversationRef; +}): ConfiguredBindingResolution | null { + const conversation = toConfiguredBindingConversationRef(params.conversation); + if (!conversation) { + return null; + } + const registry = resolveCompiledBindingRegistry(params.cfg); + const rules = registry.rulesByChannel.get(conversation.channel); + if (!rules || rules.length === 0) { + return null; + } + const resolved = resolveMatchingConfiguredBinding({ + rules, + conversation, + }); + if (!resolved) { + return null; + } + const materializedTarget = materializeConfiguredBindingRecord({ + rule: resolved.rule, + accountId: conversation.accountId, + conversation: resolved.match, + }); + return { + conversation, + compiledBinding: resolved.rule, + match: resolved.match, + ...materializedTarget, + }; +} + +export function resolveConfiguredBindingRecordBySessionKey(params: { + cfg: OpenClawConfig; + sessionKey: string; +}): ConfiguredBindingRecordResolution | null { + return resolveConfiguredBindingRecordBySessionKeyFromRegistry({ + registry: resolveCompiledBindingRegistry(params.cfg), + sessionKey: params.sessionKey, + }); +} diff --git a/src/channels/plugins/configured-binding-session-lookup.ts b/src/channels/plugins/configured-binding-session-lookup.ts new file mode 100644 index 00000000000..e4baa4057d8 --- /dev/null +++ b/src/channels/plugins/configured-binding-session-lookup.ts @@ -0,0 +1,74 @@ +import type { ConfiguredBindingRecordResolution } from "./binding-types.js"; +import type { CompiledConfiguredBindingRegistry } from "./configured-binding-compiler.js"; +import { listConfiguredBindingConsumers } from "./configured-binding-consumers.js"; +import { + materializeConfiguredBindingRecord, + resolveAccountMatchPriority, + resolveCompiledBindingChannel, +} from "./configured-binding-match.js"; + +export function resolveConfiguredBindingRecordBySessionKeyFromRegistry(params: { + registry: CompiledConfiguredBindingRegistry; + sessionKey: string; +}): ConfiguredBindingRecordResolution | null { + const sessionKey = params.sessionKey.trim(); + if (!sessionKey) { + return null; + } + + for (const consumer of listConfiguredBindingConsumers()) { + const parsed = consumer.parseSessionKey?.({ sessionKey }); + if (!parsed) { + continue; + } + const channel = resolveCompiledBindingChannel(parsed.channel); + if (!channel) { + continue; + } + const rules = params.registry.rulesByChannel.get(channel); + if (!rules || rules.length === 0) { + continue; + } + let wildcardMatch: ConfiguredBindingRecordResolution | null = null; + let exactMatch: ConfiguredBindingRecordResolution | null = null; + for (const rule of rules) { + if (rule.targetFactory.driverId !== consumer.id) { + continue; + } + const accountMatchPriority = resolveAccountMatchPriority( + rule.accountPattern, + parsed.accountId, + ); + if (accountMatchPriority === 0) { + continue; + } + const materializedTarget = materializeConfiguredBindingRecord({ + rule, + accountId: parsed.accountId, + conversation: rule.target, + }); + const matchesSessionKey = + consumer.matchesSessionKey?.({ + sessionKey, + compiledBinding: rule, + accountId: parsed.accountId, + materializedTarget, + }) ?? materializedTarget.record.targetSessionKey === sessionKey; + if (matchesSessionKey) { + if (accountMatchPriority === 2) { + exactMatch = materializedTarget; + break; + } + wildcardMatch = materializedTarget; + } + } + if (exactMatch) { + return exactMatch; + } + if (wildcardMatch) { + return wildcardMatch; + } + } + + return null; +} diff --git a/src/channels/plugins/contracts/inbound.contract.test.ts b/src/channels/plugins/contracts/inbound.contract.test.ts index aeb231cb628..f4f3ffa0a87 100644 --- a/src/channels/plugins/contracts/inbound.contract.test.ts +++ b/src/channels/plugins/contracts/inbound.contract.test.ts @@ -62,6 +62,18 @@ vi.mock("../../../../extensions/whatsapp/src/auto-reply/deliver-reply.js", () => deliverWebReply: vi.fn(async () => {}), })); +const { processDiscordMessage } = + await import("../../../../extensions/discord/src/monitor/message-handler.process.js"); +const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } = + await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js"); +const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js"); +const { prepareSlackMessage } = + await import("../../../../extensions/slack/src/monitor/message-handler/prepare.js"); +const { createInboundSlackTestContext } = + await import("../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js"); +const { buildTelegramMessageContextForTest } = + await import("../../../../extensions/telegram/src/bot-message-context.test-harness.js"); + function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount { return { accountId: "default", @@ -94,10 +106,6 @@ describe("channel inbound contract", () => { }); it("keeps Discord inbound context finalized", async () => { - const { processDiscordMessage } = - await import("../../../../extensions/discord/src/monitor/message-handler.process.js"); - const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } = - await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js"); const messageCtx = await createBaseDiscordMessageContext({ cfg: { messages: {} }, ackReactionScope: "direct", @@ -111,7 +119,6 @@ describe("channel inbound contract", () => { }); it("keeps Signal inbound context finalized", async () => { - const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js"); const ctx = finalizeInboundContext({ Body: "Alice: hi", BodyForAgent: "hi", @@ -139,10 +146,6 @@ describe("channel inbound contract", () => { }); it("keeps Slack inbound context finalized", async () => { - const { prepareSlackMessage } = - await import("../../../../extensions/slack/src/monitor/message-handler/prepare.js"); - const { createInboundSlackTestContext } = - await import("../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js"); const ctx = createInboundSlackTestContext({ cfg: { channels: { slack: { enabled: true } }, @@ -163,9 +166,6 @@ describe("channel inbound contract", () => { }); it("keeps Telegram inbound context finalized", async () => { - const { buildTelegramMessageContextForTest } = - await import("../../../../extensions/telegram/src/bot-message-context.test-harness.js"); - const context = await buildTelegramMessageContextForTest({ cfg: { agents: { @@ -200,7 +200,6 @@ describe("channel inbound contract", () => { }); it("keeps WhatsApp inbound context finalized", async () => { - const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js"); const ctx = finalizeInboundContext({ Body: "Alice: hi", BodyForAgent: "hi", diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 339651437d3..d651b6ef012 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -2,10 +2,10 @@ import { expect, vi } from "vitest"; import { __testing as discordThreadBindingTesting, createThreadBindingManager as createDiscordThreadBindingManager, -} from "../../../../extensions/discord/src/monitor/thread-bindings.manager.js"; -import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/src/thread-bindings.js"; -import { setMatrixRuntime } from "../../../../extensions/matrix/src/runtime.js"; -import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/src/thread-bindings.js"; +} from "../../../../extensions/discord/runtime-api.js"; +import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js"; +import { setMatrixRuntime } from "../../../../extensions/matrix/api.js"; +import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { getSessionBindingService, diff --git a/src/channels/plugins/message-capability-matrix.test.ts b/src/channels/plugins/message-capability-matrix.test.ts index b8d289aa56b..9ab42ad4c51 100644 --- a/src/channels/plugins/message-capability-matrix.test.ts +++ b/src/channels/plugins/message-capability-matrix.test.ts @@ -75,18 +75,15 @@ describe("channel action capability matrix", () => { 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 }); + const discordResult = discordPlugin.actions?.getCapabilities?.({ cfg: {} as OpenClawConfig }); - expect(result).toEqual(["interactive", "components"]); + expect(discordResult).toEqual(["interactive", "components"]); expect(discordGetCapabilitiesMock).toHaveBeenCalledWith({ cfg: {} }); }); - it("exposes Mattermost buttons only when an account is configured", () => { + it("exposes configured channel capabilities only when required credentials are present", () => { const configuredCfg = { channels: { mattermost: { @@ -103,61 +100,57 @@ describe("channel action capability matrix", () => { }, }, } as OpenClawConfig; + const configuredFeishuCfg = { + channels: { + feishu: { + enabled: true, + appId: "cli_a", + appSecret: "secret", + }, + }, + } as OpenClawConfig; + const disabledFeishuCfg = { + channels: { + feishu: { + enabled: false, + appId: "cli_a", + appSecret: "secret", + }, + }, + } as OpenClawConfig; + const configuredMsteamsCfg = { + channels: { + msteams: { + enabled: true, + tenantId: "tenant", + appId: "app", + appPassword: "secret", + }, + }, + } as OpenClawConfig; + const disabledMsteamsCfg = { + channels: { + msteams: { + enabled: false, + tenantId: "tenant", + appId: "app", + appPassword: "secret", + }, + }, + } 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([]); + expect(feishuPlugin.actions?.getCapabilities?.({ cfg: configuredFeishuCfg })).toEqual([ + "cards", + ]); + expect(feishuPlugin.actions?.getCapabilities?.({ cfg: disabledFeishuCfg })).toEqual([]); + expect(msteamsPlugin.actions?.getCapabilities?.({ cfg: configuredMsteamsCfg })).toEqual([ + "cards", + ]); + expect(msteamsPlugin.actions?.getCapabilities?.({ cfg: disabledMsteamsCfg })).toEqual([]); }); it("keeps Zalo actions on the empty capability set", () => { diff --git a/src/channels/plugins/stateful-target-builtins.ts b/src/channels/plugins/stateful-target-builtins.ts new file mode 100644 index 00000000000..0d87ca31d2d --- /dev/null +++ b/src/channels/plugins/stateful-target-builtins.ts @@ -0,0 +1,13 @@ +import { acpStatefulBindingTargetDriver } from "./acp-stateful-target-driver.js"; +import { + registerStatefulBindingTargetDriver, + unregisterStatefulBindingTargetDriver, +} from "./stateful-target-drivers.js"; + +export function ensureStatefulTargetBuiltinsRegistered(): void { + registerStatefulBindingTargetDriver(acpStatefulBindingTargetDriver); +} + +export function resetStatefulTargetBuiltinsForTesting(): void { + unregisterStatefulBindingTargetDriver(acpStatefulBindingTargetDriver.id); +} diff --git a/src/channels/plugins/stateful-target-drivers.ts b/src/channels/plugins/stateful-target-drivers.ts new file mode 100644 index 00000000000..ede52472c57 --- /dev/null +++ b/src/channels/plugins/stateful-target-drivers.ts @@ -0,0 +1,89 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { + ConfiguredBindingResolution, + StatefulBindingTargetDescriptor, +} from "./binding-types.js"; + +export type StatefulBindingTargetReadyResult = { ok: true } | { ok: false; error: string }; +export type StatefulBindingTargetSessionResult = + | { ok: true; sessionKey: string } + | { ok: false; sessionKey: string; error: string }; +export type StatefulBindingTargetResetResult = + | { ok: true } + | { ok: false; skipped?: boolean; error?: string }; + +export type StatefulBindingTargetDriver = { + id: string; + ensureReady: (params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution; + }) => Promise; + ensureSession: (params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution; + }) => Promise; + resolveTargetBySessionKey?: (params: { + cfg: OpenClawConfig; + sessionKey: string; + }) => StatefulBindingTargetDescriptor | null; + resetInPlace?: (params: { + cfg: OpenClawConfig; + sessionKey: string; + bindingTarget: StatefulBindingTargetDescriptor; + reason: "new" | "reset"; + }) => Promise; +}; + +const registeredStatefulBindingTargetDrivers = new Map(); + +function listStatefulBindingTargetDrivers(): StatefulBindingTargetDriver[] { + return [...registeredStatefulBindingTargetDrivers.values()]; +} + +export function registerStatefulBindingTargetDriver(driver: StatefulBindingTargetDriver): void { + const id = driver.id.trim(); + if (!id) { + throw new Error("Stateful binding target driver id is required"); + } + const normalized = { ...driver, id }; + const existing = registeredStatefulBindingTargetDrivers.get(id); + if (existing) { + return; + } + registeredStatefulBindingTargetDrivers.set(id, normalized); +} + +export function unregisterStatefulBindingTargetDriver(id: string): void { + registeredStatefulBindingTargetDrivers.delete(id.trim()); +} + +export function getStatefulBindingTargetDriver(id: string): StatefulBindingTargetDriver | null { + const normalizedId = id.trim(); + if (!normalizedId) { + return null; + } + return registeredStatefulBindingTargetDrivers.get(normalizedId) ?? null; +} + +export function resolveStatefulBindingTargetBySessionKey(params: { + cfg: OpenClawConfig; + sessionKey: string; +}): { driver: StatefulBindingTargetDriver; bindingTarget: StatefulBindingTargetDescriptor } | null { + const sessionKey = params.sessionKey.trim(); + if (!sessionKey) { + return null; + } + for (const driver of listStatefulBindingTargetDrivers()) { + const bindingTarget = driver.resolveTargetBySessionKey?.({ + cfg: params.cfg, + sessionKey, + }); + if (bindingTarget) { + return { + driver, + bindingTarget, + }; + } + } + return null; +} diff --git a/src/channels/plugins/target-parsing.ts b/src/channels/plugins/target-parsing.ts index beea68adca3..7efa740de37 100644 --- a/src/channels/plugins/target-parsing.ts +++ b/src/channels/plugins/target-parsing.ts @@ -1,5 +1,5 @@ -import { parseDiscordTarget } from "../../../extensions/discord/src/targets.js"; -import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js"; +import { parseDiscordTarget } from "../../../extensions/discord/api.js"; +import { parseTelegramTarget } from "../../../extensions/telegram/api.js"; import type { ChatType } from "../chat-type.js"; import { normalizeChatChannelId } from "../registry.js"; import { getChannelPlugin, normalizeChannelId } from "./registry.js"; diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index eff6878e85e..c31d6057223 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -1,6 +1,6 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; +import type { ConfiguredBindingRule } from "../../config/bindings.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"; @@ -541,24 +541,26 @@ export type ChannelAllowlistAdapter = { supportsScope?: (params: { scope: "dm" | "group" | "all" }) => boolean; }; -export type ChannelAcpBindingAdapter = { - normalizeConfiguredBindingTarget?: (params: { - binding: AgentAcpBinding; +export type ChannelConfiguredBindingConversationRef = { + conversationId: string; + parentConversationId?: string; +}; + +export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingConversationRef & { + matchPriority?: number; +}; + +export type ChannelConfiguredBindingProvider = { + compileConfiguredBinding: (params: { + binding: ConfiguredBindingRule; conversationId: string; - }) => { + }) => ChannelConfiguredBindingConversationRef | null; + matchInboundConversation: (params: { + binding: ConfiguredBindingRule; + compiledBinding: ChannelConfiguredBindingConversationRef; conversationId: string; parentConversationId?: string; - } | null; - matchConfiguredBinding?: (params: { - binding: AgentAcpBinding; - bindingConversationId: string; - conversationId: string; - parentConversationId?: string; - }) => { - conversationId: string; - parentConversationId?: string; - matchPriority?: number; - } | null; + }) => ChannelConfiguredBindingMatch | null; }; export type ChannelSecurityAdapter = { diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index 6798545d22f..b4405a063de 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -17,7 +17,7 @@ import type { ChannelSetupAdapter, ChannelStatusAdapter, ChannelAllowlistAdapter, - ChannelAcpBindingAdapter, + ChannelConfiguredBindingProvider, } from "./types.adapters.js"; import type { ChannelAgentTool, @@ -78,7 +78,7 @@ export type ChannelPlugin, +): string { + if (capabilities.length === 0) { + return "-"; + } + return capabilities.map((entry) => entry.kind).join(", "); +} + +function formatHookSummary(params: { + usesLegacyBeforeAgentStart: boolean; + typedHookCount: number; + customHookCount: number; +}): string { + const parts: string[] = []; + if (params.usesLegacyBeforeAgentStart) { + parts.push("before_agent_start"); + } + const nonLegacyTypedHookCount = + params.typedHookCount - (params.usesLegacyBeforeAgentStart ? 1 : 0); + if (nonLegacyTypedHookCount > 0) { + parts.push(`${nonLegacyTypedHookCount} typed`); + } + if (params.customHookCount > 0) { + parts.push(`${params.customHookCount} custom`); + } + return parts.length > 0 ? parts.join(", ") : "-"; +} + +function formatInstallLines(install: PluginInstallRecord | undefined): string[] { + if (!install) { + return []; + } + const lines = [`Source: ${install.source}`]; + if (install.spec) { + lines.push(`Spec: ${install.spec}`); + } + if (install.sourcePath) { + lines.push(`Source path: ${shortenHomePath(install.sourcePath)}`); + } + if (install.installPath) { + lines.push(`Install path: ${shortenHomePath(install.installPath)}`); + } + if (install.version) { + lines.push(`Recorded version: ${install.version}`); + } + if (install.installedAt) { + lines.push(`Installed at: ${install.installedAt}`); + } + return lines; +} + function applySlotSelectionForPlugin( config: OpenClawConfig, pluginId: string, @@ -542,88 +609,196 @@ export function registerPluginsCli(program: Command) { }); plugins - .command("info") - .description("Show plugin details") - .argument("", "Plugin id") + .command("inspect") + .alias("info") + .description("Inspect plugin details") + .argument("[id]", "Plugin id") + .option("--all", "Inspect all plugins") .option("--json", "Print JSON") - .action((id: string, opts: PluginInfoOptions) => { - const report = buildPluginStatusReport(); - const plugin = report.plugins.find((p) => p.id === id || p.name === id); - if (!plugin) { + .action((id: string | undefined, opts: PluginInspectOptions) => { + const cfg = loadConfig(); + const report = buildPluginStatusReport({ config: cfg }); + if (opts.all) { + if (id) { + defaultRuntime.error("Pass either a plugin id or --all, not both."); + process.exit(1); + } + const inspectAll = buildAllPluginInspectReports({ + config: cfg, + report, + }); + const inspectAllWithInstall = inspectAll.map((inspect) => ({ + ...inspect, + install: cfg.plugins?.installs?.[inspect.plugin.id], + })); + + if (opts.json) { + defaultRuntime.log(JSON.stringify(inspectAllWithInstall, null, 2)); + return; + } + + const tableWidth = getTerminalTableWidth(); + const rows = inspectAll.map((inspect) => ({ + Name: inspect.plugin.name || inspect.plugin.id, + ID: + inspect.plugin.name && inspect.plugin.name !== inspect.plugin.id + ? inspect.plugin.id + : "", + Status: + inspect.plugin.status === "loaded" + ? theme.success("loaded") + : inspect.plugin.status === "disabled" + ? theme.warn("disabled") + : theme.error("error"), + Shape: inspect.shape, + Capabilities: formatCapabilityKinds(inspect.capabilities), + Hooks: formatHookSummary({ + usesLegacyBeforeAgentStart: inspect.usesLegacyBeforeAgentStart, + typedHookCount: inspect.typedHooks.length, + customHookCount: inspect.customHooks.length, + }), + })); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Name", header: "Name", minWidth: 14, flex: true }, + { key: "ID", header: "ID", minWidth: 10, flex: true }, + { key: "Status", header: "Status", minWidth: 10 }, + { key: "Shape", header: "Shape", minWidth: 18 }, + { key: "Capabilities", header: "Capabilities", minWidth: 28, flex: true }, + { key: "Hooks", header: "Hooks", minWidth: 20, flex: true }, + ], + rows, + }).trimEnd(), + ); + return; + } + + if (!id) { + defaultRuntime.error("Provide a plugin id or use --all."); + process.exit(1); + } + + const inspect = buildPluginInspectReport({ + id, + config: cfg, + report, + }); + if (!inspect) { defaultRuntime.error(`Plugin not found: ${id}`); process.exit(1); } - const cfg = loadConfig(); - const install = cfg.plugins?.installs?.[plugin.id]; + const install = cfg.plugins?.installs?.[inspect.plugin.id]; if (opts.json) { - defaultRuntime.log(JSON.stringify(plugin, null, 2)); + defaultRuntime.log( + JSON.stringify( + { + ...inspect, + install, + }, + null, + 2, + ), + ); return; } const lines: string[] = []; - lines.push(theme.heading(plugin.name || plugin.id)); - if (plugin.name && plugin.name !== plugin.id) { - lines.push(theme.muted(`id: ${plugin.id}`)); + lines.push(theme.heading(inspect.plugin.name || inspect.plugin.id)); + if (inspect.plugin.name && inspect.plugin.name !== inspect.plugin.id) { + lines.push(theme.muted(`id: ${inspect.plugin.id}`)); } - if (plugin.description) { - lines.push(plugin.description); + if (inspect.plugin.description) { + lines.push(inspect.plugin.description); } lines.push(""); - lines.push(`${theme.muted("Status:")} ${plugin.status}`); - lines.push(`${theme.muted("Format:")} ${plugin.format ?? "openclaw"}`); - if (plugin.bundleFormat) { - lines.push(`${theme.muted("Bundle format:")} ${plugin.bundleFormat}`); + lines.push(`${theme.muted("Status:")} ${inspect.plugin.status}`); + lines.push(`${theme.muted("Format:")} ${inspect.plugin.format ?? "openclaw"}`); + if (inspect.plugin.bundleFormat) { + lines.push(`${theme.muted("Bundle format:")} ${inspect.plugin.bundleFormat}`); } - lines.push(`${theme.muted("Source:")} ${shortenHomeInString(plugin.source)}`); - lines.push(`${theme.muted("Origin:")} ${plugin.origin}`); - if (plugin.version) { - lines.push(`${theme.muted("Version:")} ${plugin.version}`); + lines.push(`${theme.muted("Source:")} ${shortenHomeInString(inspect.plugin.source)}`); + lines.push(`${theme.muted("Origin:")} ${inspect.plugin.origin}`); + if (inspect.plugin.version) { + lines.push(`${theme.muted("Version:")} ${inspect.plugin.version}`); } - if (plugin.toolNames.length > 0) { - lines.push(`${theme.muted("Tools:")} ${plugin.toolNames.join(", ")}`); - } - if (plugin.hookNames.length > 0) { - lines.push(`${theme.muted("Hooks:")} ${plugin.hookNames.join(", ")}`); - } - if (plugin.gatewayMethods.length > 0) { - lines.push(`${theme.muted("Gateway methods:")} ${plugin.gatewayMethods.join(", ")}`); - } - if (plugin.providerIds.length > 0) { - lines.push(`${theme.muted("Providers:")} ${plugin.providerIds.join(", ")}`); - } - if ((plugin.bundleCapabilities?.length ?? 0) > 0) { + lines.push(`${theme.muted("Shape:")} ${inspect.shape}`); + lines.push(`${theme.muted("Capability mode:")} ${inspect.capabilityMode}`); + lines.push( + `${theme.muted("Legacy before_agent_start:")} ${inspect.usesLegacyBeforeAgentStart ? "yes" : "no"}`, + ); + if ((inspect.plugin.bundleCapabilities?.length ?? 0) > 0) { lines.push( - `${theme.muted("Bundle capabilities:")} ${plugin.bundleCapabilities?.join(", ")}`, + `${theme.muted("Bundle capabilities:")} ${inspect.plugin.bundleCapabilities?.join(", ")}`, ); } - if (plugin.cliCommands.length > 0) { - lines.push(`${theme.muted("CLI commands:")} ${plugin.cliCommands.join(", ")}`); + lines.push( + ...formatInspectSection( + "Capabilities", + inspect.capabilities.map( + (entry) => + `${entry.kind}: ${entry.ids.length > 0 ? entry.ids.join(", ") : "(registered)"}`, + ), + ), + ); + lines.push( + ...formatInspectSection( + "Typed hooks", + inspect.typedHooks.map((entry) => + entry.priority == null ? entry.name : `${entry.name} (priority ${entry.priority})`, + ), + ), + ); + lines.push( + ...formatInspectSection( + "Custom hooks", + inspect.customHooks.map((entry) => `${entry.name}: ${entry.events.join(", ")}`), + ), + ); + lines.push( + ...formatInspectSection( + "Tools", + inspect.tools.map((entry) => { + const names = entry.names.length > 0 ? entry.names.join(", ") : "(anonymous)"; + return entry.optional ? `${names} [optional]` : names; + }), + ), + ); + lines.push(...formatInspectSection("Commands", inspect.commands)); + lines.push(...formatInspectSection("CLI commands", inspect.cliCommands)); + lines.push(...formatInspectSection("Services", inspect.services)); + lines.push(...formatInspectSection("Gateway methods", inspect.gatewayMethods)); + if (inspect.httpRouteCount > 0) { + lines.push(...formatInspectSection("HTTP routes", [String(inspect.httpRouteCount)])); } - if (plugin.services.length > 0) { - lines.push(`${theme.muted("Services:")} ${plugin.services.join(", ")}`); + const policyLines: string[] = []; + if (typeof inspect.policy.allowPromptInjection === "boolean") { + policyLines.push(`allowPromptInjection: ${inspect.policy.allowPromptInjection}`); } - if (plugin.error) { - lines.push(`${theme.error("Error:")} ${plugin.error}`); + if (typeof inspect.policy.allowModelOverride === "boolean") { + policyLines.push(`allowModelOverride: ${inspect.policy.allowModelOverride}`); } - if (install) { - lines.push(""); - lines.push(`${theme.muted("Install:")} ${install.source}`); - if (install.spec) { - lines.push(`${theme.muted("Spec:")} ${install.spec}`); - } - if (install.sourcePath) { - lines.push(`${theme.muted("Source path:")} ${shortenHomePath(install.sourcePath)}`); - } - if (install.installPath) { - lines.push(`${theme.muted("Install path:")} ${shortenHomePath(install.installPath)}`); - } - if (install.version) { - lines.push(`${theme.muted("Recorded version:")} ${install.version}`); - } - if (install.installedAt) { - lines.push(`${theme.muted("Installed at:")} ${install.installedAt}`); - } + if (inspect.policy.hasAllowedModelsConfig) { + policyLines.push( + `allowedModels: ${ + inspect.policy.allowedModels.length > 0 + ? inspect.policy.allowedModels.join(", ") + : "(configured but empty)" + }`, + ); + } + lines.push(...formatInspectSection("Policy", policyLines)); + lines.push( + ...formatInspectSection( + "Diagnostics", + inspect.diagnostics.map((entry) => `${entry.level.toUpperCase()}: ${entry.message}`), + ), + ); + lines.push(...formatInspectSection("Install", formatInstallLines(install))); + if (inspect.plugin.error) { + lines.push("", `${theme.error("Error:")} ${inspect.plugin.error}`); } defaultRuntime.log(lines.join("\n")); }); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 77593f876aa..abab0eb5cf4 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -206,6 +206,14 @@ describe("update-cli", () => { return call; }; + const expectPackageInstallSpec = (spec: string) => { + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runCommandWithTimeout).toHaveBeenCalledWith( + ["npm", "i", "-g", spec, "--no-fund", "--no-audit", "--loglevel=error"], + expect.any(Object), + ); + }; + const makeOkUpdateResult = (overrides: Partial = {}): UpdateRunResult => ({ status: "ok", @@ -257,6 +265,27 @@ describe("update-cli", () => { return tempDir; }; + const setupUpdatedRootRefresh = (params?: { + gatewayUpdateImpl?: () => Promise; + }) => { + const root = createCaseDir("openclaw-updated-root"); + const entryPath = path.join(root, "dist", "entry.js"); + pathExists.mockImplementation(async (candidate: string) => candidate === entryPath); + if (params?.gatewayUpdateImpl) { + vi.mocked(runGatewayUpdate).mockImplementation(params.gatewayUpdateImpl); + } else { + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "npm", + root, + steps: [], + durationMs: 100, + }); + } + serviceLoaded.mockResolvedValue(true); + return { root, entryPath }; + }; + beforeEach(() => { vi.clearAllMocks(); vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(process.cwd()); @@ -347,37 +376,77 @@ describe("update-cli", () => { setStdoutTty(false); }); - it("updateCommand --dry-run previews without mutating", async () => { - vi.mocked(defaultRuntime.log).mockClear(); - serviceLoaded.mockResolvedValue(true); + it("updateCommand dry-run previews without mutating and bypasses downgrade confirmation", async () => { + const cases = [ + { + name: "preview mode", + run: async () => { + vi.mocked(defaultRuntime.log).mockClear(); + serviceLoaded.mockResolvedValue(true); + await updateCommand({ dryRun: true, channel: "beta" }); + }, + assert: () => { + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runDaemonInstall).not.toHaveBeenCalled(); + expect(runRestartScript).not.toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); - await updateCommand({ dryRun: true, channel: "beta" }); + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect(logs.join("\n")).toContain("Update dry-run"); + expect(logs.join("\n")).toContain("No changes were applied."); + }, + }, + { + name: "downgrade bypass", + run: async () => { + await setupNonInteractiveDowngrade(); + vi.mocked(defaultRuntime.exit).mockClear(); + await updateCommand({ dryRun: true }); + }, + assert: () => { + expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe( + false, + ); + expect(runGatewayUpdate).not.toHaveBeenCalled(); + }, + }, + ] as const; - expect(writeConfigFile).not.toHaveBeenCalled(); - expect(runGatewayUpdate).not.toHaveBeenCalled(); - expect(runDaemonInstall).not.toHaveBeenCalled(); - expect(runRestartScript).not.toHaveBeenCalled(); - expect(runDaemonRestart).not.toHaveBeenCalled(); - - const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); - expect(logs.join("\n")).toContain("Update dry-run"); - expect(logs.join("\n")).toContain("No changes were applied."); + for (const testCase of cases) { + vi.clearAllMocks(); + await testCase.run(); + testCase.assert(); + } }); - it("updateStatusCommand prints table output", async () => { - await updateStatusCommand({ json: false }); + it("updateStatusCommand renders table and json output", async () => { + const cases = [ + { + name: "table output", + options: { json: false }, + assert: () => { + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => call[0]); + expect(logs.join("\n")).toContain("OpenClaw update status"); + }, + }, + { + name: "json output", + options: { json: true }, + assert: () => { + const last = vi.mocked(defaultRuntime.log).mock.calls.at(-1)?.[0]; + expect(typeof last).toBe("string"); + const parsed = JSON.parse(String(last)); + expect(parsed.channel.value).toBe("stable"); + }, + }, + ] as const; - const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => call[0]); - expect(logs.join("\n")).toContain("OpenClaw update status"); - }); - - it("updateStatusCommand emits JSON", async () => { - await updateStatusCommand({ json: true }); - - const last = vi.mocked(defaultRuntime.log).mock.calls.at(-1)?.[0]; - expect(typeof last).toBe("string"); - const parsed = JSON.parse(String(last)); - expect(parsed.channel.value).toBe("stable"); + for (const testCase of cases) { + vi.mocked(defaultRuntime.log).mockClear(); + await updateStatusCommand(testCase.options); + testCase.assert(); + } }); it.each([ @@ -412,28 +481,47 @@ describe("update-cli", () => { expectedChannel: "beta" as const, expectedTag: undefined as string | undefined, }, - ])("$name", async ({ mode, options, prepare, expectedChannel, expectedTag }) => { - await prepare(); - if (mode) { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode })); - } - - await updateCommand(options); - - if (expectedChannel !== undefined) { - const call = expectUpdateCallChannel(expectedChannel); - if (expectedTag !== undefined) { - expect(call?.tag).toBe(expectedTag); + { + name: "switches git installs to package mode for explicit beta and persists it", + mode: "git" as const, + options: { channel: "beta" }, + prepare: async () => {}, + expectedChannel: undefined as string | undefined, + expectedTag: undefined as string | undefined, + expectedPersistedChannel: "beta" as const, + }, + ])( + "$name", + async ({ mode, options, prepare, expectedChannel, expectedTag, expectedPersistedChannel }) => { + await prepare(); + if (mode) { + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode })); } - return; - } - expect(runGatewayUpdate).not.toHaveBeenCalled(); - expect(runCommandWithTimeout).toHaveBeenCalledWith( - ["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"], - expect.any(Object), - ); - }); + await updateCommand(options); + + if (expectedChannel !== undefined) { + const call = expectUpdateCallChannel(expectedChannel); + if (expectedTag !== undefined) { + expect(call?.tag).toBe(expectedTag); + } + } else { + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runCommandWithTimeout).toHaveBeenCalledWith( + ["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"], + expect.any(Object), + ); + } + + if (expectedPersistedChannel !== undefined) { + expect(writeConfigFile).toHaveBeenCalled(); + const writeCall = vi.mocked(writeConfigFile).mock.calls[0]?.[0] as { + update?: { channel?: string }; + }; + expect(writeCall?.update?.channel).toBe(expectedPersistedChannel); + } + }, + ); it("falls back to latest when beta tag is older than release", async () => { const tempDir = createCaseDir("openclaw-update"); @@ -456,18 +544,54 @@ describe("update-cli", () => { ); }); - it("honors --tag override", async () => { - const tempDir = createCaseDir("openclaw-update"); - - mockPackageInstallStatus(tempDir); - - await updateCommand({ tag: "next" }); - - expect(runGatewayUpdate).not.toHaveBeenCalled(); - expect(runCommandWithTimeout).toHaveBeenCalledWith( - ["npm", "i", "-g", "openclaw@next", "--no-fund", "--no-audit", "--loglevel=error"], - expect.any(Object), - ); + it("resolves package install specs from tags and env overrides", async () => { + for (const scenario of [ + { + name: "explicit dist-tag", + run: async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + await updateCommand({ tag: "next" }); + }, + expectedSpec: "openclaw@next", + }, + { + name: "main shorthand", + run: async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + await updateCommand({ yes: true, tag: "main" }); + }, + expectedSpec: "github:openclaw/openclaw#main", + }, + { + name: "explicit git package spec", + run: async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + await updateCommand({ yes: true, tag: "github:openclaw/openclaw#main" }); + }, + expectedSpec: "github:openclaw/openclaw#main", + }, + { + name: "OPENCLAW_UPDATE_PACKAGE_SPEC override", + run: async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + await withEnvAsync( + { OPENCLAW_UPDATE_PACKAGE_SPEC: "http://10.211.55.2:8138/openclaw-next.tgz" }, + async () => { + await updateCommand({ yes: true, tag: "latest" }); + }, + ); + }, + expectedSpec: "http://10.211.55.2:8138/openclaw-next.tgz", + }, + ]) { + vi.clearAllMocks(); + readPackageName.mockResolvedValue("openclaw"); + readPackageVersion.mockResolvedValue("1.0.0"); + resolveGlobalManager.mockResolvedValue("npm"); + vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(process.cwd()); + await scenario.run(); + expectPackageInstallSpec(scenario.expectedSpec); + } }); it("prepends portable Git PATH for package updates on Windows", async () => { @@ -523,258 +647,244 @@ describe("update-cli", () => { expect(updateOptions?.env?.NODE_LLAMA_CPP_SKIP_DOWNLOAD).toBe("1"); }); - it("uses OPENCLAW_UPDATE_PACKAGE_SPEC for package updates", async () => { - const tempDir = createCaseDir("openclaw-update"); - mockPackageInstallStatus(tempDir); - - await withEnvAsync( - { OPENCLAW_UPDATE_PACKAGE_SPEC: "http://10.211.55.2:8138/openclaw-next.tgz" }, - async () => { - await updateCommand({ yes: true, tag: "latest" }); - }, - ); - - expect(runGatewayUpdate).not.toHaveBeenCalled(); - expect(runCommandWithTimeout).toHaveBeenCalledWith( - [ - "npm", - "i", - "-g", - "http://10.211.55.2:8138/openclaw-next.tgz", - "--no-fund", - "--no-audit", - "--loglevel=error", - ], - expect.any(Object), - ); - }); - - it("maps --tag main to the GitHub main package spec for package updates", async () => { - const tempDir = createCaseDir("openclaw-update"); - mockPackageInstallStatus(tempDir); - - await updateCommand({ yes: true, tag: "main" }); - - expect(runGatewayUpdate).not.toHaveBeenCalled(); - expect(runCommandWithTimeout).toHaveBeenCalledWith( - [ - "npm", - "i", - "-g", - "github:openclaw/openclaw#main", - "--no-fund", - "--no-audit", - "--loglevel=error", - ], - expect.any(Object), - ); - }); - - it("passes explicit git package specs through for package updates", async () => { - const tempDir = createCaseDir("openclaw-update"); - mockPackageInstallStatus(tempDir); - - await updateCommand({ yes: true, tag: "github:openclaw/openclaw#main" }); - - expect(runGatewayUpdate).not.toHaveBeenCalled(); - expect(runCommandWithTimeout).toHaveBeenCalledWith( - [ - "npm", - "i", - "-g", - "github:openclaw/openclaw#main", - "--no-fund", - "--no-audit", - "--loglevel=error", - ], - expect.any(Object), - ); - }); - - it("updateCommand outputs JSON when --json is set", async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); - vi.mocked(defaultRuntime.log).mockClear(); - - await updateCommand({ json: true }); - - const logCalls = vi.mocked(defaultRuntime.log).mock.calls; - const jsonOutput = logCalls.find((call) => { - try { - JSON.parse(call[0] as string); - return true; - } catch { - return false; - } - }); - expect(jsonOutput).toBeDefined(); - }); - - it("updateCommand exits with error on failure", async () => { - const mockResult: UpdateRunResult = { - status: "error", - mode: "git", - reason: "rebase-failed", - steps: [], - durationMs: 100, - }; - - vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); - vi.mocked(defaultRuntime.exit).mockClear(); - - await updateCommand({}); - - expect(defaultRuntime.exit).toHaveBeenCalledWith(1); - }); - - it("updateCommand refreshes gateway service env when service is already installed", async () => { - const mockResult: UpdateRunResult = { - status: "ok", - mode: "git", - steps: [], - durationMs: 100, - }; - - vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); - vi.mocked(runDaemonInstall).mockResolvedValue(undefined); - serviceLoaded.mockResolvedValue(true); - - await updateCommand({}); - - expect(runDaemonInstall).toHaveBeenCalledWith({ - force: true, - json: undefined, - }); - expect(runRestartScript).toHaveBeenCalled(); - expect(runDaemonRestart).not.toHaveBeenCalled(); - }); - - it("updateCommand refreshes service env from updated install root when available", async () => { - const root = createCaseDir("openclaw-updated-root"); - const entryPath = path.join(root, "dist", "entry.js"); - pathExists.mockImplementation(async (candidate: string) => candidate === entryPath); - - vi.mocked(runGatewayUpdate).mockResolvedValue({ - status: "ok", - mode: "npm", - root, - steps: [], - durationMs: 100, - }); - serviceLoaded.mockResolvedValue(true); - - await updateCommand({}); - - expect(runCommandWithTimeout).toHaveBeenCalledWith( - [expect.stringMatching(/node/), entryPath, "gateway", "install", "--force"], - expect.objectContaining({ cwd: root, timeoutMs: 60_000 }), - ); - expect(runDaemonInstall).not.toHaveBeenCalled(); - expect(runRestartScript).toHaveBeenCalled(); - }); - - it("updateCommand preserves invocation-relative service env overrides during refresh", async () => { - const root = createCaseDir("openclaw-updated-root"); - const entryPath = path.join(root, "dist", "entry.js"); - pathExists.mockImplementation(async (candidate: string) => candidate === entryPath); - - vi.mocked(runGatewayUpdate).mockResolvedValue({ - status: "ok", - mode: "npm", - root, - steps: [], - durationMs: 100, - }); - serviceLoaded.mockResolvedValue(true); - - await withEnvAsync( + it("updateCommand reports success and failure outcomes", async () => { + const cases = [ { - OPENCLAW_STATE_DIR: "./state", - OPENCLAW_CONFIG_PATH: "./config/openclaw.json", - }, - async () => { - await updateCommand({}); - }, - ); - - expect(runCommandWithTimeout).toHaveBeenCalledWith( - [expect.stringMatching(/node/), entryPath, "gateway", "install", "--force"], - expect.objectContaining({ - cwd: root, - env: expect.objectContaining({ - OPENCLAW_STATE_DIR: path.resolve("./state"), - OPENCLAW_CONFIG_PATH: path.resolve("./config/openclaw.json"), - }), - timeoutMs: 60_000, - }), - ); - expect(runDaemonInstall).not.toHaveBeenCalled(); - }); - - it("updateCommand reuses the captured invocation cwd when process.cwd later fails", async () => { - const root = createCaseDir("openclaw-updated-root"); - const entryPath = path.join(root, "dist", "entry.js"); - pathExists.mockImplementation(async (candidate: string) => candidate === entryPath); - - const originalCwd = process.cwd(); - let restoreCwd: (() => void) | undefined; - vi.mocked(runGatewayUpdate).mockImplementation(async () => { - const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => { - throw new Error("ENOENT: current working directory is gone"); - }); - restoreCwd = () => cwdSpy.mockRestore(); - return { - status: "ok", - mode: "npm", - root, - steps: [], - durationMs: 100, - }; - }); - serviceLoaded.mockResolvedValue(true); - - try { - await withEnvAsync( - { - OPENCLAW_STATE_DIR: "./state", + name: "outputs JSON when --json is set", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); + vi.mocked(defaultRuntime.log).mockClear(); + await updateCommand({ json: true }); }, - async () => { + assert: () => { + const logCalls = vi.mocked(defaultRuntime.log).mock.calls; + const jsonOutput = logCalls.find((call) => { + try { + JSON.parse(call[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonOutput).toBeDefined(); + }, + }, + { + name: "exits with error on failure", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "error", + mode: "git", + reason: "rebase-failed", + steps: [], + durationMs: 100, + } satisfies UpdateRunResult); + vi.mocked(defaultRuntime.exit).mockClear(); await updateCommand({}); }, - ); - } finally { - restoreCwd?.(); + assert: () => { + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }, + }, + ] as const; + + for (const testCase of cases) { + vi.clearAllMocks(); + await testCase.run(); + testCase.assert(); } + }); + + it("updateCommand handles service env refresh and restart behavior", async () => { + const cases = [ + { + name: "refreshes service env when already installed", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "git", + steps: [], + durationMs: 100, + } satisfies UpdateRunResult); + vi.mocked(runDaemonInstall).mockResolvedValue(undefined); + serviceLoaded.mockResolvedValue(true); + + await updateCommand({}); + }, + assert: () => { + expect(runDaemonInstall).toHaveBeenCalledWith({ + force: true, + json: undefined, + }); + expect(runRestartScript).toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); + }, + }, + { + name: "falls back to daemon restart when service env refresh cannot complete", + run: async () => { + vi.mocked(runDaemonRestart).mockResolvedValue(true); + await runRestartFallbackScenario({ daemonInstall: "fail" }); + }, + assert: () => { + expect(runDaemonInstall).toHaveBeenCalledWith({ + force: true, + json: undefined, + }); + expect(runDaemonRestart).toHaveBeenCalled(); + }, + }, + { + name: "keeps going when daemon install succeeds but restart fallback still handles relaunch", + run: async () => { + vi.mocked(runDaemonRestart).mockResolvedValue(true); + await runRestartFallbackScenario({ daemonInstall: "ok" }); + }, + assert: () => { + expect(runDaemonInstall).toHaveBeenCalledWith({ + force: true, + json: undefined, + }); + expect(runDaemonRestart).toHaveBeenCalled(); + }, + }, + { + name: "skips service env refresh when --no-restart is set", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); + serviceLoaded.mockResolvedValue(true); + + await updateCommand({ restart: false }); + }, + assert: () => { + expect(runDaemonInstall).not.toHaveBeenCalled(); + expect(runRestartScript).not.toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); + }, + }, + { + name: "skips success message when restart does not run", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); + vi.mocked(runDaemonRestart).mockResolvedValue(false); + vi.mocked(defaultRuntime.log).mockClear(); + await updateCommand({ restart: true }); + }, + assert: () => { + const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe( + false, + ); + }, + }, + ] as const; + + for (const testCase of cases) { + vi.clearAllMocks(); + await testCase.run(); + testCase.assert(); + } + }); + + it.each([ + { + name: "updateCommand refreshes service env from updated install root when available", + invoke: async () => { + await updateCommand({}); + }, + expectedOptions: (root: string) => expect.objectContaining({ cwd: root, timeoutMs: 60_000 }), + assertExtra: () => { + expect(runDaemonInstall).not.toHaveBeenCalled(); + expect(runRestartScript).toHaveBeenCalled(); + }, + }, + { + name: "updateCommand preserves invocation-relative service env overrides during refresh", + invoke: async () => { + await withEnvAsync( + { + OPENCLAW_STATE_DIR: "./state", + OPENCLAW_CONFIG_PATH: "./config/openclaw.json", + }, + async () => { + await updateCommand({}); + }, + ); + }, + expectedOptions: (root: string) => + expect.objectContaining({ + cwd: root, + env: expect.objectContaining({ + OPENCLAW_STATE_DIR: path.resolve("./state"), + OPENCLAW_CONFIG_PATH: path.resolve("./config/openclaw.json"), + }), + timeoutMs: 60_000, + }), + assertExtra: () => { + expect(runDaemonInstall).not.toHaveBeenCalled(); + }, + }, + { + name: "updateCommand reuses the captured invocation cwd when process.cwd later fails", + invoke: async () => { + const originalCwd = process.cwd(); + let restoreCwd: (() => void) | undefined; + const { root } = setupUpdatedRootRefresh({ + gatewayUpdateImpl: async () => { + const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("ENOENT: current working directory is gone"); + }); + restoreCwd = () => cwdSpy.mockRestore(); + return { + status: "ok", + mode: "npm", + root, + steps: [], + durationMs: 100, + }; + }, + }); + try { + await withEnvAsync( + { + OPENCLAW_STATE_DIR: "./state", + }, + async () => { + await updateCommand({}); + }, + ); + } finally { + restoreCwd?.(); + } + return { originalCwd }; + }, + customSetup: true, + expectedOptions: (_root: string, context?: { originalCwd: string }) => + expect.objectContaining({ + cwd: expect.any(String), + env: expect.objectContaining({ + OPENCLAW_STATE_DIR: path.resolve(context?.originalCwd ?? process.cwd(), "./state"), + }), + timeoutMs: 60_000, + }), + assertExtra: () => { + expect(runDaemonInstall).not.toHaveBeenCalled(); + }, + }, + ])("$name", async (testCase) => { + const setup = testCase.customSetup ? undefined : setupUpdatedRootRefresh(); + const context = (await testCase.invoke()) as { originalCwd: string } | undefined; + const runCommandWithTimeoutMock = vi.mocked(runCommandWithTimeout) as unknown as { + mock: { calls: Array<[unknown, { cwd?: string }?]> }; + }; + const root = setup?.root ?? runCommandWithTimeoutMock.mock.calls[0]?.[1]?.cwd; + const entryPath = setup?.entryPath ?? path.join(String(root), "dist", "entry.js"); expect(runCommandWithTimeout).toHaveBeenCalledWith( [expect.stringMatching(/node/), entryPath, "gateway", "install", "--force"], - expect.objectContaining({ - cwd: root, - env: expect.objectContaining({ - OPENCLAW_STATE_DIR: path.resolve(originalCwd, "./state"), - }), - timeoutMs: 60_000, - }), + testCase.expectedOptions(String(root), context), ); - expect(runDaemonInstall).not.toHaveBeenCalled(); - }); - - it("updateCommand falls back to restart when env refresh install fails", async () => { - await runRestartFallbackScenario({ daemonInstall: "fail" }); - }); - - it("updateCommand falls back to restart when no detached restart script is available", async () => { - await runRestartFallbackScenario({ daemonInstall: "ok" }); - }); - - it("updateCommand does not refresh service env when --no-restart is set", async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); - serviceLoaded.mockResolvedValue(true); - - await updateCommand({ restart: false }); - - expect(runDaemonInstall).not.toHaveBeenCalled(); - expect(runRestartScript).not.toHaveBeenCalled(); - expect(runDaemonRestart).not.toHaveBeenCalled(); + testCase.assertExtra(); }); it("updateCommand continues after doctor sub-step and clears update flag", async () => { @@ -806,54 +916,46 @@ describe("update-cli", () => { } }); - it("updateCommand skips success message when restart does not run", async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); - vi.mocked(runDaemonRestart).mockResolvedValue(false); - vi.mocked(defaultRuntime.log).mockClear(); + it("validates update command invocation errors", async () => { + const cases = [ + { + name: "update command invalid timeout", + run: async () => await updateCommand({ timeout: "invalid" }), + requireTty: false, + expectedError: "timeout", + }, + { + name: "update status command invalid timeout", + run: async () => await updateStatusCommand({ timeout: "invalid" }), + requireTty: false, + expectedError: "timeout", + }, + { + name: "update wizard invalid timeout", + run: async () => await updateWizardCommand({ timeout: "invalid" }), + requireTty: true, + expectedError: "timeout", + }, + { + name: "update wizard requires a TTY", + run: async () => await updateWizardCommand({}), + requireTty: false, + expectedError: "Update wizard requires a TTY", + }, + ] as const; - await updateCommand({ restart: true }); + for (const testCase of cases) { + setTty(testCase.requireTty); + vi.mocked(defaultRuntime.error).mockClear(); + vi.mocked(defaultRuntime.exit).mockClear(); - const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); - expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe(false); - }); + await testCase.run(); - it.each([ - { - name: "update command", - run: async () => await updateCommand({ timeout: "invalid" }), - requireTty: false, - }, - { - name: "update status command", - run: async () => await updateStatusCommand({ timeout: "invalid" }), - requireTty: false, - }, - { - name: "update wizard command", - run: async () => await updateWizardCommand({ timeout: "invalid" }), - requireTty: true, - }, - ])("validates timeout option for $name", async ({ run, requireTty }) => { - setTty(requireTty); - vi.mocked(defaultRuntime.error).mockClear(); - vi.mocked(defaultRuntime.exit).mockClear(); - - await run(); - - expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout")); - expect(defaultRuntime.exit).toHaveBeenCalledWith(1); - }); - - it("persists update channel when --channel is set", async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); - - await updateCommand({ channel: "beta" }); - - expect(writeConfigFile).toHaveBeenCalled(); - const call = vi.mocked(writeConfigFile).mock.calls[0]?.[0] as { - update?: { channel?: string }; - }; - expect(call?.update?.channel).toBe("beta"); + expect(defaultRuntime.error, testCase.name).toHaveBeenCalledWith( + expect.stringContaining(testCase.expectedError), + ); + expect(defaultRuntime.exit, testCase.name).toHaveBeenCalledWith(1); + } }); it.each([ @@ -888,29 +990,6 @@ describe("update-cli", () => { ).toBe(shouldRunPackageUpdate); }); - it("dry-run bypasses downgrade confirmation checks in non-interactive mode", async () => { - await setupNonInteractiveDowngrade(); - vi.mocked(defaultRuntime.exit).mockClear(); - - await updateCommand({ dryRun: true }); - - expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(false); - expect(runGatewayUpdate).not.toHaveBeenCalled(); - }); - - it("updateWizardCommand requires a TTY", async () => { - setTty(false); - vi.mocked(defaultRuntime.error).mockClear(); - vi.mocked(defaultRuntime.exit).mockClear(); - - await updateWizardCommand({}); - - expect(defaultRuntime.error).toHaveBeenCalledWith( - expect.stringContaining("Update wizard requires a TTY"), - ); - expect(defaultRuntime.exit).toHaveBeenCalledWith(1); - }); - it("updateWizardCommand offers dev checkout and forwards selections", async () => { const tempDir = createCaseDir("openclaw-update-wizard"); await withEnvAsync({ OPENCLAW_GIT_DIR: tempDir }, async () => { diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 5b4fc2c9040..04d92a2d76d 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import "../cron/isolated-agent.mocks.js"; +import * as authProfilesModule from "../agents/auth-profiles.js"; import * as cliRunnerModule from "../agents/cli-runner.js"; import { FailoverError } from "../agents/failover-error.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; @@ -11,7 +12,7 @@ import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import * as commandSecretGatewayModule from "../cli/command-secret-gateway.js"; import type { OpenClawConfig } from "../config/config.js"; import * as configModule from "../config/config.js"; -import * as sessionsModule from "../config/sessions.js"; +import * as sessionPathsModule from "../config/sessions/paths.js"; import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -19,6 +20,24 @@ import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/chan import { agentCommand, agentCommandFromIngress } from "./agent.js"; import * as agentDeliveryModule from "./agent/delivery.js"; +vi.mock("../logging/subsystem.js", () => { + const createMockLogger = () => ({ + subsystem: "test", + isEnabled: vi.fn(() => true), + trace: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + fatal: vi.fn(), + raw: vi.fn(), + child: vi.fn(() => createMockLogger()), + }); + return { + createSubsystemLogger: vi.fn(() => createMockLogger()), + }; +}); + vi.mock("../agents/auth-profiles.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -27,10 +46,13 @@ vi.mock("../agents/auth-profiles.js", async (importOriginal) => { }; }); -vi.mock("../agents/workspace.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../agents/workspace.js", () => { + const resolveDefaultAgentWorkspaceDir = () => "/tmp/openclaw-workspace"; return { - ...actual, + DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/openclaw-workspace", + DEFAULT_AGENTS_FILENAME: "AGENTS.md", + DEFAULT_IDENTITY_FILENAME: "IDENTITY.md", + resolveDefaultAgentWorkspaceDir, ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => ({ dir })), }; }); @@ -405,13 +427,35 @@ describe("agentCommand", () => { }); }); + it("requires explicit allowModelOverride for ingress runs", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store); + await expect( + // Runtime guard for non-TS callers; TS callsites are statically typed. + agentCommandFromIngress( + { + message: "hi", + to: "+1555", + senderIsOwner: false, + } as never, + runtime, + ), + ).rejects.toThrow("allowModelOverride must be explicitly set for ingress agent runs."); + }); + }); + it("honors explicit senderIsOwner for ingress runs", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store); - await agentCommandFromIngress({ message: "hi", to: "+1555", senderIsOwner: false }, runtime); + await agentCommandFromIngress( + { message: "hi", to: "+1555", senderIsOwner: false, allowModelOverride: false }, + runtime, + ); const ingressCall = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; expect(ingressCall?.senderIsOwner).toBe(false); + expect(ingressCall).not.toHaveProperty("allowModelOverride"); }); }); @@ -462,7 +506,7 @@ describe("agentCommand", () => { const store = path.join(customStoreDir, "sessions.json"); writeSessionStoreSeed(store, {}); mockConfig(home, store); - const resolveSessionFilePathSpy = vi.spyOn(sessionsModule, "resolveSessionFilePath"); + const resolveSessionFilePathSpy = vi.spyOn(sessionPathsModule, "resolveSessionFilePath"); await agentCommand({ message: "resume me", sessionId: "session-custom-123" }, runtime); @@ -686,6 +730,149 @@ describe("agentCommand", () => { }); }); + it("applies per-run provider and model overrides without persisting them", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store, { + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, + }); + + await agentCommand( + { + message: "use the override", + sessionKey: "agent:main:subagent:run-override", + provider: "openai", + model: "gpt-4.1-mini", + }, + runtime, + ); + + expectLastRunProviderModel("openai", "gpt-4.1-mini"); + + const saved = readSessionStore<{ + providerOverride?: string; + modelOverride?: string; + }>(store); + expect(saved["agent:main:subagent:run-override"]?.providerOverride).toBeUndefined(); + expect(saved["agent:main:subagent:run-override"]?.modelOverride).toBeUndefined(); + }); + }); + + it("rejects explicit override values that contain control characters", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store, { + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, + }); + + await expect( + agentCommand( + { + message: "use an invalid override", + sessionKey: "agent:main:subagent:invalid-override", + provider: "openai\u001b[31m", + model: "gpt-4.1-mini", + }, + runtime, + ), + ).rejects.toThrow("Provider override contains invalid control characters."); + }); + }); + + it("sanitizes provider/model text in model-allowlist errors", async () => { + const parseModelRefSpy = vi.spyOn(modelSelectionModule, "parseModelRef"); + parseModelRefSpy.mockImplementationOnce(() => ({ + provider: "anthropic\u001b[31m", + model: "claude-haiku-4-5\u001b[32m", + })); + try { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store, { + models: { + "openai/gpt-4.1-mini": {}, + }, + }); + + await expect( + agentCommand( + { + message: "use disallowed override", + sessionKey: "agent:main:subagent:sanitized-override-error", + model: "claude-haiku-4-5", + }, + runtime, + ), + ).rejects.toThrow( + 'Model override "anthropic/claude-haiku-4-5" is not allowed for agent "main".', + ); + }); + } finally { + parseModelRefSpy.mockRestore(); + } + }); + + it("keeps stored auth profile overrides during one-off cross-provider runs", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + writeSessionStoreSeed(store, { + "agent:main:subagent:temp-openai-run": { + sessionId: "session-temp-openai-run", + updatedAt: Date.now(), + authProfileOverride: "anthropic:work", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: 2, + }, + }); + mockConfig(home, store, { + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, + }); + vi.mocked(authProfilesModule.ensureAuthProfileStore).mockReturnValue({ + version: 1, + profiles: { + "anthropic:work": { + provider: "anthropic", + }, + }, + } as never); + + await agentCommand( + { + message: "use a different provider once", + sessionKey: "agent:main:subagent:temp-openai-run", + provider: "openai", + model: "gpt-4.1-mini", + }, + runtime, + ); + + expectLastRunProviderModel("openai", "gpt-4.1-mini"); + expect(getLastEmbeddedCall()?.authProfileId).toBeUndefined(); + + const saved = readSessionStore<{ + authProfileOverride?: string; + authProfileOverrideSource?: string; + authProfileOverrideCompactionCount?: number; + }>(store); + expect(saved["agent:main:subagent:temp-openai-run"]?.authProfileOverride).toBe( + "anthropic:work", + ); + expect(saved["agent:main:subagent:temp-openai-run"]?.authProfileOverrideSource).toBe("user"); + expect(saved["agent:main:subagent:temp-openai-run"]?.authProfileOverrideCompactionCount).toBe( + 2, + ); + }); + }); + it("keeps explicit sessionKey even when sessionId exists elsewhere", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/auth-choice.apply.oauth.ts b/src/commands/auth-choice.apply.oauth.ts index a2a3104e447..1966d2bd8d8 100644 --- a/src/commands/auth-choice.apply.oauth.ts +++ b/src/commands/auth-choice.apply.oauth.ts @@ -1,94 +1,7 @@ -import { applyAuthProfileConfig, writeOAuthCredentials } from "../plugins/provider-auth-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { loginChutes } from "./chutes-oauth.js"; -import { isRemoteEnvironment } from "./oauth-env.js"; -import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; -import { openUrl } from "./onboard-helpers.js"; export async function applyAuthChoiceOAuth( - params: ApplyAuthChoiceParams, + _params: ApplyAuthChoiceParams, ): Promise { - if (params.authChoice === "chutes") { - let nextConfig = params.config; - const isRemote = isRemoteEnvironment(); - const redirectUri = - process.env.CHUTES_OAUTH_REDIRECT_URI?.trim() || "http://127.0.0.1:1456/oauth-callback"; - const scopes = process.env.CHUTES_OAUTH_SCOPES?.trim() || "openid profile chutes:invoke"; - const clientId = - process.env.CHUTES_CLIENT_ID?.trim() || - String( - await params.prompter.text({ - message: "Enter Chutes OAuth client id", - placeholder: "cid_xxx", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined; - - await params.prompter.note( - isRemote - ? [ - "You are running in a remote/VPS environment.", - "A URL will be shown for you to open in your LOCAL browser.", - "After signing in, paste the redirect URL back here.", - "", - `Redirect URI: ${redirectUri}`, - ].join("\n") - : [ - "Browser will open for Chutes authentication.", - "If the callback doesn't auto-complete, paste the redirect URL.", - "", - `Redirect URI: ${redirectUri}`, - ].join("\n"), - "Chutes OAuth", - ); - - const spin = params.prompter.progress("Starting OAuth flow…"); - try { - const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({ - isRemote, - prompter: params.prompter, - runtime: params.runtime, - spin, - openUrl, - localBrowserMessage: "Complete sign-in in browser…", - }); - - const creds = await loginChutes({ - app: { - clientId, - clientSecret, - redirectUri, - scopes: scopes.split(/\s+/).filter(Boolean), - }, - manual: isRemote, - onAuth, - onPrompt, - onProgress: (msg) => spin.update(msg), - }); - - spin.stop("Chutes OAuth complete"); - const profileId = await writeOAuthCredentials("chutes", creds, params.agentDir); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider: "chutes", - mode: "oauth", - }); - } catch (err) { - spin.stop("Chutes OAuth failed"); - params.runtime.error(String(err)); - await params.prompter.note( - [ - "Trouble with OAuth?", - "Verify CHUTES_CLIENT_ID (and CHUTES_CLIENT_SECRET if required).", - `Verify the OAuth app redirect URI includes: ${redirectUri}`, - "Chutes docs: https://chutes.ai/docs/sign-in-with-chutes/overview", - ].join("\n"), - "OAuth help", - ); - } - return { config: nextConfig }; - } - return null; } diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 8d6316e9acb..dd270a6d3d2 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -2,6 +2,7 @@ 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 chutesPlugin from "../../extensions/chutes/index.js"; import cloudflareAiGatewayPlugin from "../../extensions/cloudflare-ai-gateway/index.js"; import googlePlugin from "../../extensions/google/index.js"; import huggingfacePlugin from "../../extensions/huggingface/index.js"; @@ -84,6 +85,7 @@ type StoredAuthProfile = { function createDefaultProviderPlugins() { return registerProviderPlugins( anthropicPlugin, + chutesPlugin, cloudflareAiGatewayPlugin, googlePlugin, huggingfacePlugin, @@ -1345,7 +1347,7 @@ describe("applyAuthChoice", () => { const runtime = createExitThrowingRuntime(); const text: WizardPrompter["text"] = vi.fn(async (params) => { - if (params.message === "Paste the redirect URL") { + if (params.message.startsWith("Paste the redirect URL")) { const runtimeLog = runtime.log as ReturnType; const lastLog = runtimeLog.mock.calls.at(-1)?.[0]; const urlLine = typeof lastLog === "string" ? lastLog : String(lastLog ?? ""); @@ -1370,7 +1372,7 @@ describe("applyAuthChoice", () => { expect(text).toHaveBeenCalledWith( expect.objectContaining({ - message: "Paste the redirect URL", + message: expect.stringContaining("Paste the redirect URL"), }), ); expect(result.config.auth?.profiles?.["chutes:remote-user"]).toMatchObject({ diff --git a/src/commands/channels.mock-harness.ts b/src/commands/channels.mock-harness.ts index d1f412b0399..6a448a9750e 100644 --- a/src/commands/channels.mock-harness.ts +++ b/src/commands/channels.mock-harness.ts @@ -24,9 +24,8 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -vi.mock("../../extensions/telegram/src/update-offset-store.js", async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock("../../extensions/telegram/api.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, deleteTelegramUpdateOffset: offsetMocks.deleteTelegramUpdateOffset, diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 931a983a8ee..113e7edd637 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -96,6 +96,30 @@ describe("buildGatewayInstallPlan", () => { expect(plan.workingDirectory).toBe("/Users/me"); expect(plan.environment).toEqual({ OPENCLAW_PORT: "3000" }); expect(mocks.resolvePreferredNodePath).not.toHaveBeenCalled(); + expect(mocks.buildServiceEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ + env: {}, + port: 3000, + extraPathDirs: ["/custom"], + }), + ); + }); + + it("does not prepend '.' when nodePath is a bare executable name", async () => { + mockNodeGatewayPlanFixture(); + + await buildGatewayInstallPlan({ + env: {}, + port: 3000, + runtime: "node", + nodePath: "node", + }); + + expect(mocks.buildServiceEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ + extraPathDirs: undefined, + }), + ); }); it("emits warnings when renderSystemNodeWarning returns one", async () => { diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 91248cb86a7..fcd4a6447fb 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -11,6 +11,7 @@ import { buildServiceEnvironment } from "../daemon/service-env.js"; import { emitDaemonInstallRuntimeWarning, resolveDaemonInstallRuntimeInputs, + resolveDaemonNodeBinDir, } from "./daemon-install-plan.shared.js"; import type { DaemonInstallWarnFn } from "./daemon-install-runtime-warning.js"; import type { GatewayDaemonRuntime } from "./daemon-runtime.js"; @@ -87,6 +88,9 @@ export async function buildGatewayInstallPlan(params: { process.platform === "darwin" ? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE) : undefined, + // Keep npm/pnpm available to the service when the selected daemon node comes from + // a version-manager bin directory that isn't covered by static PATH guesses. + extraPathDirs: resolveDaemonNodeBinDir(nodePath), }); // Merge config env vars into the service environment (vars + inline env keys). diff --git a/src/commands/daemon-install-plan.shared.test.ts b/src/commands/daemon-install-plan.shared.test.ts index 399b521a5d5..8d7a3520eaf 100644 --- a/src/commands/daemon-install-plan.shared.test.ts +++ b/src/commands/daemon-install-plan.shared.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveDaemonInstallRuntimeInputs, + resolveDaemonNodeBinDir, resolveGatewayDevMode, } from "./daemon-install-plan.shared.js"; @@ -29,3 +30,13 @@ describe("resolveDaemonInstallRuntimeInputs", () => { }); }); }); + +describe("resolveDaemonNodeBinDir", () => { + it("returns the absolute node bin directory", () => { + expect(resolveDaemonNodeBinDir("/custom/node/bin/node")).toEqual(["/custom/node/bin"]); + }); + + it("ignores bare executable names", () => { + expect(resolveDaemonNodeBinDir("node")).toBeUndefined(); + }); +}); diff --git a/src/commands/daemon-install-plan.shared.ts b/src/commands/daemon-install-plan.shared.ts index b3a970d05f4..cb2f701e632 100644 --- a/src/commands/daemon-install-plan.shared.ts +++ b/src/commands/daemon-install-plan.shared.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; import { emitNodeRuntimeWarning, @@ -42,3 +43,11 @@ export async function emitDaemonInstallRuntimeWarning(params: { title: params.title, }); } + +export function resolveDaemonNodeBinDir(nodePath?: string): string[] | undefined { + const trimmed = nodePath?.trim(); + if (!trimmed || !path.isAbsolute(trimmed)) { + return undefined; + } + return [path.dirname(trimmed)]; +} diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index b75e3bbc5d4..320e8e1258c 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -258,7 +258,7 @@ vi.mock("../pairing/pairing-store.js", () => ({ upsertChannelPairingRequest: vi.fn().mockResolvedValue({ code: "000000", created: false }), })); -vi.mock("../../extensions/telegram/src/token.js", () => ({ +vi.mock("../../extensions/telegram/api.js", () => ({ resolveTelegramToken: vi.fn(() => ({ token: "", source: "none" })), })); diff --git a/src/commands/node-daemon-install-helpers.test.ts b/src/commands/node-daemon-install-helpers.test.ts new file mode 100644 index 00000000000..536bea1d014 --- /dev/null +++ b/src/commands/node-daemon-install-helpers.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolvePreferredNodePath: vi.fn(), + resolveNodeProgramArguments: vi.fn(), + resolveSystemNodeInfo: vi.fn(), + renderSystemNodeWarning: vi.fn(), + buildNodeServiceEnvironment: vi.fn(), +})); + +vi.mock("../daemon/runtime-paths.js", () => ({ + resolvePreferredNodePath: mocks.resolvePreferredNodePath, + resolveSystemNodeInfo: mocks.resolveSystemNodeInfo, + renderSystemNodeWarning: mocks.renderSystemNodeWarning, +})); + +vi.mock("../daemon/program-args.js", () => ({ + resolveNodeProgramArguments: mocks.resolveNodeProgramArguments, +})); + +vi.mock("../daemon/service-env.js", () => ({ + buildNodeServiceEnvironment: mocks.buildNodeServiceEnvironment, +})); + +import { buildNodeInstallPlan } from "./node-daemon-install-helpers.js"; + +afterEach(() => { + vi.resetAllMocks(); +}); + +describe("buildNodeInstallPlan", () => { + it("passes the selected node bin directory into the node service environment", async () => { + mocks.resolveNodeProgramArguments.mockResolvedValue({ + programArguments: ["node", "node-host"], + workingDirectory: "/Users/me", + }); + mocks.resolveSystemNodeInfo.mockResolvedValue({ + path: "/opt/node/bin/node", + version: "22.0.0", + supported: true, + }); + mocks.renderSystemNodeWarning.mockReturnValue(undefined); + mocks.buildNodeServiceEnvironment.mockReturnValue({ + OPENCLAW_SERVICE_VERSION: "2026.3.14", + }); + + const plan = await buildNodeInstallPlan({ + env: {}, + host: "127.0.0.1", + port: 18789, + runtime: "node", + nodePath: "/custom/node/bin/node", + }); + + expect(plan.environment).toEqual({ + OPENCLAW_SERVICE_VERSION: "2026.3.14", + }); + expect(mocks.resolvePreferredNodePath).not.toHaveBeenCalled(); + expect(mocks.buildNodeServiceEnvironment).toHaveBeenCalledWith({ + env: {}, + extraPathDirs: ["/custom/node/bin"], + }); + }); + + it("does not prepend '.' when nodePath is a bare executable name", async () => { + mocks.resolveNodeProgramArguments.mockResolvedValue({ + programArguments: ["node", "node-host"], + workingDirectory: "/Users/me", + }); + mocks.resolveSystemNodeInfo.mockResolvedValue({ + path: "/usr/bin/node", + version: "22.0.0", + supported: true, + }); + mocks.renderSystemNodeWarning.mockReturnValue(undefined); + mocks.buildNodeServiceEnvironment.mockReturnValue({ + OPENCLAW_SERVICE_VERSION: "2026.3.14", + }); + + await buildNodeInstallPlan({ + env: {}, + host: "127.0.0.1", + port: 18789, + runtime: "node", + nodePath: "node", + }); + + expect(mocks.buildNodeServiceEnvironment).toHaveBeenCalledWith({ + env: {}, + extraPathDirs: undefined, + }); + }); +}); diff --git a/src/commands/node-daemon-install-helpers.ts b/src/commands/node-daemon-install-helpers.ts index 2f86d1c3b5e..321dff5a664 100644 --- a/src/commands/node-daemon-install-helpers.ts +++ b/src/commands/node-daemon-install-helpers.ts @@ -4,6 +4,7 @@ import { buildNodeServiceEnvironment } from "../daemon/service-env.js"; import { emitDaemonInstallRuntimeWarning, resolveDaemonInstallRuntimeInputs, + resolveDaemonNodeBinDir, } from "./daemon-install-plan.shared.js"; import type { DaemonInstallWarnFn } from "./daemon-install-runtime-warning.js"; import type { NodeDaemonRuntime } from "./node-daemon-runtime.js"; @@ -54,7 +55,12 @@ export async function buildNodeInstallPlan(params: { title: "Node daemon runtime", }); - const environment = buildNodeServiceEnvironment({ env: params.env }); + const environment = buildNodeServiceEnvironment({ + env: params.env, + // Match the gateway install path so supervised node services keep the chosen + // node toolchain on PATH for sibling binaries like npm/pnpm when needed. + extraPathDirs: resolveDaemonNodeBinDir(nodePath), + }); const description = formatNodeServiceDescription({ version: environment.OPENCLAW_SERVICE_VERSION, }); diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index 12ce55844c3..2f4f9ce260f 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -77,14 +77,17 @@ vi.mock("./status.link-channel.js", () => ({ resolveLinkChannelContext: vi.fn(async () => undefined), })); +const { hasPotentialConfiguredChannels } = await import("../channels/config-presence.js"); +const { buildChannelSummary } = await import("../infra/channel-summary.js"); +const { resolveLinkChannelContext } = await import("./status.link-channel.js"); +const { getStatusSummary } = await import("./status.summary.js"); + describe("getStatusSummary", () => { beforeEach(() => { vi.clearAllMocks(); }); it("includes runtimeVersion in the status payload", async () => { - const { getStatusSummary } = await import("./status.summary.js"); - const summary = await getStatusSummary(); expect(summary.runtimeVersion).toBe("2026.3.8"); @@ -93,11 +96,7 @@ describe("getStatusSummary", () => { }); it("skips channel summary imports when no channels are configured", async () => { - const { hasPotentialConfiguredChannels } = await import("../channels/config-presence.js"); vi.mocked(hasPotentialConfiguredChannels).mockReturnValue(false); - const { buildChannelSummary } = await import("../infra/channel-summary.js"); - const { resolveLinkChannelContext } = await import("./status.link-channel.js"); - const { getStatusSummary } = await import("./status.summary.js"); const summary = await getStatusSummary(); diff --git a/src/config/bindings.ts b/src/config/bindings.ts index b035fa3be15..5cbcd19c552 100644 --- a/src/config/bindings.ts +++ b/src/config/bindings.ts @@ -1,6 +1,8 @@ import type { OpenClawConfig } from "./config.js"; import type { AgentAcpBinding, AgentBinding, AgentRouteBinding } from "./types.agents.js"; +export type ConfiguredBindingRule = AgentBinding; + function normalizeBindingType(binding: AgentBinding): "route" | "acp" { return binding.type === "acp" ? "acp" : "route"; } diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 177711dcc03..43dec5acfef 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -93,6 +93,40 @@ describe("plugins.entries.*.hooks.allowPromptInjection", () => { }); }); +describe("plugins.entries.*.subagent", () => { + it("accepts trusted subagent override settings", () => { + const result = OpenClawSchema.safeParse({ + plugins: { + entries: { + "voice-call": { + subagent: { + allowModelOverride: true, + allowedModels: ["anthropic/claude-haiku-4-5"], + }, + }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid trusted subagent override settings", () => { + const result = OpenClawSchema.safeParse({ + plugins: { + entries: { + "voice-call": { + subagent: { + allowModelOverride: "yes", + allowedModels: [1], + }, + }, + }, + }, + }); + expect(result.success).toBe(false); + }); +}); + describe("web search provider config", () => { it("accepts kimi provider and config", () => { const res = validateConfigObject( diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index e915350ee62..f1542bcb7de 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -349,6 +349,9 @@ const TARGET_KEYS = [ "plugins.entries.*.enabled", "plugins.entries.*.hooks", "plugins.entries.*.hooks.allowPromptInjection", + "plugins.entries.*.subagent", + "plugins.entries.*.subagent.allowModelOverride", + "plugins.entries.*.subagent.allowedModels", "plugins.entries.*.apiKey", "plugins.entries.*.env", "plugins.entries.*.config", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index bb059bf5cad..4518d393ed2 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -385,7 +385,7 @@ export const FIELD_HELP: Record = { "gateway.controlUi.root": "Optional filesystem root for Control UI assets (defaults to dist/control-ui).", "gateway.controlUi.allowedOrigins": - "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.", + 'Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting ["*"] means allow any browser origin and should be avoided outside tightly controlled local testing.', "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": "DANGEROUS toggle that enables Host-header based origin fallback for Control UI/WebChat websocket checks. This mode is supported when your deployment intentionally relies on Host-header origin policy; explicit gateway.controlUi.allowedOrigins remains the recommended hardened default.", "gateway.controlUi.allowInsecureAuth": @@ -979,6 +979,12 @@ export const FIELD_HELP: Record = { "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "plugins.entries.*.hooks.allowPromptInjection": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "plugins.entries.*.subagent": + "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "plugins.entries.*.subagent.allowModelOverride": + "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "plugins.entries.*.subagent.allowedModels": + 'Allowed override targets for trusted plugin subagent runs as canonical "provider/model" refs. Use "*" only when you intentionally allow any model.', "plugins.entries.*.apiKey": "Optional API key field consumed by plugins that accept direct key configuration in entry settings. Use secret/env substitution and avoid committing real credentials into config files.", "plugins.entries.*.env": @@ -1239,7 +1245,7 @@ export const FIELD_HELP: Record = { "hooks.path": "HTTP path used by the hooks endpoint (for example `/hooks`) on the gateway control server. Use a non-guessable path and combine it with token validation for defense in depth.", "hooks.token": - "Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.", + "Shared bearer token checked by hooks ingress for request authentication before mappings run. Treat holders as full-trust callers for the hook ingress surface, not as a separate non-owner role. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.", "hooks.defaultSessionKey": "Fallback session key used for hook deliveries when a request does not provide one through allowed channels. Use a stable but scoped key to avoid mixing unrelated automation conversations.", "hooks.allowRequestSessionKey": @@ -1247,7 +1253,7 @@ export const FIELD_HELP: Record = { "hooks.allowedSessionKeyPrefixes": "Allowlist of accepted session-key prefixes for inbound hook requests when caller-provided keys are enabled. Use narrow prefixes to prevent arbitrary session-key injection.", "hooks.allowedAgentIds": - "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.", + "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.", "hooks.maxBodyBytes": "Maximum accepted webhook payload size in bytes before the request is rejected. Keep this bounded to reduce abuse risk and protect memory usage under bursty integrations.", "hooks.presets": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 62302e976af..ae1c8d2829d 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -863,6 +863,9 @@ export const FIELD_LABELS: Record = { "plugins.entries.*.enabled": "Plugin Enabled", "plugins.entries.*.hooks": "Plugin Hook Policy", "plugins.entries.*.hooks.allowPromptInjection": "Allow Prompt Injection Hooks", + "plugins.entries.*.subagent": "Plugin Subagent Policy", + "plugins.entries.*.subagent.allowModelOverride": "Allow Plugin Subagent Model Override", + "plugins.entries.*.subagent.allowedModels": "Plugin Subagent Allowed Models", "plugins.entries.*.apiKey": "Plugin API Key", // pragma: allowlist secret "plugins.entries.*.env": "Plugin Environment Variables", "plugins.entries.*.config": "Plugin Config", diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 2773b6d0fe7..eedf63913eb 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -3,7 +3,9 @@ import fsPromises from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { upsertAcpSessionMeta } from "../../acp/runtime/session-meta.js"; import * as jsonFiles from "../../infra/json-files.js"; +import type { OpenClawConfig } from "../config.js"; import { clearSessionStoreCacheForTest, loadSessionStore, @@ -279,6 +281,72 @@ describe("session store lock (Promise chain mutex)", () => { expect(store[key]?.modelProvider).toBeUndefined(); expect(store[key]?.model).toBeUndefined(); }); + + it("preserves ACP metadata when replacing a session entry wholesale", async () => { + const key = "agent:codex:acp:binding:discord:default:feedface"; + const acp = { + backend: "acpx", + agent: "codex", + runtimeSessionName: "codex-discord", + mode: "persistent" as const, + state: "idle" as const, + lastActivityAt: 100, + }; + const { storePath } = await makeTmpStore({ + [key]: { + sessionId: "sess-acp", + updatedAt: 100, + acp, + }, + }); + + await updateSessionStore(storePath, (store) => { + store[key] = { + sessionId: "sess-acp", + updatedAt: 200, + modelProvider: "openai-codex", + model: "gpt-5.4", + }; + }); + + const store = loadSessionStore(storePath); + expect(store[key]?.acp).toEqual(acp); + expect(store[key]?.modelProvider).toBe("openai-codex"); + expect(store[key]?.model).toBe("gpt-5.4"); + }); + + it("allows explicit ACP metadata removal through the ACP session helper", async () => { + const key = "agent:codex:acp:binding:discord:default:deadbeef"; + const { storePath } = await makeTmpStore({ + [key]: { + sessionId: "sess-acp-clear", + updatedAt: 100, + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "codex-discord", + mode: "persistent", + state: "idle", + lastActivityAt: 100, + }, + }, + }); + const cfg = { + session: { + store: storePath, + }, + } as OpenClawConfig; + + const result = await upsertAcpSessionMeta({ + cfg, + sessionKey: key, + mutate: () => null, + }); + + expect(result?.acp).toBeUndefined(); + const store = loadSessionStore(storePath); + expect(store[key]?.acp).toBeUndefined(); + }); }); describe("appendAssistantMessageToSessionTranscript", () => { diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index a70285c4c62..3936086beb8 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -309,6 +309,12 @@ type SaveSessionStoreOptions = { skipMaintenance?: boolean; /** Active session key for warn-only maintenance. */ activeSessionKey?: string; + /** + * Session keys that are allowed to drop persisted ACP metadata during this update. + * All other updates preserve existing `entry.acp` blocks when callers replace the + * whole session entry without carrying ACP state forward. + */ + allowDropAcpMetaSessionKeys?: string[]; /** Optional callback for warn-only maintenance. */ onWarn?: (warning: SessionMaintenanceWarning) => void | Promise; /** Optional callback with maintenance stats after a save. */ @@ -337,6 +343,64 @@ function updateSessionStoreWriteCaches(params: { }); } +function resolveMutableSessionStoreKey( + store: Record, + sessionKey: string, +): string | undefined { + const trimmed = sessionKey.trim(); + if (!trimmed) { + return undefined; + } + if (Object.prototype.hasOwnProperty.call(store, trimmed)) { + return trimmed; + } + const normalized = normalizeStoreSessionKey(trimmed); + if (Object.prototype.hasOwnProperty.call(store, normalized)) { + return normalized; + } + return Object.keys(store).find((key) => normalizeStoreSessionKey(key) === normalized); +} + +function collectAcpMetadataSnapshot( + store: Record, +): Map> { + const snapshot = new Map>(); + for (const [sessionKey, entry] of Object.entries(store)) { + if (entry?.acp) { + snapshot.set(sessionKey, entry.acp); + } + } + return snapshot; +} + +function preserveExistingAcpMetadata(params: { + previousAcpByKey: Map>; + nextStore: Record; + allowDropSessionKeys?: string[]; +}): void { + const allowDrop = new Set( + (params.allowDropSessionKeys ?? []).map((key) => normalizeStoreSessionKey(key)), + ); + for (const [previousKey, previousAcp] of params.previousAcpByKey.entries()) { + const normalizedKey = normalizeStoreSessionKey(previousKey); + if (allowDrop.has(normalizedKey)) { + continue; + } + const nextKey = resolveMutableSessionStoreKey(params.nextStore, previousKey); + if (!nextKey) { + continue; + } + const nextEntry = params.nextStore[nextKey]; + if (!nextEntry || nextEntry.acp) { + continue; + } + params.nextStore[nextKey] = { + ...nextEntry, + acp: previousAcp, + }; + } +} + async function saveSessionStoreUnlocked( storePath: string, store: Record, @@ -526,7 +590,13 @@ export async function updateSessionStore( return await withSessionStoreLock(storePath, async () => { // Always re-read inside the lock to avoid clobbering concurrent writers. const store = loadSessionStore(storePath, { skipCache: true }); + const previousAcpByKey = collectAcpMetadataSnapshot(store); const result = await mutator(store); + preserveExistingAcpMetadata({ + previousAcpByKey, + nextStore: store, + allowDropSessionKeys: opts?.allowDropAcpMetaSessionKeys, + }); await saveSessionStoreUnlocked(storePath, store, opts); return result; }); diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 62d750b0470..af37ba2020f 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -4,6 +4,15 @@ export type PluginEntryConfig = { /** Controls prompt mutation via before_prompt_build and prompt fields from legacy before_agent_start. */ allowPromptInjection?: boolean; }; + subagent?: { + /** Explicitly allow this plugin to request per-run provider/model overrides for subagent runs. */ + allowModelOverride?: boolean; + /** + * Allowed override targets as canonical provider/model refs. + * Use "*" to explicitly allow any model for this plugin. + */ + allowedModels?: string[]; + }; config?: Record; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index b32a86dc68f..f8ad6bfcbc9 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -155,6 +155,13 @@ const PluginEntrySchema = z }) .strict() .optional(), + subagent: z + .object({ + allowModelOverride: z.boolean().optional(), + allowedModels: z.array(z.string()).optional(), + }) + .strict() + .optional(), config: z.record(z.string(), z.unknown()).optional(), }) .strict(); diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 703ee88bf57..82c3501343b 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -109,6 +109,113 @@ class MockContextEngine implements ContextEngine { } } +class LegacySessionKeyStrictEngine implements ContextEngine { + readonly info: ContextEngineInfo = { + id: "legacy-sessionkey-strict", + name: "Legacy SessionKey Strict Engine", + }; + readonly ingestCalls: Array> = []; + readonly assembleCalls: Array> = []; + readonly compactCalls: Array> = []; + readonly ingestedMessages: AgentMessage[] = []; + + private rejectSessionKey(params: { sessionKey?: string }): void { + if (Object.prototype.hasOwnProperty.call(params, "sessionKey")) { + throw new Error("Unrecognized key(s) in object: 'sessionKey'"); + } + } + + async ingest(params: { + sessionId: string; + sessionKey?: string; + message: AgentMessage; + isHeartbeat?: boolean; + }): Promise { + this.ingestCalls.push({ ...params }); + this.rejectSessionKey(params); + this.ingestedMessages.push(params.message); + return { ingested: true }; + } + + async assemble(params: { + sessionId: string; + sessionKey?: string; + messages: AgentMessage[]; + tokenBudget?: number; + }): Promise { + this.assembleCalls.push({ ...params }); + this.rejectSessionKey(params); + return { + messages: params.messages, + estimatedTokens: 7, + }; + } + + async compact(params: { + sessionId: string; + sessionKey?: string; + sessionFile: string; + tokenBudget?: number; + compactionTarget?: "budget" | "threshold"; + customInstructions?: string; + runtimeContext?: Record; + }): Promise { + this.compactCalls.push({ ...params }); + this.rejectSessionKey(params); + return { + ok: true, + compacted: true, + result: { + tokensBefore: 50, + tokensAfter: 25, + }, + }; + } +} + +class SessionKeyRuntimeErrorEngine implements ContextEngine { + readonly info: ContextEngineInfo = { + id: "sessionkey-runtime-error", + name: "SessionKey Runtime Error Engine", + }; + assembleCalls = 0; + constructor(private readonly errorMessage = "sessionKey lookup failed") {} + + async ingest(_params: { + sessionId: string; + sessionKey?: string; + message: AgentMessage; + isHeartbeat?: boolean; + }): Promise { + return { ingested: true }; + } + + async assemble(_params: { + sessionId: string; + sessionKey?: string; + messages: AgentMessage[]; + tokenBudget?: number; + }): Promise { + this.assembleCalls += 1; + throw new Error(this.errorMessage); + } + + async compact(_params: { + sessionId: string; + sessionKey?: string; + sessionFile: string; + tokenBudget?: number; + compactionTarget?: "budget" | "threshold"; + customInstructions?: string; + runtimeContext?: Record; + }): Promise { + return { + ok: true, + compacted: false, + }; + } +} + // ═══════════════════════════════════════════════════════════════════════════ // 1. Engine contract tests // ═══════════════════════════════════════════════════════════════════════════ @@ -130,57 +237,6 @@ describe("Engine contract tests", () => { expect(engine.info.id).toBe("mock"); }); - it("ingest() returns IngestResult with ingested boolean", async () => { - const engine = new MockContextEngine(); - const result = await engine.ingest({ - sessionId: "s1", - message: makeMockMessage(), - }); - - expect(result).toHaveProperty("ingested"); - expect(typeof result.ingested).toBe("boolean"); - expect(result.ingested).toBe(true); - }); - - it("assemble() returns AssembleResult with messages array and estimatedTokens", async () => { - const engine = new MockContextEngine(); - const msgs = [makeMockMessage(), makeMockMessage("assistant", "world")]; - const result = await engine.assemble({ - sessionId: "s1", - messages: msgs, - }); - - expect(Array.isArray(result.messages)).toBe(true); - expect(result.messages).toHaveLength(2); - expect(typeof result.estimatedTokens).toBe("number"); - expect(result.estimatedTokens).toBe(42); - expect(result.systemPromptAddition).toBe("mock system addition"); - }); - - it("compact() returns CompactResult with ok, compacted, reason, result fields", async () => { - const engine = new MockContextEngine(); - const result = await engine.compact({ - sessionId: "s1", - sessionFile: "/tmp/session.json", - }); - - expect(typeof result.ok).toBe("boolean"); - expect(typeof result.compacted).toBe("boolean"); - expect(result.ok).toBe(true); - expect(result.compacted).toBe(true); - expect(result.reason).toBe("mock compaction"); - expect(result.result).toBeDefined(); - expect(result.result!.summary).toBe("mock summary"); - expect(result.result!.tokensBefore).toBe(100); - expect(result.result!.tokensAfter).toBe(50); - }); - - it("dispose() is callable (optional method)", async () => { - const engine = new MockContextEngine(); - // Should complete without error - await expect(engine.dispose()).resolves.toBeUndefined(); - }); - it("legacy compact preserves runtimeContext currentTokenCount when top-level value is absent", async () => { const engine = new LegacyContextEngine(); @@ -206,14 +262,7 @@ describe("Engine contract tests", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("Registry tests", () => { - it("registerContextEngine() stores a factory", () => { - const factory = () => new MockContextEngine(); - registerContextEngine("reg-test-1", factory); - - expect(getContextEngineFactory("reg-test-1")).toBe(factory); - }); - - it("getContextEngineFactory() returns the factory", () => { + it("registerContextEngine() stores retrievable factories", () => { const factory = () => new MockContextEngine(); registerContextEngine("reg-test-2", factory); @@ -325,6 +374,97 @@ describe("Registry tests", () => { // 3. Default engine selection // ═══════════════════════════════════════════════════════════════════════════ +describe("Legacy sessionKey compatibility", () => { + it("memoizes legacy mode after the first strict compatibility retry", async () => { + const engineId = `legacy-sessionkey-${Date.now().toString(36)}`; + const strictEngine = new LegacySessionKeyStrictEngine(); + registerContextEngine(engineId, () => strictEngine); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + const firstAssembled = await engine.assemble({ + sessionId: "s1", + sessionKey: "agent:main:test", + messages: [makeMockMessage()], + }); + const compacted = await engine.compact({ + sessionId: "s1", + sessionKey: "agent:main:test", + sessionFile: "/tmp/session.json", + }); + + expect(firstAssembled.estimatedTokens).toBe(7); + expect(compacted.compacted).toBe(true); + expect(strictEngine.assembleCalls).toHaveLength(2); + expect(strictEngine.assembleCalls[0]).toHaveProperty("sessionKey", "agent:main:test"); + expect(strictEngine.assembleCalls[1]).not.toHaveProperty("sessionKey"); + expect(strictEngine.compactCalls).toHaveLength(1); + expect(strictEngine.compactCalls[0]).not.toHaveProperty("sessionKey"); + }); + + it("retries strict ingest once and ingests each message only once", async () => { + const engineId = `legacy-sessionkey-ingest-${Date.now().toString(36)}`; + const strictEngine = new LegacySessionKeyStrictEngine(); + registerContextEngine(engineId, () => strictEngine); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + const firstMessage = makeMockMessage("user", "first"); + const secondMessage = makeMockMessage("assistant", "second"); + + await engine.ingest({ + sessionId: "s1", + sessionKey: "agent:main:test", + message: firstMessage, + }); + await engine.ingest({ + sessionId: "s1", + sessionKey: "agent:main:test", + message: secondMessage, + }); + + expect(strictEngine.ingestCalls).toHaveLength(3); + expect(strictEngine.ingestCalls[0]).toHaveProperty("sessionKey", "agent:main:test"); + expect(strictEngine.ingestCalls[1]).not.toHaveProperty("sessionKey"); + expect(strictEngine.ingestCalls[2]).not.toHaveProperty("sessionKey"); + expect(strictEngine.ingestedMessages).toEqual([firstMessage, secondMessage]); + }); + + it("does not retry non-compat runtime errors", async () => { + const engineId = `sessionkey-runtime-${Date.now().toString(36)}`; + const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine(); + registerContextEngine(engineId, () => runtimeErrorEngine); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + + await expect( + engine.assemble({ + sessionId: "s1", + sessionKey: "agent:main:test", + messages: [makeMockMessage()], + }), + ).rejects.toThrow("sessionKey lookup failed"); + expect(runtimeErrorEngine.assembleCalls).toBe(1); + }); + + it("does not treat 'Unknown sessionKey' runtime failures as schema-compat errors", async () => { + const engineId = `sessionkey-unknown-runtime-${Date.now().toString(36)}`; + const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine( + 'Unknown sessionKey "agent:main:missing"', + ); + registerContextEngine(engineId, () => runtimeErrorEngine); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + + await expect( + engine.assemble({ + sessionId: "s1", + sessionKey: "agent:main:missing", + messages: [makeMockMessage()], + }), + ).rejects.toThrow('Unknown sessionKey "agent:main:missing"'); + expect(runtimeErrorEngine.assembleCalls).toBe(1); + }); +}); + describe("Default engine selection", () => { // Ensure both legacy and a custom test engine are registered before these tests. beforeEach(() => { @@ -369,13 +509,7 @@ describe("Default engine selection", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("Invalid engine fallback", () => { - it("resolveContextEngine() with config pointing to unregistered engine throws with helpful error", async () => { - await expect(resolveContextEngine(configWithSlot("nonexistent-engine"))).rejects.toThrow( - /nonexistent-engine/, - ); - }); - - it("error message includes the requested id and available ids", async () => { + it("includes the requested id and available ids in unknown-engine errors", async () => { // Ensure at least legacy is registered so we see it in the available list registerLegacyContextEngine(); @@ -441,16 +575,11 @@ describe("LegacyContextEngine parity", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("Initialization guard", () => { - it("ensureContextEnginesInitialized() is idempotent (calling twice does not throw)", async () => { + it("ensureContextEnginesInitialized() is idempotent and registers legacy", async () => { const { ensureContextEnginesInitialized } = await import("./init.js"); expect(() => ensureContextEnginesInitialized()).not.toThrow(); expect(() => ensureContextEnginesInitialized()).not.toThrow(); - }); - - it("after init, 'legacy' engine is registered", async () => { - const { ensureContextEnginesInitialized } = await import("./init.js"); - ensureContextEnginesInitialized(); const ids = listContextEngineIds(); expect(ids).toContain("legacy"); diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index 1701877790a..2c5cac439c0 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -13,6 +13,202 @@ type RegisterContextEngineForOwnerOptions = { allowSameOwnerRefresh?: boolean; }; +const LEGACY_SESSION_KEY_COMPAT = Symbol.for("openclaw.contextEngine.sessionKeyCompat"); +const SESSION_KEY_COMPAT_METHODS = [ + "bootstrap", + "ingest", + "ingestBatch", + "afterTurn", + "assemble", + "compact", +] as const; + +type SessionKeyCompatMethodName = (typeof SESSION_KEY_COMPAT_METHODS)[number]; +type SessionKeyCompatParams = { + sessionKey?: string; +}; + +function isSessionKeyCompatMethodName(value: PropertyKey): value is SessionKeyCompatMethodName { + return ( + typeof value === "string" && (SESSION_KEY_COMPAT_METHODS as readonly string[]).includes(value) + ); +} + +function hasOwnSessionKey(params: unknown): params is SessionKeyCompatParams { + return ( + params !== null && + typeof params === "object" && + Object.prototype.hasOwnProperty.call(params, "sessionKey") + ); +} + +function withoutSessionKey(params: T): T { + const legacyParams = { ...params }; + delete legacyParams.sessionKey; + return legacyParams; +} + +function issueRejectsSessionKeyStrictly(issue: unknown): boolean { + if (!issue || typeof issue !== "object") { + return false; + } + + const issueRecord = issue as { + code?: unknown; + keys?: unknown; + message?: unknown; + }; + if ( + issueRecord.code === "unrecognized_keys" && + Array.isArray(issueRecord.keys) && + issueRecord.keys.some((key) => key === "sessionKey") + ) { + return true; + } + + return isSessionKeyCompatibilityError(issueRecord.message); +} + +function* iterateErrorChain(error: unknown) { + let current = error; + const seen = new Set(); + while (current !== undefined && current !== null && !seen.has(current)) { + yield current; + seen.add(current); + if (typeof current !== "object") { + break; + } + current = (current as { cause?: unknown }).cause; + } +} + +const SESSION_KEY_UNKNOWN_FIELD_PATTERNS = [ + /\bunrecognized key(?:\(s\)|s)? in object:.*['"`]sessionKey['"`]/i, + /\badditional propert(?:y|ies)\b.*['"`]sessionKey['"`]/i, + /\bmust not have additional propert(?:y|ies)\b.*['"`]sessionKey['"`]/i, + /\b(?:unexpected|extraneous)\s+(?:property|properties|field|fields|key|keys)\b.*['"`]sessionKey['"`]/i, + /\b(?:unknown|invalid)\s+(?:property|properties|field|fields|key|keys)\b.*['"`]sessionKey['"`]/i, + /['"`]sessionKey['"`].*\b(?:was|is)\s+not allowed\b/i, + /"code"\s*:\s*"unrecognized_keys"[^]*"sessionKey"/i, +] as const; + +function isSessionKeyUnknownFieldValidationMessage(message: string): boolean { + return SESSION_KEY_UNKNOWN_FIELD_PATTERNS.some((pattern) => pattern.test(message)); +} + +function isSessionKeyCompatibilityError(error: unknown): boolean { + for (const candidate of iterateErrorChain(error)) { + if (Array.isArray(candidate)) { + if (candidate.some((entry) => issueRejectsSessionKeyStrictly(entry))) { + return true; + } + continue; + } + + if (typeof candidate === "string") { + if (isSessionKeyUnknownFieldValidationMessage(candidate)) { + return true; + } + continue; + } + + if (!candidate || typeof candidate !== "object") { + continue; + } + + const issueContainer = candidate as { + message?: unknown; + issues?: unknown; + errors?: unknown; + }; + + if ( + Array.isArray(issueContainer.issues) && + issueContainer.issues.some((issue) => issueRejectsSessionKeyStrictly(issue)) + ) { + return true; + } + + if ( + Array.isArray(issueContainer.errors) && + issueContainer.errors.some((issue) => issueRejectsSessionKeyStrictly(issue)) + ) { + return true; + } + + if ( + typeof issueContainer.message === "string" && + isSessionKeyUnknownFieldValidationMessage(issueContainer.message) + ) { + return true; + } + } + + return false; +} + +async function invokeWithLegacySessionKeyCompat( + method: (params: TParams) => Promise | TResult, + params: TParams, + opts?: { + onLegacyModeDetected?: () => void; + }, +): Promise { + if (!hasOwnSessionKey(params)) { + return await method(params); + } + + try { + return await method(params); + } catch (error) { + if (!isSessionKeyCompatibilityError(error)) { + throw error; + } + opts?.onLegacyModeDetected?.(); + return await method(withoutSessionKey(params)); + } +} + +function wrapContextEngineWithSessionKeyCompat(engine: ContextEngine): ContextEngine { + const marked = engine as ContextEngine & { + [LEGACY_SESSION_KEY_COMPAT]?: boolean; + }; + if (marked[LEGACY_SESSION_KEY_COMPAT]) { + return engine; + } + + let isLegacy = false; + const proxy: ContextEngine = new Proxy(engine, { + get(target, property, receiver) { + if (property === LEGACY_SESSION_KEY_COMPAT) { + return true; + } + + const value = Reflect.get(target, property, receiver); + if (typeof value !== "function") { + return value; + } + + if (!isSessionKeyCompatMethodName(property)) { + return value.bind(target); + } + + return (params: SessionKeyCompatParams) => { + const method = value.bind(target) as (params: SessionKeyCompatParams) => unknown; + if (isLegacy && hasOwnSessionKey(params)) { + return method(withoutSessionKey(params)); + } + return invokeWithLegacySessionKeyCompat(method, params, { + onLegacyModeDetected: () => { + isLegacy = true; + }, + }); + }; + }, + }); + return proxy; +} + // --------------------------------------------------------------------------- // Registry (module-level singleton) // --------------------------------------------------------------------------- @@ -139,5 +335,5 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise { const unique = [...new Set(parts)]; expect(parts.length).toBe(unique.length); }); + + it("prepends explicit runtime bin directories before guessed user paths", () => { + const result = buildMinimalServicePath({ + platform: "linux", + extraDirs: ["/home/alice/.nvm/versions/node/v22.22.0/bin"], + env: { HOME: "/home/alice" }, + }); + const parts = splitPath(result, "linux"); + + expect(parts[0]).toBe("/home/alice/.nvm/versions/node/v22.22.0/bin"); + expect(parts).toContain("/home/alice/.nvm/current/bin"); + }); }); describe("buildServiceEnvironment", () => { @@ -344,6 +356,19 @@ describe("buildServiceEnvironment", () => { expect(env).not.toHaveProperty("PATH"); expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway"); }); + + it("prepends extra runtime directories to the gateway service PATH", () => { + const env = buildServiceEnvironment({ + env: { HOME: "/home/user" }, + port: 18789, + platform: "linux", + extraPathDirs: ["/home/user/.nvm/versions/node/v22.22.0/bin"], + }); + + expect(env.PATH?.split(path.posix.delimiter)[0]).toBe( + "/home/user/.nvm/versions/node/v22.22.0/bin", + ); + }); }); describe("buildNodeServiceEnvironment", () => { @@ -416,6 +441,18 @@ describe("buildNodeServiceEnvironment", () => { }); expect(env.TMPDIR).toBe(os.tmpdir()); }); + + it("prepends extra runtime directories to the node service PATH", () => { + const env = buildNodeServiceEnvironment({ + env: { HOME: "/home/user" }, + platform: "linux", + extraPathDirs: ["/home/user/.nvm/versions/node/v22.22.0/bin"], + }); + + expect(env.PATH?.split(path.posix.delimiter)[0]).toBe( + "/home/user/.nvm/versions/node/v22.22.0/bin", + ); + }); }); describe("shared Node TLS env defaults", () => { diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index fb6fff41839..cb26c210efb 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -247,10 +247,11 @@ export function buildServiceEnvironment(params: { port: number; launchdLabel?: string; platform?: NodeJS.Platform; + extraPathDirs?: string[]; }): Record { - const { env, port, launchdLabel } = params; + const { env, port, launchdLabel, extraPathDirs } = params; const platform = params.platform ?? process.platform; - const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform); + const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform, extraPathDirs); const profile = env.OPENCLAW_PROFILE; const resolvedLaunchdLabel = launchdLabel || (platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined); @@ -271,10 +272,11 @@ export function buildServiceEnvironment(params: { export function buildNodeServiceEnvironment(params: { env: Record; platform?: NodeJS.Platform; + extraPathDirs?: string[]; }): Record { - const { env } = params; + const { env, extraPathDirs } = params; const platform = params.platform ?? process.platform; - const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform); + const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform, extraPathDirs); const gatewayToken = env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim() || undefined; return { @@ -313,6 +315,7 @@ function buildCommonServiceEnvironment( function resolveSharedServiceEnvironmentFields( env: Record, platform: NodeJS.Platform, + extraPathDirs: string[] | undefined, ): SharedServiceEnvironmentFields { const stateDir = env.OPENCLAW_STATE_DIR; const configPath = env.OPENCLAW_CONFIG_PATH; @@ -331,7 +334,10 @@ function resolveSharedServiceEnvironmentFields( tmpDir, // On Windows, Scheduled Tasks should inherit the current task PATH instead of // freezing the install-time snapshot into gateway.cmd/node-host.cmd. - minimalPath: platform === "win32" ? undefined : buildMinimalServicePath({ env, platform }), + minimalPath: + platform === "win32" + ? undefined + : buildMinimalServicePath({ env, platform, extraDirs: extraPathDirs }), proxyEnv, nodeCaCerts, nodeUseSystemCa, diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 5ac82138f28..5809da5bcee 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -117,6 +117,7 @@ function buildAgentCommandInput(params: { bestEffortDeliver: false as const, // HTTP API callers are authenticated operator clients for this gateway context. senderIsOwner: true as const, + allowModelOverride: true as const, }; } diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 065b20cdf62..9c9e7384445 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -256,6 +256,7 @@ async function runResponsesAgentCommand(params: { bestEffortDeliver: false, // HTTP API callers are authenticated operator clients for this gateway context. senderIsOwner: true, + allowModelOverride: true, }, defaultRuntime, params.deps, diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 11369a4ed4a..b9c844b135b 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -75,6 +75,8 @@ export const AgentParamsSchema = Type.Object( { message: NonEmptyString, agentId: Type.Optional(NonEmptyString), + provider: Type.Optional(Type.String()), + model: Type.Optional(Type.String()), to: Type.Optional(Type.String()), replyTo: Type.Optional(Type.String()), sessionId: Type.Optional(Type.String()), diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index f3b74416c70..06613d9e180 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -303,6 +303,107 @@ describe("gateway agent handler", () => { expect(capturedEntry?.acp).toEqual(existingAcpMeta); }); + it("forwards provider and model overrides for admin-scoped callers", async () => { + primeMainAgentRun(); + + await invokeAgent( + { + message: "test override", + agentId: "main", + sessionKey: "agent:main:main", + provider: "anthropic", + model: "claude-haiku-4-5", + idempotencyKey: "test-idem-model-override", + }, + { + reqId: "test-idem-model-override", + client: { + connect: { + scopes: ["operator.admin"], + }, + } as AgentHandlerArgs["client"], + }, + ); + + const lastCall = mocks.agentCommand.mock.calls.at(-1); + expect(lastCall?.[0]).toEqual( + expect.objectContaining({ + provider: "anthropic", + model: "claude-haiku-4-5", + }), + ); + }); + + it("rejects provider and model overrides for write-scoped callers", async () => { + primeMainAgentRun(); + mocks.agentCommand.mockClear(); + const respond = vi.fn(); + + await invokeAgent( + { + message: "test override", + agentId: "main", + sessionKey: "agent:main:main", + provider: "anthropic", + model: "claude-haiku-4-5", + idempotencyKey: "test-idem-model-override-write", + }, + { + reqId: "test-idem-model-override-write", + client: { + connect: { + scopes: ["operator.write"], + }, + } as AgentHandlerArgs["client"], + respond, + }, + ); + + expect(mocks.agentCommand).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: "provider/model overrides are not authorized for this caller.", + }), + ); + }); + + it("forwards provider and model overrides when internal override authorization is set", async () => { + primeMainAgentRun(); + + await invokeAgent( + { + message: "test override", + agentId: "main", + sessionKey: "agent:main:main", + provider: "anthropic", + model: "claude-haiku-4-5", + idempotencyKey: "test-idem-model-override-internal", + }, + { + reqId: "test-idem-model-override-internal", + client: { + connect: { + scopes: ["operator.write"], + }, + internal: { + allowModelOverride: true, + }, + } as AgentHandlerArgs["client"], + }, + ); + + const lastCall = mocks.agentCommand.mock.calls.at(-1); + expect(lastCall?.[0]).toEqual( + expect.objectContaining({ + provider: "anthropic", + model: "claude-haiku-4-5", + senderIsOwner: false, + }), + ); + }); + it("preserves cliSessionIds from existing session entry", async () => { const existingCliSessionIds = { "claude-cli": "abc-123-def" }; const existingClaudeCliSessionId = "abc-123-def"; diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 5a7507345df..9ab032a2edd 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -71,6 +71,12 @@ function resolveSenderIsOwnerFromClient(client: GatewayRequestHandlerOptions["cl return scopes.includes(ADMIN_SCOPE); } +function resolveAllowModelOverrideFromClient( + client: GatewayRequestHandlerOptions["client"], +): boolean { + return resolveSenderIsOwnerFromClient(client) || client?.internal?.allowModelOverride === true; +} + async function runSessionResetFromAgent(params: { key: string; reason: "new" | "reset"; @@ -162,6 +168,8 @@ export const agentHandlers: GatewayRequestHandlers = { const request = p as { message: string; agentId?: string; + provider?: string; + model?: string; to?: string; replyTo?: string; sessionId?: string; @@ -192,6 +200,21 @@ export const agentHandlers: GatewayRequestHandlers = { inputProvenance?: InputProvenance; }; const senderIsOwner = resolveSenderIsOwnerFromClient(client); + const allowModelOverride = resolveAllowModelOverrideFromClient(client); + const requestedModelOverride = Boolean(request.provider || request.model); + if (requestedModelOverride && !allowModelOverride) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "provider/model overrides are not authorized for this caller.", + ), + ); + return; + } + const providerOverride = allowModelOverride ? request.provider : undefined; + const modelOverride = allowModelOverride ? request.model : undefined; const cfg = loadConfig(); const idem = request.idempotencyKey; const normalizedSpawned = normalizeSpawnedRunMetadata({ @@ -584,6 +607,8 @@ export const agentHandlers: GatewayRequestHandlers = { ingressOpts: { message, images, + provider: providerOverride, + model: modelOverride, to: resolvedTo, sessionId: resolvedSessionId, sessionKey: resolvedSessionKey, @@ -619,6 +644,7 @@ export const agentHandlers: GatewayRequestHandlers = { workspaceDir: sessionEntry?.spawnedWorkspaceDir, }), senderIsOwner, + allowModelOverride, }, runId, idempotencyKey: idem, diff --git a/src/gateway/server-methods/types.ts b/src/gateway/server-methods/types.ts index 4998a84c842..ab3a5c889c2 100644 --- a/src/gateway/server-methods/types.ts +++ b/src/gateway/server-methods/types.ts @@ -21,6 +21,10 @@ export type GatewayClient = { canvasHostUrl?: string; canvasCapability?: string; canvasCapabilityExpiresAtMs?: number; + /** Internal-only auth context that cannot be supplied through gateway RPC payloads. */ + internal?: { + allowModelOverride?: boolean; + }; }; export type RespondFn = ( diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 8ab24644101..c2aa3c454c7 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -310,6 +310,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt sourceTool: "gateway.voice.transcript", }, senderIsOwner: false, + allowModelOverride: false, }, defaultRuntime, ctx.deps, @@ -441,6 +442,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt typeof link?.timeoutSeconds === "number" ? link.timeoutSeconds.toString() : undefined, messageChannel: "node", senderIsOwner: false, + allowModelOverride: false, }, defaultRuntime, ctx.deps, diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index ddaaa64c02b..1ad6bf858ef 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -1,10 +1,14 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { PluginRegistry } from "../plugins/registry.js"; +import type { PluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { PluginDiagnostic } from "../plugins/types.js"; import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js"; const loadOpenClawPlugins = vi.hoisted(() => vi.fn()); +const primeConfiguredBindingRegistry = vi.hoisted(() => + vi.fn(() => ({ bindingCount: 0, channelCount: 0 })), +); type HandleGatewayRequestOptions = GatewayRequestOptions & { extraHandlers?: Record; }; @@ -16,10 +20,27 @@ vi.mock("../plugins/loader.js", () => ({ loadOpenClawPlugins, })); +vi.mock("../channels/plugins/binding-registry.js", () => ({ + primeConfiguredBindingRegistry, +})); + vi.mock("./server-methods.js", () => ({ handleGatewayRequest, })); +vi.mock("../channels/registry.js", () => ({ + CHAT_CHANNEL_ORDER: [], + CHANNEL_IDS: [], + listChatChannels: () => [], + listChatChannelAliases: () => [], + getChatChannelMeta: () => null, + normalizeChatChannelId: () => null, + normalizeChannelId: () => null, + normalizeAnyChannelId: () => null, + formatChannelPrimerLine: () => "", + formatChannelSelectionLine: () => "", +})); + const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ plugins: [], tools: [], @@ -37,6 +58,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ httpRoutes: [], cliRegistrars: [], services: [], + conversationBindingResolvedHandlers: [], diagnostics, }); @@ -51,12 +73,24 @@ function getLastDispatchedContext(): GatewayRequestContext | undefined { return call?.context; } +function getLastDispatchedParams(): Record | undefined { + const call = handleGatewayRequest.mock.calls.at(-1)?.[0]; + return call?.req?.params as Record | undefined; +} + +function getLastDispatchedClientScopes(): string[] { + const call = handleGatewayRequest.mock.calls.at(-1)?.[0]; + const scopes = call?.client?.connect?.scopes; + return Array.isArray(scopes) ? scopes : []; +} + async function importServerPluginsModule(): Promise { return import("./server-plugins.js"); } async function createSubagentRuntime( serverPlugins: ServerPluginsModule, + cfg: Record = {}, ): Promise { const log = { info: vi.fn(), @@ -66,7 +100,7 @@ async function createSubagentRuntime( }; loadOpenClawPlugins.mockReturnValue(createRegistry([])); serverPlugins.loadGatewayPlugins({ - cfg: {}, + cfg, workspaceDir: "/tmp", log, coreGatewayHandlers: {}, @@ -84,6 +118,7 @@ async function createSubagentRuntime( beforeEach(async () => { loadOpenClawPlugins.mockReset(); + primeConfiguredBindingRegistry.mockClear().mockReturnValue({ bindingCount: 0, channelCount: 0 }); handleGatewayRequest.mockReset(); const runtimeModule = await import("../plugins/runtime/index.js"); runtimeModule.clearGatewaySubagentRuntime(); @@ -178,6 +213,215 @@ describe("loadGatewayPlugins", () => { expect(typeof subagent?.getSession).toBe("function"); }); + test("forwards provider and model overrides when the request scope is authorized", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins); + const gatewayScopeModule = await import("../plugins/runtime/gateway-request-scope.js"); + const scope = { + context: createTestContext("request-scope-forward-overrides"), + client: { + connect: { + scopes: ["operator.admin"], + }, + } as GatewayRequestOptions["client"], + isWebchatConnect: () => false, + } satisfies PluginRuntimeGatewayRequestScope; + + await gatewayScopeModule.withPluginRuntimeGatewayRequestScope(scope, () => + runtime.run({ + sessionKey: "s-override", + message: "use the override", + provider: "anthropic", + model: "claude-haiku-4-5", + deliver: false, + }), + ); + + expect(getLastDispatchedParams()).toMatchObject({ + sessionKey: "s-override", + message: "use the override", + provider: "anthropic", + model: "claude-haiku-4-5", + deliver: false, + }); + }); + + test("rejects provider/model overrides for fallback runs without explicit authorization", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins); + serverPlugins.setFallbackGatewayContext(createTestContext("fallback-deny-overrides")); + + await expect( + runtime.run({ + sessionKey: "s-fallback-override", + message: "use the override", + provider: "anthropic", + model: "claude-haiku-4-5", + deliver: false, + }), + ).rejects.toThrow( + "provider/model override requires plugin identity in fallback subagent runs.", + ); + }); + + test("allows trusted fallback provider/model overrides when plugin config is explicit", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins, { + plugins: { + entries: { + "voice-call": { + subagent: { + allowModelOverride: true, + allowedModels: ["anthropic/claude-haiku-4-5"], + }, + }, + }, + }, + }); + serverPlugins.setFallbackGatewayContext(createTestContext("fallback-trusted-overrides")); + const gatewayScopeModule = await import("../plugins/runtime/gateway-request-scope.js"); + + await gatewayScopeModule.withPluginRuntimePluginIdScope("voice-call", () => + runtime.run({ + sessionKey: "s-trusted-override", + message: "use trusted override", + provider: "anthropic", + model: "claude-haiku-4-5", + deliver: false, + }), + ); + + expect(getLastDispatchedParams()).toMatchObject({ + sessionKey: "s-trusted-override", + provider: "anthropic", + model: "claude-haiku-4-5", + }); + }); + + test("allows trusted fallback model-only overrides when the model ref is canonical", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins, { + plugins: { + entries: { + "voice-call": { + subagent: { + allowModelOverride: true, + allowedModels: ["anthropic/claude-haiku-4-5"], + }, + }, + }, + }, + }); + serverPlugins.setFallbackGatewayContext(createTestContext("fallback-model-only-override")); + const gatewayScopeModule = await import("../plugins/runtime/gateway-request-scope.js"); + + await gatewayScopeModule.withPluginRuntimePluginIdScope("voice-call", () => + runtime.run({ + sessionKey: "s-model-only-override", + message: "use trusted model-only override", + model: "anthropic/claude-haiku-4-5", + deliver: false, + }), + ); + + expect(getLastDispatchedParams()).toMatchObject({ + sessionKey: "s-model-only-override", + model: "anthropic/claude-haiku-4-5", + }); + expect(getLastDispatchedParams()).not.toHaveProperty("provider"); + }); + + test("rejects trusted fallback overrides when the configured allowlist normalizes to empty", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins, { + plugins: { + entries: { + "voice-call": { + subagent: { + allowModelOverride: true, + allowedModels: ["anthropic"], + }, + }, + }, + }, + }); + serverPlugins.setFallbackGatewayContext(createTestContext("fallback-invalid-allowlist")); + const gatewayScopeModule = await import("../plugins/runtime/gateway-request-scope.js"); + + await expect( + gatewayScopeModule.withPluginRuntimePluginIdScope("voice-call", () => + runtime.run({ + sessionKey: "s-invalid-allowlist", + message: "use trusted override", + provider: "anthropic", + model: "claude-haiku-4-5", + deliver: false, + }), + ), + ).rejects.toThrow( + 'plugin "voice-call" configured subagent.allowedModels, but none of the entries normalized to a valid provider/model target.', + ); + }); + + test("uses least-privilege synthetic fallback scopes without admin", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins); + serverPlugins.setFallbackGatewayContext(createTestContext("synthetic-least-privilege")); + + await runtime.run({ + sessionKey: "s-synthetic", + message: "run synthetic", + deliver: false, + }); + + expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]); + expect(getLastDispatchedClientScopes()).not.toContain("operator.admin"); + }); + + test("allows fallback session reads with synthetic write scope", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins); + serverPlugins.setFallbackGatewayContext(createTestContext("synthetic-session-read")); + const { authorizeOperatorScopesForMethod } = await import("./method-scopes.js"); + + handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { + const scopes = Array.isArray(opts.client?.connect?.scopes) ? opts.client.connect.scopes : []; + const auth = authorizeOperatorScopesForMethod("sessions.get", scopes); + if (!auth.allowed) { + opts.respond(false, undefined, { + code: "INVALID_REQUEST", + message: `missing scope: ${auth.missingScope}`, + }); + return; + } + opts.respond(true, { messages: [{ id: "m-1" }] }); + }); + + await expect( + runtime.getSessionMessages({ + sessionKey: "s-read", + }), + ).resolves.toEqual({ + messages: [{ id: "m-1" }], + }); + + expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]); + expect(getLastDispatchedClientScopes()).not.toContain("operator.admin"); + }); + + test("keeps admin scope for fallback session deletion", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins); + serverPlugins.setFallbackGatewayContext(createTestContext("synthetic-delete-session")); + + await runtime.deleteSession({ + sessionKey: "s-delete", + deleteTranscript: true, + }); + + expect(getLastDispatchedClientScopes()).toEqual(["operator.admin"]); + }); + test("can prefer setup-runtime channel plugins during startup loads", async () => { const { loadGatewayPlugins } = await importServerPluginsModule(); loadOpenClawPlugins.mockReturnValue(createRegistry([])); @@ -205,6 +449,29 @@ describe("loadGatewayPlugins", () => { ); }); + test("primes configured bindings during gateway startup", async () => { + const { loadGatewayPlugins } = await importServerPluginsModule(); + loadOpenClawPlugins.mockReturnValue(createRegistry([])); + + const log = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + const cfg = {}; + loadGatewayPlugins({ + cfg, + workspaceDir: "/tmp", + log, + coreGatewayHandlers: {}, + baseMethods: [], + }); + + expect(primeConfiguredBindingRegistry).toHaveBeenCalledWith({ cfg }); + }); + test("can suppress duplicate diagnostics when reloading full runtime plugins", async () => { const { loadGatewayPlugins } = await importServerPluginsModule(); const diagnostics: PluginDiagnostic[] = [ @@ -236,7 +503,6 @@ describe("loadGatewayPlugins", () => { expect(log.error).not.toHaveBeenCalled(); expect(log.info).not.toHaveBeenCalled(); }); - test("shares fallback context across module reloads for existing runtimes", async () => { const first = await importServerPluginsModule(); const runtime = await createSubagentRuntime(first); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 587aa71dc41..a997c93cbbc 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -1,9 +1,13 @@ import { randomUUID } from "node:crypto"; +import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js"; +import { primeConfiguredBindingRegistry } from "../channels/plugins/binding-registry.js"; import type { loadConfig } from "../config/config.js"; +import { normalizePluginsConfig } from "../plugins/config-state.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import { setGatewaySubagentRuntime } from "../plugins/runtime/index.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; +import { ADMIN_SCOPE, WRITE_SCOPE } from "./method-scopes.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "./protocol/client-info.js"; import type { ErrorShape } from "./protocol/index.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; @@ -46,9 +50,168 @@ export function setFallbackGatewayContext(ctx: GatewayRequestContext): void { fallbackGatewayContextState.context = ctx; } +type PluginSubagentOverridePolicy = { + allowModelOverride: boolean; + allowAnyModel: boolean; + hasConfiguredAllowlist: boolean; + allowedModels: Set; +}; + +type PluginSubagentPolicyState = { + policies: Record; +}; + +const PLUGIN_SUBAGENT_POLICY_STATE_KEY: unique symbol = Symbol.for( + "openclaw.pluginSubagentOverridePolicyState", +); + +const pluginSubagentPolicyState: PluginSubagentPolicyState = (() => { + const globalState = globalThis as typeof globalThis & { + [PLUGIN_SUBAGENT_POLICY_STATE_KEY]?: PluginSubagentPolicyState; + }; + const existing = globalState[PLUGIN_SUBAGENT_POLICY_STATE_KEY]; + if (existing) { + return existing; + } + const created: PluginSubagentPolicyState = { + policies: {}, + }; + globalState[PLUGIN_SUBAGENT_POLICY_STATE_KEY] = created; + return created; +})(); + +function normalizeAllowedModelRef(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + if (trimmed === "*") { + return "*"; + } + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash >= trimmed.length - 1) { + return null; + } + const providerRaw = trimmed.slice(0, slash).trim(); + const modelRaw = trimmed.slice(slash + 1).trim(); + if (!providerRaw || !modelRaw) { + return null; + } + const normalized = normalizeModelRef(providerRaw, modelRaw); + return `${normalized.provider}/${normalized.model}`; +} + +function setPluginSubagentOverridePolicies(cfg: ReturnType): void { + const normalized = normalizePluginsConfig(cfg.plugins); + const policies: PluginSubagentPolicyState["policies"] = {}; + for (const [pluginId, entry] of Object.entries(normalized.entries)) { + const allowModelOverride = entry.subagent?.allowModelOverride === true; + const hasConfiguredAllowlist = entry.subagent?.hasAllowedModelsConfig === true; + const configuredAllowedModels = entry.subagent?.allowedModels ?? []; + const allowedModels = new Set(); + let allowAnyModel = false; + for (const modelRef of configuredAllowedModels) { + const normalizedModelRef = normalizeAllowedModelRef(modelRef); + if (!normalizedModelRef) { + continue; + } + if (normalizedModelRef === "*") { + allowAnyModel = true; + continue; + } + allowedModels.add(normalizedModelRef); + } + if ( + !allowModelOverride && + !hasConfiguredAllowlist && + allowedModels.size === 0 && + !allowAnyModel + ) { + continue; + } + policies[pluginId] = { + allowModelOverride, + allowAnyModel, + hasConfiguredAllowlist, + allowedModels, + }; + } + pluginSubagentPolicyState.policies = policies; +} + +function authorizeFallbackModelOverride(params: { + pluginId?: string; + provider?: string; + model?: string; +}): { allowed: true } | { allowed: false; reason: string } { + const pluginId = params.pluginId?.trim(); + if (!pluginId) { + return { + allowed: false, + reason: "provider/model override requires plugin identity in fallback subagent runs.", + }; + } + const policy = pluginSubagentPolicyState.policies[pluginId]; + if (!policy?.allowModelOverride) { + return { + allowed: false, + reason: `plugin "${pluginId}" is not trusted for fallback provider/model override requests.`, + }; + } + if (policy.allowAnyModel) { + return { allowed: true }; + } + if (policy.hasConfiguredAllowlist && policy.allowedModels.size === 0) { + return { + allowed: false, + reason: `plugin "${pluginId}" configured subagent.allowedModels, but none of the entries normalized to a valid provider/model target.`, + }; + } + if (policy.allowedModels.size === 0) { + return { allowed: true }; + } + const requestedModelRef = resolveRequestedFallbackModelRef(params); + if (!requestedModelRef) { + return { + allowed: false, + reason: + "fallback provider/model overrides that use an allowlist must resolve to a canonical provider/model target.", + }; + } + if (policy.allowedModels.has(requestedModelRef)) { + return { allowed: true }; + } + return { + allowed: false, + reason: `model override "${requestedModelRef}" is not allowlisted for plugin "${pluginId}".`, + }; +} + +function resolveRequestedFallbackModelRef(params: { + provider?: string; + model?: string; +}): string | null { + if (params.provider && params.model) { + const normalizedRequest = normalizeModelRef(params.provider, params.model); + return `${normalizedRequest.provider}/${normalizedRequest.model}`; + } + const rawModel = params.model?.trim(); + if (!rawModel || !rawModel.includes("/")) { + return null; + } + const parsed = parseModelRef(rawModel, ""); + if (!parsed?.provider || !parsed.model) { + return null; + } + return `${parsed.provider}/${parsed.model}`; +} + // ── Internal gateway dispatch for plugin runtime ──────────────────── -function createSyntheticOperatorClient(): GatewayRequestOptions["client"] { +function createSyntheticOperatorClient(params?: { + allowModelOverride?: boolean; + scopes?: string[]; +}): GatewayRequestOptions["client"] { return { connect: { minProtocol: PROTOCOL_VERSION, @@ -60,14 +223,30 @@ function createSyntheticOperatorClient(): GatewayRequestOptions["client"] { mode: GATEWAY_CLIENT_MODES.BACKEND, }, role: "operator", - scopes: ["operator.admin", "operator.approvals", "operator.pairing"], + scopes: params?.scopes ?? [WRITE_SCOPE], + }, + internal: { + allowModelOverride: params?.allowModelOverride === true, }, }; } +function hasAdminScope(client: GatewayRequestOptions["client"]): boolean { + const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; + return scopes.includes(ADMIN_SCOPE); +} + +function canClientUseModelOverride(client: GatewayRequestOptions["client"]): boolean { + return hasAdminScope(client) || client?.internal?.allowModelOverride === true; +} + async function dispatchGatewayMethod( method: string, params: Record, + options?: { + allowSyntheticModelOverride?: boolean; + syntheticScopes?: string[]; + }, ): Promise { const scope = getPluginRuntimeGatewayRequestScope(); const context = scope?.context ?? fallbackGatewayContextState.context; @@ -86,7 +265,12 @@ async function dispatchGatewayMethod( method, params, }, - client: scope?.client ?? createSyntheticOperatorClient(), + client: + scope?.client ?? + createSyntheticOperatorClient({ + allowModelOverride: options?.allowSyntheticModelOverride === true, + scopes: options?.syntheticScopes, + }), isWebchatConnect, respond: (ok, payload, error) => { if (!result) { @@ -116,14 +300,42 @@ function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { return { async run(params) { - const payload = await dispatchGatewayMethod<{ runId?: string }>("agent", { - sessionKey: params.sessionKey, - message: params.message, - deliver: params.deliver ?? false, - ...(params.extraSystemPrompt && { extraSystemPrompt: params.extraSystemPrompt }), - ...(params.lane && { lane: params.lane }), - ...(params.idempotencyKey && { idempotencyKey: params.idempotencyKey }), - }); + const scope = getPluginRuntimeGatewayRequestScope(); + const overrideRequested = Boolean(params.provider || params.model); + const hasRequestScopeClient = Boolean(scope?.client); + let allowOverride = hasRequestScopeClient && canClientUseModelOverride(scope?.client ?? null); + let allowSyntheticModelOverride = false; + if (overrideRequested && !allowOverride && !hasRequestScopeClient) { + const fallbackAuth = authorizeFallbackModelOverride({ + pluginId: scope?.pluginId, + provider: params.provider, + model: params.model, + }); + if (!fallbackAuth.allowed) { + throw new Error(fallbackAuth.reason); + } + allowOverride = true; + allowSyntheticModelOverride = true; + } + if (overrideRequested && !allowOverride) { + throw new Error("provider/model override is not authorized for this plugin subagent run."); + } + const payload = await dispatchGatewayMethod<{ runId?: string }>( + "agent", + { + sessionKey: params.sessionKey, + message: params.message, + deliver: params.deliver ?? false, + ...(allowOverride && params.provider && { provider: params.provider }), + ...(allowOverride && params.model && { model: params.model }), + ...(params.extraSystemPrompt && { extraSystemPrompt: params.extraSystemPrompt }), + ...(params.lane && { lane: params.lane }), + ...(params.idempotencyKey && { idempotencyKey: params.idempotencyKey }), + }, + { + allowSyntheticModelOverride, + }, + ); const runId = payload?.runId; if (typeof runId !== "string" || !runId) { throw new Error("Gateway agent method returned an invalid runId."); @@ -152,10 +364,16 @@ function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { return getSessionMessages(params); }, async deleteSession(params) { - await dispatchGatewayMethod("sessions.delete", { - key: params.sessionKey, - deleteTranscript: params.deleteTranscript ?? true, - }); + await dispatchGatewayMethod( + "sessions.delete", + { + key: params.sessionKey, + deleteTranscript: params.deleteTranscript ?? true, + }, + { + syntheticScopes: [ADMIN_SCOPE], + }, + ); }, }; } @@ -176,6 +394,7 @@ export function loadGatewayPlugins(params: { preferSetupRuntimeForChannelPlugins?: boolean; logDiagnostics?: boolean; }) { + setPluginSubagentOverridePolicies(params.cfg); // Set the process-global gateway subagent runtime BEFORE loading plugins. // Gateway-owned registries may already exist from schema loads, so the // gateway path opts those runtimes into late binding rather than changing @@ -198,6 +417,7 @@ export function loadGatewayPlugins(params: { }, preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins, }); + primeConfiguredBindingRegistry({ cfg: params.cfg }); const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods])); if ((params.logDiagnostics ?? true) && pluginRegistry.diagnostics.length > 0) { diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 44863f61f31..9452c26eb33 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -36,14 +36,12 @@ export function registerControlUiAndPairingSuite(): void { expectedOk: boolean; expectedErrorSubstring?: string; expectedErrorCode?: string; - expectStatusChecks: boolean; }> = [ { name: "allows trusted-proxy control ui operator without device identity", role: "operator", withUnpairedNodeDevice: false, expectedOk: true, - expectStatusChecks: true, }, { name: "rejects trusted-proxy control ui node role without device identity", @@ -52,7 +50,6 @@ export function registerControlUiAndPairingSuite(): void { expectedOk: false, expectedErrorSubstring: "control ui requires device identity", expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, - expectStatusChecks: false, }, { name: "requires pairing for trusted-proxy control ui node role with unpaired device", @@ -61,7 +58,6 @@ export function registerControlUiAndPairingSuite(): void { expectedOk: false, expectedErrorSubstring: "pairing required", expectedErrorCode: ConnectErrorDetailCodes.PAIRING_REQUIRED, - expectStatusChecks: false, }, ]; @@ -96,6 +92,26 @@ export function registerControlUiAndPairingSuite(): void { expect(admin.ok).toBe(true); }; + const expectStatusMissingScopeButHealthOk = async (ws: WebSocket) => { + const status = await rpcReq(ws, "status"); + expect(status.ok).toBe(false); + expect(status.error?.message ?? "").toContain("missing scope"); + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(true); + }; + + const expectAdminRpcDenied = async (ws: WebSocket) => { + const admin = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(admin.ok).toBe(false); + expect(admin.error?.message).toBe("missing scope: operator.admin"); + }; + + const expectTalkSecretsDenied = async (ws: WebSocket) => { + const talk = await rpcReq(ws, "talk.config", { includeSecrets: true }); + expect(talk.ok).toBe(false); + expect(talk.error?.message).toBe("missing scope: operator.read"); + }; + const connectControlUiWithoutDeviceAndExpectOk = async (params: { ws: WebSocket; token?: string; @@ -221,17 +237,34 @@ export function registerControlUiAndPairingSuite(): void { ws.close(); return; } - if (tc.expectStatusChecks) { - await expectStatusAndHealthOk(ws); - if (tc.role === "operator") { - await expectAdminRpcOk(ws); - } - } ws.close(); }); }); } + test("clears self-declared scopes for trusted-proxy control ui without device identity", async () => { + await configureTrustedProxyControlUiAuth(); + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); + try { + const res = await connectReq(ws, { + skipDefaultAuth: true, + scopes: ["operator.admin"], + device: null, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(true); + expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined(); + + await expectStatusMissingScopeButHealthOk(ws); + await expectAdminRpcDenied(ws); + await expectTalkSecretsDenied(ws); + } finally { + ws.close(); + } + }); + }); + test("allows localhost control ui without device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; const { server, ws, prevToken } = await startServerWithClient("secret", { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index f7eec2153ad..51e4a6fc0c4 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -532,9 +532,9 @@ export function attachGatewayWsMessageHandler(params: { isLocalClient, }); // Shared token/password auth can bypass pairing for trusted operators, but - // device-less backend clients must not self-declare scopes. Control UI - // keeps its explicitly allowed device-less scopes on the allow path. - if (!device && (!isControlUi || decision.kind !== "allow")) { + // device-less clients must not keep self-declared scopes unless the + // operator explicitly chose a local break-glass Control UI mode. + if (!device && (!isControlUi || decision.kind !== "allow" || trustedProxyAuthOk)) { clearUnboundScopes(); } if (decision.kind === "allow") { diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 36d24537a14..bfd2603bc0a 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -155,6 +155,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({ cliRegistrars: [], services: [], commands: [], + conversationBindingResolvedHandlers: [], diagnostics: [], }); @@ -573,7 +574,7 @@ vi.mock("../commands/health.js", () => ({ vi.mock("../commands/status.js", () => ({ getStatusSummary: vi.fn().mockResolvedValue({ ok: true }), })); -vi.mock("../../extensions/whatsapp/src/send.js", () => ({ +vi.mock("../../extensions/whatsapp/runtime-api.js", () => ({ sendMessageWhatsApp: (...args: unknown[]) => (hoisted.sendWhatsAppMock as (...args: unknown[]) => unknown)(...args), sendPollWhatsApp: (...args: unknown[]) => diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts index cffd27162b0..03af9a053ac 100644 --- a/src/infra/git-commit.test.ts +++ b/src/infra/git-commit.test.ts @@ -40,13 +40,15 @@ async function makeFakeGitRepo( describe("git commit resolution", () => { const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + let resolveCommitHash: (typeof import("./git-commit.js"))["resolveCommitHash"]; + let __testing: (typeof import("./git-commit.js"))["__testing"]; beforeEach(async () => { vi.restoreAllMocks(); vi.doUnmock("node:fs"); vi.doUnmock("node:module"); vi.resetModules(); - const { __testing } = await import("./git-commit.js"); + ({ resolveCommitHash, __testing } = await import("./git-commit.js")); __testing.clearCachedGitCommits(); }); @@ -54,9 +56,8 @@ describe("git commit resolution", () => { vi.restoreAllMocks(); vi.doUnmock("node:fs"); vi.doUnmock("node:module"); - vi.resetModules(); - const { __testing } = await import("./git-commit.js"); __testing.clearCachedGitCommits(); + vi.resetModules(); }); it("resolves commit metadata from the caller module root instead of the caller cwd", async () => { @@ -85,7 +86,6 @@ describe("git commit resolution", () => { .trim() .slice(0, 7); - const { resolveCommitHash } = await import("./git-commit.js"); const entryModuleUrl = pathToFileURL(path.join(repoRoot, "src", "entry.ts")).href; vi.spyOn(process, "cwd").mockReturnValue(otherRepo); @@ -101,7 +101,6 @@ describe("git commit resolution", () => { .trim() .slice(0, 7); - const { resolveCommitHash } = await import("./git-commit.js"); const entryModuleUrl = pathToFileURL(path.join(repoRoot, "src", "entry.ts")).href; expect( @@ -117,7 +116,6 @@ describe("git commit resolution", () => { it("caches build-info fallback results per resolved search directory", async () => { const temp = await makeTempDir("git-commit-build-info-cache"); - const { resolveCommitHash } = await import("./git-commit.js"); const readBuildInfoCommit = vi.fn(() => "deadbee"); expect(resolveCommitHash({ cwd: temp, env: {}, readers: { readBuildInfoCommit } })).toBe( @@ -133,7 +131,6 @@ describe("git commit resolution", () => { it("caches package.json fallback results per resolved search directory", async () => { const temp = await makeTempDir("git-commit-package-json-cache"); - const { resolveCommitHash } = await import("./git-commit.js"); const readPackageJsonCommit = vi.fn(() => "badc0ff"); expect( @@ -169,8 +166,6 @@ describe("git commit resolution", () => { .trim() .slice(0, 7); - const { resolveCommitHash } = await import("./git-commit.js"); - expect(() => resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: repoRoot, env: {} }), ).not.toThrow(); @@ -201,8 +196,6 @@ describe("git commit resolution", () => { ); const moduleUrl = pathToFileURL(path.join(packageRoot, "dist", "entry.js")).href; - const { resolveCommitHash } = await import("./git-commit.js"); - expect( resolveCommitHash({ moduleUrl, @@ -227,8 +220,6 @@ describe("git commit resolution", () => { head: "89abcdef0123456789abcdef0123456789abcdef\n", }); - const { resolveCommitHash } = await import("./git-commit.js"); - expect(resolveCommitHash({ cwd: repoA, env: {} })).toBe("0123456"); expect(resolveCommitHash({ cwd: repoB, env: {} })).toBe("89abcde"); expect(resolveCommitHash({ cwd: repoA, env: {} })).toBe("0123456"); @@ -241,7 +232,6 @@ describe("git commit resolution", () => { head: "not-a-commit\n", }); - const { resolveCommitHash } = await import("./git-commit.js"); const readGitCommit = vi.fn(() => null); expect(resolveCommitHash({ cwd: repoRoot, env: {}, readers: { readGitCommit } })).toBeNull(); @@ -257,7 +247,6 @@ describe("git commit resolution", () => { await makeFakeGitRepo(repoRoot, { head: "0123456789abcdef0123456789abcdef01234567\n", }); - const { resolveCommitHash } = await import("./git-commit.js"); const readGitCommit = vi.fn(() => { const error = Object.assign(new Error(`EACCES: permission denied`), { code: "EACCES", @@ -294,8 +283,6 @@ describe("git commit resolution", () => { it("formats env-provided commit strings consistently", async () => { const temp = await makeTempDir("git-commit-env"); - const { resolveCommitHash } = await import("./git-commit.js"); - expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "ABCDEF0123456789" } })).toBe( "abcdef0", ); @@ -308,8 +295,6 @@ describe("git commit resolution", () => { it("rejects unsafe HEAD refs and accepts valid refs", async () => { const temp = await makeTempDir("git-commit-refs"); - const { resolveCommitHash } = await import("./git-commit.js"); - const absoluteRepo = path.join(temp, "absolute"); await makeFakeGitRepo(absoluteRepo, { head: "ref: /tmp/evil\n" }); expect(resolveCommitHash({ cwd: absoluteRepo, env: {} })).toBeNull(); @@ -347,8 +332,6 @@ describe("git commit resolution", () => { commondir: "../common-git", }); - const { resolveCommitHash } = await import("./git-commit.js"); - expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("bbbbbbb"); }); @@ -363,8 +346,6 @@ describe("git commit resolution", () => { }, }); - const { resolveCommitHash } = await import("./git-commit.js"); - expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("ccccccc"); }); }); diff --git a/src/infra/heartbeat-runner.test-harness.ts b/src/infra/heartbeat-runner.test-harness.ts index f884aabfe87..1099fdf50ab 100644 --- a/src/infra/heartbeat-runner.test-harness.ts +++ b/src/infra/heartbeat-runner.test-harness.ts @@ -1,10 +1,7 @@ import { beforeEach } from "vitest"; -import { slackPlugin } from "../../extensions/slack/src/channel.js"; -import { setSlackRuntime } from "../../extensions/slack/src/runtime.js"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; -import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; -import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; -import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js"; +import { slackPlugin, setSlackRuntime } from "../../extensions/slack/index.js"; +import { telegramPlugin, setTelegramRuntime } from "../../extensions/telegram/index.js"; +import { whatsappPlugin, setWhatsAppRuntime } from "../../extensions/whatsapp/index.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createPluginRuntime } from "../plugins/runtime/index.js"; diff --git a/src/infra/heartbeat-runner.test-utils.ts b/src/infra/heartbeat-runner.test-utils.ts index a5d72b4adad..3ced54d8333 100644 --- a/src/infra/heartbeat-runner.test-utils.ts +++ b/src/infra/heartbeat-runner.test-utils.ts @@ -2,8 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { vi } from "vitest"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; -import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; +import { telegramPlugin, setTelegramRuntime } from "../../extensions/telegram/index.js"; import * as replyModule from "../auto-reply/reply.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveMainSessionKey } from "../config/sessions.js"; diff --git a/src/infra/host-env-security-policy.json b/src/infra/host-env-security-policy.json index 9e3ad27581e..bf99f458e58 100644 --- a/src/infra/host-env-security-policy.json +++ b/src/infra/host-env-security-policy.json @@ -17,7 +17,12 @@ "PS4", "GCONV_PATH", "IFS", - "SSLKEYLOGFILE" + "SSLKEYLOGFILE", + "JAVA_TOOL_OPTIONS", + "_JAVA_OPTIONS", + "JDK_JAVA_OPTIONS", + "PYTHONBREAKPOINT", + "DOTNET_STARTUP_HOOKS" ], "blockedOverrideKeys": [ "HOME", diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index acb756b62a2..fe194eabc28 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -48,6 +48,16 @@ describe("isDangerousHostEnvVarName", () => { expect(isDangerousHostEnvVarName("DYLD_INSERT_LIBRARIES")).toBe(true); expect(isDangerousHostEnvVarName("ld_preload")).toBe(true); expect(isDangerousHostEnvVarName("BASH_FUNC_echo%%")).toBe(true); + expect(isDangerousHostEnvVarName("JAVA_TOOL_OPTIONS")).toBe(true); + expect(isDangerousHostEnvVarName("java_tool_options")).toBe(true); + expect(isDangerousHostEnvVarName("_JAVA_OPTIONS")).toBe(true); + expect(isDangerousHostEnvVarName("_java_options")).toBe(true); + expect(isDangerousHostEnvVarName("JDK_JAVA_OPTIONS")).toBe(true); + expect(isDangerousHostEnvVarName("jdk_java_options")).toBe(true); + expect(isDangerousHostEnvVarName("PYTHONBREAKPOINT")).toBe(true); + expect(isDangerousHostEnvVarName("pythonbreakpoint")).toBe(true); + expect(isDangerousHostEnvVarName("DOTNET_STARTUP_HOOKS")).toBe(true); + expect(isDangerousHostEnvVarName("dotnet_startup_hooks")).toBe(true); expect(isDangerousHostEnvVarName("PATH")).toBe(false); expect(isDangerousHostEnvVarName("FOO")).toBe(false); }); diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index ed082e92fb9..8aec91a62ef 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -198,7 +198,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { }); }); + it("replaces the pinned lookup when a dispatcher override hostname is provided", () => { + const originalLookup = vi.fn() as unknown as PinnedHostname["lookup"]; + const pinned: PinnedHostname = { + hostname: "api.telegram.org", + addresses: ["149.154.167.221"], + lookup: originalLookup, + }; + + createPinnedDispatcher(pinned, { + mode: "direct", + pinnedHostname: { + hostname: "api.telegram.org", + addresses: ["149.154.167.220"], + }, + }); + + const firstCallArg = agentCtor.mock.calls.at(-1)?.[0] as + | { connect?: { lookup?: PinnedHostname["lookup"] } } + | undefined; + expect(firstCallArg?.connect?.lookup).toBeTypeOf("function"); + + const lookup = firstCallArg?.connect?.lookup; + const callback = vi.fn(); + lookup?.("api.telegram.org", callback); + + expect(callback).toHaveBeenCalledWith(null, "149.154.167.220", 4); + expect(originalLookup).not.toHaveBeenCalled(); + }); + + it("rejects pinned override addresses that violate SSRF policy", () => { + const originalLookup = vi.fn() as unknown as PinnedHostname["lookup"]; + const pinned: PinnedHostname = { + hostname: "api.telegram.org", + addresses: ["149.154.167.221"], + lookup: originalLookup, + }; + + expect(() => + createPinnedDispatcher( + pinned, + { + mode: "direct", + pinnedHostname: { + hostname: "api.telegram.org", + addresses: ["127.0.0.1"], + }, + }, + undefined, + ), + ).toThrow(/private|internal|blocked/i); + }); + it("keeps env proxy route while pinning the direct no-proxy path", () => { const lookup = vi.fn() as unknown as PinnedHostname["lookup"]; const pinned: PinnedHostname = { diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 28420ea373f..a8847c26642 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -99,6 +99,15 @@ describe("ssrf pinning", () => { expect(result.address).toBe("1.2.3.4"); }); + it("fails loud when a pinned lookup is created without any addresses", () => { + expect(() => + createPinnedLookup({ + hostname: "example.com", + addresses: [], + }), + ).toThrow("Pinned lookup requires at least one address for example.com"); + }); + it("enforces hostname allowlist when configured", async () => { const lookup = vi.fn(async () => [ { address: "93.184.216.34", family: 4 }, diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index db70664a43f..fd633fcb20d 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -67,6 +67,13 @@ export function isPrivateNetworkAllowedByPolicy(policy?: SsrFPolicy): boolean { return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true; } +function shouldSkipPrivateNetworkChecks(hostname: string, policy?: SsrFPolicy): boolean { + return ( + isPrivateNetworkAllowedByPolicy(policy) || + normalizeHostnameSet(policy?.allowedHostnames).has(hostname) + ); +} + function resolveIpv4SpecialUseBlockOptions(policy?: SsrFPolicy): Ipv4SpecialUseBlockOptions { return { allowRfc2544BenchmarkRange: policy?.allowRfc2544BenchmarkRange === true, @@ -198,6 +205,9 @@ export function createPinnedLookup(params: { fallback?: typeof dnsLookupCb; }): typeof dnsLookupCb { const normalizedHost = normalizeHostname(params.hostname); + if (params.addresses.length === 0) { + throw new Error(`Pinned lookup requires at least one address for ${params.hostname}`); + } const fallback = params.fallback ?? dnsLookupCb; const fallbackLookup = fallback as unknown as ( hostname: string, @@ -255,20 +265,28 @@ export type PinnedHostname = { lookup: typeof dnsLookupCb; }; +export type PinnedHostnameOverride = { + hostname: string; + addresses: string[]; +}; + export type PinnedDispatcherPolicy = | { mode: "direct"; connect?: Record; + pinnedHostname?: PinnedHostnameOverride; } | { mode: "env-proxy"; connect?: Record; proxyTls?: Record; + pinnedHostname?: PinnedHostnameOverride; } | { mode: "explicit-proxy"; proxyUrl: string; proxyTls?: Record; + pinnedHostname?: PinnedHostnameOverride; }; function dedupeAndPreferIpv4(results: readonly LookupAddress[]): string[] { @@ -298,11 +316,8 @@ export async function resolvePinnedHostnameWithPolicy( throw new Error("Invalid hostname"); } - const allowPrivateNetwork = isPrivateNetworkAllowedByPolicy(params.policy); - const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames); const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist); - const isExplicitAllowed = allowedHostnames.has(normalized); - const skipPrivateNetworkChecks = allowPrivateNetwork || isExplicitAllowed; + const skipPrivateNetworkChecks = shouldSkipPrivateNetworkChecks(normalized, params.policy); if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) { throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`); @@ -352,19 +367,50 @@ function withPinnedLookup( return connect ? { ...connect, lookup } : { lookup }; } +function resolvePinnedDispatcherLookup( + pinned: PinnedHostname, + override?: PinnedHostnameOverride, + policy?: SsrFPolicy, +): PinnedHostname["lookup"] { + if (!override) { + return pinned.lookup; + } + const normalizedOverrideHost = normalizeHostname(override.hostname); + if (!normalizedOverrideHost || normalizedOverrideHost !== pinned.hostname) { + throw new Error( + `Pinned dispatcher override hostname mismatch: expected ${pinned.hostname}, got ${override.hostname}`, + ); + } + const records = override.addresses.map((address) => ({ + address, + family: address.includes(":") ? 6 : 4, + })); + if (!shouldSkipPrivateNetworkChecks(pinned.hostname, policy)) { + assertAllowedResolvedAddressesOrThrow(records, policy); + } + return createPinnedLookup({ + hostname: pinned.hostname, + addresses: [...override.addresses], + fallback: pinned.lookup, + }); +} + export function createPinnedDispatcher( pinned: PinnedHostname, policy?: PinnedDispatcherPolicy, + ssrfPolicy?: SsrFPolicy, ): Dispatcher { + const lookup = resolvePinnedDispatcherLookup(pinned, policy?.pinnedHostname, ssrfPolicy); + if (!policy || policy.mode === "direct") { return new Agent({ - connect: withPinnedLookup(pinned.lookup, policy?.connect), + connect: withPinnedLookup(lookup, policy?.connect), }); } if (policy.mode === "env-proxy") { return new EnvHttpProxyAgent({ - connect: withPinnedLookup(pinned.lookup, policy.connect), + connect: withPinnedLookup(lookup, policy.connect), ...(policy.proxyTls ? { proxyTls: { ...policy.proxyTls } } : {}), }); } diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index ba24bdb15df..fbbb9e6e2c8 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -239,68 +239,63 @@ describe("runMessageAction media behavior", () => { ); }); - it("rewrites sandboxed media paths for sendAttachment", async () => { - await withSandbox(async (sandboxDir) => { - await runMessageAction({ - cfg, - action: "sendAttachment", - params: { - channel: "bluebubbles", - target: "+15551234567", - media: "./data/pic.png", - message: "caption", - }, - sandboxRoot: sandboxDir, + it("enforces sandboxed attachment paths for attachment actions", async () => { + for (const testCase of [ + { + name: "sendAttachment rewrite", + action: "sendAttachment" as const, + target: "+15551234567", + media: "./data/pic.png", + message: "caption", + expectedPath: path.join("data", "pic.png"), + }, + { + name: "setGroupIcon rewrite", + action: "setGroupIcon" as const, + target: "group:123", + media: "./icons/group.png", + expectedPath: path.join("icons", "group.png"), + }, + ]) { + vi.mocked(loadWebMedia).mockClear(); + await withSandbox(async (sandboxDir) => { + await runMessageAction({ + cfg, + action: testCase.action, + params: { + channel: "bluebubbles", + target: testCase.target, + media: testCase.media, + ...(testCase.message ? { message: testCase.message } : {}), + }, + sandboxRoot: sandboxDir, + }); + + const call = vi.mocked(loadWebMedia).mock.calls[0]; + expect(call?.[0], testCase.name).toBe(path.join(sandboxDir, testCase.expectedPath)); + expect(call?.[1], testCase.name).toEqual( + expect.objectContaining({ + sandboxValidated: true, + }), + ); }); + } - const call = vi.mocked(loadWebMedia).mock.calls[0]; - expect(call?.[0]).toBe(path.join(sandboxDir, "data", "pic.png")); - expect(call?.[1]).toEqual( - expect.objectContaining({ - sandboxValidated: true, - }), - ); - }); - }); - - it("rewrites sandboxed media paths for setGroupIcon", async () => { - await withSandbox(async (sandboxDir) => { - await runMessageAction({ - cfg, - action: "setGroupIcon", - params: { - channel: "bluebubbles", - target: "group:123", - media: "./icons/group.png", - }, - sandboxRoot: sandboxDir, - }); - - const call = vi.mocked(loadWebMedia).mock.calls[0]; - expect(call?.[0]).toBe(path.join(sandboxDir, "icons", "group.png")); - expect(call?.[1]).toEqual( - expect.objectContaining({ - sandboxValidated: true, - }), - ); - }); - }); - - it("rejects local absolute path for sendAttachment when sandboxRoot is missing", async () => { - await expectRejectsLocalAbsolutePathWithoutSandbox({ - action: "sendAttachment", - target: "+15551234567", - message: "caption", - tempPrefix: "msg-attachment-", - }); - }); - - it("rejects local absolute path for setGroupIcon when sandboxRoot is missing", async () => { - await expectRejectsLocalAbsolutePathWithoutSandbox({ - action: "setGroupIcon", - target: "group:123", - tempPrefix: "msg-group-icon-", - }); + for (const testCase of [ + { + action: "sendAttachment" as const, + target: "+15551234567", + message: "caption", + tempPrefix: "msg-attachment-", + }, + { + action: "setGroupIcon" as const, + target: "group:123", + tempPrefix: "msg-group-icon-", + }, + ]) { + await expectRejectsLocalAbsolutePathWithoutSandbox(testCase); + } }); }); @@ -356,36 +351,35 @@ describe("runMessageAction media behavior", () => { ).rejects.toThrow(/data:/i); }); - it("rewrites sandbox-relative media paths", async () => { - await withSandbox(async (sandboxDir) => { - await expectSandboxMediaRewrite({ - sandboxDir, + it("rewrites in-sandbox media references before dry send", async () => { + for (const testCase of [ + { + name: "relative media path", media: "./data/file.txt", message: "", expectedRelativePath: path.join("data", "file.txt"), - }); - }); - }); - - it("rewrites /workspace media paths to host sandbox root", async () => { - await withSandbox(async (sandboxDir) => { - await expectSandboxMediaRewrite({ - sandboxDir, + }, + { + name: "/workspace media path", media: "/workspace/data/file.txt", message: "", expectedRelativePath: path.join("data", "file.txt"), - }); - }); - }); - - it("rewrites MEDIA directives under sandbox", async () => { - await withSandbox(async (sandboxDir) => { - await expectSandboxMediaRewrite({ - sandboxDir, + }, + { + name: "MEDIA directive", message: "Hello\nMEDIA: ./data/note.ogg", expectedRelativePath: path.join("data", "note.ogg"), + }, + ]) { + await withSandbox(async (sandboxDir) => { + await expectSandboxMediaRewrite({ + sandboxDir, + media: testCase.media, + message: testCase.message, + expectedRelativePath: testCase.expectedRelativePath, + }); }); - }); + } }); it("allows media paths under preferred OpenClaw tmp root", async () => { diff --git a/src/infra/outbound/message-action-runner.test-helpers.ts b/src/infra/outbound/message-action-runner.test-helpers.ts index 8ca1ea6a822..78a2585cfc0 100644 --- a/src/infra/outbound/message-action-runner.test-helpers.ts +++ b/src/infra/outbound/message-action-runner.test-helpers.ts @@ -1,7 +1,5 @@ -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { setSlackRuntime } from "../../../extensions/slack/src/runtime.js"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { setTelegramRuntime } from "../../../extensions/telegram/src/runtime.js"; +import { slackPlugin, setSlackRuntime } from "../../../extensions/slack/index.js"; +import { telegramPlugin, setTelegramRuntime } from "../../../extensions/telegram/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createPluginRuntime } from "../../plugins/runtime/index.js"; diff --git a/src/infra/outbound/targets.shared-test.ts b/src/infra/outbound/targets.shared-test.ts index 91c2ca9b84d..dae0ca82dd5 100644 --- a/src/infra/outbound/targets.shared-test.ts +++ b/src/infra/outbound/targets.shared-test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; +import { telegramPlugin } from "../../../extensions/telegram/index.js"; +import { whatsappPlugin } from "../../../extensions/whatsapp/index.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { resolveOutboundTarget } from "./targets.js"; diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index dfebf6c2ad2..9b6c871379b 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { runNodeMain } from "../../scripts/run-node.mjs"; async function withTempDir(run: (dir: string) => Promise): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-run-node-")); @@ -70,7 +71,6 @@ describe("run-node script", () => { }; }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["--version"], @@ -130,7 +130,6 @@ describe("run-node script", () => { return createExitedProcess(0); }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -205,7 +204,6 @@ describe("run-node script", () => { return { status: 1, stdout: "" }; }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -233,7 +231,6 @@ describe("run-node script", () => { return createExitedProcess(0); }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -282,7 +279,6 @@ describe("run-node script", () => { }; const spawnSync = () => ({ status: 1, stdout: "" }); - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -354,7 +350,6 @@ describe("run-node script", () => { }; const spawnSync = () => ({ status: 1, stdout: "" }); - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -419,7 +414,6 @@ describe("run-node script", () => { return { status: 1, stdout: "" }; }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -490,7 +484,6 @@ describe("run-node script", () => { return { status: 1, stdout: "" }; }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -560,7 +553,6 @@ describe("run-node script", () => { return { status: 1, stdout: "" }; }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -636,7 +628,6 @@ describe("run-node script", () => { return { status: 1, stdout: "" }; }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -696,7 +687,6 @@ describe("run-node script", () => { }; const spawnSync = () => ({ status: 1, stdout: "" }); - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], @@ -758,7 +748,6 @@ describe("run-node script", () => { return { status: 1, stdout: "" }; }; - const { runNodeMain } = await import("../../scripts/run-node.mjs"); const exitCode = await runNodeMain({ cwd: tmp, args: ["status"], diff --git a/src/media-understanding/apply.echo-transcript.test.ts b/src/media-understanding/apply.echo-transcript.test.ts index ae62d294989..6411ab0f48d 100644 --- a/src/media-understanding/apply.echo-transcript.test.ts +++ b/src/media-understanding/apply.echo-transcript.test.ts @@ -10,26 +10,28 @@ import { createSafeAudioFixtureBuffer } from "./runner.test-utils.js"; // Module mocks // --------------------------------------------------------------------------- -vi.mock("../agents/model-auth.js", () => ({ - resolveApiKeyForProvider: vi.fn(async () => ({ +type ResolveApiKeyForProvider = typeof import("../agents/model-auth.js").resolveApiKeyForProvider; + +const resolveApiKeyForProviderMock = vi.hoisted(() => + vi.fn(async () => ({ apiKey: "test-key", // pragma: allowlist secret source: "test", mode: "api-key", })), - requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => { - if (auth?.apiKey) { - return auth.apiKey; - } - throw new Error(`No API key resolved for provider "${provider}" (auth mode: ${auth?.mode}).`); - }, - resolveAwsSdkEnvVarName: vi.fn(() => undefined), - resolveEnvApiKey: vi.fn(() => null), - resolveModelAuthMode: vi.fn(() => "api-key"), - getApiKeyForModel: vi.fn(async () => ({ apiKey: "test-key", source: "test", mode: "api-key" })), - getCustomProviderApiKey: vi.fn(() => undefined), - ensureAuthProfileStore: vi.fn(async () => ({})), - resolveAuthProfileOrder: vi.fn(() => []), -})); +); +const hasAvailableAuthForProviderMock = vi.hoisted(() => + vi.fn(async (...args: Parameters) => { + const resolved = await resolveApiKeyForProviderMock(...args); + return Boolean(resolved?.apiKey); + }), +); +const getApiKeyForModelMock = vi.hoisted(() => + vi.fn(async () => ({ apiKey: "test-key", source: "test", mode: "api-key" })), +); +const fetchRemoteMediaMock = vi.hoisted(() => vi.fn()); +const runExecMock = vi.hoisted(() => vi.fn()); +const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); +const mockDeliverOutboundPayloads = vi.hoisted(() => vi.fn()); const { MediaFetchErrorMock } = vi.hoisted(() => { class MediaFetchErrorMock extends Error { @@ -43,22 +45,6 @@ const { MediaFetchErrorMock } = vi.hoisted(() => { return { MediaFetchErrorMock }; }); -vi.mock("../media/fetch.js", () => ({ - fetchRemoteMedia: vi.fn(), - MediaFetchError: MediaFetchErrorMock, -})); - -vi.mock("../process/exec.js", () => ({ - runExec: vi.fn(), - runCommandWithTimeout: vi.fn(), -})); - -const mockDeliverOutboundPayloads = vi.fn(); - -vi.mock("../infra/outbound/deliver.js", () => ({ - deliverOutboundPayloads: (...args: unknown[]) => mockDeliverOutboundPayloads(...args), -})); - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -145,6 +131,38 @@ function createAudioConfigWithoutEchoFlag() { describe("applyMediaUnderstanding – echo transcript", () => { beforeAll(async () => { + vi.resetModules(); + vi.doMock("../agents/model-auth.js", () => ({ + resolveApiKeyForProvider: resolveApiKeyForProviderMock, + hasAvailableAuthForProvider: hasAvailableAuthForProviderMock, + requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => { + if (auth?.apiKey) { + return auth.apiKey; + } + throw new Error( + `No API key resolved for provider "${provider}" (auth mode: ${auth?.mode}).`, + ); + }, + resolveAwsSdkEnvVarName: vi.fn(() => undefined), + resolveEnvApiKey: vi.fn(() => null), + resolveModelAuthMode: vi.fn(() => "api-key"), + getApiKeyForModel: getApiKeyForModelMock, + getCustomProviderApiKey: vi.fn(() => undefined), + ensureAuthProfileStore: vi.fn(async () => ({})), + resolveAuthProfileOrder: vi.fn(() => []), + })); + vi.doMock("../media/fetch.js", () => ({ + fetchRemoteMedia: fetchRemoteMediaMock, + MediaFetchError: MediaFetchErrorMock, + })); + vi.doMock("../process/exec.js", () => ({ + runExec: runExecMock, + runCommandWithTimeout: runCommandWithTimeoutMock, + })); + vi.doMock("../infra/outbound/deliver-runtime.js", () => ({ + deliverOutboundPayloads: (...args: unknown[]) => mockDeliverOutboundPayloads(...args), + })); + const baseDir = resolvePreferredOpenClawTmpDir(); await fs.mkdir(baseDir, { recursive: true }); suiteTempMediaRootDir = await fs.mkdtemp(path.join(baseDir, TEMP_MEDIA_PREFIX)); @@ -155,6 +173,12 @@ describe("applyMediaUnderstanding – echo transcript", () => { }); beforeEach(() => { + resolveApiKeyForProviderMock.mockClear(); + hasAvailableAuthForProviderMock.mockClear(); + getApiKeyForModelMock.mockClear(); + fetchRemoteMediaMock.mockClear(); + runExecMock.mockReset(); + runCommandWithTimeoutMock.mockReset(); mockDeliverOutboundPayloads.mockClear(); mockDeliverOutboundPayloads.mockResolvedValue([{ channel: "whatsapp", messageId: "echo-1" }]); clearMediaUnderstandingBinaryCacheForTests?.(); diff --git a/src/media/fetch.telegram-network.test.ts b/src/media/fetch.telegram-network.test.ts index 60e60f1c48c..faf16314d98 100644 --- a/src/media/fetch.telegram-network.test.ts +++ b/src/media/fetch.telegram-network.test.ts @@ -22,7 +22,7 @@ vi.mock("undici", () => ({ })); let resolveTelegramTransport: typeof import("../../extensions/telegram/src/fetch.js").resolveTelegramTransport; -let shouldRetryTelegramIpv4Fallback: typeof import("../../extensions/telegram/src/fetch.js").shouldRetryTelegramIpv4Fallback; +let shouldRetryTelegramTransportFallback: typeof import("../../extensions/telegram/src/fetch.js").shouldRetryTelegramTransportFallback; let fetchRemoteMedia: typeof import("./fetch.js").fetchRemoteMedia; describe("fetchRemoteMedia telegram network policy", () => { @@ -30,7 +30,7 @@ describe("fetchRemoteMedia telegram network policy", () => { beforeEach(async () => { vi.resetModules(); - ({ resolveTelegramTransport, shouldRetryTelegramIpv4Fallback } = + ({ resolveTelegramTransport, shouldRetryTelegramTransportFallback } = await import("../../extensions/telegram/src/fetch.js")); ({ fetchRemoteMedia } = await import("./fetch.js")); }); @@ -70,7 +70,7 @@ describe("fetchRemoteMedia telegram network policy", () => { await fetchRemoteMedia({ url: "https://api.telegram.org/file/bottok/photos/1.jpg", fetchImpl: telegramTransport.sourceFetch, - dispatcherPolicy: telegramTransport.pinnedDispatcherPolicy, + dispatcherAttempts: telegramTransport.dispatcherAttempts, lookupFn, maxBytes: 1024, ssrfPolicy: { @@ -120,7 +120,7 @@ describe("fetchRemoteMedia telegram network policy", () => { await fetchRemoteMedia({ url: "https://api.telegram.org/file/bottok/files/1.pdf", fetchImpl: telegramTransport.sourceFetch, - dispatcherPolicy: telegramTransport.pinnedDispatcherPolicy, + dispatcherAttempts: telegramTransport.dispatcherAttempts, lookupFn, maxBytes: 1024, ssrfPolicy: { @@ -167,9 +167,8 @@ describe("fetchRemoteMedia telegram network policy", () => { await fetchRemoteMedia({ url: "https://api.telegram.org/file/bottok/photos/2.jpg", fetchImpl: telegramTransport.sourceFetch, - dispatcherPolicy: telegramTransport.pinnedDispatcherPolicy, - fallbackDispatcherPolicy: telegramTransport.fallbackPinnedDispatcherPolicy, - shouldRetryFetchError: shouldRetryTelegramIpv4Fallback, + dispatcherAttempts: telegramTransport.dispatcherAttempts, + shouldRetryFetchError: shouldRetryTelegramTransportFallback, lookupFn, maxBytes: 1024, ssrfPolicy: { @@ -214,14 +213,83 @@ describe("fetchRemoteMedia telegram network policy", () => { ); }); - it("preserves both primary and fallback errors when Telegram media retry fails twice", async () => { + it("retries Telegram file downloads with pinned Telegram IP after IPv4 fallback fails", async () => { + const lookupFn = vi.fn(async () => [ + { address: "149.154.167.221", family: 4 }, + { address: "2001:67c:4e8:f004::9", family: 6 }, + ]) as unknown as LookupFn; + undiciMocks.fetch + .mockRejectedValueOnce(createTelegramFetchFailedError("EHOSTUNREACH")) + .mockRejectedValueOnce(createTelegramFetchFailedError("ETIMEDOUT")) + .mockResolvedValueOnce( + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + + const telegramTransport = resolveTelegramTransport(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + await fetchRemoteMedia({ + url: "https://api.telegram.org/file/bottok/photos/3.jpg", + fetchImpl: telegramTransport.sourceFetch, + dispatcherAttempts: telegramTransport.dispatcherAttempts, + shouldRetryFetchError: shouldRetryTelegramTransportFallback, + lookupFn, + maxBytes: 1024, + ssrfPolicy: { + allowedHostnames: ["api.telegram.org"], + allowRfc2544BenchmarkRange: true, + }, + }); + + const thirdInit = undiciMocks.fetch.mock.calls[2]?.[1] as + | (RequestInit & { + dispatcher?: { + options?: { + connect?: Record; + }; + }; + }) + | undefined; + const callback = vi.fn(); + ( + thirdInit?.dispatcher?.options?.connect?.lookup as + | (( + hostname: string, + callback: (err: null, address: string, family: number) => void, + ) => void) + | undefined + )?.("api.telegram.org", callback); + + expect(undiciMocks.fetch).toHaveBeenCalledTimes(3); + expect(thirdInit?.dispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + lookup: expect.any(Function), + }), + ); + expect(callback).toHaveBeenCalledWith(null, "149.154.167.220", 4); + }); + + it("preserves both primary and final fallback errors when Telegram media retry chain fails", async () => { const lookupFn = vi.fn(async () => [ { address: "149.154.167.220", family: 4 }, { address: "2001:67c:4e8:f004::9", family: 6 }, ]) as unknown as LookupFn; const primaryError = createTelegramFetchFailedError("EHOSTUNREACH"); + const ipv4Error = createTelegramFetchFailedError("ETIMEDOUT"); const fallbackError = createTelegramFetchFailedError("ETIMEDOUT"); - undiciMocks.fetch.mockRejectedValueOnce(primaryError).mockRejectedValueOnce(fallbackError); + undiciMocks.fetch + .mockRejectedValueOnce(primaryError) + .mockRejectedValueOnce(ipv4Error) + .mockRejectedValueOnce(fallbackError); const telegramTransport = resolveTelegramTransport(undefined, { network: { @@ -232,11 +300,10 @@ describe("fetchRemoteMedia telegram network policy", () => { await expect( fetchRemoteMedia({ - url: "https://api.telegram.org/file/bottok/photos/3.jpg", + url: "https://api.telegram.org/file/bottok/photos/4.jpg", fetchImpl: telegramTransport.sourceFetch, - dispatcherPolicy: telegramTransport.pinnedDispatcherPolicy, - fallbackDispatcherPolicy: telegramTransport.fallbackPinnedDispatcherPolicy, - shouldRetryFetchError: shouldRetryTelegramIpv4Fallback, + dispatcherAttempts: telegramTransport.dispatcherAttempts, + shouldRetryFetchError: shouldRetryTelegramTransportFallback, lookupFn, maxBytes: 1024, ssrfPolicy: { @@ -250,6 +317,7 @@ describe("fetchRemoteMedia telegram network policy", () => { cause: expect.objectContaining({ name: "Error", cause: fallbackError, + attemptErrors: [primaryError, ipv4Error, fallbackError], primaryError, }), }); diff --git a/src/media/fetch.ts b/src/media/fetch.ts index 020ac8040bd..3893b1366d4 100644 --- a/src/media/fetch.ts +++ b/src/media/fetch.ts @@ -26,6 +26,11 @@ export class MediaFetchError extends Error { export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; +export type FetchDispatcherAttempt = { + dispatcherPolicy?: PinnedDispatcherPolicy; + lookupFn?: LookupFn; +}; + type FetchMediaOptions = { url: string; fetchImpl?: FetchLike; @@ -37,8 +42,7 @@ type FetchMediaOptions = { readIdleTimeoutMs?: number; ssrfPolicy?: SsrFPolicy; lookupFn?: LookupFn; - dispatcherPolicy?: PinnedDispatcherPolicy; - fallbackDispatcherPolicy?: PinnedDispatcherPolicy; + dispatcherAttempts?: FetchDispatcherAttempt[]; shouldRetryFetchError?: (error: unknown) => boolean; }; @@ -101,8 +105,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise Promise) | null = null; - const runGuardedFetch = async (policy?: PinnedDispatcherPolicy) => + const attempts = + dispatcherAttempts && dispatcherAttempts.length > 0 + ? dispatcherAttempts + : [{ dispatcherPolicy: undefined, lookupFn }]; + const runGuardedFetch = async (attempt: FetchDispatcherAttempt) => await fetchWithSsrFGuard( withStrictGuardedFetchMode({ url, @@ -118,32 +125,43 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise>; + const attemptErrors: unknown[] = []; + for (let i = 0; i < attempts.length; i += 1) { + try { + result = await runGuardedFetch(attempts[i]); + break; + } catch (err) { + if ( + typeof shouldRetryFetchError !== "function" || + !shouldRetryFetchError(err) || + i === attempts.length - 1 + ) { + if (attemptErrors.length > 0) { + const combined = new Error( + `Primary fetch failed and fallback fetch also failed for ${sourceUrl}`, + { cause: err }, + ); + ( + combined as Error & { + primaryError?: unknown; + attemptErrors?: unknown[]; + } + ).primaryError = attemptErrors[0]; + (combined as Error & { attemptErrors?: unknown[] }).attemptErrors = [ + ...attemptErrors, + err, + ]; + throw combined; + } + throw err; } - } else { - throw err; + attemptErrors.push(err); } } res = result.response; diff --git a/src/memory/embeddings.test.ts b/src/memory/embeddings.test.ts index e9a533f4f9d..8cf984522e2 100644 --- a/src/memory/embeddings.test.ts +++ b/src/memory/embeddings.test.ts @@ -33,6 +33,7 @@ function readFirstFetchRequest(fetchMock: { mock: { calls: unknown[][] } }) { type EmbeddingsModule = typeof import("./embeddings.js"); type AuthModule = typeof import("../agents/model-auth.js"); +type ResolvedProviderAuth = Awaited>; let authModule: AuthModule; let createEmbeddingProvider: EmbeddingsModule["createEmbeddingProvider"]; @@ -302,41 +303,6 @@ describe("embedding provider remote overrides", () => { }); describe("embedding provider auto selection", () => { - it("prefers openai when a key resolves", async () => { - vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => { - if (provider === "openai") { - return { apiKey: "openai-key", source: "env: OPENAI_API_KEY", mode: "api-key" }; - } - throw new Error(`No API key found for provider "${provider}".`); - }); - - const result = await createAutoProvider(); - expectAutoSelectedProvider(result, "openai"); - }); - - it("uses gemini when openai is missing", async () => { - const fetchMock = createGeminiFetchMock(); - vi.stubGlobal("fetch", fetchMock); - mockPublicPinnedHostname(); - vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => { - if (provider === "openai") { - throw new Error('No API key found for provider "openai".'); - } - if (provider === "google") { - return { apiKey: "gemini-key", source: "env: GEMINI_API_KEY", mode: "api-key" }; - } - throw new Error(`Unexpected provider ${provider}`); - }); - - const result = await createAutoProvider(); - const provider = expectAutoSelectedProvider(result, "gemini"); - await provider.embedQuery("hello"); - const [url] = fetchMock.mock.calls[0] ?? []; - expect(url).toBe( - `https://generativelanguage.googleapis.com/v1beta/models/${DEFAULT_GEMINI_EMBEDDING_MODEL}:embedContent`, - ); - }); - it("keeps explicit model when openai is selected", async () => { const fetchMock = vi.fn(async (_input?: unknown, _init?: unknown) => ({ ok: true, @@ -370,22 +336,79 @@ describe("embedding provider auto selection", () => { expect(payload.model).toBe("text-embedding-3-small"); }); - it("uses mistral when openai/gemini/voyage are missing", async () => { - const fetchMock = createFetchMock(); - vi.stubGlobal("fetch", fetchMock); - mockPublicPinnedHostname(); - vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => { - if (provider === "mistral") { - return { apiKey: "mistral-key", source: "env: MISTRAL_API_KEY", mode: "api-key" }; // pragma: allowlist secret - } - throw new Error(`No API key found for provider "${provider}".`); - }); + it("selects the first available remote provider in auto mode", async () => { + const cases: Array<{ + name: string; + expectedProvider: "openai" | "gemini" | "mistral"; + fetchMockFactory: typeof createFetchMock | typeof createGeminiFetchMock; + resolveApiKey: (provider: string) => ResolvedProviderAuth; + expectedUrl: string; + }> = [ + { + name: "openai first", + expectedProvider: "openai" as const, + fetchMockFactory: createFetchMock, + resolveApiKey(provider: string): ResolvedProviderAuth { + if (provider === "openai") { + return { apiKey: "openai-key", source: "env: OPENAI_API_KEY", mode: "api-key" }; + } + throw new Error(`No API key found for provider "${provider}".`); + }, + expectedUrl: "https://api.openai.com/v1/embeddings", + }, + { + name: "gemini fallback", + expectedProvider: "gemini" as const, + fetchMockFactory: createGeminiFetchMock, + resolveApiKey(provider: string): ResolvedProviderAuth { + if (provider === "openai") { + throw new Error('No API key found for provider "openai".'); + } + if (provider === "google") { + return { + apiKey: "gemini-key", + source: "env: GEMINI_API_KEY", + mode: "api-key" as const, + }; + } + throw new Error(`Unexpected provider ${provider}`); + }, + expectedUrl: `https://generativelanguage.googleapis.com/v1beta/models/${DEFAULT_GEMINI_EMBEDDING_MODEL}:embedContent`, + }, + { + name: "mistral after earlier misses", + expectedProvider: "mistral" as const, + fetchMockFactory: createFetchMock, + resolveApiKey(provider: string): ResolvedProviderAuth { + if (provider === "mistral") { + return { + apiKey: "mistral-key", + source: "env: MISTRAL_API_KEY", + mode: "api-key" as const, + }; + } + throw new Error(`No API key found for provider "${provider}".`); + }, + expectedUrl: "https://api.mistral.ai/v1/embeddings", + }, + ]; - const result = await createAutoProvider(); - const provider = expectAutoSelectedProvider(result, "mistral"); - await provider.embedQuery("hello"); - const [url] = fetchMock.mock.calls[0] ?? []; - expect(url).toBe("https://api.mistral.ai/v1/embeddings"); + for (const testCase of cases) { + vi.resetAllMocks(); + vi.unstubAllGlobals(); + const fetchMock = testCase.fetchMockFactory(); + vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); + vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => + testCase.resolveApiKey(provider), + ); + + const result = await createAutoProvider(); + const provider = expectAutoSelectedProvider(result, testCase.expectedProvider); + await provider.embedQuery("hello"); + const [url] = fetchMock.mock.calls[0] ?? []; + expect(url, testCase.name).toBe(testCase.expectedUrl); + } }); }); @@ -661,56 +684,54 @@ describe("local embedding ensureContext concurrency", () => { }); describe("FTS-only fallback when no provider available", () => { - it("returns null provider with reason when auto mode finds no providers", async () => { - vi.mocked(authModule.resolveApiKeyForProvider).mockRejectedValue( - new Error('No API key found for provider "openai"'), - ); - - const result = await createEmbeddingProvider({ - config: {} as never, - provider: "auto", - model: "", - fallback: "none", - }); - - expect(result.provider).toBeNull(); - expect(result.requestedProvider).toBe("auto"); - expect(result.providerUnavailableReason).toBeDefined(); - expect(result.providerUnavailableReason).toContain("No API key"); - }); - - it("returns null provider when explicit provider fails with missing API key", async () => { - vi.mocked(authModule.resolveApiKeyForProvider).mockRejectedValue( - new Error('No API key found for provider "openai"'), - ); - - const result = await createEmbeddingProvider({ - config: {} as never, - provider: "openai", - model: "text-embedding-3-small", - fallback: "none", - }); - - expect(result.provider).toBeNull(); - expect(result.requestedProvider).toBe("openai"); - expect(result.providerUnavailableReason).toBeDefined(); - }); - - it("returns null provider when both primary and fallback fail with missing API keys", async () => { + it("returns null provider when all requested auth paths fail", async () => { vi.mocked(authModule.resolveApiKeyForProvider).mockRejectedValue( new Error("No API key found for provider"), ); - const result = await createEmbeddingProvider({ - config: {} as never, - provider: "openai", - model: "text-embedding-3-small", - fallback: "gemini", - }); - - expect(result.provider).toBeNull(); - expect(result.requestedProvider).toBe("openai"); - expect(result.fallbackFrom).toBe("openai"); - expect(result.providerUnavailableReason).toContain("Fallback to gemini failed"); + for (const testCase of [ + { + name: "auto mode", + options: { + config: {} as never, + provider: "auto" as const, + model: "", + fallback: "none" as const, + }, + requestedProvider: "auto", + fallbackFrom: undefined, + reasonIncludes: "No API key", + }, + { + name: "explicit provider only", + options: { + config: {} as never, + provider: "openai" as const, + model: "text-embedding-3-small", + fallback: "none" as const, + }, + requestedProvider: "openai", + fallbackFrom: undefined, + reasonIncludes: "No API key", + }, + { + name: "primary and fallback", + options: { + config: {} as never, + provider: "openai" as const, + model: "text-embedding-3-small", + fallback: "gemini" as const, + }, + requestedProvider: "openai", + fallbackFrom: "openai", + reasonIncludes: "Fallback to gemini failed", + }, + ]) { + const result = await createEmbeddingProvider(testCase.options); + expect(result.provider, testCase.name).toBeNull(); + expect(result.requestedProvider, testCase.name).toBe(testCase.requestedProvider); + expect(result.fallbackFrom, testCase.name).toBe(testCase.fallbackFrom); + expect(result.providerUnavailableReason, testCase.name).toContain(testCase.reasonIncludes); + } }); }); diff --git a/src/memory/manager.async-search.test.ts b/src/memory/manager.async-search.test.ts index dca8cc52892..7250314cd55 100644 --- a/src/memory/manager.async-search.test.ts +++ b/src/memory/manager.async-search.test.ts @@ -87,6 +87,7 @@ describe("memory search async sync", () => { }); manager = await createMemoryManagerOrThrow(cfg); + (manager as unknown as { dirty: boolean }).dirty = true; await manager.search("hello"); await vi.waitFor(() => { expect((manager as unknown as { syncing: Promise | null }).syncing).toBeTruthy(); diff --git a/src/plugin-sdk/account-resolution.ts b/src/plugin-sdk/account-resolution.ts index 533d88187d0..f5f1229a798 100644 --- a/src/plugin-sdk/account-resolution.ts +++ b/src/plugin-sdk/account-resolution.ts @@ -13,19 +13,13 @@ export { normalizeE164, pathExists, resolveUserPath } from "../utils.js"; export { resolveDiscordAccount, type ResolvedDiscordAccount, -} from "../../extensions/discord/src/accounts.js"; -export { - resolveSlackAccount, - type ResolvedSlackAccount, -} from "../../extensions/slack/src/accounts.js"; +} from "../../extensions/discord/api.js"; +export { resolveSlackAccount, type ResolvedSlackAccount } from "../../extensions/slack/api.js"; export { resolveTelegramAccount, type ResolvedTelegramAccount, -} from "../../extensions/telegram/src/accounts.js"; -export { - resolveSignalAccount, - type ResolvedSignalAccount, -} from "../../extensions/signal/src/accounts.js"; +} from "../../extensions/telegram/api.js"; +export { resolveSignalAccount, type ResolvedSignalAccount } from "../../extensions/signal/api.js"; /** Resolve an account by id, then fall back to the default account when the primary lacks credentials. */ export function resolveAccountWithDefaultFallback(params: { diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 6375bdea76c..88300031290 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -62,13 +62,13 @@ export { export { buildSecretInputSchema } from "./secret-input-schema.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; -export type { ParsedChatTarget } from "../../extensions/imessage/src/target-parsing-helpers.js"; +export type { ParsedChatTarget } from "../../extensions/imessage/api.js"; export { parseChatAllowTargetPrefixes, parseChatTargetPrefixesOrThrow, resolveServicePrefixedAllowTarget, resolveServicePrefixedTarget, -} from "../../extensions/imessage/src/target-parsing-helpers.js"; +} from "../../extensions/imessage/api.js"; export { stripMarkdown } from "../line/markdown-to-line.js"; export { parseFiniteNumber } from "../infra/parse-finite-number.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index 3f3e0d0033a..7321adb1264 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -4,6 +4,36 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const ALLOWED_EXTENSION_PUBLIC_SEAMS = new Set([ + "api.js", + "index.js", + "login-qr-api.js", + "runtime-api.js", + "setup-entry.js", +]); +const GUARDED_CHANNEL_EXTENSIONS = new Set([ + "bluebubbles", + "discord", + "feishu", + "googlechat", + "imessage", + "irc", + "line", + "matrix", + "mattermost", + "msteams", + "nextcloud-talk", + "nostr", + "signal", + "slack", + "synology-chat", + "telegram", + "tlon", + "twitch", + "whatsapp", + "zalo", + "zalouser", +]); type GuardedSource = { path: string; @@ -13,27 +43,27 @@ type GuardedSource = { const SAME_CHANNEL_SDK_GUARDS: GuardedSource[] = [ { path: "extensions/discord/src/shared.ts", - forbiddenPatterns: [/openclaw\/plugin-sdk\/discord/, /plugin-sdk-internal\/discord/], + forbiddenPatterns: [/["']openclaw\/plugin-sdk\/discord["']/, /plugin-sdk-internal\/discord/], }, { path: "extensions/slack/src/shared.ts", - forbiddenPatterns: [/openclaw\/plugin-sdk\/slack/, /plugin-sdk-internal\/slack/], + forbiddenPatterns: [/["']openclaw\/plugin-sdk\/slack["']/, /plugin-sdk-internal\/slack/], }, { path: "extensions/telegram/src/shared.ts", - forbiddenPatterns: [/openclaw\/plugin-sdk\/telegram/, /plugin-sdk-internal\/telegram/], + forbiddenPatterns: [/["']openclaw\/plugin-sdk\/telegram["']/, /plugin-sdk-internal\/telegram/], }, { path: "extensions/imessage/src/shared.ts", - forbiddenPatterns: [/openclaw\/plugin-sdk\/imessage/, /plugin-sdk-internal\/imessage/], + forbiddenPatterns: [/["']openclaw\/plugin-sdk\/imessage["']/, /plugin-sdk-internal\/imessage/], }, { path: "extensions/whatsapp/src/shared.ts", - forbiddenPatterns: [/openclaw\/plugin-sdk\/whatsapp/, /plugin-sdk-internal\/whatsapp/], + forbiddenPatterns: [/["']openclaw\/plugin-sdk\/whatsapp["']/, /plugin-sdk-internal\/whatsapp/], }, { path: "extensions/signal/src/shared.ts", - forbiddenPatterns: [/openclaw\/plugin-sdk\/signal/, /plugin-sdk-internal\/signal/], + forbiddenPatterns: [/["']openclaw\/plugin-sdk\/signal["']/, /plugin-sdk-internal\/signal/], }, ]; @@ -135,6 +165,47 @@ function collectExtensionSourceFiles(): string[] { if ( fullPath.includes(".test.") || fullPath.includes(".fixture.") || + fullPath.includes(".snap") || + fullPath.includes("test-support") || + fullPath.endsWith("/api.ts") || + fullPath.endsWith("/runtime-api.ts") + ) { + continue; + } + files.push(fullPath); + } + } + return files; +} + +function collectCoreSourceFiles(): string[] { + const srcDir = resolve(ROOT_DIR, "..", "src"); + const files: string[] = []; + const stack = [srcDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + for (const entry of readdirSync(current, { withFileTypes: true })) { + const fullPath = resolve(current, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") { + continue; + } + stack.push(fullPath); + continue; + } + if (!entry.isFile() || !/\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(entry.name)) { + continue; + } + if (entry.name.endsWith(".d.ts")) { + continue; + } + if ( + fullPath.includes(".test.") || + fullPath.includes(".spec.") || + fullPath.includes(".fixture.") || fullPath.includes(".snap") ) { continue; @@ -145,6 +216,27 @@ function collectExtensionSourceFiles(): string[] { return files; } +function collectExtensionImports(text: string): string[] { + return [...text.matchAll(/["']([^"']*extensions\/[^"']+\.(?:[cm]?[jt]sx?))["']/g)].map( + (match) => match[1] ?? "", + ); +} + +function expectOnlyApprovedExtensionSeams(file: string, imports: string[]): void { + for (const specifier of imports) { + const normalized = specifier.replaceAll("\\", "/"); + const extensionId = normalized.match(/extensions\/([^/]+)\//)?.[1] ?? null; + if (!extensionId || !GUARDED_CHANNEL_EXTENSIONS.has(extensionId)) { + continue; + } + const basename = normalized.split("/").at(-1) ?? ""; + expect( + ALLOWED_EXTENSION_PUBLIC_SEAMS.has(basename), + `${file} should only import approved extension seams, got ${specifier}`, + ).toBe(true); + } +} + describe("channel import guardrails", () => { it("keeps channel helper modules off their own SDK barrels", () => { for (const source of SAME_CHANNEL_SDK_GUARDS) { @@ -177,4 +269,34 @@ describe("channel import guardrails", () => { ); } }); + + it("keeps core production files off extension private src imports", () => { + for (const file of collectCoreSourceFiles()) { + const text = readFileSync(file, "utf8"); + expect(text, `${file} should not import extensions/*/src`).not.toMatch( + /["'][^"']*extensions\/[^/"']+\/src\//, + ); + } + }); + + it("keeps extension production files off other extensions' private src imports", () => { + for (const file of collectExtensionSourceFiles()) { + const text = readFileSync(file, "utf8"); + expect(text, `${file} should not import another extension's src`).not.toMatch( + /["'][^"']*\.\.\/(?:\.\.\/)?(?!src\/)[^/"']+\/src\//, + ); + } + }); + + it("keeps core extension imports limited to approved public seams", () => { + for (const file of collectCoreSourceFiles()) { + expectOnlyApprovedExtensionSeams(file, collectExtensionImports(readFileSync(file, "utf8"))); + } + }); + + it("keeps extension-to-extension imports limited to approved public seams", () => { + for (const file of collectExtensionSourceFiles()) { + expectOnlyApprovedExtensionSeams(file, collectExtensionImports(readFileSync(file, "utf8"))); + } + }); }); diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index 9f723eff1fa..ad8d9ff5293 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -1,6 +1,22 @@ // Legacy compat surface for external plugins that still depend on older // broad plugin-sdk imports. Keep this file intentionally small. +const shouldWarnCompatImport = + process.env.VITEST !== "true" && + process.env.NODE_ENV !== "test" && + process.env.OPENCLAW_SUPPRESS_PLUGIN_SDK_COMPAT_WARNING !== "1"; + +if (shouldWarnCompatImport) { + process.emitWarning( + "openclaw/plugin-sdk/compat is deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/ imports.", + { + code: "OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED", + detail: + "Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat temporarily while migrating.", + }, + ); +} + export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { resolveControlCommandGate } from "../channels/command-gating.js"; diff --git a/src/plugin-sdk/conversation-runtime.ts b/src/plugin-sdk/conversation-runtime.ts index 77380f6aa9a..66b7e3b938f 100644 --- a/src/plugin-sdk/conversation-runtime.ts +++ b/src/plugin-sdk/conversation-runtime.ts @@ -1,6 +1,43 @@ -// Public pairing/session-binding helpers for plugins that manage conversation ownership. +// Public binding helpers for both runtime plugin-owned bindings and +// config-driven channel bindings. -export * from "../acp/persistent-bindings.route.js"; +export { + createConversationBindingRecord, + getConversationBindingCapabilities, + listSessionBindingRecords, + resolveConversationBindingRecord, + touchConversationBindingRecord, + unbindConversationBindingRecord, +} from "../bindings/records.js"; +export { + ensureConfiguredBindingRouteReady, + resolveConfiguredBindingRoute, + type ConfiguredBindingRouteResult, +} from "../channels/plugins/binding-routing.js"; +export { + primeConfiguredBindingRegistry, + resolveConfiguredBinding, + resolveConfiguredBindingRecord, + resolveConfiguredBindingRecordBySessionKey, + resolveConfiguredBindingRecordForConversation, +} from "../channels/plugins/binding-registry.js"; +export { + ensureConfiguredBindingTargetReady, + ensureConfiguredBindingTargetSession, + resetConfiguredBindingTargetInPlace, +} from "../channels/plugins/binding-targets.js"; +export type { + ConfiguredBindingConversation, + ConfiguredBindingResolution, + CompiledConfiguredBinding, + StatefulBindingTargetDescriptor, +} from "../channels/plugins/binding-types.js"; +export type { + StatefulBindingTargetDriver, + StatefulBindingTargetReadyResult, + StatefulBindingTargetResetResult, + StatefulBindingTargetSessionResult, +} from "../channels/plugins/stateful-target-drivers.js"; export { type BindingStatus, type BindingTargetKind, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index b683ecbb945..56f0bdafa26 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -59,6 +59,18 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../channels/plugins/setup-helpers.js"; +export { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "../channels/plugins/config-helpers.js"; +export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; +export { getChatChannelMeta } from "../channels/registry.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { DEFAULT_SECRET_FILE_MAX_BYTES, diff --git a/src/plugin-sdk/discord-core.ts b/src/plugin-sdk/discord-core.ts new file mode 100644 index 00000000000..3e87e17ef42 --- /dev/null +++ b/src/plugin-sdk/discord-core.ts @@ -0,0 +1,3 @@ +export type { ChannelPlugin } from "./channel-plugin-common.js"; +export { buildChannelConfigSchema, getChatChannelMeta } from "./channel-plugin-common.js"; +export { DiscordConfigSchema } from "../config/zod-schema.providers-core.js"; diff --git a/src/plugin-sdk/discord-send.ts b/src/plugin-sdk/discord-send.ts index 6cca5f9f803..679b5109a5e 100644 --- a/src/plugin-sdk/discord-send.ts +++ b/src/plugin-sdk/discord-send.ts @@ -1,4 +1,4 @@ -import type { DiscordSendResult } from "../../extensions/discord/src/send.types.js"; +import type { DiscordSendResult } from "../../extensions/discord/api.js"; type DiscordSendOptionInput = { replyToId?: string | null; diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 273df91e908..91bde97a5aa 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -1,18 +1,24 @@ -export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; +export type { + ChannelAccountSnapshot, + ChannelGatewayContext, + ChannelMessageActionAdapter, +} from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; -export type { DiscordPluralKitConfig } from "../../extensions/discord/src/pluralkit.js"; -export type { InspectedDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; -export type { ResolvedDiscordAccount } from "../../extensions/discord/src/accounts.js"; -export type { - DiscordSendComponents, - DiscordSendEmbeds, -} from "../../extensions/discord/src/send.shared.js"; +export type { DiscordPluralKitConfig } from "../../extensions/discord/api.js"; +export type { InspectedDiscordAccount } from "../../extensions/discord/api.js"; +export type { ResolvedDiscordAccount } from "../../extensions/discord/api.js"; +export type { DiscordSendComponents, DiscordSendEmbeds } from "../../extensions/discord/api.js"; export type { ThreadBindingManager, ThreadBindingRecord, ThreadBindingTargetKind, -} from "../../extensions/discord/src/monitor/thread-bindings.js"; +} from "../../extensions/discord/runtime-api.js"; +export type { + ChannelConfiguredBindingProvider, + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingMatch, +} from "../channels/plugins/types.adapters.js"; export type { ChannelMessageActionContext, ChannelPlugin, @@ -32,6 +38,7 @@ export { normalizeAccountId, setAccountEnabledInConfigSection, } from "./channel-plugin-common.js"; +export { formatDocsLink } from "../terminal/links.js"; export { projectCredentialSnapshotFields, @@ -61,29 +68,29 @@ export { createDiscordActionGate, listDiscordAccountIds, resolveDefaultDiscordAccountId, -} from "../../extensions/discord/src/accounts.js"; -export { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; +} from "../../extensions/discord/api.js"; +export { inspectDiscordAccount } from "../../extensions/discord/api.js"; export { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, -} from "../../extensions/discord/src/normalize.js"; -export { collectDiscordAuditChannelIds } from "../../extensions/discord/src/audit.js"; -export { collectDiscordStatusIssues } from "../../extensions/discord/src/status-issues.js"; +} from "../../extensions/discord/api.js"; +export { collectDiscordAuditChannelIds } from "../../extensions/discord/runtime-api.js"; +export { collectDiscordStatusIssues } from "../../extensions/discord/api.js"; export { DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, -} from "../../extensions/discord/src/monitor/timeouts.js"; -export { normalizeExplicitDiscordSessionKey } from "../../extensions/discord/src/session-key-normalization.js"; +} from "../../extensions/discord/runtime-api.js"; +export { normalizeExplicitDiscordSessionKey } from "../../extensions/discord/api.js"; export { autoBindSpawnedDiscordSubagent, listThreadBindingsBySessionKey, unbindThreadBindingsBySessionKey, -} from "../../extensions/discord/src/monitor/thread-bindings.js"; -export { getGateway } from "../../extensions/discord/src/monitor/gateway-registry.js"; -export { getPresence } from "../../extensions/discord/src/monitor/presence-cache.js"; -export { readDiscordComponentSpec } from "../../extensions/discord/src/components.js"; -export { resolveDiscordChannelId } from "../../extensions/discord/src/targets.js"; +} from "../../extensions/discord/runtime-api.js"; +export { getGateway } from "../../extensions/discord/runtime-api.js"; +export { getPresence } from "../../extensions/discord/runtime-api.js"; +export { readDiscordComponentSpec } from "../../extensions/discord/api.js"; +export { resolveDiscordChannelId } from "../../extensions/discord/api.js"; export { addRoleDiscord, banMemberDiscord, @@ -127,5 +134,5 @@ export { unpinMessageDiscord, uploadEmojiDiscord, uploadStickerDiscord, -} from "../../extensions/discord/src/send.js"; -export { discordMessageActions } from "../../extensions/discord/src/channel-actions.js"; +} from "../../extensions/discord/runtime-api.js"; +export { discordMessageActions } from "../../extensions/discord/runtime-api.js"; diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index ee15823738b..3a4fa4779c4 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -31,6 +31,11 @@ export type { ChannelMeta, ChannelOutboundAdapter, } from "../channels/plugins/types.js"; +export type { + ChannelConfiguredBindingProvider, + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingMatch, +} from "../channels/plugins/types.adapters.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export { createReplyPrefixContext } from "../channels/reply-prefix.js"; export { createTypingCallbacks } from "../channels/typing.js"; @@ -62,8 +67,8 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export { evaluateSenderGroupAccessForPolicy } from "./group-access.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { feishuSetupWizard } from "../../extensions/feishu/src/setup-surface.js"; -export { feishuSetupAdapter } from "../../extensions/feishu/src/setup-core.js"; +export { feishuSetupWizard } from "../../extensions/feishu/api.js"; +export { feishuSetupAdapter } from "../../extensions/feishu/api.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { readJsonFileWithFallback } from "./json-store.js"; export { createScopedPairingAccess } from "./pairing-access.js"; @@ -79,7 +84,7 @@ export { withTempDownloadPath } from "./temp-path.js"; export { buildFeishuConversationId, parseFeishuConversationId, -} from "../../extensions/feishu/src/conversation-id.js"; +} from "../../extensions/feishu/api.js"; export { createFixedWindowRateLimiter, createWebhookAnomalyTracker, diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index ce05a95b47a..ce6d5f44511 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -65,8 +65,8 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { googlechatSetupAdapter } from "../../extensions/googlechat/src/setup-core.js"; -export { googlechatSetupWizard } from "../../extensions/googlechat/src/setup-surface.js"; +export { googlechatSetupAdapter } from "../../extensions/googlechat/api.js"; +export { googlechatSetupWizard } from "../../extensions/googlechat/api.js"; export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; diff --git a/src/plugin-sdk/imessage-core.ts b/src/plugin-sdk/imessage-core.ts new file mode 100644 index 00000000000..ac93a67f307 --- /dev/null +++ b/src/plugin-sdk/imessage-core.ts @@ -0,0 +1,14 @@ +export type { ChannelPlugin } from "./channel-plugin-common.js"; +export { + DEFAULT_ACCOUNT_ID, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + getChatChannelMeta, + setAccountEnabledInConfigSection, +} from "./channel-plugin-common.js"; +export { + formatTrimmedAllowFromEntries, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, +} from "./channel-config-helpers.js"; +export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; diff --git a/src/plugin-sdk/imessage-targets.ts b/src/plugin-sdk/imessage-targets.ts index b3353edc3df..4a7f535be48 100644 --- a/src/plugin-sdk/imessage-targets.ts +++ b/src/plugin-sdk/imessage-targets.ts @@ -1 +1 @@ -export { normalizeIMessageHandle } from "../../extensions/imessage/src/targets.js"; +export { normalizeIMessageHandle } from "../../extensions/imessage/api.js"; diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index 5481c117be6..adad1403eb6 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -18,6 +18,8 @@ export { normalizeAccountId, setAccountEnabledInConfigSection, } from "./channel-plugin-common.js"; +export { detectBinary } from "../plugins/setup-binary.js"; +export { formatDocsLink } from "../terminal/links.js"; export { formatTrimmedAllowFromEntries, resolveIMessageConfigAllowFrom, @@ -40,4 +42,4 @@ export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { collectStatusIssuesFromLastError } from "./status-helpers.js"; -export { sendMessageIMessage } from "../../extensions/imessage/src/send.js"; +export { sendMessageIMessage } from "../../extensions/imessage/runtime-api.js"; diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 20f685cdbd2..07d4dde6d98 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -1,9 +1,13 @@ +import { execFile } from "node:child_process"; import fs from "node:fs/promises"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { + buildPluginSdkEntrySources, buildPluginSdkPackageExports, buildPluginSdkSpecifiers, pluginSdkEntrypoints, @@ -11,6 +15,9 @@ import { import * as sdk from "./index.js"; const pluginSdkSpecifiers = buildPluginSdkSpecifiers(); +const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +const tsdownModuleUrl = pathToFileURL(require.resolve("tsdown")).href; describe("plugin-sdk exports", () => { it("does not expose runtime modules", () => { @@ -63,16 +70,33 @@ describe("plugin-sdk exports", () => { }); it("emits importable bundled subpath entries", { timeout: 240_000 }, async () => { + const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-build-")); const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-consumer-")); - const repoDistDir = path.join(process.cwd(), "dist"); try { - await expect(fs.access(path.join(repoDistDir, "plugin-sdk"))).resolves.toBeUndefined(); + const buildScriptPath = path.join(fixtureDir, "build-plugin-sdk.mjs"); + await fs.writeFile( + buildScriptPath, + `import { build } from ${JSON.stringify(tsdownModuleUrl)}; +await build(${JSON.stringify({ + clean: true, + config: false, + dts: false, + entry: buildPluginSdkEntrySources(), + env: { NODE_ENV: "production" }, + fixedExtension: false, + logLevel: "error", + outDir, + platform: "node", + })}); +`, + ); + await execFileAsync(process.execPath, [buildScriptPath], { + cwd: process.cwd(), + }); for (const entry of pluginSdkEntrypoints) { - const module = await import( - pathToFileURL(path.join(repoDistDir, "plugin-sdk", `${entry}.js`)).href - ); + const module = await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href); expect(module).toBeTypeOf("object"); } @@ -80,8 +104,8 @@ describe("plugin-sdk exports", () => { const consumerDir = path.join(fixtureDir, "consumer"); const consumerEntry = path.join(consumerDir, "import-plugin-sdk.mjs"); - await fs.mkdir(packageDir, { recursive: true }); - await fs.symlink(repoDistDir, path.join(packageDir, "dist"), "dir"); + await fs.mkdir(path.join(packageDir, "dist"), { recursive: true }); + await fs.symlink(outDir, path.join(packageDir, "dist", "plugin-sdk"), "dir"); await fs.writeFile( path.join(packageDir, "package.json"), JSON.stringify( @@ -114,6 +138,7 @@ describe("plugin-sdk exports", () => { Object.fromEntries(pluginSdkSpecifiers.map((specifier: string) => [specifier, "object"])), ); } finally { + await fs.rm(outDir, { recursive: true, force: true }); await fs.rm(fixtureDir, { recursive: true, force: true }); } }); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 16720cf8961..a683f5437ca 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -14,8 +14,25 @@ export type { ChannelMessageActionName, ChannelStatusIssue, } from "../channels/plugins/types.js"; +export type { + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingMatch, + ChannelConfiguredBindingProvider, +} from "../channels/plugins/types.adapters.js"; export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { ChannelSetupAdapter, ChannelSetupInput } from "../channels/plugins/types.js"; +export type { + ConfiguredBindingConversation, + ConfiguredBindingResolution, + CompiledConfiguredBinding, + StatefulBindingTargetDescriptor, +} from "../channels/plugins/binding-types.js"; +export type { + StatefulBindingTargetDriver, + StatefulBindingTargetReadyResult, + StatefulBindingTargetResetResult, + StatefulBindingTargetSessionResult, +} from "../channels/plugins/stateful-target-drivers.js"; export type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index 4192322d527..47ba490ec42 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -62,7 +62,7 @@ export { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount, -} from "../../extensions/irc/src/accounts.js"; +} from "../../extensions/irc/api.js"; export { readStoreAllowFromForDmPolicy, resolveEffectiveAllowFromLists, @@ -72,7 +72,7 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { dispatchInboundReplyWithBase } from "./inbound-reply-dispatch.js"; -export { ircSetupAdapter, ircSetupWizard } from "../../extensions/irc/src/setup-surface.js"; +export { ircSetupAdapter, ircSetupWizard } from "../../extensions/irc/api.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { createNormalizedOutboundDeliverer, diff --git a/src/plugin-sdk/lazy-runtime.ts b/src/plugin-sdk/lazy-runtime.ts new file mode 100644 index 00000000000..e1f829204a2 --- /dev/null +++ b/src/plugin-sdk/lazy-runtime.ts @@ -0,0 +1,7 @@ +export { + createLazyRuntimeModule, + createLazyRuntimeMethod, + createLazyRuntimeMethodBinder, + createLazyRuntimeNamedExport, + createLazyRuntimeSurface, +} from "../shared/lazy-runtime.js"; diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index b6617199472..9592fe7f12e 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -32,8 +32,8 @@ export { resolveDefaultLineAccountId, resolveLineAccount, } from "../line/accounts.js"; -export { lineSetupAdapter } from "../../extensions/line/src/setup-core.js"; -export { lineSetupWizard } from "../../extensions/line/src/setup-surface.js"; +export { lineSetupAdapter } from "../../extensions/line/api.js"; +export { lineSetupWizard } from "../../extensions/line/api.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js"; export { diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 164575e04e1..099b53792da 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -108,5 +108,5 @@ export { buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, } from "./status-helpers.js"; -export { matrixSetupWizard } from "../../extensions/matrix/src/setup-surface.js"; -export { matrixSetupAdapter } from "../../extensions/matrix/src/setup-core.js"; +export { matrixSetupWizard } from "../../extensions/matrix/api.js"; +export { matrixSetupAdapter } from "../../extensions/matrix/api.js"; diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index b30e6c6914a..1185558de79 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -117,5 +117,5 @@ export { createDefaultChannelRuntimeState, } from "./status-helpers.js"; export { normalizeStringEntries } from "../shared/string-normalization.js"; -export { msteamsSetupWizard } from "../../extensions/msteams/src/setup-surface.js"; -export { msteamsSetupAdapter } from "../../extensions/msteams/src/setup-core.js"; +export { msteamsSetupWizard } from "../../extensions/msteams/api.js"; +export { msteamsSetupAdapter } from "../../extensions/msteams/api.js"; diff --git a/src/plugin-sdk/nostr.ts b/src/plugin-sdk/nostr.ts index a2997c5702c..362344810fa 100644 --- a/src/plugin-sdk/nostr.ts +++ b/src/plugin-sdk/nostr.ts @@ -19,4 +19,4 @@ export { } from "./status-helpers.js"; export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { mapAllowFromEntries } from "./channel-config-helpers.js"; -export { nostrSetupAdapter, nostrSetupWizard } from "../../extensions/nostr/src/setup-surface.js"; +export { nostrSetupAdapter, nostrSetupWizard } from "../../extensions/nostr/api.js"; diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index d30dd81f7d6..84373befb88 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -3,6 +3,7 @@ export type { OpenClawConfig } from "../config/config.js"; export type { SecretInput } from "../config/types.secrets.js"; export type { ProviderAuthResult } from "../plugins/types.js"; +export type { ProviderAuthContext } from "../plugins/types.js"; export type { AuthProfileStore, OAuthCredential } from "../agents/auth-profiles/types.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; @@ -16,6 +17,7 @@ export { } from "../agents/auth-profiles.js"; export { MINIMAX_OAUTH_MARKER, + resolveOAuthApiKeyMarker, resolveNonEnvSecretRefApiKeyMarker, } from "../agents/model-auth-markers.js"; export { @@ -35,6 +37,7 @@ export { } from "../plugins/provider-auth-token.js"; export { applyAuthProfileConfig, buildApiKeyCredential } from "../plugins/provider-auth-helpers.js"; export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; +export { loginChutes } from "../commands/chutes-oauth.js"; export { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js"; export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; export { coerceSecretRef } from "../config/types.secrets.js"; diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index c2a68c7b579..996135c9011 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -81,6 +81,14 @@ export { buildHuggingfaceModelDefinition, } from "../agents/huggingface-models.js"; export { discoverKilocodeModels } from "../agents/kilocode-models.js"; +export { + buildChutesModelDefinition, + CHUTES_BASE_URL, + CHUTES_DEFAULT_MODEL_ID, + CHUTES_DEFAULT_MODEL_REF, + CHUTES_MODEL_CATALOG, + discoverChutesModels, +} from "../agents/chutes-models.js"; export { resolveOllamaApiBase } from "../agents/ollama-models.js"; export { buildSyntheticModelDefinition, diff --git a/src/plugin-sdk/setup-tools.ts b/src/plugin-sdk/setup-tools.ts new file mode 100644 index 00000000000..d2a625c608d --- /dev/null +++ b/src/plugin-sdk/setup-tools.ts @@ -0,0 +1,4 @@ +export { formatCliCommand } from "../cli/command-format.js"; +export { detectBinary } from "../plugins/setup-binary.js"; +export { installSignalCli } from "../plugins/signal-cli-install.js"; +export { formatDocsLink } from "../terminal/links.js"; diff --git a/src/plugin-sdk/signal-core.ts b/src/plugin-sdk/signal-core.ts new file mode 100644 index 00000000000..42b1facd2af --- /dev/null +++ b/src/plugin-sdk/signal-core.ts @@ -0,0 +1,10 @@ +export type { ChannelPlugin } from "./channel-plugin-common.js"; +export { + DEFAULT_ACCOUNT_ID, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + getChatChannelMeta, + setAccountEnabledInConfigSection, +} from "./channel-plugin-common.js"; +export { SignalConfigSchema } from "../config/zod-schema.providers-core.js"; +export { normalizeE164 } from "../utils.js"; diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index da3d839e356..f44dfa2f9ff 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -1,7 +1,7 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { SignalAccountConfig } from "../config/types.js"; -export type { ResolvedSignalAccount } from "../../extensions/signal/src/accounts.js"; +export type { ResolvedSignalAccount } from "../../extensions/signal/api.js"; export type { ChannelMessageActionContext, ChannelPlugin, @@ -21,11 +21,15 @@ export { normalizeAccountId, setAccountEnabledInConfigSection, } from "./channel-plugin-common.js"; +export { formatCliCommand } from "../cli/command-format.js"; +export { formatDocsLink } from "../terminal/links.js"; export { looksLikeSignalTargetId, normalizeSignalMessagingTarget, } from "../channels/plugins/normalize/signal.js"; +export { detectBinary } from "../plugins/setup-binary.js"; +export { installSignalCli } from "../plugins/signal-cli-install.js"; export { resolveAllowlistProviderRuntimeGroupPolicy, @@ -47,10 +51,7 @@ export { listEnabledSignalAccounts, listSignalAccountIds, resolveDefaultSignalAccountId, -} from "../../extensions/signal/src/accounts.js"; -export { resolveSignalReactionLevel } from "../../extensions/signal/src/reaction-level.js"; -export { - removeReactionSignal, - sendReactionSignal, -} from "../../extensions/signal/src/send-reactions.js"; -export { sendMessageSignal } from "../../extensions/signal/src/send.js"; +} from "../../extensions/signal/api.js"; +export { resolveSignalReactionLevel } from "../../extensions/signal/runtime-api.js"; +export { removeReactionSignal, sendReactionSignal } from "../../extensions/signal/runtime-api.js"; +export { sendMessageSignal } from "../../extensions/signal/runtime-api.js"; diff --git a/src/plugin-sdk/slack-core.ts b/src/plugin-sdk/slack-core.ts new file mode 100644 index 00000000000..8df7ad669a7 --- /dev/null +++ b/src/plugin-sdk/slack-core.ts @@ -0,0 +1,4 @@ +export type { OpenClawConfig } from "../config/config.js"; +export type { ChannelPlugin } from "./channel-plugin-common.js"; +export { buildChannelConfigSchema, getChatChannelMeta } from "./channel-plugin-common.js"; +export { SlackConfigSchema } from "../config/zod-schema.providers-core.js"; diff --git a/src/plugin-sdk/slack-message-actions.ts b/src/plugin-sdk/slack-message-actions.ts index ef7a5f12876..64863623503 100644 --- a/src/plugin-sdk/slack-message-actions.ts +++ b/src/plugin-sdk/slack-message-actions.ts @@ -1,6 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { parseSlackBlocksInput } from "../../extensions/slack/src/blocks-input.js"; -import { buildSlackInteractiveBlocks } from "../../extensions/slack/src/blocks-render.js"; +import { parseSlackBlocksInput, buildSlackInteractiveBlocks } from "../../extensions/slack/api.js"; import { readNumberParam, readStringParam } from "../agents/tools/common.js"; import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; import { normalizeInteractiveReply } from "../interactive/payload.js"; diff --git a/src/plugin-sdk/slack-targets.ts b/src/plugin-sdk/slack-targets.ts index be9ded918cf..20ea56e44d1 100644 --- a/src/plugin-sdk/slack-targets.ts +++ b/src/plugin-sdk/slack-targets.ts @@ -3,4 +3,4 @@ export { resolveSlackChannelId, type SlackTarget, type SlackTargetKind, -} from "../../extensions/slack/src/targets.js"; +} from "../../extensions/slack/api.js"; diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 0e8ce16aef2..4b78d14480d 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -1,7 +1,7 @@ export type { OpenClawConfig } from "../config/config.js"; export type { SlackAccountConfig } from "../config/types.slack.js"; -export type { InspectedSlackAccount } from "../../extensions/slack/src/account-inspect.js"; -export type { ResolvedSlackAccount } from "../../extensions/slack/src/accounts.js"; +export type { InspectedSlackAccount } from "../../extensions/slack/api.js"; +export type { ResolvedSlackAccount } from "../../extensions/slack/api.js"; export type { ChannelMessageActionContext, ChannelPlugin, @@ -21,6 +21,7 @@ export { normalizeAccountId, setAccountEnabledInConfigSection, } from "./channel-plugin-common.js"; +export { formatDocsLink } from "../terminal/links.js"; export { projectCredentialSnapshotFields, @@ -51,18 +52,15 @@ export { listSlackAccountIds, resolveDefaultSlackAccountId, resolveSlackReplyToMode, -} from "../../extensions/slack/src/accounts.js"; -export { isSlackInteractiveRepliesEnabled } from "../../extensions/slack/src/interactive-replies.js"; -export { inspectSlackAccount } from "../../extensions/slack/src/account-inspect.js"; +} from "../../extensions/slack/api.js"; +export { isSlackInteractiveRepliesEnabled } from "../../extensions/slack/api.js"; +export { inspectSlackAccount } from "../../extensions/slack/api.js"; export { parseSlackTarget, resolveSlackChannelId } from "./slack-targets.js"; -export { - extractSlackToolSend, - listSlackMessageActions, -} from "../../extensions/slack/src/message-actions.js"; -export { buildSlackThreadingToolContext } from "../../extensions/slack/src/threading-tool-context.js"; -export { parseSlackBlocksInput } from "../../extensions/slack/src/blocks-input.js"; -export { handleSlackHttpRequest } from "../../extensions/slack/src/http/index.js"; -export { sendMessageSlack } from "../../extensions/slack/src/send.js"; +export { extractSlackToolSend, listSlackMessageActions } from "../../extensions/slack/api.js"; +export { buildSlackThreadingToolContext } from "../../extensions/slack/api.js"; +export { parseSlackBlocksInput } from "../../extensions/slack/api.js"; +export { handleSlackHttpRequest } from "../../extensions/slack/api.js"; +export { sendMessageSlack } from "../../extensions/slack/runtime-api.js"; export { deleteSlackMessage, downloadSlackFile, @@ -78,8 +76,8 @@ export { removeSlackReaction, sendSlackMessage, unpinSlackMessage, -} from "../../extensions/slack/src/actions.js"; -export { recordSlackThreadParticipation } from "../../extensions/slack/src/sent-thread-cache.js"; +} from "../../extensions/slack/api.js"; +export { recordSlackThreadParticipation } from "../../extensions/slack/api.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; export { createSlackActions } from "../channels/plugins/slack.actions.js"; export type { SlackActionContext } from "../agents/tools/slack-actions.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 7a43a159b73..4e73ce9c26e 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -7,6 +7,7 @@ import type { } from "openclaw/plugin-sdk/core"; import * as discordSdk from "openclaw/plugin-sdk/discord"; import * as imessageSdk from "openclaw/plugin-sdk/imessage"; +import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime"; import * as lineSdk from "openclaw/plugin-sdk/line"; import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; import * as nostrSdk from "openclaw/plugin-sdk/nostr"; @@ -41,6 +42,19 @@ const bundledExtensionSubpathLoaders = pluginSdkSubpaths.map((id: string) => ({ })); const asExports = (mod: object) => mod as Record; +const ircSdk = await import("openclaw/plugin-sdk/irc"); +const feishuSdk = await import("openclaw/plugin-sdk/feishu"); +const googlechatSdk = await import("openclaw/plugin-sdk/googlechat"); +const zaloSdk = await import("openclaw/plugin-sdk/zalo"); +const synologyChatSdk = await import("openclaw/plugin-sdk/synology-chat"); +const zalouserSdk = await import("openclaw/plugin-sdk/zalouser"); +const tlonSdk = await import("openclaw/plugin-sdk/tlon"); +const acpxSdk = await import("openclaw/plugin-sdk/acpx"); +const bluebubblesSdk = await import("openclaw/plugin-sdk/bluebubbles"); +const matrixSdk = await import("openclaw/plugin-sdk/matrix"); +const mattermostSdk = await import("openclaw/plugin-sdk/mattermost"); +const nextcloudTalkSdk = await import("openclaw/plugin-sdk/nextcloud-talk"); +const twitchSdk = await import("openclaw/plugin-sdk/twitch"); describe("plugin-sdk subpath exports", () => { it("exports compat helpers", () => { @@ -86,6 +100,12 @@ describe("plugin-sdk subpath exports", () => { expect(typeof setupSdk.formatResolvedUnresolvedNote).toBe("function"); }); + it("exports shared lazy runtime helpers from the dedicated subpath", () => { + expect(typeof lazyRuntimeSdk.createLazyRuntimeSurface).toBe("function"); + expect(typeof lazyRuntimeSdk.createLazyRuntimeModule).toBe("function"); + expect(typeof lazyRuntimeSdk.createLazyRuntimeNamedExport).toBe("function"); + }); + it("exports narrow self-hosted provider setup helpers", () => { expect(typeof selfHostedProviderSetupSdk.buildVllmProvider).toBe("function"); expect(typeof selfHostedProviderSetupSdk.buildSglangProvider).toBe("function"); @@ -162,7 +182,6 @@ describe("plugin-sdk subpath exports", () => { }); it("exports IRC helpers", async () => { - const ircSdk = await import("openclaw/plugin-sdk/irc"); expect(typeof ircSdk.resolveIrcAccount).toBe("function"); expect(typeof ircSdk.ircSetupWizard).toBe("object"); expect(typeof ircSdk.ircSetupAdapter).toBe("object"); @@ -177,7 +196,6 @@ describe("plugin-sdk subpath exports", () => { }); it("exports Feishu helpers", async () => { - const feishuSdk = await import("openclaw/plugin-sdk/feishu"); expect(typeof feishuSdk.feishuSetupWizard).toBe("object"); expect(typeof feishuSdk.feishuSetupAdapter).toBe("object"); }); @@ -202,38 +220,32 @@ describe("plugin-sdk subpath exports", () => { }); it("exports Google Chat helpers", async () => { - const googlechatSdk = await import("openclaw/plugin-sdk/googlechat"); expect(typeof googlechatSdk.googlechatSetupWizard).toBe("object"); expect(typeof googlechatSdk.googlechatSetupAdapter).toBe("object"); }); it("exports Zalo helpers", async () => { - const zaloSdk = await import("openclaw/plugin-sdk/zalo"); expect(typeof zaloSdk.zaloSetupWizard).toBe("object"); expect(typeof zaloSdk.zaloSetupAdapter).toBe("object"); }); it("exports Synology Chat helpers", async () => { - const synologyChatSdk = await import("openclaw/plugin-sdk/synology-chat"); expect(typeof synologyChatSdk.synologyChatSetupWizard).toBe("object"); expect(typeof synologyChatSdk.synologyChatSetupAdapter).toBe("object"); }); it("exports Zalouser helpers", async () => { - const zalouserSdk = await import("openclaw/plugin-sdk/zalouser"); expect(typeof zalouserSdk.zalouserSetupWizard).toBe("object"); expect(typeof zalouserSdk.zalouserSetupAdapter).toBe("object"); }); it("exports Tlon helpers", async () => { - const tlonSdk = await import("openclaw/plugin-sdk/tlon"); expect(typeof tlonSdk.fetchWithSsrFGuard).toBe("function"); expect(typeof tlonSdk.tlonSetupWizard).toBe("object"); expect(typeof tlonSdk.tlonSetupAdapter).toBe("object"); }); it("exports acpx helpers", async () => { - const acpxSdk = await import("openclaw/plugin-sdk/acpx"); expect(typeof acpxSdk.listKnownProviderAuthEnvVarNames).toBe("function"); expect(typeof acpxSdk.omitEnvKeysCaseInsensitive).toBe("function"); }); @@ -247,26 +259,15 @@ describe("plugin-sdk subpath exports", () => { }); it("keeps the newly added bundled plugin-sdk contracts available", async () => { - const bluebubbles = await import("openclaw/plugin-sdk/bluebubbles"); - expect(typeof bluebubbles.parseFiniteNumber).toBe("function"); - - const matrix = await import("openclaw/plugin-sdk/matrix"); - expect(typeof matrix.matrixSetupWizard).toBe("object"); - expect(typeof matrix.matrixSetupAdapter).toBe("object"); - - const mattermost = await import("openclaw/plugin-sdk/mattermost"); - expect(typeof mattermost.parseStrictPositiveInteger).toBe("function"); - - const nextcloudTalk = await import("openclaw/plugin-sdk/nextcloud-talk"); - expect(typeof nextcloudTalk.waitForAbortSignal).toBe("function"); - - const twitch = await import("openclaw/plugin-sdk/twitch"); - expect(typeof twitch.DEFAULT_ACCOUNT_ID).toBe("string"); - expect(typeof twitch.normalizeAccountId).toBe("function"); - expect(typeof twitch.twitchSetupWizard).toBe("object"); - expect(typeof twitch.twitchSetupAdapter).toBe("object"); - - const zalo = await import("openclaw/plugin-sdk/zalo"); - expect(typeof zalo.resolveClientIp).toBe("function"); + expect(typeof bluebubblesSdk.parseFiniteNumber).toBe("function"); + expect(typeof matrixSdk.matrixSetupWizard).toBe("object"); + expect(typeof matrixSdk.matrixSetupAdapter).toBe("object"); + expect(typeof mattermostSdk.parseStrictPositiveInteger).toBe("function"); + expect(typeof nextcloudTalkSdk.waitForAbortSignal).toBe("function"); + expect(typeof twitchSdk.DEFAULT_ACCOUNT_ID).toBe("string"); + expect(typeof twitchSdk.normalizeAccountId).toBe("function"); + expect(typeof twitchSdk.twitchSetupWizard).toBe("object"); + expect(typeof twitchSdk.twitchSetupAdapter).toBe("object"); + expect(typeof zaloSdk.resolveClientIp).toBe("function"); }); }); diff --git a/src/plugin-sdk/synology-chat.ts b/src/plugin-sdk/synology-chat.ts index f5fae73fbb2..17b916385bc 100644 --- a/src/plugin-sdk/synology-chat.ts +++ b/src/plugin-sdk/synology-chat.ts @@ -20,4 +20,4 @@ export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { synologyChatSetupAdapter, synologyChatSetupWizard, -} from "../../extensions/synology-chat/src/setup-surface.js"; +} from "../../extensions/synology-chat/api.js"; diff --git a/src/plugin-sdk/telegram-core.ts b/src/plugin-sdk/telegram-core.ts new file mode 100644 index 00000000000..a020a333fd3 --- /dev/null +++ b/src/plugin-sdk/telegram-core.ts @@ -0,0 +1,5 @@ +export type { OpenClawConfig } from "../config/config.js"; +export type { ChannelPlugin } from "./channel-plugin-common.js"; +export { buildChannelConfigSchema, getChatChannelMeta } from "./channel-plugin-common.js"; +export { normalizeAccountId } from "../routing/session-key.js"; +export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index db53fa92a35..9a94e7c2d1c 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -12,14 +12,16 @@ export type { TelegramActionConfig, TelegramNetworkConfig, } from "../config/types.js"; -export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; -export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js"; -export type { TelegramProbe } from "../../extensions/telegram/src/probe.js"; export type { - TelegramButtonStyle, - TelegramInlineButtons, -} from "../../extensions/telegram/src/button-types.js"; -export type { StickerMetadata } from "../../extensions/telegram/src/bot/types.js"; + ChannelConfiguredBindingProvider, + ChannelConfiguredBindingConversationRef, + ChannelConfiguredBindingMatch, +} from "../channels/plugins/types.adapters.js"; +export type { InspectedTelegramAccount } from "../../extensions/telegram/api.js"; +export type { ResolvedTelegramAccount } from "../../extensions/telegram/api.js"; +export type { TelegramProbe } from "../../extensions/telegram/runtime-api.js"; +export type { TelegramButtonStyle, TelegramInlineButtons } from "../../extensions/telegram/api.js"; +export type { StickerMetadata } from "../../extensions/telegram/api.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; @@ -65,26 +67,26 @@ export { listTelegramAccountIds, resolveDefaultTelegramAccountId, resolveTelegramPollActionGateState, -} from "../../extensions/telegram/src/accounts.js"; -export { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; +} from "../../extensions/telegram/api.js"; +export { inspectTelegramAccount } from "../../extensions/telegram/api.js"; export { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget, -} from "../../extensions/telegram/src/normalize.js"; +} from "../../extensions/telegram/api.js"; export { parseTelegramReplyToMessageId, parseTelegramThreadId, -} from "../../extensions/telegram/src/outbound-params.js"; +} from "../../extensions/telegram/api.js"; export { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, -} from "../../extensions/telegram/src/allow-from.js"; -export { fetchTelegramChatId } from "../../extensions/telegram/src/api-fetch.js"; +} from "../../extensions/telegram/api.js"; +export { fetchTelegramChatId } from "../../extensions/telegram/api.js"; export { resolveTelegramInlineButtonsScope, resolveTelegramTargetChatType, -} from "../../extensions/telegram/src/inline-buttons.js"; -export { resolveTelegramReactionLevel } from "../../extensions/telegram/src/reaction-level.js"; +} from "../../extensions/telegram/api.js"; +export { resolveTelegramReactionLevel } from "../../extensions/telegram/api.js"; export { createForumTopicTelegram, deleteMessageTelegram, @@ -94,12 +96,12 @@ export { sendMessageTelegram, sendPollTelegram, sendStickerTelegram, -} from "../../extensions/telegram/src/send.js"; -export { getCacheStats, searchStickers } from "../../extensions/telegram/src/sticker-cache.js"; -export { resolveTelegramToken } from "../../extensions/telegram/src/token.js"; -export { telegramMessageActions } from "../../extensions/telegram/src/channel-actions.js"; -export { collectTelegramStatusIssues } from "../../extensions/telegram/src/status-issues.js"; -export { sendTelegramPayloadMessages } from "../../extensions/telegram/src/outbound-adapter.js"; +} from "../../extensions/telegram/runtime-api.js"; +export { getCacheStats, searchStickers } from "../../extensions/telegram/api.js"; +export { resolveTelegramToken } from "../../extensions/telegram/runtime-api.js"; +export { telegramMessageActions } from "../../extensions/telegram/runtime-api.js"; +export { collectTelegramStatusIssues } from "../../extensions/telegram/api.js"; +export { sendTelegramPayloadMessages } from "../../extensions/telegram/api.js"; export { buildBrowseProvidersButton, buildModelsKeyboard, @@ -107,8 +109,8 @@ export { calculateTotalPages, getModelsPageSize, type ProviderInfo, -} from "../../extensions/telegram/src/model-buttons.js"; +} from "../../extensions/telegram/api.js"; export { isTelegramExecApprovalApprover, isTelegramExecApprovalClientEnabled, -} from "../../extensions/telegram/src/exec-approvals.js"; +} from "../../extensions/telegram/api.js"; diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index 291834b9648..246c4b7093e 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -27,5 +27,5 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { createLoggerBackedRuntime } from "./runtime.js"; -export { tlonSetupAdapter } from "../../extensions/tlon/src/setup-core.js"; -export { tlonSetupWizard } from "../../extensions/tlon/src/setup-surface.js"; +export { tlonSetupAdapter } from "../../extensions/tlon/api.js"; +export { tlonSetupWizard } from "../../extensions/tlon/api.js"; diff --git a/src/plugin-sdk/twitch.ts b/src/plugin-sdk/twitch.ts index 907cdd171fa..9b200cf03f7 100644 --- a/src/plugin-sdk/twitch.ts +++ b/src/plugin-sdk/twitch.ts @@ -33,7 +33,4 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { - twitchSetupAdapter, - twitchSetupWizard, -} from "../../extensions/twitch/src/setup-surface.js"; +export { twitchSetupAdapter, twitchSetupWizard } from "../../extensions/twitch/api.js"; diff --git a/src/plugin-sdk/web-media.ts b/src/plugin-sdk/web-media.ts index 1c7432ad2b5..ce734a295bb 100644 --- a/src/plugin-sdk/web-media.ts +++ b/src/plugin-sdk/web-media.ts @@ -3,4 +3,4 @@ export { loadWebMedia, loadWebMediaRaw, type WebMediaResult, -} from "../../extensions/whatsapp/src/media.js"; +} from "../../extensions/whatsapp/runtime-api.js"; diff --git a/src/plugin-sdk/whatsapp-core.ts b/src/plugin-sdk/whatsapp-core.ts new file mode 100644 index 00000000000..036fda6a5a9 --- /dev/null +++ b/src/plugin-sdk/whatsapp-core.ts @@ -0,0 +1,18 @@ +export type { ChannelPlugin } from "./channel-plugin-common.js"; +export { + DEFAULT_ACCOUNT_ID, + buildChannelConfigSchema, + getChatChannelMeta, +} from "./channel-plugin-common.js"; +export { + formatWhatsAppConfigAllowFromEntries, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, +} from "./channel-config-helpers.js"; +export { + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, +} from "../channels/plugins/group-mentions.js"; +export { resolveWhatsAppGroupIntroHint } from "../channels/plugins/whatsapp-shared.js"; +export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js"; +export { normalizeE164 } from "../utils.js"; diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index 3727cc802ec..74ab27dac2f 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -1,14 +1,11 @@ export type { ChannelMessageActionName } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; -export type { - WebChannelStatus, - WebMonitorTuning, -} from "../../extensions/whatsapp/src/auto-reply.js"; +export type { WebChannelStatus, WebMonitorTuning } from "../../extensions/whatsapp/runtime-api.js"; export type { WebInboundMessage, WebListenerCloseReason, -} from "../../extensions/whatsapp/src/inbound.js"; +} from "../../extensions/whatsapp/runtime-api.js"; export type { ChannelMessageActionContext, ChannelPlugin, @@ -28,6 +25,8 @@ export { normalizeAccountId, setAccountEnabledInConfigSection, } from "./channel-plugin-common.js"; +export { formatCliCommand } from "../cli/command-format.js"; +export { formatDocsLink } from "../terminal/links.js"; export { formatWhatsAppConfigAllowFromEntries, resolveWhatsAppConfigAllowFrom, @@ -70,14 +69,14 @@ export { hasAnyWhatsAppAuth, listEnabledWhatsAppAccounts, resolveWhatsAppAccount, -} from "../../extensions/whatsapp/src/accounts.js"; +} from "../../extensions/whatsapp/api.js"; export { WA_WEB_AUTH_DIR, logWebSelfId, logoutWeb, pickWebChannel, webAuthExists, -} from "../../extensions/whatsapp/src/auth-store.js"; +} from "../../extensions/whatsapp/runtime-api.js"; export { DEFAULT_WEB_MEDIA_BYTES, HEARTBEAT_PROMPT, @@ -85,28 +84,28 @@ export { monitorWebChannel, resolveHeartbeatRecipients, runWebHeartbeatOnce, -} from "../../extensions/whatsapp/src/auto-reply.js"; +} from "../../extensions/whatsapp/runtime-api.js"; export { extractMediaPlaceholder, extractText, monitorWebInbox, -} from "../../extensions/whatsapp/src/inbound.js"; -export { loginWeb } from "../../extensions/whatsapp/src/login.js"; +} from "../../extensions/whatsapp/runtime-api.js"; +export { loginWeb } from "../../extensions/whatsapp/runtime-api.js"; export { getDefaultLocalRoots, loadWebMedia, loadWebMediaRaw, optimizeImageToJpeg, -} from "../../extensions/whatsapp/src/media.js"; +} from "../../extensions/whatsapp/runtime-api.js"; export { sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp, -} from "../../extensions/whatsapp/src/send.js"; +} from "../../extensions/whatsapp/runtime-api.js"; export { createWaSocket, formatError, getStatusCode, waitForWaConnection, -} from "../../extensions/whatsapp/src/session.js"; -export { createWhatsAppLoginTool } from "../../extensions/whatsapp/src/agent-tools-login.js"; +} from "../../extensions/whatsapp/runtime-api.js"; +export { createWhatsAppLoginTool } from "../../extensions/whatsapp/runtime-api.js"; diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 37e3b9fde26..2655e26e18f 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -62,8 +62,8 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export type { RuntimeEnv } from "../runtime.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase, isNormalizedSenderAllowed } from "./allow-from.js"; -export { zaloSetupAdapter } from "../../extensions/zalo/src/setup-core.js"; -export { zaloSetupWizard } from "../../extensions/zalo/src/setup-surface.js"; +export { zaloSetupAdapter } from "../../extensions/zalo/api.js"; +export { zaloSetupWizard } from "../../extensions/zalo/api.js"; export { resolveDirectDmAuthorizationOutcome, resolveSenderCommandAuthorizationWithRuntime, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index b7b95910132..ed66e31754e 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -53,8 +53,8 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase } from "./allow-from.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; -export { zalouserSetupAdapter } from "../../extensions/zalouser/src/setup-core.js"; -export { zalouserSetupWizard } from "../../extensions/zalouser/src/setup-surface.js"; +export { zalouserSetupAdapter } from "../../extensions/zalouser/api.js"; +export { zalouserSetupWizard } from "../../extensions/zalouser/api.js"; export { evaluateGroupRouteAccessForPolicy, resolveSenderScopedGroupPolicy, diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 8becf375f96..01f2b14cfd7 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -78,6 +78,58 @@ describe("normalizePluginsConfig", () => { expect(result.entries["voice-call"]?.hooks).toBeUndefined(); }); + it("normalizes plugin subagent override policy settings", () => { + const result = normalizePluginsConfig({ + entries: { + "voice-call": { + subagent: { + allowModelOverride: true, + allowedModels: [" anthropic/claude-haiku-4-5 ", "", "openai/gpt-4.1-mini"], + }, + }, + }, + }); + expect(result.entries["voice-call"]?.subagent).toEqual({ + allowModelOverride: true, + hasAllowedModelsConfig: true, + allowedModels: ["anthropic/claude-haiku-4-5", "openai/gpt-4.1-mini"], + }); + }); + + it("preserves explicit subagent allowlist intent even when all entries are invalid", () => { + const result = normalizePluginsConfig({ + entries: { + "voice-call": { + subagent: { + allowModelOverride: true, + allowedModels: [42, null, "anthropic"], + } as unknown as { allowModelOverride: boolean; allowedModels: string[] }, + }, + }, + }); + expect(result.entries["voice-call"]?.subagent).toEqual({ + allowModelOverride: true, + hasAllowedModelsConfig: true, + allowedModels: ["anthropic"], + }); + }); + + it("keeps explicit invalid subagent allowlist config visible to callers", () => { + const result = normalizePluginsConfig({ + entries: { + "voice-call": { + subagent: { + allowModelOverride: "nope", + allowedModels: [42, null], + } as unknown as { allowModelOverride: boolean; allowedModels: string[] }, + }, + }, + }); + expect(result.entries["voice-call"]?.subagent).toEqual({ + hasAllowedModelsConfig: true, + }); + }); + it("normalizes legacy plugin ids to their merged bundled plugin id", () => { const result = normalizePluginsConfig({ allow: ["openai-codex", "minimax-portal-auth"], @@ -218,4 +270,9 @@ describe("resolveEnableState", () => { const state = resolveEnableState("google", "bundled", normalizePluginsConfig({})); expect(state).toEqual({ enabled: true }); }); + + it("allows bundled plugins to opt into default enablement from manifest metadata", () => { + const state = resolveEnableState("profile-aware", "bundled", normalizePluginsConfig({}), true); + expect(state).toEqual({ enabled: true }); + }); }); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 8700cf8226b..26827e50aa3 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -18,6 +18,11 @@ export type NormalizedPluginsConfig = { hooks?: { allowPromptInjection?: boolean; }; + subagent?: { + allowModelOverride?: boolean; + allowedModels?: string[]; + hasAllowedModelsConfig?: boolean; + }; config?: unknown; } >; @@ -123,11 +128,43 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr allowPromptInjection: hooks.allowPromptInjection, } : undefined; + const subagentRaw = entry.subagent; + const subagent = + subagentRaw && typeof subagentRaw === "object" && !Array.isArray(subagentRaw) + ? { + allowModelOverride: (subagentRaw as { allowModelOverride?: unknown }) + .allowModelOverride, + hasAllowedModelsConfig: Array.isArray( + (subagentRaw as { allowedModels?: unknown }).allowedModels, + ), + allowedModels: Array.isArray((subagentRaw as { allowedModels?: unknown }).allowedModels) + ? ((subagentRaw as { allowedModels?: unknown }).allowedModels as unknown[]) + .map((model) => (typeof model === "string" ? model.trim() : "")) + .filter(Boolean) + : undefined, + } + : undefined; + const normalizedSubagent = + subagent && + (typeof subagent.allowModelOverride === "boolean" || + subagent.hasAllowedModelsConfig || + (Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0)) + ? { + ...(typeof subagent.allowModelOverride === "boolean" + ? { allowModelOverride: subagent.allowModelOverride } + : {}), + ...(subagent.hasAllowedModelsConfig ? { hasAllowedModelsConfig: true } : {}), + ...(Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0 + ? { allowedModels: subagent.allowedModels } + : {}), + } + : undefined; normalized[normalizedKey] = { ...normalized[normalizedKey], enabled: typeof entry.enabled === "boolean" ? entry.enabled : normalized[normalizedKey]?.enabled, hooks: normalizedHooks ?? normalized[normalizedKey]?.hooks, + subagent: normalizedSubagent ?? normalized[normalizedKey]?.subagent, config: "config" in entry ? entry.config : normalized[normalizedKey]?.config, }; } @@ -237,6 +274,7 @@ export function resolveEnableState( id: string, origin: PluginRecord["origin"], config: NormalizedPluginsConfig, + enabledByDefault?: boolean, ): { enabled: boolean; reason?: string } { if (!config.enabled) { return { enabled: false, reason: "plugins disabled" }; @@ -261,7 +299,7 @@ export function resolveEnableState( if (entry?.enabled === true) { return { enabled: true }; } - if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) { + if (origin === "bundled" && (enabledByDefault ?? BUNDLED_ENABLED_BY_DEFAULT.has(id))) { return { enabled: true }; } if (origin === "bundled") { @@ -294,8 +332,9 @@ export function resolveEffectiveEnableState(params: { origin: PluginRecord["origin"]; config: NormalizedPluginsConfig; rootConfig?: OpenClawConfig; + enabledByDefault?: boolean; }): { enabled: boolean; reason?: string } { - const base = resolveEnableState(params.id, params.origin, params.config); + const base = resolveEnableState(params.id, params.origin, params.config, params.enabledByDefault); if ( !base.enabled && base.reason === "bundled (disabled by default)" && diff --git a/src/plugins/contracts/auth.contract.test.ts b/src/plugins/contracts/auth.contract.test.ts index 40a82482edd..355ceb43962 100644 --- a/src/plugins/contracts/auth.contract.test.ts +++ b/src/plugins/contracts/auth.contract.test.ts @@ -20,7 +20,7 @@ type LoginQwenPortalOAuth = type GithubCopilotLoginCommand = (typeof import("openclaw/plugin-sdk/provider-auth"))["githubCopilotLoginCommand"]; type CreateVpsAwareHandlers = - (typeof import("../../commands/oauth-flow.js"))["createVpsAwareOAuthHandlers"]; + (typeof import("../provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"]; const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn()); const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn()); diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index c2ec44496bb..47e098a2baf 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -58,6 +58,21 @@ const modelStudioPlugin = (await import("../../../extensions/modelstudio/index.j const cloudflareAiGatewayPlugin = ( await import("../../../extensions/cloudflare-ai-gateway/index.js") ).default; +const qwenPortalProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal"); +const githubCopilotProvider = requireProvider( + registerProviders(githubCopilotPlugin), + "github-copilot", +); +const ollamaProvider = requireProvider(registerProviders(ollamaPlugin), "ollama"); +const vllmProvider = requireProvider(registerProviders(vllmPlugin), "vllm"); +const sglangProvider = requireProvider(registerProviders(sglangPlugin), "sglang"); +const minimaxProvider = requireProvider(registerProviders(minimaxPlugin), "minimax"); +const minimaxPortalProvider = requireProvider(registerProviders(minimaxPlugin), "minimax-portal"); +const modelStudioProvider = requireProvider(registerProviders(modelStudioPlugin), "modelstudio"); +const cloudflareAiGatewayProvider = requireProvider( + registerProviders(cloudflareAiGatewayPlugin), + "cloudflare-ai-gateway", +); function createModelConfig(id: string, name = id): ModelDefinitionConfig { return { @@ -76,14 +91,6 @@ function createModelConfig(id: string, name = id): ModelDefinitionConfig { }; } -function requireQwenPortalProvider() { - return requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal"); -} - -function requireGithubCopilotProvider() { - return requireProvider(registerProviders(githubCopilotPlugin), "github-copilot"); -} - function setQwenPortalOauthSnapshot() { replaceRuntimeAuthProfileStoreSnapshots([ { @@ -124,12 +131,30 @@ function runCatalog(params: { provider: Awaited>; env?: NodeJS.ProcessEnv; resolveProviderApiKey?: () => { apiKey: string | undefined }; + resolveProviderAuth?: ( + providerId?: string, + options?: { oauthMarker?: string }, + ) => { + apiKey: string | undefined; + discoveryApiKey?: string; + mode: "api_key" | "oauth" | "token" | "none"; + source: "env" | "profile" | "none"; + profileId?: string; + }; }) { return runProviderCatalog({ provider: params.provider, config: {}, env: params.env ?? ({} as NodeJS.ProcessEnv), resolveProviderApiKey: params.resolveProviderApiKey ?? (() => ({ apiKey: undefined })), + resolveProviderAuth: + params.resolveProviderAuth ?? + ((_, options) => ({ + apiKey: options?.oauthMarker, + discoveryApiKey: undefined, + mode: options?.oauthMarker ? "oauth" : "none", + source: options?.oauthMarker ? "profile" : "none", + })), }); } @@ -143,12 +168,11 @@ describe("provider discovery contract", () => { }); it("keeps qwen portal oauth marker fallback provider-owned", async () => { - const provider = requireQwenPortalProvider(); setQwenPortalOauthSnapshot(); await expect( runCatalog({ - provider, + provider: qwenPortalProvider, }), ).resolves.toEqual({ provider: { @@ -164,12 +188,11 @@ describe("provider discovery contract", () => { }); it("keeps qwen portal env api keys higher priority than oauth markers", async () => { - const provider = requireQwenPortalProvider(); setQwenPortalOauthSnapshot(); await expect( runCatalog({ - provider, + provider: qwenPortalProvider, env: { QWEN_PORTAL_API_KEY: "env-key" } as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: "env-key" }), }), @@ -181,18 +204,15 @@ describe("provider discovery contract", () => { }); it("keeps GitHub Copilot catalog disabled without env tokens or profiles", async () => { - const provider = requireGithubCopilotProvider(); - - await expect(runCatalog({ provider })).resolves.toBeNull(); + await expect(runCatalog({ provider: githubCopilotProvider })).resolves.toBeNull(); }); it("keeps GitHub Copilot profile-only catalog fallback provider-owned", async () => { - const provider = requireGithubCopilotProvider(); setGithubCopilotProfileSnapshot(); await expect( runCatalog({ - provider, + provider: githubCopilotProvider, }), ).resolves.toEqual({ provider: { @@ -203,7 +223,6 @@ describe("provider discovery contract", () => { }); it("keeps GitHub Copilot env-token base URL resolution provider-owned", async () => { - const provider = requireGithubCopilotProvider(); resolveCopilotApiTokenMock.mockResolvedValueOnce({ token: "copilot-api-token", baseUrl: "https://copilot-proxy.example.com", @@ -212,7 +231,7 @@ describe("provider discovery contract", () => { await expect( runCatalog({ - provider, + provider: githubCopilotProvider, env: { GITHUB_TOKEN: "github-env-token", } as NodeJS.ProcessEnv, @@ -233,11 +252,9 @@ describe("provider discovery contract", () => { }); it("keeps Ollama explicit catalog normalization provider-owned", async () => { - const provider = requireProvider(registerProviders(ollamaPlugin), "ollama"); - await expect( runProviderCatalog({ - provider, + provider: ollamaProvider, config: { models: { providers: { @@ -250,6 +267,12 @@ describe("provider discovery contract", () => { }, env: {} as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), }), ).resolves.toMatchObject({ provider: { @@ -263,7 +286,6 @@ describe("provider discovery contract", () => { }); it("keeps Ollama empty autodiscovery disabled without keys or explicit config", async () => { - const provider = requireProvider(registerProviders(ollamaPlugin), "ollama"); buildOllamaProviderMock.mockResolvedValueOnce({ baseUrl: "http://127.0.0.1:11434", api: "ollama", @@ -272,17 +294,22 @@ describe("provider discovery contract", () => { await expect( runProviderCatalog({ - provider, + provider: ollamaProvider, config: {}, env: {} as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), }), ).resolves.toBeNull(); expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true }); }); it("keeps vLLM self-hosted discovery provider-owned", async () => { - const provider = requireProvider(registerProviders(vllmPlugin), "vllm"); buildVllmProviderMock.mockResolvedValueOnce({ baseUrl: "http://127.0.0.1:8000/v1", api: "openai-completions", @@ -291,7 +318,7 @@ describe("provider discovery contract", () => { await expect( runProviderCatalog({ - provider, + provider: vllmProvider, config: {}, env: { VLLM_API_KEY: "env-vllm-key", @@ -300,6 +327,12 @@ describe("provider discovery contract", () => { apiKey: "VLLM_API_KEY", discoveryApiKey: "env-vllm-key", }), + resolveProviderAuth: () => ({ + apiKey: "VLLM_API_KEY", + discoveryApiKey: "env-vllm-key", + mode: "api_key", + source: "env", + }), }), ).resolves.toEqual({ provider: { @@ -315,7 +348,6 @@ describe("provider discovery contract", () => { }); it("keeps SGLang self-hosted discovery provider-owned", async () => { - const provider = requireProvider(registerProviders(sglangPlugin), "sglang"); buildSglangProviderMock.mockResolvedValueOnce({ baseUrl: "http://127.0.0.1:30000/v1", api: "openai-completions", @@ -324,7 +356,7 @@ describe("provider discovery contract", () => { await expect( runProviderCatalog({ - provider, + provider: sglangProvider, config: {}, env: { SGLANG_API_KEY: "env-sglang-key", @@ -333,6 +365,12 @@ describe("provider discovery contract", () => { apiKey: "SGLANG_API_KEY", discoveryApiKey: "env-sglang-key", }), + resolveProviderAuth: () => ({ + apiKey: "SGLANG_API_KEY", + discoveryApiKey: "env-sglang-key", + mode: "api_key", + source: "env", + }), }), ).resolves.toEqual({ provider: { @@ -348,16 +386,20 @@ describe("provider discovery contract", () => { }); it("keeps MiniMax API catalog provider-owned", async () => { - const provider = requireProvider(registerProviders(minimaxPlugin), "minimax"); - await expect( runProviderCatalog({ - provider, + provider: minimaxProvider, config: {}, env: { MINIMAX_API_KEY: "minimax-key", } as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: "minimax-key" }), + resolveProviderAuth: () => ({ + apiKey: "minimax-key", + discoveryApiKey: undefined, + mode: "api_key", + source: "env", + }), }), ).resolves.toMatchObject({ provider: { @@ -374,7 +416,6 @@ describe("provider discovery contract", () => { }); it("keeps MiniMax portal oauth marker fallback provider-owned", async () => { - const provider = requireProvider(registerProviders(minimaxPlugin), "minimax-portal"); replaceRuntimeAuthProfileStoreSnapshots([ { store: { @@ -394,10 +435,17 @@ describe("provider discovery contract", () => { await expect( runProviderCatalog({ - provider, + provider: minimaxPortalProvider, config: {}, env: {} as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: "minimax-oauth", + discoveryApiKey: "access-token", + mode: "oauth", + source: "profile", + profileId: "minimax-portal:default", + }), }), ).resolves.toMatchObject({ provider: { @@ -411,11 +459,9 @@ describe("provider discovery contract", () => { }); it("keeps MiniMax portal explicit base URL override provider-owned", async () => { - const provider = requireProvider(registerProviders(minimaxPlugin), "minimax-portal"); - await expect( runProviderCatalog({ - provider, + provider: minimaxPortalProvider, config: { models: { providers: { @@ -429,6 +475,12 @@ describe("provider discovery contract", () => { }, env: {} as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), }), ).resolves.toMatchObject({ provider: { @@ -439,11 +491,9 @@ describe("provider discovery contract", () => { }); it("keeps Model Studio catalog provider-owned", async () => { - const provider = requireProvider(registerProviders(modelStudioPlugin), "modelstudio"); - await expect( runProviderCatalog({ - provider, + provider: modelStudioProvider, config: { models: { providers: { @@ -458,6 +508,12 @@ describe("provider discovery contract", () => { MODELSTUDIO_API_KEY: "modelstudio-key", } as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: "modelstudio-key" }), + resolveProviderAuth: () => ({ + apiKey: "modelstudio-key", + discoveryApiKey: undefined, + mode: "api_key", + source: "env", + }), }), ).resolves.toMatchObject({ provider: { @@ -473,26 +529,23 @@ describe("provider discovery contract", () => { }); it("keeps Cloudflare AI Gateway catalog disabled without stored metadata", async () => { - const provider = requireProvider( - registerProviders(cloudflareAiGatewayPlugin), - "cloudflare-ai-gateway", - ); - await expect( runProviderCatalog({ - provider, + provider: cloudflareAiGatewayProvider, config: {}, env: {} as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), }), ).resolves.toBeNull(); }); it("keeps Cloudflare AI Gateway env-managed catalog provider-owned", async () => { - const provider = requireProvider( - registerProviders(cloudflareAiGatewayPlugin), - "cloudflare-ai-gateway", - ); replaceRuntimeAuthProfileStoreSnapshots([ { store: { @@ -518,12 +571,18 @@ describe("provider discovery contract", () => { await expect( runProviderCatalog({ - provider, + provider: cloudflareAiGatewayProvider, config: {}, env: { CLOUDFLARE_AI_GATEWAY_API_KEY: "secret-value", } as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), }), ).resolves.toEqual({ provider: { diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index f33571b8008..e4b6cf1059a 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -1,41 +1,18 @@ -import amazonBedrockPlugin from "../../../extensions/amazon-bedrock/index.js"; 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 elevenLabsPlugin from "../../../extensions/elevenlabs/index.js"; import firecrawlPlugin from "../../../extensions/firecrawl/index.js"; -import githubCopilotPlugin from "../../../extensions/github-copilot/index.js"; import googlePlugin from "../../../extensions/google/index.js"; -import huggingFacePlugin from "../../../extensions/huggingface/index.js"; -import kilocodePlugin from "../../../extensions/kilocode/index.js"; -import kimiCodingPlugin from "../../../extensions/kimi-coding/index.js"; import microsoftPlugin from "../../../extensions/microsoft/index.js"; import minimaxPlugin from "../../../extensions/minimax/index.js"; import mistralPlugin from "../../../extensions/mistral/index.js"; -import modelStudioPlugin from "../../../extensions/modelstudio/index.js"; 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 "../captured-registration.js"; +import { resolvePluginProviders } from "../providers.js"; import type { ImageGenerationProviderPlugin, MediaUnderstandingProviderPlugin, @@ -75,41 +52,6 @@ type PluginRegistrationContractEntry = { toolNames: string[]; }; -const bundledProviderPlugins: RegistrablePlugin[] = [ - amazonBedrockPlugin, - 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" }, @@ -153,10 +95,30 @@ function buildCapabilityContractRegistry(params: { } export const providerContractRegistry: ProviderContractEntry[] = buildCapabilityContractRegistry({ - plugins: bundledProviderPlugins, - select: (captured) => captured.providers, + plugins: [], + select: () => [], }); +const loadedBundledProviderRegistry: ProviderContractEntry[] = resolvePluginProviders({ + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + cache: false, + activate: false, +}) + .filter((provider): provider is ProviderPlugin & { pluginId: string } => + Boolean(provider.pluginId), + ) + .map((provider) => ({ + pluginId: provider.pluginId, + provider, + })); + +providerContractRegistry.splice( + 0, + providerContractRegistry.length, + ...loadedBundledProviderRegistry, +); + export const uniqueProviderContractProviders: ProviderPlugin[] = [ ...new Map(providerContractRegistry.map((entry) => [entry.provider.id, entry.provider])).values(), ]; @@ -234,7 +196,6 @@ export const imageGenerationProviderContractRegistry: ImageGenerationProviderCon const bundledPluginRegistrationList = [ ...new Map( [ - ...bundledProviderPlugins, ...bundledSpeechPlugins, ...bundledMediaUnderstandingPlugins, ...bundledImageGenerationPlugins, @@ -243,18 +204,47 @@ const bundledPluginRegistrationList = [ ).values(), ]; -export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] = - bundledPluginRegistrationList.map((plugin) => { - const captured = captureRegistrations(plugin); - return { - pluginId: plugin.id, - providerIds: captured.providers.map((provider) => provider.id), - speechProviderIds: captured.speechProviders.map((provider) => provider.id), - mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map( - (provider) => provider.id, - ), - imageGenerationProviderIds: captured.imageGenerationProviders.map((provider) => provider.id), - webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id), - toolNames: captured.tools.map((tool) => tool.name), - }; - }); +export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] = [ + ...new Map( + providerContractRegistry.map((entry) => [ + entry.pluginId, + { + pluginId: entry.pluginId, + providerIds: providerContractRegistry + .filter((candidate) => candidate.pluginId === entry.pluginId) + .map((candidate) => candidate.provider.id), + speechProviderIds: [] as string[], + mediaUnderstandingProviderIds: [] as string[], + imageGenerationProviderIds: [] as string[], + webSearchProviderIds: [] as string[], + toolNames: [] as string[], + }, + ]), + ).values(), +]; + +for (const plugin of bundledPluginRegistrationList) { + const captured = captureRegistrations(plugin); + const existing = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === plugin.id); + const next = { + pluginId: plugin.id, + providerIds: captured.providers.map((provider) => provider.id), + speechProviderIds: captured.speechProviders.map((provider) => provider.id), + mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map( + (provider) => provider.id, + ), + imageGenerationProviderIds: captured.imageGenerationProviders.map((provider) => provider.id), + webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id), + toolNames: captured.tools.map((tool) => tool.name), + }; + if (!existing) { + pluginRegistrationContractRegistry.push(next); + continue; + } + existing.providerIds = next.providerIds.length > 0 ? next.providerIds : existing.providerIds; + existing.speechProviderIds = next.speechProviderIds; + existing.mediaUnderstandingProviderIds = next.mediaUnderstandingProviderIds; + existing.imageGenerationProviderIds = next.imageGenerationProviderIds; + existing.webSearchProviderIds = next.webSearchProviderIds; + existing.toolNames = next.toolNames; +} diff --git a/src/plugins/contracts/wizard.contract.test.ts b/src/plugins/contracts/wizard.contract.test.ts index 9af9d21d411..1e0ca6e49be 100644 --- a/src/plugins/contracts/wizard.contract.test.ts +++ b/src/plugins/contracts/wizard.contract.test.ts @@ -8,12 +8,10 @@ vi.mock("../providers.js", () => ({ resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args), })); -const { - buildProviderPluginMethodChoice, - resolveProviderModelPickerEntries, - resolveProviderPluginChoice, - resolveProviderWizardOptions, -} = await import("../provider-wizard.js"); +let buildProviderPluginMethodChoice: typeof import("../provider-wizard.js").buildProviderPluginMethodChoice; +let resolveProviderModelPickerEntries: typeof import("../provider-wizard.js").resolveProviderModelPickerEntries; +let resolveProviderPluginChoice: typeof import("../provider-wizard.js").resolveProviderPluginChoice; +let resolveProviderWizardOptions: typeof import("../provider-wizard.js").resolveProviderWizardOptions; function resolveExpectedWizardChoiceValues(providers: ProviderPlugin[]) { const values: string[] = []; @@ -72,7 +70,14 @@ function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) { } describe("provider wizard contract", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ + buildProviderPluginMethodChoice, + resolveProviderModelPickerEntries, + resolveProviderPluginChoice, + resolveProviderWizardOptions, + } = await import("../provider-wizard.js")); resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders); }); diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index 20b0df72337..fe01ed3beed 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -7,6 +7,8 @@ import type { SessionBindingAdapter, SessionBindingRecord, } from "../infra/outbound/session-binding-service.js"; +import { createEmptyPluginRegistry } from "./registry.js"; +import { setActivePluginRegistry } from "./runtime.js"; const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-binding-")); const approvalsPath = path.join(tempRoot, "plugin-binding-approvals.json"); @@ -141,10 +143,23 @@ async function resolveRequestedBinding(request: PluginBindingRequest) { throw new Error("expected pending or bound bind result"); } +async function flushMicrotasks(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +function createDeferredVoid(): { promise: Promise; resolve: () => void } { + let resolve = () => {}; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + describe("plugin conversation binding approvals", () => { beforeEach(() => { sessionBindingState.reset(); __testing.reset(); + setActivePluginRegistry(createEmptyPluginRegistry()); fs.rmSync(approvalsPath, { force: true }); unregisterSessionBindingAdapter({ channel: "discord", accountId: "default" }); unregisterSessionBindingAdapter({ channel: "discord", accountId: "work" }); @@ -366,6 +381,222 @@ describe("plugin conversation binding approvals", () => { expect(currentBinding?.detachHint).toBe("/codex_detach"); }); + it("notifies the owning plugin when a bind approval is approved", async () => { + const registry = createEmptyPluginRegistry(); + const onResolved = vi.fn(async () => undefined); + registry.conversationBindingResolvedHandlers.push({ + pluginId: "codex", + pluginRoot: "/plugins/callback-test", + handler: onResolved, + source: "/plugins/callback-test/index.ts", + rootDir: "/plugins/callback-test", + }); + setActivePluginRegistry(registry); + + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/callback-test", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:callback-test", + }, + binding: { summary: "Bind this conversation to Codex thread abc." }, + }); + + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + const approved = await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }); + + expect(approved.status).toBe("approved"); + await flushMicrotasks(); + expect(onResolved).toHaveBeenCalledWith({ + status: "approved", + binding: expect.objectContaining({ + pluginId: "codex", + pluginRoot: "/plugins/callback-test", + conversationId: "channel:callback-test", + }), + decision: "allow-once", + request: { + summary: "Bind this conversation to Codex thread abc.", + detachHint: undefined, + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:callback-test", + }, + }, + }); + }); + + it("notifies the owning plugin when a bind approval is denied", async () => { + const registry = createEmptyPluginRegistry(); + const onResolved = vi.fn(async () => undefined); + registry.conversationBindingResolvedHandlers.push({ + pluginId: "codex", + pluginRoot: "/plugins/callback-deny", + handler: onResolved, + source: "/plugins/callback-deny/index.ts", + rootDir: "/plugins/callback-deny", + }); + setActivePluginRegistry(registry); + + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/callback-deny", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + binding: { summary: "Bind this conversation to Codex thread deny." }, + }); + + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + const denied = await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "deny", + senderId: "user-1", + }); + + expect(denied.status).toBe("denied"); + await flushMicrotasks(); + expect(onResolved).toHaveBeenCalledWith({ + status: "denied", + binding: undefined, + decision: "deny", + request: { + summary: "Bind this conversation to Codex thread deny.", + detachHint: undefined, + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + }, + }); + }); + + it("does not wait for an approved bind callback before returning", async () => { + const registry = createEmptyPluginRegistry(); + const callbackGate = createDeferredVoid(); + const onResolved = vi.fn(async () => callbackGate.promise); + registry.conversationBindingResolvedHandlers.push({ + pluginId: "codex", + pluginRoot: "/plugins/callback-slow-approve", + handler: onResolved, + source: "/plugins/callback-slow-approve/index.ts", + rootDir: "/plugins/callback-slow-approve", + }); + setActivePluginRegistry(registry); + + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/callback-slow-approve", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:slow-approve", + }, + binding: { summary: "Bind this conversation to Codex thread slow-approve." }, + }); + + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + let settled = false; + const resolutionPromise = resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }).then((result) => { + settled = true; + return result; + }); + + await flushMicrotasks(); + + expect(settled).toBe(true); + expect(onResolved).toHaveBeenCalledTimes(1); + + callbackGate.resolve(); + const approved = await resolutionPromise; + expect(approved.status).toBe("approved"); + }); + + it("does not wait for a denied bind callback before returning", async () => { + const registry = createEmptyPluginRegistry(); + const callbackGate = createDeferredVoid(); + const onResolved = vi.fn(async () => callbackGate.promise); + registry.conversationBindingResolvedHandlers.push({ + pluginId: "codex", + pluginRoot: "/plugins/callback-slow-deny", + handler: onResolved, + source: "/plugins/callback-slow-deny/index.ts", + rootDir: "/plugins/callback-slow-deny", + }); + setActivePluginRegistry(registry); + + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/callback-slow-deny", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "slow-deny", + }, + binding: { summary: "Bind this conversation to Codex thread slow-deny." }, + }); + + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + let settled = false; + const resolutionPromise = resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "deny", + senderId: "user-1", + }).then((result) => { + settled = true; + return result; + }); + + await flushMicrotasks(); + + expect(settled).toBe(true); + expect(onResolved).toHaveBeenCalledTimes(1); + + callbackGate.resolve(); + const denied = await resolutionPromise; + expect(denied.status).toBe("denied"); + }); + it("returns and detaches only bindings owned by the requesting plugin root", async () => { const request = await requestPluginConversationBinding({ pluginId: "codex", diff --git a/src/plugins/conversation-binding.ts b/src/plugins/conversation-binding.ts index 4b5cb0671da..aef5ec92b40 100644 --- a/src/plugins/conversation-binding.ts +++ b/src/plugins/conversation-binding.ts @@ -2,15 +2,20 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import type { ReplyPayload } from "../auto-reply/types.js"; +import { + createConversationBindingRecord, + resolveConversationBindingRecord, + unbindConversationBindingRecord, +} from "../bindings/records.js"; import { expandHomePrefix } from "../infra/home-dir.js"; import { writeJsonAtomic } from "../infra/json-files.js"; -import { - getSessionBindingService, - type ConversationRef, -} from "../infra/outbound/session-binding-service.js"; +import { type ConversationRef } from "../infra/outbound/session-binding-service.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { getActivePluginRegistry } from "./runtime.js"; import type { PluginConversationBinding, + PluginConversationBindingResolvedEvent, + PluginConversationBindingResolutionDecision, PluginConversationBindingRequestParams, PluginConversationBindingRequestResult, } from "./types.js"; @@ -26,7 +31,9 @@ const LEGACY_CODEX_PLUGIN_SESSION_PREFIXES = [ "openclaw-codex-app-server:thread:", ] as const; -type PluginBindingApprovalDecision = "allow-once" | "allow-always" | "deny"; +// Runtime plugin conversation bindings are approval-driven and distinct from +// configured channel bindings compiled from config. +type PluginBindingApprovalDecision = PluginConversationBindingResolutionDecision; type PluginBindingApprovalEntry = { pluginRoot: string; @@ -87,7 +94,7 @@ type PluginBindingResolveResult = status: "approved"; binding: PluginConversationBinding; request: PendingPluginBindingRequest; - decision: PluginBindingApprovalDecision; + decision: Exclude; } | { status: "denied"; @@ -423,7 +430,7 @@ async function bindConversationNow(params: { accountId: ref.accountId, conversationId: ref.conversationId, }); - const record = await getSessionBindingService().bind({ + const record = await createConversationBindingRecord({ targetSessionKey, targetKind: "session", conversation: ref, @@ -574,7 +581,7 @@ export async function requestPluginConversationBinding(params: { }): Promise { const conversation = normalizeConversation(params.conversation); const ref = toConversationRef(conversation); - const existing = getSessionBindingService().resolveByConversation(ref); + const existing = resolveConversationBindingRecord(ref); const existingPluginBinding = toPluginConversationBinding(existing); const existingLegacyPluginBinding = isLegacyPluginBindingRecord({ record: existing, @@ -665,9 +672,7 @@ export async function getCurrentPluginConversationBinding(params: { pluginRoot: string; conversation: PluginBindingConversation; }): Promise { - const record = getSessionBindingService().resolveByConversation( - toConversationRef(params.conversation), - ); + const record = resolveConversationBindingRecord(toConversationRef(params.conversation)); const binding = toPluginConversationBinding(record); if (!binding || binding.pluginRoot !== params.pluginRoot) { return null; @@ -684,12 +689,12 @@ export async function detachPluginConversationBinding(params: { conversation: PluginBindingConversation; }): Promise<{ removed: boolean }> { const ref = toConversationRef(params.conversation); - const record = getSessionBindingService().resolveByConversation(ref); + const record = resolveConversationBindingRecord(ref); const binding = toPluginConversationBinding(record); if (!binding || binding.pluginRoot !== params.pluginRoot) { return { removed: false }; } - await getSessionBindingService().unbind({ + await unbindConversationBindingRecord({ bindingId: binding.bindingId, reason: "plugin-detach", }); @@ -717,6 +722,11 @@ export async function resolvePluginConversationBindingApproval(params: { } pendingRequests.delete(params.approvalId); if (params.decision === "deny") { + dispatchPluginConversationBindingResolved({ + status: "denied", + decision: "deny", + request, + }); log.info( `plugin binding denied plugin=${request.pluginId} root=${request.pluginRoot} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`, ); @@ -745,6 +755,12 @@ export async function resolvePluginConversationBindingApproval(params: { log.info( `plugin binding approved plugin=${request.pluginId} root=${request.pluginRoot} decision=${params.decision} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`, ); + dispatchPluginConversationBindingResolved({ + status: "approved", + binding, + decision: params.decision, + request, + }); return { status: "approved", binding, @@ -753,6 +769,56 @@ export async function resolvePluginConversationBindingApproval(params: { }; } +function dispatchPluginConversationBindingResolved(params: { + status: "approved" | "denied"; + binding?: PluginConversationBinding; + decision: PluginConversationBindingResolutionDecision; + request: PendingPluginBindingRequest; +}): void { + // Keep platform interaction acks fast even if the plugin does slow post-bind work. + queueMicrotask(() => { + void notifyPluginConversationBindingResolved(params).catch((error) => { + log.warn(`plugin binding resolved dispatch failed: ${String(error)}`); + }); + }); +} + +async function notifyPluginConversationBindingResolved(params: { + status: "approved" | "denied"; + binding?: PluginConversationBinding; + decision: PluginConversationBindingResolutionDecision; + request: PendingPluginBindingRequest; +}): Promise { + const registrations = getActivePluginRegistry()?.conversationBindingResolvedHandlers ?? []; + for (const registration of registrations) { + if (registration.pluginId !== params.request.pluginId) { + continue; + } + const registeredRoot = registration.pluginRoot?.trim(); + if (registeredRoot && registeredRoot !== params.request.pluginRoot) { + continue; + } + try { + const event: PluginConversationBindingResolvedEvent = { + status: params.status, + binding: params.binding, + decision: params.decision, + request: { + summary: params.request.summary, + detachHint: params.request.detachHint, + requestedBySenderId: params.request.requestedBySenderId, + conversation: params.request.conversation, + }, + }; + await registration.handler(event); + } catch (error) { + log.warn( + `plugin binding resolved callback failed plugin=${registration.pluginId} root=${registration.pluginRoot ?? ""}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + export function buildPluginBindingResolvedText(params: PluginBindingResolveResult): string { if (params.status === "expired") { return "That plugin bind approval expired. Retry the bind command."; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 151a1ddaf59..60673ffa67f 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -44,6 +44,7 @@ const { } = await importFreshPluginTestModules(); type TempPlugin = { dir: string; file: string; id: string }; +type PluginLoadConfig = NonNullable[0]>["config"]; function chmodSafeDir(dir: string) { if (process.platform === "win32") { @@ -238,6 +239,22 @@ function loadRegistryFromSinglePlugin(params: { }); } +function loadRegistryFromAllowedPlugins( + plugins: TempPlugin[], + options?: Omit[0], "cache" | "config">, +) { + return loadOpenClawPlugins({ + cache: false, + ...options, + config: { + plugins: { + load: { paths: plugins.map((plugin) => plugin.file) }, + allow: plugins.map((plugin) => plugin.id), + }, + }, + }); +} + function createWarningLogger(warnings: string[]) { return { info: () => {}, @@ -321,6 +338,307 @@ function createPluginRuntimeAliasFixture(params?: { srcBody?: string; distBody?: return { root, srcFile, distFile }; } +function loadBundleFixture(params: { + pluginId: string; + build: (bundleRoot: string) => void; + env?: NodeJS.ProcessEnv; + onlyPluginIds?: string[]; +}) { + useNoBundledPlugins(); + const workspaceDir = makeTempDir(); + const stateDir = makeTempDir(); + const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", params.pluginId); + params.build(bundleRoot); + return withEnv({ OPENCLAW_STATE_DIR: stateDir, ...params.env }, () => + loadOpenClawPlugins({ + workspaceDir, + onlyPluginIds: params.onlyPluginIds ?? [params.pluginId], + config: { + plugins: { + entries: { + [params.pluginId]: { + enabled: true, + }, + }, + }, + }, + cache: false, + }), + ); +} + +function expectNoUnwiredBundleDiagnostic( + registry: ReturnType, + pluginId: string, +) { + expect( + registry.diagnostics.some( + (diag) => + diag.pluginId === pluginId && + diag.message.includes("bundle capability detected but not wired"), + ), + ).toBe(false); +} + +function resolveLoadedPluginSource( + registry: ReturnType, + pluginId: string, +) { + return fs.realpathSync(registry.plugins.find((entry) => entry.id === pluginId)?.source ?? ""); +} + +function expectCachePartitionByPluginSource(params: { + pluginId: string; + loadFirst: () => ReturnType; + loadSecond: () => ReturnType; + expectedFirstSource: string; + expectedSecondSource: string; +}) { + const first = params.loadFirst(); + const second = params.loadSecond(); + + expect(second).not.toBe(first); + expect(resolveLoadedPluginSource(first, params.pluginId)).toBe( + fs.realpathSync(params.expectedFirstSource), + ); + expect(resolveLoadedPluginSource(second, params.pluginId)).toBe( + fs.realpathSync(params.expectedSecondSource), + ); +} + +function expectCacheMissThenHit(params: { + loadFirst: () => ReturnType; + loadVariant: () => ReturnType; +}) { + const first = params.loadFirst(); + const second = params.loadVariant(); + const third = params.loadVariant(); + + expect(second).not.toBe(first); + expect(third).toBe(second); +} + +function createSetupEntryChannelPluginFixture(params: { + id: string; + label: string; + packageName: string; + fullBlurb: string; + setupBlurb: string; + configured: boolean; + startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean; +}) { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const setupMarker = path.join(pluginDir, "setup-loaded.txt"); + const listAccountIds = params.configured ? '["default"]' : "[]"; + const resolveAccount = params.configured + ? '({ accountId: "default", token: "configured" })' + : '({ accountId: "default" })'; + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: params.packageName, + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + ...(params.startupDeferConfiguredChannelFullLoadUntilAfterListen + ? { + startup: { + deferConfiguredChannelFullLoadUntilAfterListen: true, + }, + } + : {}), + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: params.id, + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: [params.id], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: ${JSON.stringify(params.id)}, + register(api) { + api.registerChannel({ + plugin: { + id: ${JSON.stringify(params.id)}, + meta: { + id: ${JSON.stringify(params.id)}, + label: ${JSON.stringify(params.label)}, + selectionLabel: ${JSON.stringify(params.label)}, + docsPath: ${JSON.stringify(`/channels/${params.id}`)}, + blurb: ${JSON.stringify(params.fullBlurb)}, + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ${listAccountIds}, + resolveAccount: () => ${resolveAccount}, + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: ${JSON.stringify(params.id)}, + meta: { + id: ${JSON.stringify(params.id)}, + label: ${JSON.stringify(params.label)}, + selectionLabel: ${JSON.stringify(params.label)}, + docsPath: ${JSON.stringify(`/channels/${params.id}`)}, + blurb: ${JSON.stringify(params.setupBlurb)}, + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ${listAccountIds}, + resolveAccount: () => ${resolveAccount}, + }, + outbound: { deliveryMode: "direct" }, + }, +};`, + "utf-8", + ); + + return { pluginDir, fullMarker, setupMarker }; +} + +function createEnvResolvedPluginFixture(pluginId: string) { + useNoBundledPlugins(); + const openclawHome = makeTempDir(); + const ignoredHome = makeTempDir(); + const stateDir = makeTempDir(); + const pluginDir = path.join(openclawHome, "plugins", pluginId); + mkdirSafe(pluginDir); + const plugin = writePlugin({ + id: pluginId, + dir: pluginDir, + filename: "index.cjs", + body: `module.exports = { id: ${JSON.stringify(pluginId)}, register() {} };`, + }); + const env = { + ...process.env, + OPENCLAW_HOME: openclawHome, + HOME: ignoredHome, + OPENCLAW_STATE_DIR: stateDir, + CLAWDBOT_STATE_DIR: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }; + return { plugin, env }; +} + +function expectEscapingEntryRejected(params: { + id: string; + linkKind: "symlink" | "hardlink"; + sourceBody: string; +}) { + useNoBundledPlugins(); + const { outsideEntry, linkedEntry } = createEscapingEntryFixture({ + id: params.id, + sourceBody: params.sourceBody, + }); + try { + if (params.linkKind === "symlink") { + fs.symlinkSync(outsideEntry, linkedEntry); + } else { + fs.linkSync(outsideEntry, linkedEntry); + } + } catch (err) { + if (params.linkKind === "hardlink" && (err as NodeJS.ErrnoException).code === "EXDEV") { + return undefined; + } + if (params.linkKind === "symlink") { + return undefined; + } + throw err; + } + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [linkedEntry] }, + allow: [params.id], + }, + }, + }); + + const record = registry.plugins.find((entry) => entry.id === params.id); + expect(record?.status).not.toBe("loaded"); + expect(registry.diagnostics.some((entry) => entry.message.includes("escapes"))).toBe(true); + return registry; +} + +function resolvePluginSdkAlias(params: { + root: string; + srcFile: string; + distFile: string; + modulePath: string; + argv1?: string; + env?: NodeJS.ProcessEnv; +}) { + const run = () => + __testing.resolvePluginSdkAliasFile({ + srcFile: params.srcFile, + distFile: params.distFile, + modulePath: params.modulePath, + argv1: params.argv1, + }); + return params.env ? withEnv(params.env, run) : run(); +} + +function listPluginSdkAliasCandidates(params: { + root: string; + srcFile: string; + distFile: string; + modulePath: string; + env?: NodeJS.ProcessEnv; +}) { + const run = () => + __testing.listPluginSdkAliasCandidates({ + srcFile: params.srcFile, + distFile: params.distFile, + modulePath: params.modulePath, + }); + return params.env ? withEnv(params.env, run) : run(); +} + +function resolvePluginRuntimeModule(params: { + modulePath: string; + argv1?: string; + env?: NodeJS.ProcessEnv; +}) { + const run = () => + __testing.resolvePluginRuntimeModulePath({ + modulePath: params.modulePath, + argv1: params.argv1, + }); + return params.env ? withEnv(params.env, run) : run(); +} + afterEach(() => { clearPluginLoaderCache(); if (prevBundledDir === undefined) { @@ -376,147 +694,113 @@ describe("bundle plugins", () => { expect(plugin?.bundleCapabilities).toContain("skills"); }); - it("treats Claude command roots and settings as supported bundle surfaces", () => { - useNoBundledPlugins(); - const workspaceDir = makeTempDir(); - const stateDir = makeTempDir(); - const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-skills"); - mkdirSafe(path.join(bundleRoot, "commands")); - fs.writeFileSync( - path.join(bundleRoot, "commands", "review.md"), - "---\ndescription: fixture\n---\n", - ); - fs.writeFileSync(path.join(bundleRoot, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); - - const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => - loadOpenClawPlugins({ - workspaceDir, - onlyPluginIds: ["claude-skills"], - config: { - plugins: { - entries: { - "claude-skills": { - enabled: true, + it.each([ + { + name: "treats Claude command roots and settings as supported bundle surfaces", + pluginId: "claude-skills", + expectedFormat: "claude", + expectedCapabilities: ["skills", "commands", "settings"], + build: (bundleRoot: string) => { + mkdirSafe(path.join(bundleRoot, "commands")); + fs.writeFileSync( + path.join(bundleRoot, "commands", "review.md"), + "---\ndescription: fixture\n---\n", + ); + fs.writeFileSync( + path.join(bundleRoot, "settings.json"), + '{"hideThinkingBlock":true}', + "utf-8", + ); + }, + }, + { + name: "treats bundle MCP as a supported bundle surface", + pluginId: "claude-mcp", + expectedFormat: "claude", + expectedCapabilities: ["mcpServers"], + build: (bundleRoot: string) => { + mkdirSafe(path.join(bundleRoot, ".claude-plugin")); + fs.writeFileSync( + path.join(bundleRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: "Claude MCP", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(bundleRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + probe: { + command: "node", + args: ["./probe.mjs"], }, }, - }, - }, - cache: false, - }), - ); - - const plugin = registry.plugins.find((entry) => entry.id === "claude-skills"); - expect(plugin?.status).toBe("loaded"); - expect(plugin?.bundleFormat).toBe("claude"); - expect(plugin?.bundleCapabilities).toEqual( - expect.arrayContaining(["skills", "commands", "settings"]), - ); - expect( - registry.diagnostics.some( - (diag) => - diag.pluginId === "claude-skills" && - diag.message.includes("bundle capability detected but not wired"), - ), - ).toBe(false); - }); - - it("treats bundle MCP as a supported bundle surface", () => { - useNoBundledPlugins(); - const workspaceDir = makeTempDir(); - const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-mcp"); - mkdirSafe(path.join(bundleRoot, ".claude-plugin")); - fs.writeFileSync( - path.join(bundleRoot, ".claude-plugin", "plugin.json"), - JSON.stringify({ - name: "Claude MCP", - }), - "utf-8", - ); - fs.writeFileSync( - path.join(bundleRoot, ".mcp.json"), - JSON.stringify({ - mcpServers: { - probe: { - command: "node", - args: ["./probe.mjs"], - }, - }, - }), - "utf-8", - ); - - const registry = loadOpenClawPlugins({ - workspaceDir, - config: { - plugins: { - entries: { - "claude-mcp": { - enabled: true, - }, - }, - }, + }), + "utf-8", + ); }, - cache: false, - }); + }, + { + name: "treats Cursor command roots as supported bundle skill surfaces", + pluginId: "cursor-skills", + expectedFormat: "cursor", + expectedCapabilities: ["skills", "commands"], + build: (bundleRoot: string) => { + mkdirSafe(path.join(bundleRoot, ".cursor-plugin")); + mkdirSafe(path.join(bundleRoot, ".cursor", "commands")); + fs.writeFileSync( + path.join(bundleRoot, ".cursor-plugin", "plugin.json"), + JSON.stringify({ + name: "Cursor Skills", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(bundleRoot, ".cursor", "commands", "review.md"), + "---\ndescription: fixture\n---\n", + ); + }, + }, + ])("$name", ({ pluginId, expectedFormat, expectedCapabilities, build }) => { + const registry = loadBundleFixture({ pluginId, build }); + const plugin = registry.plugins.find((entry) => entry.id === pluginId); - const plugin = registry.plugins.find((entry) => entry.id === "claude-mcp"); expect(plugin?.status).toBe("loaded"); - expect(plugin?.bundleFormat).toBe("claude"); - expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(["mcpServers"])); - expect( - registry.diagnostics.some( - (diag) => - diag.pluginId === "claude-mcp" && - diag.message.includes("bundle capability detected but not wired"), - ), - ).toBe(false); + expect(plugin?.bundleFormat).toBe(expectedFormat); + expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(expectedCapabilities)); + expectNoUnwiredBundleDiagnostic(registry, pluginId); }); it("warns when bundle MCP only declares unsupported non-stdio transports", () => { - useNoBundledPlugins(); - const workspaceDir = makeTempDir(); const stateDir = makeTempDir(); - const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-mcp-url"); - fs.mkdirSync(path.join(bundleRoot, ".claude-plugin"), { recursive: true }); - fs.writeFileSync( - path.join(bundleRoot, ".claude-plugin", "plugin.json"), - JSON.stringify({ - name: "Claude MCP URL", - }), - "utf-8", - ); - fs.writeFileSync( - path.join(bundleRoot, ".mcp.json"), - JSON.stringify({ - mcpServers: { - remoteProbe: { - url: "http://127.0.0.1:8787/mcp", - }, - }, - }), - "utf-8", - ); - - const registry = withEnv( - { + const registry = loadBundleFixture({ + pluginId: "claude-mcp-url", + env: { OPENCLAW_HOME: stateDir, - OPENCLAW_STATE_DIR: stateDir, }, - () => - loadOpenClawPlugins({ - workspaceDir, - config: { - plugins: { - entries: { - "claude-mcp-url": { - enabled: true, - }, + build: (bundleRoot) => { + mkdirSafe(path.join(bundleRoot, ".claude-plugin")); + fs.writeFileSync( + path.join(bundleRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: "Claude MCP URL", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(bundleRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + remoteProbe: { + url: "http://127.0.0.1:8787/mcp", }, }, - }, - cache: false, - }), - ); + }), + "utf-8", + ); + }, + }); const plugin = registry.plugins.find((entry) => entry.id === "claude-mcp-url"); expect(plugin?.status).toBe("loaded"); @@ -530,55 +814,6 @@ describe("bundle plugins", () => { ), ).toBe(true); }); - - it("treats Cursor command roots as supported bundle skill surfaces", () => { - useNoBundledPlugins(); - const workspaceDir = makeTempDir(); - const stateDir = makeTempDir(); - const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "cursor-skills"); - mkdirSafe(path.join(bundleRoot, ".cursor-plugin")); - mkdirSafe(path.join(bundleRoot, ".cursor", "commands")); - fs.writeFileSync( - path.join(bundleRoot, ".cursor-plugin", "plugin.json"), - JSON.stringify({ - name: "Cursor Skills", - }), - "utf-8", - ); - fs.writeFileSync( - path.join(bundleRoot, ".cursor", "commands", "review.md"), - "---\ndescription: fixture\n---\n", - ); - - const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => - loadOpenClawPlugins({ - workspaceDir, - onlyPluginIds: ["cursor-skills"], - config: { - plugins: { - entries: { - "cursor-skills": { - enabled: true, - }, - }, - }, - }, - cache: false, - }), - ); - - const plugin = registry.plugins.find((entry) => entry.id === "cursor-skills"); - expect(plugin?.status).toBe("loaded"); - expect(plugin?.bundleFormat).toBe("cursor"); - expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(["skills", "commands"])); - expect( - registry.diagnostics.some( - (diag) => - diag.pluginId === "cursor-skills" && - diag.message.includes("bundle capability detected but not wired"), - ), - ).toBe(false); - }); }); afterAll(() => { @@ -616,69 +851,69 @@ describe("loadOpenClawPlugins", () => { expect(bundled?.status).toBe("disabled"); }); - it("loads bundled telegram plugin when enabled", () => { + it("handles bundled telegram plugin enablement and override rules", () => { setupBundledTelegramPlugin(); - - const registry = loadOpenClawPlugins({ - cache: false, - workspaceDir: cachedBundledTelegramDir, - config: { - plugins: { - allow: ["telegram"], - entries: { - telegram: { enabled: true }, + const cases = [ + { + name: "loads bundled telegram plugin when enabled", + config: { + plugins: { + allow: ["telegram"], + entries: { + telegram: { enabled: true }, + }, }, + } satisfies PluginLoadConfig, + assert: (registry: ReturnType) => { + expectTelegramLoaded(registry); }, }, - }); - - expectTelegramLoaded(registry); - }); - - it("loads bundled channel plugins when channels..enabled=true", () => { - setupBundledTelegramPlugin(); - - const registry = loadOpenClawPlugins({ - cache: false, - workspaceDir: cachedBundledTelegramDir, - config: { - channels: { - telegram: { + { + name: "loads bundled channel plugins when channels..enabled=true", + config: { + channels: { + telegram: { + enabled: true, + }, + }, + plugins: { enabled: true, }, - }, - plugins: { - enabled: true, + } satisfies PluginLoadConfig, + assert: (registry: ReturnType) => { + expectTelegramLoaded(registry); }, }, - }); - - expectTelegramLoaded(registry); - }); - - it("still respects explicit disable via plugins.entries for bundled channels", () => { - setupBundledTelegramPlugin(); - - const registry = loadOpenClawPlugins({ - cache: false, - workspaceDir: cachedBundledTelegramDir, - config: { - channels: { - telegram: { - enabled: true, + { + name: "still respects explicit disable via plugins.entries for bundled channels", + config: { + channels: { + telegram: { + enabled: true, + }, }, - }, - plugins: { - entries: { - telegram: { enabled: false }, + plugins: { + entries: { + telegram: { enabled: false }, + }, }, + } satisfies PluginLoadConfig, + assert: (registry: ReturnType) => { + const telegram = registry.plugins.find((entry) => entry.id === "telegram"); + expect(telegram?.status).toBe("disabled"); + expect(telegram?.error).toBe("disabled in config"); }, }, - }); + ] as const; - const telegram = registry.plugins.find((entry) => entry.id === "telegram"); - expect(telegram?.status).toBe("disabled"); - expect(telegram?.error).toBe("disabled in config"); + for (const testCase of cases) { + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: cachedBundledTelegramDir, + config: testCase.config, + }); + testCase.assert(registry); + } }); it("preserves package.json metadata for bundled memory plugins", () => { @@ -698,130 +933,150 @@ describe("loadOpenClawPlugins", () => { expect(memory?.name).toBe("Memory (Core)"); expect(memory?.version).toBe("1.2.3"); }); - it("loads plugins from config paths", () => { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; - const plugin = writePlugin({ - id: "allowed", - filename: "allowed.cjs", - body: `module.exports = { - id: "allowed", + it("handles config-path and scoped plugin loads", () => { + const scenarios = [ + { + label: "loads plugins from config paths", + run: () => { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; + const plugin = writePlugin({ + id: "allowed-config-path", + filename: "allowed-config-path.cjs", + body: `module.exports = { + id: "allowed-config-path", register(api) { - api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true })); + api.registerGatewayMethod("allowed-config-path.ping", ({ respond }) => respond(true, { ok: true })); }, };`, - }); + }); - const registry = loadOpenClawPlugins({ - cache: false, - workspaceDir: plugin.dir, - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: ["allowed"], + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["allowed-config-path"], + }, + }, + }); + + const loaded = registry.plugins.find((entry) => entry.id === "allowed-config-path"); + expect(loaded?.status).toBe("loaded"); + expect(Object.keys(registry.gatewayHandlers)).toContain("allowed-config-path.ping"); }, }, - }); + { + label: "limits imports to the requested plugin ids", + run: () => { + useNoBundledPlugins(); + const allowed = writePlugin({ + id: "allowed-scoped-only", + filename: "allowed-scoped-only.cjs", + body: `module.exports = { id: "allowed-scoped-only", register() {} };`, + }); + const skippedMarker = path.join(makeTempDir(), "skipped-loaded.txt"); + const skipped = writePlugin({ + id: "skipped-scoped-only", + filename: "skipped-scoped-only.cjs", + body: `require("node:fs").writeFileSync(${JSON.stringify(skippedMarker)}, "loaded", "utf-8"); +module.exports = { id: "skipped-scoped-only", register() { throw new Error("skipped plugin should not load"); } };`, + }); - const loaded = registry.plugins.find((entry) => entry.id === "allowed"); - expect(loaded?.status).toBe("loaded"); - expect(Object.keys(registry.gatewayHandlers)).toContain("allowed.ping"); - }); + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [allowed.file, skipped.file] }, + allow: ["allowed-scoped-only", "skipped-scoped-only"], + }, + }, + onlyPluginIds: ["allowed-scoped-only"], + }); - it("limits imports to the requested plugin ids", () => { - useNoBundledPlugins(); - const allowed = writePlugin({ - id: "allowed", - filename: "allowed.cjs", - body: `module.exports = { id: "allowed", register() {} };`, - }); - const skippedMarker = path.join(makeTempDir(), "skipped-loaded.txt"); - const skipped = writePlugin({ - id: "skipped", - filename: "skipped.cjs", - body: `require("node:fs").writeFileSync(${JSON.stringify(skippedMarker)}, "loaded", "utf-8"); -module.exports = { id: "skipped", register() { throw new Error("skipped plugin should not load"); } };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [allowed.file, skipped.file] }, - allow: ["allowed", "skipped"], + expect(registry.plugins.map((entry) => entry.id)).toEqual(["allowed-scoped-only"]); + expect(fs.existsSync(skippedMarker)).toBe(false); }, }, - onlyPluginIds: ["allowed"], - }); + { + label: "keeps scoped plugin loads in a separate cache entry", + run: () => { + useNoBundledPlugins(); + const allowed = writePlugin({ + id: "allowed-cache-scope", + filename: "allowed-cache-scope.cjs", + body: `module.exports = { id: "allowed-cache-scope", register() {} };`, + }); + const extra = writePlugin({ + id: "extra-cache-scope", + filename: "extra-cache-scope.cjs", + body: `module.exports = { id: "extra-cache-scope", register() {} };`, + }); + const options = { + config: { + plugins: { + load: { paths: [allowed.file, extra.file] }, + allow: ["allowed-cache-scope", "extra-cache-scope"], + }, + }, + }; - expect(registry.plugins.map((entry) => entry.id)).toEqual(["allowed"]); - expect(fs.existsSync(skippedMarker)).toBe(false); - }); + const full = loadOpenClawPlugins(options); + const scoped = loadOpenClawPlugins({ + ...options, + onlyPluginIds: ["allowed-cache-scope"], + }); + const scopedAgain = loadOpenClawPlugins({ + ...options, + onlyPluginIds: ["allowed-cache-scope"], + }); - it("keeps scoped plugin loads in a separate cache entry", () => { - useNoBundledPlugins(); - const allowed = writePlugin({ - id: "allowed", - filename: "allowed.cjs", - body: `module.exports = { id: "allowed", register() {} };`, - }); - const extra = writePlugin({ - id: "extra", - filename: "extra.cjs", - body: `module.exports = { id: "extra", register() {} };`, - }); - const options = { - config: { - plugins: { - load: { paths: [allowed.file, extra.file] }, - allow: ["allowed", "extra"], + expect(full.plugins.map((entry) => entry.id).toSorted()).toEqual([ + "allowed-cache-scope", + "extra-cache-scope", + ]); + expect(scoped).not.toBe(full); + expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed-cache-scope"]); + expect(scopedAgain).toBe(scoped); }, }, - }; + { + label: "can load a scoped registry without replacing the active global registry", + run: () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "allowed-nonactivating-scope", + filename: "allowed-nonactivating-scope.cjs", + body: `module.exports = { id: "allowed-nonactivating-scope", register() {} };`, + }); + const previousRegistry = createEmptyPluginRegistry(); + setActivePluginRegistry(previousRegistry, "existing-registry"); + resetGlobalHookRunner(); - const full = loadOpenClawPlugins(options); - const scoped = loadOpenClawPlugins({ - ...options, - onlyPluginIds: ["allowed"], - }); - const scopedAgain = loadOpenClawPlugins({ - ...options, - onlyPluginIds: ["allowed"], - }); + const scoped = loadOpenClawPlugins({ + cache: false, + activate: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["allowed-nonactivating-scope"], + }, + }, + onlyPluginIds: ["allowed-nonactivating-scope"], + }); - expect(full.plugins.map((entry) => entry.id).toSorted()).toEqual(["allowed", "extra"]); - expect(scoped).not.toBe(full); - expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed"]); - expect(scopedAgain).toBe(scoped); - }); - - it("can load a scoped registry without replacing the active global registry", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "allowed", - filename: "allowed.cjs", - body: `module.exports = { id: "allowed", register() {} };`, - }); - const previousRegistry = createEmptyPluginRegistry(); - setActivePluginRegistry(previousRegistry, "existing-registry"); - resetGlobalHookRunner(); - - const scoped = loadOpenClawPlugins({ - cache: false, - activate: false, - workspaceDir: plugin.dir, - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: ["allowed"], + expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed-nonactivating-scope"]); + expect(getActivePluginRegistry()).toBe(previousRegistry); + expect(getActivePluginRegistryKey()).toBe("existing-registry"); + expect(getGlobalHookRunner()).toBeNull(); }, }, - onlyPluginIds: ["allowed"], - }); + ] as const; - expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed"]); - expect(getActivePluginRegistry()).toBe(previousRegistry); - expect(getActivePluginRegistryKey()).toBe("existing-registry"); - expect(getGlobalHookRunner()).toBeNull(); + for (const scenario of scenarios) { + scenario.run(); + } }); it("only publishes plugin commands to the global registry during activating loads", async () => { @@ -926,215 +1181,231 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s resetGlobalHookRunner(); }); - it("does not reuse cached bundled plugin registries across env changes", () => { - const bundledA = makeTempDir(); - const bundledB = makeTempDir(); - const pluginA = writePlugin({ - id: "cache-root", - dir: path.join(bundledA, "cache-root"), - filename: "index.cjs", - body: `module.exports = { id: "cache-root", register() {} };`, - }); - const pluginB = writePlugin({ - id: "cache-root", - dir: path.join(bundledB, "cache-root"), - filename: "index.cjs", - body: `module.exports = { id: "cache-root", register() {} };`, - }); + it.each([ + { + name: "does not reuse cached bundled plugin registries across env changes", + pluginId: "cache-root", + setup: () => { + const bundledA = makeTempDir(); + const bundledB = makeTempDir(); + const pluginA = writePlugin({ + id: "cache-root", + dir: path.join(bundledA, "cache-root"), + filename: "index.cjs", + body: `module.exports = { id: "cache-root", register() {} };`, + }); + const pluginB = writePlugin({ + id: "cache-root", + dir: path.join(bundledB, "cache-root"), + filename: "index.cjs", + body: `module.exports = { id: "cache-root", register() {} };`, + }); - const options = { - config: { - plugins: { - allow: ["cache-root"], - entries: { - "cache-root": { enabled: true }, - }, - }, - }, - }; - - const first = loadOpenClawPlugins({ - ...options, - env: { - ...process.env, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledA, - }, - }); - const second = loadOpenClawPlugins({ - ...options, - env: { - ...process.env, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledB, - }, - }); - - expect(second).not.toBe(first); - expect( - fs.realpathSync(first.plugins.find((entry) => entry.id === "cache-root")?.source ?? ""), - ).toBe(fs.realpathSync(pluginA.file)); - expect( - fs.realpathSync(second.plugins.find((entry) => entry.id === "cache-root")?.source ?? ""), - ).toBe(fs.realpathSync(pluginB.file)); - }); - - it("does not reuse cached load-path plugin registries across env home changes", () => { - const homeA = makeTempDir(); - const homeB = makeTempDir(); - const stateDir = makeTempDir(); - const bundledDir = makeTempDir(); - const pluginA = writePlugin({ - id: "demo", - dir: path.join(homeA, "plugins", "demo"), - filename: "index.cjs", - body: `module.exports = { id: "demo", register() {} };`, - }); - const pluginB = writePlugin({ - id: "demo", - dir: path.join(homeB, "plugins", "demo"), - filename: "index.cjs", - body: `module.exports = { id: "demo", register() {} };`, - }); - - const options = { - config: { - plugins: { - allow: ["demo"], - entries: { - demo: { enabled: true }, - }, - load: { - paths: ["~/plugins/demo"], - }, - }, - }, - }; - - const first = loadOpenClawPlugins({ - ...options, - env: { - ...process.env, - HOME: homeA, - OPENCLAW_HOME: undefined, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, - }, - }); - const second = loadOpenClawPlugins({ - ...options, - env: { - ...process.env, - HOME: homeB, - OPENCLAW_HOME: undefined, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, - }, - }); - - expect(second).not.toBe(first); - expect(fs.realpathSync(first.plugins.find((entry) => entry.id === "demo")?.source ?? "")).toBe( - fs.realpathSync(pluginA.file), - ); - expect(fs.realpathSync(second.plugins.find((entry) => entry.id === "demo")?.source ?? "")).toBe( - fs.realpathSync(pluginB.file), - ); - }); - - it("does not reuse cached registries when env-resolved install paths change", () => { - useNoBundledPlugins(); - const openclawHome = makeTempDir(); - const ignoredHome = makeTempDir(); - const stateDir = makeTempDir(); - const pluginDir = path.join(openclawHome, "plugins", "tracked-install-cache"); - mkdirSafe(pluginDir); - const plugin = writePlugin({ - id: "tracked-install-cache", - dir: pluginDir, - filename: "index.cjs", - body: `module.exports = { id: "tracked-install-cache", register() {} };`, - }); - - const options = { - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: ["tracked-install-cache"], - installs: { - "tracked-install-cache": { - source: "path" as const, - installPath: "~/plugins/tracked-install-cache", - sourcePath: "~/plugins/tracked-install-cache", + const options = { + config: { + plugins: { + allow: ["cache-root"], + entries: { + "cache-root": { enabled: true }, + }, }, }, - }, - }, - }; + }; - const first = loadOpenClawPlugins({ - ...options, - env: { - ...process.env, - OPENCLAW_HOME: openclawHome, - HOME: ignoredHome, - OPENCLAW_STATE_DIR: stateDir, - CLAWDBOT_STATE_DIR: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + return { + expectedFirstSource: pluginA.file, + expectedSecondSource: pluginB.file, + loadFirst: () => + loadOpenClawPlugins({ + ...options, + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledA, + }, + }), + loadSecond: () => + loadOpenClawPlugins({ + ...options, + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledB, + }, + }), + }; }, + }, + { + name: "does not reuse cached load-path plugin registries across env home changes", + pluginId: "demo", + setup: () => { + const homeA = makeTempDir(); + const homeB = makeTempDir(); + const stateDir = makeTempDir(); + const bundledDir = makeTempDir(); + const pluginA = writePlugin({ + id: "demo", + dir: path.join(homeA, "plugins", "demo"), + filename: "index.cjs", + body: `module.exports = { id: "demo", register() {} };`, + }); + const pluginB = writePlugin({ + id: "demo", + dir: path.join(homeB, "plugins", "demo"), + filename: "index.cjs", + body: `module.exports = { id: "demo", register() {} };`, + }); + + const options = { + config: { + plugins: { + allow: ["demo"], + entries: { + demo: { enabled: true }, + }, + load: { + paths: ["~/plugins/demo"], + }, + }, + }, + }; + + return { + expectedFirstSource: pluginA.file, + expectedSecondSource: pluginB.file, + loadFirst: () => + loadOpenClawPlugins({ + ...options, + env: { + ...process.env, + HOME: homeA, + OPENCLAW_HOME: undefined, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + }, + }), + loadSecond: () => + loadOpenClawPlugins({ + ...options, + env: { + ...process.env, + HOME: homeB, + OPENCLAW_HOME: undefined, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + }, + }), + }; + }, + }, + ])("$name", ({ pluginId, setup }) => { + const { expectedFirstSource, expectedSecondSource, loadFirst, loadSecond } = setup(); + expectCachePartitionByPluginSource({ + pluginId, + loadFirst, + loadSecond, + expectedFirstSource, + expectedSecondSource, }); - const secondHome = makeTempDir(); - const secondOptions = { - ...options, - env: { - ...process.env, - OPENCLAW_HOME: secondHome, - HOME: ignoredHome, - OPENCLAW_STATE_DIR: stateDir, - CLAWDBOT_STATE_DIR: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", - }, - }; - const second = loadOpenClawPlugins(secondOptions); - const third = loadOpenClawPlugins(secondOptions); - - expect(second).not.toBe(first); - expect(third).toBe(second); }); - it("does not reuse cached registries across gateway subagent binding modes", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "cache-gateway-bindable", - filename: "cache-gateway-bindable.cjs", - body: `module.exports = { id: "cache-gateway-bindable", register() {} };`, - }); + it.each([ + { + name: "does not reuse cached registries when env-resolved install paths change", + setup: () => { + useNoBundledPlugins(); + const openclawHome = makeTempDir(); + const ignoredHome = makeTempDir(); + const stateDir = makeTempDir(); + const pluginDir = path.join(openclawHome, "plugins", "tracked-install-cache"); + mkdirSafe(pluginDir); + const plugin = writePlugin({ + id: "tracked-install-cache", + dir: pluginDir, + filename: "index.cjs", + body: `module.exports = { id: "tracked-install-cache", register() {} };`, + }); - const options = { - workspaceDir: plugin.dir, - config: { - plugins: { - allow: ["cache-gateway-bindable"], - load: { - paths: [plugin.file], + const options = { + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["tracked-install-cache"], + installs: { + "tracked-install-cache": { + source: "path" as const, + installPath: "~/plugins/tracked-install-cache", + sourcePath: "~/plugins/tracked-install-cache", + }, + }, + }, }, - }, - }, - }; + }; - const defaultRegistry = loadOpenClawPlugins(options); - const gatewayBindableRegistry = loadOpenClawPlugins({ - ...options, - runtimeOptions: { - allowGatewaySubagentBinding: true, + const secondHome = makeTempDir(); + return { + loadFirst: () => + loadOpenClawPlugins({ + ...options, + env: { + ...process.env, + OPENCLAW_HOME: openclawHome, + HOME: ignoredHome, + OPENCLAW_STATE_DIR: stateDir, + CLAWDBOT_STATE_DIR: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + }), + loadVariant: () => + loadOpenClawPlugins({ + ...options, + env: { + ...process.env, + OPENCLAW_HOME: secondHome, + HOME: ignoredHome, + OPENCLAW_STATE_DIR: stateDir, + CLAWDBOT_STATE_DIR: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + }), + }; }, - }); - const gatewayBindableAgain = loadOpenClawPlugins({ - ...options, - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, - }); + }, + { + name: "does not reuse cached registries across gateway subagent binding modes", + setup: () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "cache-gateway-bindable", + filename: "cache-gateway-bindable.cjs", + body: `module.exports = { id: "cache-gateway-bindable", register() {} };`, + }); - expect(gatewayBindableRegistry).not.toBe(defaultRegistry); - expect(gatewayBindableAgain).toBe(gatewayBindableRegistry); + const options = { + workspaceDir: plugin.dir, + config: { + plugins: { + allow: ["cache-gateway-bindable"], + load: { + paths: [plugin.file], + }, + }, + }, + }; + + return { + loadFirst: () => loadOpenClawPlugins(options), + loadVariant: () => + loadOpenClawPlugins({ + ...options, + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }), + }; + }, + }, + ])("$name", ({ setup }) => { + expectCacheMissThenHit(setup()); }); it("evicts least recently used registries when the loader cache exceeds its cap", () => { @@ -1348,12 +1619,13 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s ).toBe(true); }); - it("registers channel plugins", () => { + it("handles single-plugin channel, context engine, and cli validation", () => { useNoBundledPlugins(); - const plugin = writePlugin({ - id: "channel-demo", - filename: "channel-demo.cjs", - body: `module.exports = { id: "channel-demo", register(api) { + const scenarios = [ + { + label: "registers channel plugins", + pluginId: "channel-demo", + body: `module.exports = { id: "channel-demo", register(api) { api.registerChannel({ plugin: { id: "demo", @@ -1373,25 +1645,15 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s } }); } };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["channel-demo"], + assert: (registry: ReturnType) => { + const channel = registry.channels.find((entry) => entry.plugin.id === "demo"); + expect(channel).toBeDefined(); + }, }, - }); - - const channel = registry.channels.find((entry) => entry.plugin.id === "demo"); - expect(channel).toBeDefined(); - }); - - it("rejects duplicate channel ids during plugin registration", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "channel-dup", - filename: "channel-dup.cjs", - body: `module.exports = { id: "channel-dup", register(api) { + { + label: "rejects duplicate channel ids during plugin registration", + pluginId: "channel-dup", + body: `module.exports = { id: "channel-dup", register(api) { api.registerChannel({ plugin: { id: "demo", @@ -1429,293 +1691,202 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s } }); } };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["channel-dup"], - }, - }); - - expect(registry.channels.filter((entry) => entry.plugin.id === "demo")).toHaveLength(1); - expect( - registry.diagnostics.some( - (entry) => - entry.level === "error" && - entry.pluginId === "channel-dup" && - entry.message === "channel already registered: demo (channel-dup)", - ), - ).toBe(true); - }); - - it("registers http routes with auth and match options", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "http-demo", - filename: "http-demo.cjs", - body: `module.exports = { id: "http-demo", register(api) { - api.registerHttpRoute({ - path: "/webhook", - auth: "plugin", - match: "prefix", - handler: async () => false - }); -} };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["http-demo"], - }, - }); - - const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-demo"); - expect(route).toBeDefined(); - expect(route?.path).toBe("/webhook"); - expect(route?.auth).toBe("plugin"); - expect(route?.match).toBe("prefix"); - const httpPlugin = registry.plugins.find((entry) => entry.id === "http-demo"); - expect(httpPlugin?.httpRoutes).toBe(1); - }); - - it("rejects duplicate plugin-visible hook names", () => { - useNoBundledPlugins(); - const first = writePlugin({ - id: "hook-owner-a", - filename: "hook-owner-a.cjs", - body: `module.exports = { id: "hook-owner-a", register(api) { - api.registerHook("gateway:startup", () => {}, { name: "shared-hook" }); -} };`, - }); - const second = writePlugin({ - id: "hook-owner-b", - filename: "hook-owner-b.cjs", - body: `module.exports = { id: "hook-owner-b", register(api) { - api.registerHook("gateway:startup", () => {}, { name: "shared-hook" }); -} };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [first.file, second.file] }, - allow: ["hook-owner-a", "hook-owner-b"], + assert: (registry: ReturnType) => { + expect(registry.channels.filter((entry) => entry.plugin.id === "demo")).toHaveLength(1); + expect( + registry.diagnostics.some( + (entry) => + entry.level === "error" && + entry.pluginId === "channel-dup" && + entry.message === "channel already registered: demo (channel-dup)", + ), + ).toBe(true); }, }, - }); - - expect(registry.hooks.filter((entry) => entry.entry.hook.name === "shared-hook")).toHaveLength( - 1, - ); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "hook-owner-b" && - diag.message === "hook already registered: shared-hook (hook-owner-a)", - ), - ).toBe(true); - }); - - it("rejects duplicate plugin service ids", () => { - useNoBundledPlugins(); - const first = writePlugin({ - id: "service-owner-a", - filename: "service-owner-a.cjs", - body: `module.exports = { id: "service-owner-a", register(api) { - api.registerService({ id: "shared-service", start() {} }); -} };`, - }); - const second = writePlugin({ - id: "service-owner-b", - filename: "service-owner-b.cjs", - body: `module.exports = { id: "service-owner-b", register(api) { - api.registerService({ id: "shared-service", start() {} }); -} };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [first.file, second.file] }, - allow: ["service-owner-a", "service-owner-b"], - }, - }, - }); - - expect(registry.services.filter((entry) => entry.service.id === "shared-service")).toHaveLength( - 1, - ); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "service-owner-b" && - diag.message === "service already registered: shared-service (service-owner-a)", - ), - ).toBe(true); - }); - - it("rejects plugin context engine ids reserved by core", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "context-engine-core-collision", - filename: "context-engine-core-collision.cjs", - body: `module.exports = { id: "context-engine-core-collision", register(api) { + { + label: "rejects plugin context engine ids reserved by core", + pluginId: "context-engine-core-collision", + body: `module.exports = { id: "context-engine-core-collision", register(api) { api.registerContextEngine("legacy", () => ({})); } };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["context-engine-core-collision"], - }, - }); - - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "context-engine-core-collision" && - diag.message === "context engine id reserved by core: legacy", - ), - ).toBe(true); - }); - - it("rejects duplicate plugin context engine ids", () => { - useNoBundledPlugins(); - const first = writePlugin({ - id: "context-engine-owner-a", - filename: "context-engine-owner-a.cjs", - body: `module.exports = { id: "context-engine-owner-a", register(api) { - api.registerContextEngine("shared-context-engine-loader-test", () => ({})); -} };`, - }); - const second = writePlugin({ - id: "context-engine-owner-b", - filename: "context-engine-owner-b.cjs", - body: `module.exports = { id: "context-engine-owner-b", register(api) { - api.registerContextEngine("shared-context-engine-loader-test", () => ({})); -} };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [first.file, second.file] }, - allow: ["context-engine-owner-a", "context-engine-owner-b"], + assert: (registry: ReturnType) => { + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === "context-engine-core-collision" && + diag.message === "context engine id reserved by core: legacy", + ), + ).toBe(true); }, }, - }); - - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "context-engine-owner-b" && - diag.message === - "context engine already registered: shared-context-engine-loader-test (plugin:context-engine-owner-a)", - ), - ).toBe(true); - }); - - it("requires plugin CLI registrars to declare explicit command roots", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "cli-missing-metadata", - filename: "cli-missing-metadata.cjs", - body: `module.exports = { id: "cli-missing-metadata", register(api) { + { + label: "requires plugin CLI registrars to declare explicit command roots", + pluginId: "cli-missing-metadata", + body: `module.exports = { id: "cli-missing-metadata", register(api) { api.registerCli(() => {}); } };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["cli-missing-metadata"], - }, - }); - - expect(registry.cliRegistrars).toHaveLength(0); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "cli-missing-metadata" && - diag.message === "cli registration missing explicit commands metadata", - ), - ).toBe(true); - }); - - it("rejects duplicate plugin CLI command roots", () => { - useNoBundledPlugins(); - const first = writePlugin({ - id: "cli-owner-a", - filename: "cli-owner-a.cjs", - body: `module.exports = { id: "cli-owner-a", register(api) { - api.registerCli(() => {}, { commands: ["shared-cli"] }); -} };`, - }); - const second = writePlugin({ - id: "cli-owner-b", - filename: "cli-owner-b.cjs", - body: `module.exports = { id: "cli-owner-b", register(api) { - api.registerCli(() => {}, { commands: ["shared-cli"] }); -} };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [first.file, second.file] }, - allow: ["cli-owner-a", "cli-owner-b"], + assert: (registry: ReturnType) => { + expect(registry.cliRegistrars).toHaveLength(0); + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === "cli-missing-metadata" && + diag.message === "cli registration missing explicit commands metadata", + ), + ).toBe(true); }, }, - }); + ] as const; - expect(registry.cliRegistrars).toHaveLength(1); - expect(registry.cliRegistrars[0]?.pluginId).toBe("cli-owner-a"); - expect( - registry.diagnostics.some( - (diag) => - diag.level === "error" && - diag.pluginId === "cli-owner-b" && - diag.message === "cli command already registered: shared-cli (cli-owner-a)", - ), - ).toBe(true); + for (const scenario of scenarios) { + const plugin = writePlugin({ + id: scenario.pluginId, + filename: `${scenario.pluginId}.cjs`, + body: scenario.body, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: [scenario.pluginId], + }, + }); + + scenario.assert(registry); + } }); - it("registers http routes", () => { + it("registers plugin http routes", () => { useNoBundledPlugins(); - const plugin = writePlugin({ - id: "http-route-demo", - filename: "http-route-demo.cjs", - body: `module.exports = { id: "http-route-demo", register(api) { - api.registerHttpRoute({ path: "/demo", auth: "gateway", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); } }); -} };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["http-route-demo"], + const scenarios = [ + { + label: "defaults exact match", + pluginId: "http-route-demo", + routeOptions: + '{ path: "/demo", auth: "gateway", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); } }', + expectedPath: "/demo", + expectedAuth: "gateway", + expectedMatch: "exact", }, - }); + { + label: "keeps explicit auth and match options", + pluginId: "http-demo", + routeOptions: + '{ path: "/webhook", auth: "plugin", match: "prefix", handler: async () => false }', + expectedPath: "/webhook", + expectedAuth: "plugin", + expectedMatch: "prefix", + }, + ] as const; - const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-route-demo"); - expect(route).toBeDefined(); - expect(route?.path).toBe("/demo"); - expect(route?.auth).toBe("gateway"); - expect(route?.match).toBe("exact"); - const httpPlugin = registry.plugins.find((entry) => entry.id === "http-route-demo"); - expect(httpPlugin?.httpRoutes).toBe(1); + for (const scenario of scenarios) { + const plugin = writePlugin({ + id: scenario.pluginId, + filename: `${scenario.pluginId}.cjs`, + body: `module.exports = { id: "${scenario.pluginId}", register(api) { + api.registerHttpRoute(${scenario.routeOptions}); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: [scenario.pluginId], + }, + }); + + const route = registry.httpRoutes.find((entry) => entry.pluginId === scenario.pluginId); + expect(route, scenario.label).toBeDefined(); + expect(route?.path, scenario.label).toBe(scenario.expectedPath); + expect(route?.auth, scenario.label).toBe(scenario.expectedAuth); + expect(route?.match, scenario.label).toBe(scenario.expectedMatch); + const httpPlugin = registry.plugins.find((entry) => entry.id === scenario.pluginId); + expect(httpPlugin?.httpRoutes, scenario.label).toBe(1); + } + }); + + it("rejects duplicate plugin registrations", () => { + useNoBundledPlugins(); + const scenarios = [ + { + label: "plugin-visible hook names", + ownerA: "hook-owner-a", + ownerB: "hook-owner-b", + buildBody: (ownerId: string) => `module.exports = { id: "${ownerId}", register(api) { + api.registerHook("gateway:startup", () => {}, { name: "shared-hook" }); +} };`, + selectCount: (registry: ReturnType) => + registry.hooks.filter((entry) => entry.entry.hook.name === "shared-hook").length, + duplicateMessage: "hook already registered: shared-hook (hook-owner-a)", + }, + { + label: "plugin service ids", + ownerA: "service-owner-a", + ownerB: "service-owner-b", + buildBody: (ownerId: string) => `module.exports = { id: "${ownerId}", register(api) { + api.registerService({ id: "shared-service", start() {} }); +} };`, + selectCount: (registry: ReturnType) => + registry.services.filter((entry) => entry.service.id === "shared-service").length, + duplicateMessage: "service already registered: shared-service (service-owner-a)", + }, + { + label: "plugin context engine ids", + ownerA: "context-engine-owner-a", + ownerB: "context-engine-owner-b", + buildBody: (ownerId: string) => `module.exports = { id: "${ownerId}", register(api) { + api.registerContextEngine("shared-context-engine-loader-test", () => ({})); +} };`, + selectCount: () => 1, + duplicateMessage: + "context engine already registered: shared-context-engine-loader-test (plugin:context-engine-owner-a)", + }, + { + label: "plugin CLI command roots", + ownerA: "cli-owner-a", + ownerB: "cli-owner-b", + buildBody: (ownerId: string) => `module.exports = { id: "${ownerId}", register(api) { + api.registerCli(() => {}, { commands: ["shared-cli"] }); +} };`, + selectCount: (registry: ReturnType) => + registry.cliRegistrars.length, + duplicateMessage: "cli command already registered: shared-cli (cli-owner-a)", + assertPrimaryOwner: (registry: ReturnType) => { + expect(registry.cliRegistrars[0]?.pluginId).toBe("cli-owner-a"); + }, + }, + ] as const; + + for (const scenario of scenarios) { + const first = writePlugin({ + id: scenario.ownerA, + filename: `${scenario.ownerA}.cjs`, + body: scenario.buildBody(scenario.ownerA), + }); + const second = writePlugin({ + id: scenario.ownerB, + filename: `${scenario.ownerB}.cjs`, + body: scenario.buildBody(scenario.ownerB), + }); + + const registry = loadRegistryFromAllowedPlugins([first, second]); + + expect(scenario.selectCount(registry), scenario.label).toBe(1); + if ("assertPrimaryOwner" in scenario) { + scenario.assertPrimaryOwner?.(registry); + } + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === scenario.ownerB && + diag.message === scenario.duplicateMessage, + ), + scenario.label, + ).toBe(true); + } }); it("rewrites removed registerHttpHandler failures into migration diagnostics", () => { @@ -1777,146 +1948,140 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s expect(loaded?.error).not.toContain("api.registerHttpHandler(...) was removed"); }); - it("rejects plugin http routes missing explicit auth", () => { + it("enforces plugin http route validation and conflict rules", () => { useNoBundledPlugins(); - const plugin = writePlugin({ - id: "http-route-missing-auth", - filename: "http-route-missing-auth.cjs", - body: `module.exports = { id: "http-route-missing-auth", register(api) { + const scenarios = [ + { + label: "missing auth is rejected", + buildPlugins: () => [ + writePlugin({ + id: "http-route-missing-auth", + filename: "http-route-missing-auth.cjs", + body: `module.exports = { id: "http-route-missing-auth", register(api) { api.registerHttpRoute({ path: "/demo", handler: async () => true }); } };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["http-route-missing-auth"], - }, - }); - - expect(registry.httpRoutes.find((entry) => entry.pluginId === "http-route-missing-auth")).toBe( - undefined, - ); - expect( - registry.diagnostics.some((diag) => - String(diag.message).includes("http route registration missing or invalid auth"), - ), - ).toBe(true); - }); - - it("allows explicit replaceExisting for same-plugin http route overrides", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "http-route-replace-self", - filename: "http-route-replace-self.cjs", - body: `module.exports = { id: "http-route-replace-self", register(api) { - api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false }); - api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true }); -} };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["http-route-replace-self"], - }, - }); - - const routes = registry.httpRoutes.filter( - (entry) => entry.pluginId === "http-route-replace-self", - ); - expect(routes).toHaveLength(1); - expect(routes[0]?.path).toBe("/demo"); - expect(registry.diagnostics).toEqual([]); - }); - - it("rejects http route replacement when another plugin owns the route", () => { - useNoBundledPlugins(); - const first = writePlugin({ - id: "http-route-owner-a", - filename: "http-route-owner-a.cjs", - body: `module.exports = { id: "http-route-owner-a", register(api) { - api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false }); -} };`, - }); - const second = writePlugin({ - id: "http-route-owner-b", - filename: "http-route-owner-b.cjs", - body: `module.exports = { id: "http-route-owner-b", register(api) { - api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true }); -} };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [first.file, second.file] }, - allow: ["http-route-owner-a", "http-route-owner-b"], + }), + ], + assert: (registry: ReturnType) => { + expect( + registry.httpRoutes.find((entry) => entry.pluginId === "http-route-missing-auth"), + ).toBeUndefined(); + expect( + registry.diagnostics.some((diag) => + String(diag.message).includes("http route registration missing or invalid auth"), + ), + ).toBe(true); }, }, - }); - - const route = registry.httpRoutes.find((entry) => entry.path === "/demo"); - expect(route?.pluginId).toBe("http-route-owner-a"); - expect( - registry.diagnostics.some((diag) => - String(diag.message).includes("http route replacement rejected"), - ), - ).toBe(true); - }); - - it("rejects mixed-auth overlapping http routes", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "http-route-overlap", - filename: "http-route-overlap.cjs", - body: `module.exports = { id: "http-route-overlap", register(api) { + { + label: "same plugin can replace its own route", + buildPlugins: () => [ + writePlugin({ + id: "http-route-replace-self", + filename: "http-route-replace-self.cjs", + body: `module.exports = { id: "http-route-replace-self", register(api) { + api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false }); + api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true }); +} };`, + }), + ], + assert: (registry: ReturnType) => { + const routes = registry.httpRoutes.filter( + (entry) => entry.pluginId === "http-route-replace-self", + ); + expect(routes).toHaveLength(1); + expect(routes[0]?.path).toBe("/demo"); + expect(registry.diagnostics).toEqual([]); + }, + }, + { + label: "cross-plugin replaceExisting is rejected", + buildPlugins: () => [ + writePlugin({ + id: "http-route-owner-a", + filename: "http-route-owner-a.cjs", + body: `module.exports = { id: "http-route-owner-a", register(api) { + api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false }); +} };`, + }), + writePlugin({ + id: "http-route-owner-b", + filename: "http-route-owner-b.cjs", + body: `module.exports = { id: "http-route-owner-b", register(api) { + api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true }); +} };`, + }), + ], + assert: (registry: ReturnType) => { + const route = registry.httpRoutes.find((entry) => entry.path === "/demo"); + expect(route?.pluginId).toBe("http-route-owner-a"); + expect( + registry.diagnostics.some((diag) => + String(diag.message).includes("http route replacement rejected"), + ), + ).toBe(true); + }, + }, + { + label: "mixed-auth overlaps are rejected", + buildPlugins: () => [ + writePlugin({ + id: "http-route-overlap", + filename: "http-route-overlap.cjs", + body: `module.exports = { id: "http-route-overlap", register(api) { api.registerHttpRoute({ path: "/plugin/secure", auth: "gateway", match: "prefix", handler: async () => true }); api.registerHttpRoute({ path: "/plugin/secure/report", auth: "plugin", match: "exact", handler: async () => true }); } };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["http-route-overlap"], + }), + ], + assert: (registry: ReturnType) => { + const routes = registry.httpRoutes.filter( + (entry) => entry.pluginId === "http-route-overlap", + ); + expect(routes).toHaveLength(1); + expect(routes[0]?.path).toBe("/plugin/secure"); + expect( + registry.diagnostics.some((diag) => + String(diag.message).includes("http route overlap rejected"), + ), + ).toBe(true); + }, }, - }); - - const routes = registry.httpRoutes.filter((entry) => entry.pluginId === "http-route-overlap"); - expect(routes).toHaveLength(1); - expect(routes[0]?.path).toBe("/plugin/secure"); - expect( - registry.diagnostics.some((diag) => - String(diag.message).includes("http route overlap rejected"), - ), - ).toBe(true); - }); - - it("allows same-auth overlapping http routes", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "http-route-overlap-same-auth", - filename: "http-route-overlap-same-auth.cjs", - body: `module.exports = { id: "http-route-overlap-same-auth", register(api) { + { + label: "same-auth overlaps are allowed", + buildPlugins: () => [ + writePlugin({ + id: "http-route-overlap-same-auth", + filename: "http-route-overlap-same-auth.cjs", + body: `module.exports = { id: "http-route-overlap-same-auth", register(api) { api.registerHttpRoute({ path: "/plugin/public", auth: "plugin", match: "prefix", handler: async () => true }); api.registerHttpRoute({ path: "/plugin/public/report", auth: "plugin", match: "exact", handler: async () => true }); } };`, - }); - - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["http-route-overlap-same-auth"], + }), + ], + assert: (registry: ReturnType) => { + const routes = registry.httpRoutes.filter( + (entry) => entry.pluginId === "http-route-overlap-same-auth", + ); + expect(routes).toHaveLength(2); + expect(registry.diagnostics).toEqual([]); + }, }, - }); + ] as const; - const routes = registry.httpRoutes.filter( - (entry) => entry.pluginId === "http-route-overlap-same-auth", - ); - expect(routes).toHaveLength(2); - expect(registry.diagnostics).toEqual([]); + for (const scenario of scenarios) { + const plugins = scenario.buildPlugins(); + const registry = + plugins.length === 1 + ? loadRegistryFromSinglePlugin({ + plugin: plugins[0], + pluginConfig: { + allow: [plugins[0].id], + }, + }) + : loadRegistryFromAllowedPlugins(plugins); + scenario.assert(registry); + } }); it("respects explicit disable in config", () => { @@ -2019,429 +2184,130 @@ module.exports = { ); }); - it("uses package setupEntry for setup-only channel loads", () => { - useNoBundledPlugins(); - const pluginDir = makeTempDir(); - const fullMarker = path.join(pluginDir, "full-loaded.txt"); - const setupMarker = path.join(pluginDir, "setup-loaded.txt"); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify( - { - name: "@openclaw/setup-entry-test", - openclaw: { - extensions: ["./index.cjs"], - setupEntry: "./setup-entry.cjs", - }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "setup-entry-test", - configSchema: EMPTY_PLUGIN_SCHEMA, - channels: ["setup-entry-test"], - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "index.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); -module.exports = { - id: "setup-entry-test", - register(api) { - api.registerChannel({ - plugin: { + it.each([ + { + name: "uses package setupEntry for setup-only channel loads", + fixture: { id: "setup-entry-test", - meta: { - id: "setup-entry-test", - label: "Setup Entry Test", - selectionLabel: "Setup Entry Test", - docsPath: "/channels/setup-entry-test", - blurb: "full entry should not run in setup-only mode", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, + label: "Setup Entry Test", + packageName: "@openclaw/setup-entry-test", + fullBlurb: "full entry should not run in setup-only mode", + setupBlurb: "setup entry", + configured: false, }, - }); - }, -};`, - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "setup-entry.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); -module.exports = { - plugin: { - id: "setup-entry-test", - meta: { - id: "setup-entry-test", - label: "Setup Entry Test", - selectionLabel: "Setup Entry Test", - docsPath: "/channels/setup-entry-test", - blurb: "setup entry", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, - }, -};`, - "utf-8", - ); - - const setupRegistry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [pluginDir] }, - allow: ["setup-entry-test"], - entries: { - "setup-entry-test": { enabled: false }, - }, - }, - }, - includeSetupOnlyChannelPlugins: true, - }); - - expect(fs.existsSync(setupMarker)).toBe(true); - expect(fs.existsSync(fullMarker)).toBe(false); - expect(setupRegistry.channelSetups).toHaveLength(1); - expect(setupRegistry.channels).toHaveLength(0); - }); - - it("uses package setupEntry for enabled but unconfigured channel loads", () => { - useNoBundledPlugins(); - const pluginDir = makeTempDir(); - const fullMarker = path.join(pluginDir, "full-loaded.txt"); - const setupMarker = path.join(pluginDir, "setup-loaded.txt"); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify( - { - name: "@openclaw/setup-runtime-test", - openclaw: { - extensions: ["./index.cjs"], - setupEntry: "./setup-entry.cjs", - }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "setup-runtime-test", - configSchema: EMPTY_PLUGIN_SCHEMA, - channels: ["setup-runtime-test"], - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "index.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); -module.exports = { - id: "setup-runtime-test", - register(api) { - api.registerChannel({ - plugin: { - id: "setup-runtime-test", - meta: { - id: "setup-runtime-test", - label: "Setup Runtime Test", - selectionLabel: "Setup Runtime Test", - docsPath: "/channels/setup-runtime-test", - blurb: "full entry should not run while unconfigured", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, - }, - }); - }, -};`, - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "setup-entry.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); -module.exports = { - plugin: { - id: "setup-runtime-test", - meta: { - id: "setup-runtime-test", - label: "Setup Runtime Test", - selectionLabel: "Setup Runtime Test", - docsPath: "/channels/setup-runtime-test", - blurb: "setup runtime", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, - }, -};`, - "utf-8", - ); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [pluginDir] }, - allow: ["setup-runtime-test"], - }, - }, - }); - - expect(fs.existsSync(setupMarker)).toBe(true); - expect(fs.existsSync(fullMarker)).toBe(false); - expect(registry.channelSetups).toHaveLength(1); - expect(registry.channels).toHaveLength(1); - }); - - it("can prefer setupEntry for configured channel loads during startup", () => { - useNoBundledPlugins(); - const pluginDir = makeTempDir(); - const fullMarker = path.join(pluginDir, "full-loaded.txt"); - const setupMarker = path.join(pluginDir, "setup-loaded.txt"); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify( - { - name: "@openclaw/setup-runtime-preferred-test", - openclaw: { - extensions: ["./index.cjs"], - setupEntry: "./setup-entry.cjs", - startup: { - deferConfiguredChannelFullLoadUntilAfterListen: true, + load: ({ pluginDir }: { pluginDir: string }) => + loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-entry-test"], + entries: { + "setup-entry-test": { enabled: false }, + }, }, }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "setup-runtime-preferred-test", - configSchema: EMPTY_PLUGIN_SCHEMA, - channels: ["setup-runtime-preferred-test"], - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "index.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); -module.exports = { - id: "setup-runtime-preferred-test", - register(api) { - api.registerChannel({ - plugin: { + includeSetupOnlyChannelPlugins: true, + }), + expectFullLoaded: false, + expectSetupLoaded: true, + expectedChannels: 0, + }, + { + name: "uses package setupEntry for enabled but unconfigured channel loads", + fixture: { + id: "setup-runtime-test", + label: "Setup Runtime Test", + packageName: "@openclaw/setup-runtime-test", + fullBlurb: "full entry should not run while unconfigured", + setupBlurb: "setup runtime", + configured: false, + }, + load: ({ pluginDir }: { pluginDir: string }) => + loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-test"], + }, + }, + }), + expectFullLoaded: false, + expectSetupLoaded: true, + expectedChannels: 1, + }, + { + name: "can prefer setupEntry for configured channel loads during startup", + fixture: { id: "setup-runtime-preferred-test", - meta: { - id: "setup-runtime-preferred-test", - label: "Setup Runtime Preferred Test", - selectionLabel: "Setup Runtime Preferred Test", - docsPath: "/channels/setup-runtime-preferred-test", - blurb: "full entry should be deferred while startup is still cold", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ accountId: "default", token: "configured" }), - }, - outbound: { deliveryMode: "direct" }, + label: "Setup Runtime Preferred Test", + packageName: "@openclaw/setup-runtime-preferred-test", + fullBlurb: "full entry should be deferred while startup is still cold", + setupBlurb: "setup runtime preferred", + configured: true, + startupDeferConfiguredChannelFullLoadUntilAfterListen: true, }, - }); - }, -};`, - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "setup-entry.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); -module.exports = { - plugin: { - id: "setup-runtime-preferred-test", - meta: { - id: "setup-runtime-preferred-test", - label: "Setup Runtime Preferred Test", - selectionLabel: "Setup Runtime Preferred Test", - docsPath: "/channels/setup-runtime-preferred-test", - blurb: "setup runtime preferred", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ accountId: "default", token: "configured" }), - }, - outbound: { deliveryMode: "direct" }, - }, -};`, - "utf-8", - ); - - const registry = loadOpenClawPlugins({ - cache: false, - preferSetupRuntimeForChannelPlugins: true, - config: { - channels: { - "setup-runtime-preferred-test": { - enabled: true, - token: "configured", + load: ({ pluginDir }: { pluginDir: string }) => + loadOpenClawPlugins({ + cache: false, + preferSetupRuntimeForChannelPlugins: true, + config: { + channels: { + "setup-runtime-preferred-test": { + enabled: true, + token: "configured", + }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-preferred-test"], + }, }, - }, - plugins: { - load: { paths: [pluginDir] }, - allow: ["setup-runtime-preferred-test"], - }, - }, - }); - - expect(fs.existsSync(setupMarker)).toBe(true); - expect(fs.existsSync(fullMarker)).toBe(false); - expect(registry.channelSetups).toHaveLength(1); - expect(registry.channels).toHaveLength(1); - }); - - it("does not prefer setupEntry for configured channel loads without startup opt-in", () => { - useNoBundledPlugins(); - const pluginDir = makeTempDir(); - const fullMarker = path.join(makeTempDir(), "full-loaded.txt"); - const setupMarker = path.join(makeTempDir(), "setup-loaded.txt"); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify( - { - name: "@openclaw/setup-runtime-not-preferred-test", - openclaw: { - extensions: ["./index.cjs"], - setupEntry: "./setup-entry.cjs", - }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "setup-runtime-not-preferred-test", - configSchema: EMPTY_PLUGIN_SCHEMA, - channels: ["setup-runtime-not-preferred-test"], - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "index.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); -module.exports = { - id: "setup-runtime-not-preferred-test", - register(api) { - api.registerChannel({ - plugin: { + }), + expectFullLoaded: false, + expectSetupLoaded: true, + expectedChannels: 1, + }, + { + name: "does not prefer setupEntry for configured channel loads without startup opt-in", + fixture: { id: "setup-runtime-not-preferred-test", - meta: { - id: "setup-runtime-not-preferred-test", - label: "Setup Runtime Not Preferred Test", - selectionLabel: "Setup Runtime Not Preferred Test", - docsPath: "/channels/setup-runtime-not-preferred-test", - blurb: "full entry should still load without explicit startup opt-in", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ accountId: "default", token: "configured" }), - }, - outbound: { deliveryMode: "direct" }, + label: "Setup Runtime Not Preferred Test", + packageName: "@openclaw/setup-runtime-not-preferred-test", + fullBlurb: "full entry should still load without explicit startup opt-in", + setupBlurb: "setup runtime not preferred", + configured: true, }, - }); - }, -};`, - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "setup-entry.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); -module.exports = { - plugin: { - id: "setup-runtime-not-preferred-test", - meta: { - id: "setup-runtime-not-preferred-test", - label: "Setup Runtime Not Preferred Test", - selectionLabel: "Setup Runtime Not Preferred Test", - docsPath: "/channels/setup-runtime-not-preferred-test", - blurb: "setup runtime not preferred", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ accountId: "default", token: "configured" }), - }, - outbound: { deliveryMode: "direct" }, - }, -};`, - "utf-8", - ); - - const registry = loadOpenClawPlugins({ - cache: false, - preferSetupRuntimeForChannelPlugins: true, - config: { - channels: { - "setup-runtime-not-preferred-test": { - enabled: true, - token: "configured", + load: ({ pluginDir }: { pluginDir: string }) => + loadOpenClawPlugins({ + cache: false, + preferSetupRuntimeForChannelPlugins: true, + config: { + channels: { + "setup-runtime-not-preferred-test": { + enabled: true, + token: "configured", + }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-not-preferred-test"], + }, }, - }, - plugins: { - load: { paths: [pluginDir] }, - allow: ["setup-runtime-not-preferred-test"], - }, - }, - }); + }), + expectFullLoaded: true, + expectSetupLoaded: false, + expectedChannels: 1, + }, + ])("$name", ({ fixture, load, expectFullLoaded, expectSetupLoaded, expectedChannels }) => { + const built = createSetupEntryChannelPluginFixture(fixture); + const registry = load({ pluginDir: built.pluginDir }); - expect(fs.existsSync(fullMarker)).toBe(true); - expect(fs.existsSync(setupMarker)).toBe(false); + expect(fs.existsSync(built.fullMarker)).toBe(expectFullLoaded); + expect(fs.existsSync(built.setupMarker)).toBe(expectSetupLoaded); expect(registry.channelSetups).toHaveLength(1); - expect(registry.channels).toHaveLength(1); + expect(registry.channels).toHaveLength(expectedChannels); }); it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => { @@ -2560,305 +2426,483 @@ module.exports = { ).toBe(true); }); - it("enforces memory slot selection", () => { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; - const memoryA = writePlugin({ - id: "memory-a", - body: `module.exports = { id: "memory-a", kind: "memory", register() {} };`, - }); - const memoryB = writePlugin({ - id: "memory-b", - body: `module.exports = { id: "memory-b", kind: "memory", register() {} };`, - }); + it("enforces memory slot loading rules", () => { + const scenarios = [ + { + label: "enforces memory slot selection", + loadRegistry: () => { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; + const memoryA = writePlugin({ + id: "memory-a", + body: `module.exports = { id: "memory-a", kind: "memory", register() {} };`, + }); + const memoryB = writePlugin({ + id: "memory-b", + body: `module.exports = { id: "memory-b", kind: "memory", register() {} };`, + }); - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [memoryA.file, memoryB.file] }, - slots: { memory: "memory-b" }, - }, - }, - }); - - const a = registry.plugins.find((entry) => entry.id === "memory-a"); - const b = registry.plugins.find((entry) => entry.id === "memory-b"); - expect(b?.status).toBe("loaded"); - expect(a?.status).toBe("disabled"); - }); - - it("skips importing bundled memory plugins that are disabled by memory slot", () => { - const bundledDir = makeTempDir(); - const memoryADir = path.join(bundledDir, "memory-a"); - const memoryBDir = path.join(bundledDir, "memory-b"); - mkdirSafe(memoryADir); - mkdirSafe(memoryBDir); - writePlugin({ - id: "memory-a", - dir: memoryADir, - filename: "index.cjs", - body: `throw new Error("memory-a should not be imported when slot selects memory-b");`, - }); - writePlugin({ - id: "memory-b", - dir: memoryBDir, - filename: "index.cjs", - body: `module.exports = { id: "memory-b", kind: "memory", register() {} };`, - }); - fs.writeFileSync( - path.join(memoryADir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "memory-a", - kind: "memory", - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(memoryBDir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "memory-b", - kind: "memory", - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - allow: ["memory-a", "memory-b"], - slots: { memory: "memory-b" }, - entries: { - "memory-a": { enabled: true }, - "memory-b": { enabled: true }, - }, - }, - }, - }); - - const a = registry.plugins.find((entry) => entry.id === "memory-a"); - const b = registry.plugins.find((entry) => entry.id === "memory-b"); - expect(a?.status).toBe("disabled"); - expect(String(a?.error ?? "")).toContain('memory slot set to "memory-b"'); - expect(b?.status).toBe("loaded"); - }); - - it("disables memory plugins when slot is none", () => { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; - const memory = writePlugin({ - id: "memory-off", - body: `module.exports = { id: "memory-off", kind: "memory", register() {} };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [memory.file] }, - slots: { memory: "none" }, - }, - }, - }); - - const entry = registry.plugins.find((item) => item.id === "memory-off"); - expect(entry?.status).toBe("disabled"); - }); - - it("prefers higher-precedence plugins with the same id", () => { - const bundledDir = makeTempDir(); - writePlugin({ - id: "shadow", - body: `module.exports = { id: "shadow", register() {} };`, - dir: bundledDir, - filename: "shadow.cjs", - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - - const override = writePlugin({ - id: "shadow", - body: `module.exports = { id: "shadow", register() {} };`, - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [override.file] }, - entries: { - shadow: { enabled: true }, - }, - }, - }, - }); - - const entries = registry.plugins.filter((entry) => entry.id === "shadow"); - const loaded = entries.find((entry) => entry.status === "loaded"); - const overridden = entries.find((entry) => entry.status === "disabled"); - expect(loaded?.origin).toBe("config"); - expect(overridden?.origin).toBe("bundled"); - }); - - it("prefers bundled plugin over auto-discovered global duplicate ids", () => { - const bundledDir = makeTempDir(); - writePlugin({ - id: "feishu", - body: `module.exports = { id: "feishu", register() {} };`, - dir: bundledDir, - filename: "index.cjs", - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - - const stateDir = makeTempDir(); - withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => { - const globalDir = path.join(stateDir, "extensions", "feishu"); - mkdirSafe(globalDir); - writePlugin({ - id: "feishu", - body: `module.exports = { id: "feishu", register() {} };`, - dir: globalDir, - filename: "index.cjs", - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - allow: ["feishu"], - entries: { - feishu: { enabled: true }, - }, - }, - }, - }); - - const entries = registry.plugins.filter((entry) => entry.id === "feishu"); - const loaded = entries.find((entry) => entry.status === "loaded"); - const overridden = entries.find((entry) => entry.status === "disabled"); - expect(loaded?.origin).toBe("bundled"); - expect(overridden?.origin).toBe("global"); - expect(overridden?.error).toContain("overridden by bundled plugin"); - }); - }); - - it("prefers an explicitly installed global plugin over a bundled duplicate", () => { - const bundledDir = makeTempDir(); - writePlugin({ - id: "zalouser", - body: `module.exports = { id: "zalouser", register() {} };`, - dir: bundledDir, - filename: "index.cjs", - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - - const stateDir = makeTempDir(); - withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => { - const globalDir = path.join(stateDir, "extensions", "zalouser"); - mkdirSafe(globalDir); - writePlugin({ - id: "zalouser", - body: `module.exports = { id: "zalouser", register() {} };`, - dir: globalDir, - filename: "index.cjs", - }); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - allow: ["zalouser"], - installs: { - zalouser: { - source: "npm", - installPath: globalDir, + return loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [memoryA.file, memoryB.file] }, + slots: { memory: "memory-b" }, }, }, - entries: { - zalouser: { enabled: true }, - }, - }, + }); }, - }); + assert: (registry: ReturnType) => { + const a = registry.plugins.find((entry) => entry.id === "memory-a"); + const b = registry.plugins.find((entry) => entry.id === "memory-b"); + expect(b?.status).toBe("loaded"); + expect(a?.status).toBe("disabled"); + }, + }, + { + label: "skips importing bundled memory plugins that are disabled by memory slot", + loadRegistry: () => { + const bundledDir = makeTempDir(); + const memoryADir = path.join(bundledDir, "memory-a"); + const memoryBDir = path.join(bundledDir, "memory-b"); + mkdirSafe(memoryADir); + mkdirSafe(memoryBDir); + writePlugin({ + id: "memory-a", + dir: memoryADir, + filename: "index.cjs", + body: `throw new Error("memory-a should not be imported when slot selects memory-b");`, + }); + writePlugin({ + id: "memory-b", + dir: memoryBDir, + filename: "index.cjs", + body: `module.exports = { id: "memory-b", kind: "memory", register() {} };`, + }); + fs.writeFileSync( + path.join(memoryADir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "memory-a", + kind: "memory", + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(memoryBDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "memory-b", + kind: "memory", + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; - const entries = registry.plugins.filter((entry) => entry.id === "zalouser"); + return loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + allow: ["memory-a", "memory-b"], + slots: { memory: "memory-b" }, + entries: { + "memory-a": { enabled: true }, + "memory-b": { enabled: true }, + }, + }, + }, + }); + }, + assert: (registry: ReturnType) => { + const a = registry.plugins.find((entry) => entry.id === "memory-a"); + const b = registry.plugins.find((entry) => entry.id === "memory-b"); + expect(a?.status).toBe("disabled"); + expect(String(a?.error ?? "")).toContain('memory slot set to "memory-b"'); + expect(b?.status).toBe("loaded"); + }, + }, + { + label: "disables memory plugins when slot is none", + loadRegistry: () => { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; + const memory = writePlugin({ + id: "memory-off", + body: `module.exports = { id: "memory-off", kind: "memory", register() {} };`, + }); + + return loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [memory.file] }, + slots: { memory: "none" }, + }, + }, + }); + }, + assert: (registry: ReturnType) => { + const entry = registry.plugins.find((item) => item.id === "memory-off"); + expect(entry?.status).toBe("disabled"); + }, + }, + ] as const; + + for (const scenario of scenarios) { + const registry = scenario.loadRegistry(); + scenario.assert(registry); + } + }); + + it("resolves duplicate plugin ids by source precedence", () => { + const scenarios = [ + { + label: "config load overrides bundled", + pluginId: "shadow", + bundledFilename: "shadow.cjs", + loadRegistry: () => { + const bundledDir = makeTempDir(); + writePlugin({ + id: "shadow", + body: `module.exports = { id: "shadow", register() {} };`, + dir: bundledDir, + filename: "shadow.cjs", + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + + const override = writePlugin({ + id: "shadow", + body: `module.exports = { id: "shadow", register() {} };`, + }); + + return loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [override.file] }, + entries: { + shadow: { enabled: true }, + }, + }, + }, + }); + }, + expectedLoadedOrigin: "config", + expectedDisabledOrigin: "bundled", + }, + { + label: "bundled beats auto-discovered global duplicate", + pluginId: "feishu", + bundledFilename: "index.cjs", + loadRegistry: () => { + const bundledDir = makeTempDir(); + writePlugin({ + id: "feishu", + body: `module.exports = { id: "feishu", register() {} };`, + dir: bundledDir, + filename: "index.cjs", + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + + const stateDir = makeTempDir(); + return withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => { + const globalDir = path.join(stateDir, "extensions", "feishu"); + mkdirSafe(globalDir); + writePlugin({ + id: "feishu", + body: `module.exports = { id: "feishu", register() {} };`, + dir: globalDir, + filename: "index.cjs", + }); + + return loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + allow: ["feishu"], + entries: { + feishu: { enabled: true }, + }, + }, + }, + }); + }); + }, + expectedLoadedOrigin: "bundled", + expectedDisabledOrigin: "global", + expectedDisabledError: "overridden by bundled plugin", + }, + { + label: "installed global beats bundled duplicate", + pluginId: "zalouser", + bundledFilename: "index.cjs", + loadRegistry: () => { + const bundledDir = makeTempDir(); + writePlugin({ + id: "zalouser", + body: `module.exports = { id: "zalouser", register() {} };`, + dir: bundledDir, + filename: "index.cjs", + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + + const stateDir = makeTempDir(); + return withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => { + const globalDir = path.join(stateDir, "extensions", "zalouser"); + mkdirSafe(globalDir); + writePlugin({ + id: "zalouser", + body: `module.exports = { id: "zalouser", register() {} };`, + dir: globalDir, + filename: "index.cjs", + }); + + return loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + allow: ["zalouser"], + installs: { + zalouser: { + source: "npm", + installPath: globalDir, + }, + }, + entries: { + zalouser: { enabled: true }, + }, + }, + }, + }); + }); + }, + expectedLoadedOrigin: "global", + expectedDisabledOrigin: "bundled", + expectedDisabledError: "overridden by global plugin", + }, + ] as const; + + for (const scenario of scenarios) { + const registry = scenario.loadRegistry(); + const entries = registry.plugins.filter((entry) => entry.id === scenario.pluginId); const loaded = entries.find((entry) => entry.status === "loaded"); const overridden = entries.find((entry) => entry.status === "disabled"); - expect(loaded?.origin).toBe("global"); - expect(overridden?.origin).toBe("bundled"); - expect(overridden?.error).toContain("overridden by global plugin"); - }); + expect(loaded?.origin, scenario.label).toBe(scenario.expectedLoadedOrigin); + expect(overridden?.origin, scenario.label).toBe(scenario.expectedDisabledOrigin); + if ("expectedDisabledError" in scenario) { + expect(overridden?.error, scenario.label).toContain(scenario.expectedDisabledError); + } + } }); - it("warns when plugins.allow is empty and non-bundled plugins are discoverable", () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "warn-open-allow", - body: `module.exports = { id: "warn-open-allow", register() {} };`, - }); - const warnings: string[] = []; - loadOpenClawPlugins({ - cache: false, - logger: createWarningLogger(warnings), - config: { - plugins: { - load: { paths: [plugin.file] }, - }, - }, - }); - expect( - warnings.some((msg) => msg.includes("plugins.allow is empty") && msg.includes(plugin.id)), - ).toBe(true); - }); - - it("dedupes the open allowlist warning for repeated loads of the same plugin set", () => { + it("warns about open allowlists for discoverable plugins once per plugin set", () => { useNoBundledPlugins(); clearPluginLoaderCache(); + const scenarios = [ + { + label: "single load warns", + pluginId: "warn-open-allow", + loads: 1, + expectedWarnings: 1, + }, + { + label: "repeated identical loads dedupe warning", + pluginId: "warn-open-allow-once", + loads: 2, + expectedWarnings: 1, + }, + ] as const; + + for (const scenario of scenarios) { + const plugin = writePlugin({ + id: scenario.pluginId, + body: `module.exports = { id: "${scenario.pluginId}", register() {} };`, + }); + const warnings: string[] = []; + const options = { + cache: false, + logger: createWarningLogger(warnings), + config: { + plugins: { + load: { paths: [plugin.file] }, + }, + }, + }; + + for (let index = 0; index < scenario.loads; index += 1) { + loadOpenClawPlugins(options); + } + + const openAllowWarnings = warnings.filter((msg) => msg.includes("plugins.allow is empty")); + expect(openAllowWarnings, scenario.label).toHaveLength(scenario.expectedWarnings); + expect( + openAllowWarnings.some((msg) => msg.includes(scenario.pluginId)), + scenario.label, + ).toBe(true); + } + }); + + it("handles workspace-discovered plugins according to trust and precedence", () => { + useNoBundledPlugins(); + const scenarios = [ + { + label: "untrusted workspace plugins stay disabled", + pluginId: "workspace-helper", + loadRegistry: () => { + const workspaceDir = makeTempDir(); + const workspaceExtDir = path.join( + workspaceDir, + ".openclaw", + "extensions", + "workspace-helper", + ); + mkdirSafe(workspaceExtDir); + writePlugin({ + id: "workspace-helper", + body: `module.exports = { id: "workspace-helper", register() {} };`, + dir: workspaceExtDir, + filename: "index.cjs", + }); + + return loadOpenClawPlugins({ + cache: false, + workspaceDir, + config: { + plugins: { + enabled: true, + }, + }, + }); + }, + assert: (registry: ReturnType) => { + const workspacePlugin = registry.plugins.find((entry) => entry.id === "workspace-helper"); + expect(workspacePlugin?.origin).toBe("workspace"); + expect(workspacePlugin?.status).toBe("disabled"); + expect(workspacePlugin?.error).toContain("workspace plugin (disabled by default)"); + }, + }, + { + label: "trusted workspace plugins load", + pluginId: "workspace-helper", + loadRegistry: () => { + const workspaceDir = makeTempDir(); + const workspaceExtDir = path.join( + workspaceDir, + ".openclaw", + "extensions", + "workspace-helper", + ); + mkdirSafe(workspaceExtDir); + writePlugin({ + id: "workspace-helper", + body: `module.exports = { id: "workspace-helper", register() {} };`, + dir: workspaceExtDir, + filename: "index.cjs", + }); + + return loadOpenClawPlugins({ + cache: false, + workspaceDir, + config: { + plugins: { + enabled: true, + allow: ["workspace-helper"], + }, + }, + }); + }, + assert: (registry: ReturnType) => { + const workspacePlugin = registry.plugins.find((entry) => entry.id === "workspace-helper"); + expect(workspacePlugin?.origin).toBe("workspace"); + expect(workspacePlugin?.status).toBe("loaded"); + }, + }, + { + label: "bundled plugins stay ahead of trusted workspace duplicates", + pluginId: "shadowed", + loadRegistry: () => { + const bundledDir = makeTempDir(); + writePlugin({ + id: "shadowed", + body: `module.exports = { id: "shadowed", register() {} };`, + dir: bundledDir, + filename: "index.cjs", + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + + const workspaceDir = makeTempDir(); + const workspaceExtDir = path.join(workspaceDir, ".openclaw", "extensions", "shadowed"); + mkdirSafe(workspaceExtDir); + writePlugin({ + id: "shadowed", + body: `module.exports = { id: "shadowed", register() {} };`, + dir: workspaceExtDir, + filename: "index.cjs", + }); + + return loadOpenClawPlugins({ + cache: false, + workspaceDir, + config: { + plugins: { + enabled: true, + allow: ["shadowed"], + entries: { + shadowed: { enabled: true }, + }, + }, + }, + }); + }, + assert: (registry: ReturnType) => { + const entries = registry.plugins.filter((entry) => entry.id === "shadowed"); + const loaded = entries.find((entry) => entry.status === "loaded"); + const overridden = entries.find((entry) => entry.status === "disabled"); + expect(loaded?.origin).toBe("bundled"); + expect(overridden?.origin).toBe("workspace"); + expect(overridden?.error).toContain("overridden by bundled plugin"); + }, + }, + ] as const; + + for (const scenario of scenarios) { + const registry = scenario.loadRegistry(); + scenario.assert(registry); + } + }); + + it("loads bundled plugins when manifest metadata opts into default enablement", () => { + const bundledDir = makeTempDir(); const plugin = writePlugin({ - id: "warn-open-allow-once", - body: `module.exports = { id: "warn-open-allow-once", register() {} };`, - }); - const warnings: string[] = []; - const options = { - cache: false, - logger: createWarningLogger(warnings), - config: { - plugins: { - load: { paths: [plugin.file] }, - }, - }, - }; - - loadOpenClawPlugins(options); - loadOpenClawPlugins(options); - - expect(warnings.filter((msg) => msg.includes("plugins.allow is empty"))).toHaveLength(1); - }); - - it("does not auto-load workspace-discovered plugins unless explicitly trusted", () => { - useNoBundledPlugins(); - const workspaceDir = makeTempDir(); - const workspaceExtDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-helper"); - mkdirSafe(workspaceExtDir); - writePlugin({ - id: "workspace-helper", - body: `module.exports = { id: "workspace-helper", register() {} };`, - dir: workspaceExtDir, + id: "profile-aware", + body: `module.exports = { id: "profile-aware", register() {} };`, + dir: bundledDir, filename: "index.cjs", }); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "profile-aware", + enabledByDefault: true, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; const registry = loadOpenClawPlugins({ cache: false, - workspaceDir, + workspaceDir: bundledDir, config: { plugins: { enabled: true, @@ -2866,38 +2910,9 @@ module.exports = { }, }); - const workspacePlugin = registry.plugins.find((entry) => entry.id === "workspace-helper"); - expect(workspacePlugin?.origin).toBe("workspace"); - expect(workspacePlugin?.status).toBe("disabled"); - expect(workspacePlugin?.error).toContain("workspace plugin (disabled by default)"); - }); - - it("loads workspace-discovered plugins when plugins.allow explicitly trusts them", () => { - useNoBundledPlugins(); - const workspaceDir = makeTempDir(); - const workspaceExtDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-helper"); - mkdirSafe(workspaceExtDir); - writePlugin({ - id: "workspace-helper", - body: `module.exports = { id: "workspace-helper", register() {} };`, - dir: workspaceExtDir, - filename: "index.cjs", - }); - - const registry = loadOpenClawPlugins({ - cache: false, - workspaceDir, - config: { - plugins: { - enabled: true, - allow: ["workspace-helper"], - }, - }, - }); - - const workspacePlugin = registry.plugins.find((entry) => entry.id === "workspace-helper"); - expect(workspacePlugin?.origin).toBe("workspace"); - expect(workspacePlugin?.status).toBe("loaded"); + const bundledPlugin = registry.plugins.find((entry) => entry.id === "profile-aware"); + expect(bundledPlugin?.origin).toBe("bundled"); + expect(bundledPlugin?.status).toBe("loaded"); }); it("keeps scoped and unscoped plugin ids distinct", () => { @@ -2930,234 +2945,140 @@ module.exports = { ).toBe(false); }); - it("keeps bundled plugins ahead of trusted workspace duplicates with the same id", () => { - const bundledDir = makeTempDir(); - writePlugin({ - id: "shadowed", - body: `module.exports = { id: "shadowed", register() {} };`, - dir: bundledDir, - filename: "index.cjs", - }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + it("evaluates load-path provenance warnings", () => { + useNoBundledPlugins(); + const scenarios = [ + { + label: "warns when loaded non-bundled plugin has no install/load-path provenance", + loadRegistry: () => { + const stateDir = makeTempDir(); + return withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => { + const globalDir = path.join(stateDir, "extensions", "rogue"); + mkdirSafe(globalDir); + writePlugin({ + id: "rogue", + body: `module.exports = { id: "rogue", register() {} };`, + dir: globalDir, + filename: "index.cjs", + }); - const workspaceDir = makeTempDir(); - const workspaceExtDir = path.join(workspaceDir, ".openclaw", "extensions", "shadowed"); - mkdirSafe(workspaceExtDir); - writePlugin({ - id: "shadowed", - body: `module.exports = { id: "shadowed", register() {} };`, - dir: workspaceExtDir, - filename: "index.cjs", - }); + const warnings: string[] = []; + const registry = loadOpenClawPlugins({ + cache: false, + logger: createWarningLogger(warnings), + config: { + plugins: { + allow: ["rogue"], + }, + }, + }); - const registry = loadOpenClawPlugins({ - cache: false, - workspaceDir, - config: { - plugins: { - enabled: true, - allow: ["shadowed"], - entries: { - shadowed: { enabled: true }, - }, + return { registry, warnings, pluginId: "rogue", expectWarning: true }; + }); }, }, - }); + { + label: "does not warn about missing provenance for env-resolved load paths", + loadRegistry: () => { + const { plugin, env } = createEnvResolvedPluginFixture("tracked-load-path"); + const warnings: string[] = []; + const registry = loadOpenClawPlugins({ + cache: false, + logger: createWarningLogger(warnings), + env, + config: { + plugins: { + load: { paths: ["~/plugins/tracked-load-path"] }, + allow: [plugin.id], + }, + }, + }); - const entries = registry.plugins.filter((entry) => entry.id === "shadowed"); - const loaded = entries.find((entry) => entry.status === "loaded"); - const overridden = entries.find((entry) => entry.status === "disabled"); - expect(loaded?.origin).toBe("bundled"); - expect(overridden?.origin).toBe("workspace"); - expect(overridden?.error).toContain("overridden by bundled plugin"); - }); - - it("warns when loaded non-bundled plugin has no install/load-path provenance", () => { - useNoBundledPlugins(); - const stateDir = makeTempDir(); - withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => { - const globalDir = path.join(stateDir, "extensions", "rogue"); - mkdirSafe(globalDir); - writePlugin({ - id: "rogue", - body: `module.exports = { id: "rogue", register() {} };`, - dir: globalDir, - filename: "index.cjs", - }); - - const warnings: string[] = []; - const registry = loadOpenClawPlugins({ - cache: false, - logger: createWarningLogger(warnings), - config: { - plugins: { - allow: ["rogue"], - }, + return { + registry, + warnings, + pluginId: plugin.id, + expectWarning: false, + expectedSource: plugin.file, + }; }, - }); + }, + { + label: "does not warn about missing provenance for env-resolved install paths", + loadRegistry: () => { + const { plugin, env } = createEnvResolvedPluginFixture("tracked-install-path"); + const warnings: string[] = []; + const registry = loadOpenClawPlugins({ + cache: false, + logger: createWarningLogger(warnings), + env, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: [plugin.id], + installs: { + [plugin.id]: { + source: "path", + installPath: `~/plugins/${plugin.id}`, + sourcePath: `~/plugins/${plugin.id}`, + }, + }, + }, + }, + }); - const rogue = registry.plugins.find((entry) => entry.id === "rogue"); - expect(rogue?.status).toBe("loaded"); + return { + registry, + warnings, + pluginId: plugin.id, + expectWarning: false, + expectedSource: plugin.file, + }; + }, + }, + ] as const; + + for (const scenario of scenarios) { + const loadedScenario = scenario.loadRegistry(); + const { registry, warnings, pluginId, expectWarning } = loadedScenario; + const expectedSource = + "expectedSource" in loadedScenario ? loadedScenario.expectedSource : undefined; + const plugin = registry.plugins.find((entry) => entry.id === pluginId); + expect(plugin?.status, scenario.label).toBe("loaded"); + if (expectedSource) { + expect(plugin?.source, scenario.label).toBe(expectedSource); + } expect( warnings.some( (msg) => - msg.includes("rogue") && msg.includes("loaded without install/load-path provenance"), + msg.includes(pluginId) && msg.includes("loaded without install/load-path provenance"), ), - ).toBe(true); - }); + scenario.label, + ).toBe(expectWarning); + } }); - it("does not warn about missing provenance for env-resolved load paths", () => { - useNoBundledPlugins(); - const openclawHome = makeTempDir(); - const ignoredHome = makeTempDir(); - const stateDir = makeTempDir(); - const pluginDir = path.join(openclawHome, "plugins", "tracked-load-path"); - mkdirSafe(pluginDir); - const plugin = writePlugin({ - id: "tracked-load-path", - dir: pluginDir, - filename: "index.cjs", - body: `module.exports = { id: "tracked-load-path", register() {} };`, - }); - - const warnings: string[] = []; - const registry = loadOpenClawPlugins({ - cache: false, - logger: createWarningLogger(warnings), - env: { - ...process.env, - OPENCLAW_HOME: openclawHome, - HOME: ignoredHome, - OPENCLAW_STATE_DIR: stateDir, - CLAWDBOT_STATE_DIR: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", - }, - config: { - plugins: { - load: { paths: ["~/plugins/tracked-load-path"] }, - allow: ["tracked-load-path"], - }, - }, - }); - - expect(registry.plugins.find((entry) => entry.id === "tracked-load-path")?.source).toBe( - plugin.file, - ); - expect( - warnings.some((msg) => msg.includes("loaded without install/load-path provenance")), - ).toBe(false); - }); - - it("does not warn about missing provenance for env-resolved install paths", () => { - useNoBundledPlugins(); - const openclawHome = makeTempDir(); - const ignoredHome = makeTempDir(); - const stateDir = makeTempDir(); - const pluginDir = path.join(openclawHome, "plugins", "tracked-install-path"); - mkdirSafe(pluginDir); - const plugin = writePlugin({ - id: "tracked-install-path", - dir: pluginDir, - filename: "index.cjs", - body: `module.exports = { id: "tracked-install-path", register() {} };`, - }); - - const warnings: string[] = []; - const registry = loadOpenClawPlugins({ - cache: false, - logger: createWarningLogger(warnings), - env: { - ...process.env, - OPENCLAW_HOME: openclawHome, - HOME: ignoredHome, - OPENCLAW_STATE_DIR: stateDir, - CLAWDBOT_STATE_DIR: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", - }, - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: ["tracked-install-path"], - installs: { - "tracked-install-path": { - source: "path", - installPath: "~/plugins/tracked-install-path", - sourcePath: "~/plugins/tracked-install-path", - }, - }, - }, - }, - }); - - expect(registry.plugins.find((entry) => entry.id === "tracked-install-path")?.source).toBe( - plugin.file, - ); - expect( - warnings.some((msg) => msg.includes("loaded without install/load-path provenance")), - ).toBe(false); - }); - - it("rejects plugin entry files that escape plugin root via symlink", () => { - useNoBundledPlugins(); - const { outsideEntry, linkedEntry } = createEscapingEntryFixture({ + it.each([ + { + name: "rejects plugin entry files that escape plugin root via symlink", id: "symlinked", - sourceBody: - 'module.exports = { id: "symlinked", register() { throw new Error("should not run"); } };', - }); - try { - fs.symlinkSync(outsideEntry, linkedEntry); - } catch { - return; - } - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [linkedEntry] }, - allow: ["symlinked"], - }, - }, - }); - - const record = registry.plugins.find((entry) => entry.id === "symlinked"); - expect(record?.status).not.toBe("loaded"); - expect(registry.diagnostics.some((entry) => entry.message.includes("escapes"))).toBe(true); - }); - - it("rejects plugin entry files that escape plugin root via hardlink", () => { - if (process.platform === "win32") { - return; - } - useNoBundledPlugins(); - const { outsideEntry, linkedEntry } = createEscapingEntryFixture({ + linkKind: "symlink" as const, + }, + { + name: "rejects plugin entry files that escape plugin root via hardlink", id: "hardlinked", - sourceBody: - 'module.exports = { id: "hardlinked", register() { throw new Error("should not run"); } };', - }); - try { - fs.linkSync(outsideEntry, linkedEntry); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "EXDEV") { - return; - } - throw err; + linkKind: "hardlink" as const, + skip: process.platform === "win32", + }, + ])("$name", ({ id, linkKind, skip }) => { + if (skip) { + return; } - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [linkedEntry] }, - allow: ["hardlinked"], - }, - }, + expectEscapingEntryRejected({ + id, + linkKind, + sourceBody: `module.exports = { id: "${id}", register() { throw new Error("should not run"); } };`, }); - - const record = registry.plugins.find((entry) => entry.id === "hardlinked"); - expect(record?.status).not.toBe("loaded"); - expect(registry.diagnostics.some((entry) => entry.message.includes("escapes"))).toBe(true); }); it("allows bundled plugin entry files that are hardlinked aliases", () => { @@ -3295,56 +3216,112 @@ module.exports = { }); }); - it("prefers dist plugin-sdk alias when loader runs from dist", () => { - const { root, distFile } = createPluginSdkAliasFixture(); - - const resolved = __testing.resolvePluginSdkAliasFile({ + it.each([ + { + name: "prefers dist plugin-sdk alias when loader runs from dist", + buildFixture: () => createPluginSdkAliasFixture(), + modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"), srcFile: "index.ts", distFile: "index.js", - modulePath: path.join(root, "dist", "plugins", "loader.js"), + expected: "dist" as const, + }, + { + name: "prefers src plugin-sdk alias when loader runs from src in non-production", + buildFixture: () => createPluginSdkAliasFixture(), + modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"), + srcFile: "index.ts", + distFile: "index.js", + env: { NODE_ENV: undefined }, + expected: "src" as const, + }, + { + name: "falls back to src plugin-sdk alias when dist is missing in production", + buildFixture: () => { + const fixture = createPluginSdkAliasFixture(); + fs.rmSync(fixture.distFile); + return fixture; + }, + modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"), + srcFile: "index.ts", + distFile: "index.js", + env: { NODE_ENV: "production", VITEST: undefined }, + expected: "src" as const, + }, + { + name: "prefers dist root-alias shim when loader runs from dist", + buildFixture: () => + createPluginSdkAliasFixture({ + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + srcBody: "module.exports = {};\n", + distBody: "module.exports = {};\n", + }), + modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"), + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + expected: "dist" as const, + }, + { + name: "prefers src root-alias shim when loader runs from src in non-production", + buildFixture: () => + createPluginSdkAliasFixture({ + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + srcBody: "module.exports = {};\n", + distBody: "module.exports = {};\n", + }), + modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"), + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + env: { NODE_ENV: undefined }, + expected: "src" as const, + }, + { + name: "resolves plugin-sdk alias from package root when loader runs from transpiler cache path", + buildFixture: () => createPluginSdkAliasFixture(), + modulePath: () => "/tmp/tsx-cache/openclaw-loader.js", + argv1: (root: string) => path.join(root, "openclaw.mjs"), + srcFile: "index.ts", + distFile: "index.js", + env: { NODE_ENV: undefined }, + expected: "src" as const, + }, + ])("$name", ({ buildFixture, modulePath, argv1, srcFile, distFile, env, expected }) => { + const fixture = buildFixture(); + const resolved = resolvePluginSdkAlias({ + root: fixture.root, + srcFile, + distFile, + modulePath: modulePath(fixture.root), + argv1: argv1?.(fixture.root), + env, }); - expect(resolved).toBe(distFile); + expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile); }); - it("prefers dist candidates first for production src runtime", () => { - const { root, srcFile, distFile } = createPluginSdkAliasFixture(); - - const candidates = withEnv({ NODE_ENV: "production", VITEST: undefined }, () => - __testing.listPluginSdkAliasCandidates({ - srcFile: "index.ts", - distFile: "index.js", - modulePath: path.join(root, "src", "plugins", "loader.ts"), - }), - ); - - expect(candidates.indexOf(distFile)).toBeLessThan(candidates.indexOf(srcFile)); - }); - - it("prefers src plugin-sdk alias when loader runs from src in non-production", () => { - const { root, srcFile } = createPluginSdkAliasFixture(); - - const resolved = withEnv({ NODE_ENV: undefined }, () => - __testing.resolvePluginSdkAliasFile({ - srcFile: "index.ts", - distFile: "index.js", - modulePath: path.join(root, "src", "plugins", "loader.ts"), - }), - ); - expect(resolved).toBe(srcFile); - }); - - it("prefers src candidates first for non-production src runtime", () => { - const { root, srcFile, distFile } = createPluginSdkAliasFixture(); - - const candidates = withEnv({ NODE_ENV: undefined }, () => - __testing.listPluginSdkAliasCandidates({ - srcFile: "index.ts", - distFile: "index.js", - modulePath: path.join(root, "src", "plugins", "loader.ts"), - }), - ); - - expect(candidates.indexOf(srcFile)).toBeLessThan(candidates.indexOf(distFile)); + it.each([ + { + name: "prefers dist candidates first for production src runtime", + env: { NODE_ENV: "production", VITEST: undefined }, + expectedFirst: "dist" as const, + }, + { + name: "prefers src candidates first for non-production src runtime", + env: { NODE_ENV: undefined }, + expectedFirst: "src" as const, + }, + ])("$name", ({ env, expectedFirst }) => { + const fixture = createPluginSdkAliasFixture(); + const candidates = listPluginSdkAliasCandidates({ + root: fixture.root, + srcFile: "index.ts", + distFile: "index.js", + modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"), + env, + }); + const first = expectedFirst === "dist" ? fixture.distFile : fixture.srcFile; + const second = expectedFirst === "dist" ? fixture.srcFile : fixture.distFile; + expect(candidates.indexOf(first)).toBeLessThan(candidates.indexOf(second)); }); it("derives plugin-sdk subpaths from package exports", () => { @@ -3354,36 +3331,6 @@ module.exports = { expect(subpaths).not.toContain("root-alias"); }); - it("falls back to src plugin-sdk alias when dist is missing in production", () => { - const { root, srcFile, distFile } = createPluginSdkAliasFixture(); - fs.rmSync(distFile); - - const resolved = withEnv({ NODE_ENV: "production", VITEST: undefined }, () => - __testing.resolvePluginSdkAliasFile({ - srcFile: "index.ts", - distFile: "index.js", - modulePath: path.join(root, "src", "plugins", "loader.ts"), - }), - ); - expect(resolved).toBe(srcFile); - }); - - it("prefers dist root-alias shim when loader runs from dist", () => { - const { root, distFile } = createPluginSdkAliasFixture({ - srcFile: "root-alias.cjs", - distFile: "root-alias.cjs", - srcBody: "module.exports = {};\n", - distBody: "module.exports = {};\n", - }); - - const resolved = __testing.resolvePluginSdkAliasFile({ - srcFile: "root-alias.cjs", - distFile: "root-alias.cjs", - modulePath: path.join(root, "dist", "plugins", "loader.js"), - }); - expect(resolved).toBe(distFile); - }); - it("configures the plugin loader jiti boundary to prefer native dist modules", () => { const options = __testing.buildPluginLoaderJitiOptions({}); @@ -3394,56 +3341,26 @@ module.exports = { expect("alias" in options).toBe(false); }); - it("prefers src root-alias shim when loader runs from src in non-production", () => { - const { root, srcFile } = createPluginSdkAliasFixture({ - srcFile: "root-alias.cjs", - distFile: "root-alias.cjs", - srcBody: "module.exports = {};\n", - distBody: "module.exports = {};\n", + it.each([ + { + name: "prefers dist plugin runtime module when loader runs from dist", + modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"), + expected: "dist" as const, + }, + { + name: "resolves plugin runtime module from package root when loader runs from transpiler cache path", + modulePath: () => "/tmp/tsx-cache/openclaw-loader.js", + argv1: (root: string) => path.join(root, "openclaw.mjs"), + env: { NODE_ENV: undefined }, + expected: "src" as const, + }, + ])("$name", ({ modulePath, argv1, env, expected }) => { + const fixture = createPluginRuntimeAliasFixture(); + const resolved = resolvePluginRuntimeModule({ + modulePath: modulePath(fixture.root), + argv1: argv1?.(fixture.root), + env, }); - - const resolved = withEnv({ NODE_ENV: undefined }, () => - __testing.resolvePluginSdkAliasFile({ - srcFile: "root-alias.cjs", - distFile: "root-alias.cjs", - modulePath: path.join(root, "src", "plugins", "loader.ts"), - }), - ); - expect(resolved).toBe(srcFile); - }); - - it("resolves plugin-sdk alias from package root when loader runs from transpiler cache path", () => { - const { root, srcFile } = createPluginSdkAliasFixture(); - - const resolved = withEnv({ NODE_ENV: undefined }, () => - __testing.resolvePluginSdkAliasFile({ - srcFile: "index.ts", - distFile: "index.js", - modulePath: "/tmp/tsx-cache/openclaw-loader.js", - argv1: path.join(root, "openclaw.mjs"), - }), - ); - expect(resolved).toBe(srcFile); - }); - - it("prefers dist plugin runtime module when loader runs from dist", () => { - const { root, distFile } = createPluginRuntimeAliasFixture(); - - const resolved = __testing.resolvePluginRuntimeModulePath({ - modulePath: path.join(root, "dist", "plugins", "loader.js"), - }); - expect(resolved).toBe(distFile); - }); - - it("resolves plugin runtime module from package root when loader runs from transpiler cache path", () => { - const { root, srcFile } = createPluginRuntimeAliasFixture(); - - const resolved = withEnv({ NODE_ENV: undefined }, () => - __testing.resolvePluginRuntimeModulePath({ - modulePath: "/tmp/tsx-cache/openclaw-loader.js", - argv1: path.join(root, "openclaw.mjs"), - }), - ); - expect(resolved).toBe(srcFile); + expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile); }); }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 8d064d477c3..251a08beb4e 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1035,6 +1035,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi origin: candidate.origin, config: normalized, rootConfig: cfg, + enabledByDefault: manifestRecord.enabledByDefault, }); const entry = normalized.entries[pluginId]; const record = createPluginRecord({ diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index c60e5444443..14a571c9250 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -203,6 +203,7 @@ describe("loadPluginManifestRegistry", () => { const dir = makeTempDir(); writeManifest(dir, { id: "openai", + enabledByDefault: true, providers: ["openai", "openai-codex"], providerAuthEnvVars: { openai: ["OPENAI_API_KEY"], @@ -227,6 +228,7 @@ describe("loadPluginManifestRegistry", () => { expect(registry.plugins[0]?.providerAuthEnvVars).toEqual({ openai: ["OPENAI_API_KEY"], }); + expect(registry.plugins[0]?.enabledByDefault).toBe(true); expect(registry.plugins[0]?.providerAuthChoices).toEqual([ { provider: "openai", diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 7a5c10d67f0..eea801a72ea 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -35,6 +35,7 @@ export type PluginManifestRecord = { name?: string; description?: string; version?: string; + enabledByDefault?: boolean; format?: PluginFormat; bundleFormat?: PluginBundleFormat; bundleCapabilities?: string[]; @@ -154,6 +155,7 @@ function buildRecord(params: { description: normalizeManifestLabel(params.manifest.description) ?? params.candidate.packageDescription, version: normalizeManifestLabel(params.manifest.version) ?? params.candidate.packageVersion, + enabledByDefault: params.manifest.enabledByDefault === true ? true : undefined, format: params.candidate.format ?? "openclaw", bundleFormat: params.candidate.bundleFormat, kind: params.manifest.kind, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index dd8615d7350..a75a2a9b6ab 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -11,6 +11,7 @@ export const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const; export type PluginManifest = { id: string; configSchema: Record; + enabledByDefault?: boolean; kind?: PluginKind; channels?: string[]; providers?: string[]; @@ -180,6 +181,7 @@ export function loadPluginManifest( } const kind = typeof raw.kind === "string" ? (raw.kind as PluginKind) : undefined; + const enabledByDefault = raw.enabledByDefault === true; const name = typeof raw.name === "string" ? raw.name.trim() : undefined; const description = typeof raw.description === "string" ? raw.description.trim() : undefined; const version = typeof raw.version === "string" ? raw.version.trim() : undefined; @@ -199,6 +201,7 @@ export function loadPluginManifest( manifest: { id, configSchema, + ...(enabledByDefault ? { enabledByDefault } : {}), kind, channels, providers, diff --git a/src/plugins/provider-catalog.test.ts b/src/plugins/provider-catalog.test.ts index a49e82a98e6..e7dcf201226 100644 --- a/src/plugins/provider-catalog.test.ts +++ b/src/plugins/provider-catalog.test.ts @@ -27,6 +27,11 @@ function createCatalogContext(params: { resolveProviderApiKey: (providerId) => ({ apiKey: providerId ? params.apiKeys?.[providerId] : undefined, }), + resolveProviderAuth: (providerId) => ({ + apiKey: providerId ? params.apiKeys?.[providerId] : undefined, + mode: providerId && params.apiKeys?.[providerId] ? "api_key" : "none", + source: providerId && params.apiKeys?.[providerId] ? "env" : "none", + }), }; } diff --git a/src/plugins/provider-discovery.test.ts b/src/plugins/provider-discovery.test.ts index 4952961062b..30efba6081b 100644 --- a/src/plugins/provider-discovery.test.ts +++ b/src/plugins/provider-discovery.test.ts @@ -120,6 +120,12 @@ describe("runProviderCatalog", () => { config: {}, env: {}, resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), }); expect(result).toEqual({ diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index e249bf6e45a..b3816e2faf1 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -81,6 +81,16 @@ export function runProviderCatalog(params: { apiKey: string | undefined; discoveryApiKey?: string; }; + resolveProviderAuth: ( + providerId?: string, + options?: { oauthMarker?: string }, + ) => { + apiKey: string | undefined; + discoveryApiKey?: string; + mode: "api_key" | "oauth" | "token" | "none"; + source: "env" | "profile" | "none"; + profileId?: string; + }; }) { return resolveProviderCatalogHook(params.provider)?.run({ config: params.config, @@ -88,5 +98,6 @@ export function runProviderCatalog(params: { workspaceDir: params.workspaceDir, env: params.env, resolveProviderApiKey: params.resolveProviderApiKey, + resolveProviderAuth: params.resolveProviderAuth, }); } diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index b8da58c1921..ff804babb43 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -70,6 +70,11 @@ describe("resolvePluginProviders", () => { config: expect.objectContaining({ plugins: expect.objectContaining({ allow: expect.arrayContaining(["openrouter", "google", "kilocode", "moonshot"]), + entries: expect.objectContaining({ + google: { enabled: true }, + kilocode: { enabled: true }, + moonshot: { enabled: true }, + }), }), }), cache: false, @@ -89,6 +94,10 @@ describe("resolvePluginProviders", () => { plugins: expect.objectContaining({ enabled: true, allow: expect.arrayContaining(["google", "moonshot"]), + entries: expect.objectContaining({ + google: { enabled: true }, + moonshot: { enabled: true }, + }), }), }), cache: false, diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 45c84986e6c..e966e9d4128 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -1,6 +1,9 @@ import { normalizeProviderId } from "../agents/provider-id.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { withBundledPluginAllowlistCompat } from "./bundled-compat.js"; +import { + withBundledPluginAllowlistCompat, + withBundledPluginEnablementCompat, +} from "./bundled-compat.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; @@ -165,13 +168,20 @@ export function resolvePluginProviders(params: { pluginIds: bundledProviderCompatPluginIds, }) : params.config; - const config = params.bundledProviderVitestCompat + const maybeVitestCompat = params.bundledProviderVitestCompat ? withBundledProviderVitestCompat({ config: maybeAllowlistCompat, pluginIds: bundledProviderCompatPluginIds, env: params.env, }) : maybeAllowlistCompat; + const config = + params.bundledProviderAllowlistCompat || params.bundledProviderVitestCompat + ? withBundledPluginEnablementCompat({ + config: maybeVitestCompat, + pluginIds: bundledProviderCompatPluginIds, + }) + : maybeVitestCompat; const registry = loadOpenClawPlugins({ config, workspaceDir: params.workspaceDir, diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index ca4e40ee54c..3e89c8462b5 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -14,6 +14,7 @@ import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import { registerPluginInteractiveHandler } from "./interactive.js"; import { normalizeRegisteredProvider } from "./provider-validation.js"; +import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js"; import type { PluginRuntime } from "./runtime/types.js"; import { defaultSlotIdForKey } from "./slots.js"; import { @@ -27,6 +28,7 @@ import type { OpenClawPluginChannelRegistration, OpenClawPluginCliRegistrar, OpenClawPluginCommandDefinition, + PluginConversationBindingResolvedEvent, OpenClawPluginHttpRouteAuth, OpenClawPluginHttpRouteMatch, OpenClawPluginHttpRouteHandler, @@ -146,6 +148,15 @@ export type PluginCommandRegistration = { rootDir?: string; }; +export type PluginConversationBindingResolvedHandlerRegistration = { + pluginId: string; + pluginName?: string; + pluginRoot?: string; + handler: (event: PluginConversationBindingResolvedEvent) => void | Promise; + source: string; + rootDir?: string; +}; + export type PluginRecord = { id: string; name: string; @@ -198,6 +209,7 @@ export type PluginRegistry = { cliRegistrars: PluginCliRegistration[]; services: PluginServiceRegistration[]; commands: PluginCommandRegistration[]; + conversationBindingResolvedHandlers: PluginConversationBindingResolvedHandlerRegistration[]; diagnostics: PluginDiagnostic[]; }; @@ -246,6 +258,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { cliRegistrars: [], services: [], commands: [], + conversationBindingResolvedHandlers: [], diagnostics: [], }; } @@ -828,6 +841,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } as TypedPluginHookRegistration); }; + const registerConversationBindingResolvedHandler = ( + record: PluginRecord, + handler: (event: PluginConversationBindingResolvedEvent) => void | Promise, + ) => { + registry.conversationBindingResolvedHandlers.push({ + pluginId: record.id, + pluginName: record.name, + pluginRoot: record.rootDir, + handler, + source: record.source, + rootDir: record.rootDir, + }); + }; + const normalizeLogger = (logger: PluginLogger): PluginLogger => ({ info: logger.info, warn: logger.warn, @@ -835,6 +862,36 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { debug: logger.debug, }); + const pluginRuntimeById = new Map(); + + const resolvePluginRuntime = (pluginId: string): PluginRuntime => { + const cached = pluginRuntimeById.get(pluginId); + if (cached) { + return cached; + } + const runtime = new Proxy(registryParams.runtime, { + get(target, prop, receiver) { + if (prop !== "subagent") { + return Reflect.get(target, prop, receiver); + } + const subagent = Reflect.get(target, prop, receiver); + return { + run: (params) => withPluginRuntimePluginIdScope(pluginId, () => subagent.run(params)), + waitForRun: (params) => + withPluginRuntimePluginIdScope(pluginId, () => subagent.waitForRun(params)), + getSessionMessages: (params) => + withPluginRuntimePluginIdScope(pluginId, () => subagent.getSessionMessages(params)), + getSession: (params) => + withPluginRuntimePluginIdScope(pluginId, () => subagent.getSession(params)), + deleteSession: (params) => + withPluginRuntimePluginIdScope(pluginId, () => subagent.deleteSession(params)), + } satisfies PluginRuntime["subagent"]; + }, + }); + pluginRuntimeById.set(pluginId, runtime); + return runtime; + }; + const createApi = ( record: PluginRecord, params: { @@ -855,7 +912,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registrationMode, config: params.config, pluginConfig: params.pluginConfig, - runtime: registryParams.runtime, + runtime: resolvePluginRuntime(record.id), logger: normalizeLogger(registryParams.logger), registerTool: registrationMode === "full" ? (tool, opts) => registerTool(record, tool, opts) : () => {}, @@ -911,6 +968,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } } : () => {}, + onConversationBindingResolved: + registrationMode === "full" + ? (handler) => registerConversationBindingResolvedHandler(record, handler) + : () => {}, registerCommand: registrationMode === "full" ? (command) => registerCommand(record, command) : () => {}, registerContextEngine: (id, factory) => { diff --git a/src/plugins/runtime/gateway-request-scope.test.ts b/src/plugins/runtime/gateway-request-scope.test.ts index ef31350e2a3..4d00d04fd74 100644 --- a/src/plugins/runtime/gateway-request-scope.test.ts +++ b/src/plugins/runtime/gateway-request-scope.test.ts @@ -20,4 +20,17 @@ describe("gateway request scope", () => { expect(second.getPluginRuntimeGatewayRequestScope()).toEqual(TEST_SCOPE); }); }); + + it("attaches plugin id to the active scope", async () => { + const runtimeScope = await import("./gateway-request-scope.js"); + + await runtimeScope.withPluginRuntimeGatewayRequestScope(TEST_SCOPE, async () => { + await runtimeScope.withPluginRuntimePluginIdScope("voice-call", async () => { + expect(runtimeScope.getPluginRuntimeGatewayRequestScope()).toEqual({ + ...TEST_SCOPE, + pluginId: "voice-call", + }); + }); + }); + }); }); diff --git a/src/plugins/runtime/gateway-request-scope.ts b/src/plugins/runtime/gateway-request-scope.ts index 72a6f5af402..7a4ffbb608b 100644 --- a/src/plugins/runtime/gateway-request-scope.ts +++ b/src/plugins/runtime/gateway-request-scope.ts @@ -8,6 +8,7 @@ export type PluginRuntimeGatewayRequestScope = { context?: GatewayRequestContext; client?: GatewayRequestOptions["client"]; isWebchatConnect: GatewayRequestOptions["isWebchatConnect"]; + pluginId?: string; }; const PLUGIN_RUNTIME_GATEWAY_REQUEST_SCOPE_KEY: unique symbol = Symbol.for( @@ -37,6 +38,20 @@ export function withPluginRuntimeGatewayRequestScope( return pluginRuntimeGatewayRequestScope.run(scope, run); } +/** + * Runs work under the current gateway request scope while attaching plugin identity. + */ +export function withPluginRuntimePluginIdScope(pluginId: string, run: () => T): T { + const current = pluginRuntimeGatewayRequestScope.getStore(); + const scoped: PluginRuntimeGatewayRequestScope = current + ? { ...current, pluginId } + : { + pluginId, + isWebchatConnect: () => false, + }; + return pluginRuntimeGatewayRequestScope.run(scoped, run); +} + /** * Returns the current plugin gateway request scope when called from a plugin request handler. */ diff --git a/src/plugins/runtime/runtime-discord-ops.runtime.ts b/src/plugins/runtime/runtime-discord-ops.runtime.ts index 182e9c75d41..e1bc99166af 100644 --- a/src/plugins/runtime/runtime-discord-ops.runtime.ts +++ b/src/plugins/runtime/runtime-discord-ops.runtime.ts @@ -1,12 +1,12 @@ -import { auditDiscordChannelPermissions as auditDiscordChannelPermissionsImpl } from "../../../extensions/discord/src/audit.js"; +import { auditDiscordChannelPermissions as auditDiscordChannelPermissionsImpl } from "../../../extensions/discord/runtime-api.js"; import { listDiscordDirectoryGroupsLive as listDiscordDirectoryGroupsLiveImpl, listDiscordDirectoryPeersLive as listDiscordDirectoryPeersLiveImpl, -} from "../../../extensions/discord/src/directory-live.js"; -import { monitorDiscordProvider as monitorDiscordProviderImpl } from "../../../extensions/discord/src/monitor.js"; -import { probeDiscord as probeDiscordImpl } from "../../../extensions/discord/src/probe.js"; -import { resolveDiscordChannelAllowlist as resolveDiscordChannelAllowlistImpl } from "../../../extensions/discord/src/resolve-channels.js"; -import { resolveDiscordUserAllowlist as resolveDiscordUserAllowlistImpl } from "../../../extensions/discord/src/resolve-users.js"; +} from "../../../extensions/discord/runtime-api.js"; +import { monitorDiscordProvider as monitorDiscordProviderImpl } from "../../../extensions/discord/runtime-api.js"; +import { probeDiscord as probeDiscordImpl } from "../../../extensions/discord/runtime-api.js"; +import { resolveDiscordChannelAllowlist as resolveDiscordChannelAllowlistImpl } from "../../../extensions/discord/runtime-api.js"; +import { resolveDiscordUserAllowlist as resolveDiscordUserAllowlistImpl } from "../../../extensions/discord/runtime-api.js"; import { createThreadDiscord as createThreadDiscordImpl, deleteMessageDiscord as deleteMessageDiscordImpl, @@ -18,7 +18,7 @@ import { sendPollDiscord as sendPollDiscordImpl, sendTypingDiscord as sendTypingDiscordImpl, unpinMessageDiscord as unpinMessageDiscordImpl, -} from "../../../extensions/discord/src/send.js"; +} from "../../../extensions/discord/runtime-api.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeDiscordOps = Pick< diff --git a/src/plugins/runtime/runtime-discord.ts b/src/plugins/runtime/runtime-discord.ts index 4878bff3d81..8264a7f04df 100644 --- a/src/plugins/runtime/runtime-discord.ts +++ b/src/plugins/runtime/runtime-discord.ts @@ -1,4 +1,4 @@ -import { discordMessageActions } from "../../../extensions/discord/src/channel-actions.js"; +import { discordMessageActions } from "../../../extensions/discord/runtime-api.js"; import { getThreadBindingManager, resolveThreadBindingIdleTimeoutMs, @@ -8,122 +8,72 @@ import { setThreadBindingIdleTimeoutBySessionKey, setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, -} from "../../../extensions/discord/src/monitor/thread-bindings.js"; -import { createLazyRuntimeMethod, createLazyRuntimeSurface } from "../../shared/lazy-runtime.js"; +} from "../../../extensions/discord/runtime-api.js"; +import { + createLazyRuntimeMethodBinder, + createLazyRuntimeSurface, +} from "../../shared/lazy-runtime.js"; import { createDiscordTypingLease } from "./runtime-discord-typing.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; -type RuntimeDiscordOps = typeof import("./runtime-discord-ops.runtime.js").runtimeDiscordOps; - const loadRuntimeDiscordOps = createLazyRuntimeSurface( () => import("./runtime-discord-ops.runtime.js"), ({ runtimeDiscordOps }) => runtimeDiscordOps, ); -const auditChannelPermissionsLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.auditChannelPermissions); +const bindDiscordRuntimeMethod = createLazyRuntimeMethodBinder(loadRuntimeDiscordOps); -const listDirectoryGroupsLiveLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.listDirectoryGroupsLive); - -const listDirectoryPeersLiveLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.listDirectoryPeersLive); - -const probeDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.probeDiscord); - -const resolveChannelAllowlistLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.resolveChannelAllowlist); - -const resolveUserAllowlistLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.resolveUserAllowlist); - -const sendComponentMessageLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.sendComponentMessage); - -const sendMessageDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.sendMessageDiscord); - -const sendPollDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.sendPollDiscord); - -const monitorDiscordProviderLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.monitorDiscordProvider); - -const sendTypingDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.typing.pulse); - -const editMessageDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.editMessage); - -const deleteMessageDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->( - loadRuntimeDiscordOps, +const auditChannelPermissionsLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.auditChannelPermissions, +); +const listDirectoryGroupsLiveLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.listDirectoryGroupsLive, +); +const listDirectoryPeersLiveLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.listDirectoryPeersLive, +); +const probeDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.probeDiscord, +); +const resolveChannelAllowlistLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.resolveChannelAllowlist, +); +const resolveUserAllowlistLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.resolveUserAllowlist, +); +const sendComponentMessageLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.sendComponentMessage, +); +const sendMessageDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.sendMessageDiscord, +); +const sendPollDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.sendPollDiscord, +); +const monitorDiscordProviderLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.monitorDiscordProvider, +); +const sendTypingDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.typing.pulse, +); +const editMessageDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.editMessage, +); +const deleteMessageDiscordLazy = bindDiscordRuntimeMethod( (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.deleteMessage, ); - -const pinMessageDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.pinMessage); - -const unpinMessageDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.unpinMessage); - -const createThreadDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.createThread); - -const editChannelDiscordLazy = createLazyRuntimeMethod< - RuntimeDiscordOps, - Parameters, - ReturnType ->(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.editChannel); +const pinMessageDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.pinMessage, +); +const unpinMessageDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.unpinMessage, +); +const createThreadDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.createThread, +); +const editChannelDiscordLazy = bindDiscordRuntimeMethod( + (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.editChannel, +); export function createRuntimeDiscord(): PluginRuntimeChannel["discord"] { return { diff --git a/src/plugins/runtime/runtime-imessage.ts b/src/plugins/runtime/runtime-imessage.ts index 01430cacc3c..56136197626 100644 --- a/src/plugins/runtime/runtime-imessage.ts +++ b/src/plugins/runtime/runtime-imessage.ts @@ -1,6 +1,8 @@ -import { monitorIMessageProvider } from "../../../extensions/imessage/src/monitor.js"; -import { probeIMessage } from "../../../extensions/imessage/src/probe.js"; -import { sendMessageIMessage } from "../../../extensions/imessage/src/send.js"; +import { + monitorIMessageProvider, + probeIMessage, + sendMessageIMessage, +} from "../../../extensions/imessage/runtime-api.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; export function createRuntimeIMessage(): PluginRuntimeChannel["imessage"] { diff --git a/src/plugins/runtime/runtime-media.ts b/src/plugins/runtime/runtime-media.ts index 90b28eea31e..abf88724981 100644 --- a/src/plugins/runtime/runtime-media.ts +++ b/src/plugins/runtime/runtime-media.ts @@ -1,4 +1,4 @@ -import { loadWebMedia } from "../../../extensions/whatsapp/src/media.js"; +import { loadWebMedia } from "../../../extensions/whatsapp/runtime-api.js"; import { isVoiceCompatibleAudio } from "../../media/audio.js"; import { mediaKindFromMime } from "../../media/constants.js"; import { getImageMetadata, resizeToJpeg } from "../../media/image-ops.js"; diff --git a/src/plugins/runtime/runtime-signal.ts b/src/plugins/runtime/runtime-signal.ts index 2465ecbdbbc..dc83f3fd1e2 100644 --- a/src/plugins/runtime/runtime-signal.ts +++ b/src/plugins/runtime/runtime-signal.ts @@ -1,6 +1,8 @@ -import { monitorSignalProvider } from "../../../extensions/signal/src/index.js"; -import { probeSignal } from "../../../extensions/signal/src/probe.js"; -import { sendMessageSignal } from "../../../extensions/signal/src/send.js"; +import { + monitorSignalProvider, + probeSignal, + sendMessageSignal, +} from "../../../extensions/signal/runtime-api.js"; import { signalMessageActions } from "../../channels/plugins/actions/signal.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; diff --git a/src/plugins/runtime/runtime-slack-ops.runtime.ts b/src/plugins/runtime/runtime-slack-ops.runtime.ts index 4f4dc1aeda7..65b7ed9e884 100644 --- a/src/plugins/runtime/runtime-slack-ops.runtime.ts +++ b/src/plugins/runtime/runtime-slack-ops.runtime.ts @@ -1,12 +1,12 @@ import { listSlackDirectoryGroupsLive as listSlackDirectoryGroupsLiveImpl, listSlackDirectoryPeersLive as listSlackDirectoryPeersLiveImpl, -} from "../../../extensions/slack/src/directory-live.js"; -import { monitorSlackProvider as monitorSlackProviderImpl } from "../../../extensions/slack/src/index.js"; -import { probeSlack as probeSlackImpl } from "../../../extensions/slack/src/probe.js"; -import { resolveSlackChannelAllowlist as resolveSlackChannelAllowlistImpl } from "../../../extensions/slack/src/resolve-channels.js"; -import { resolveSlackUserAllowlist as resolveSlackUserAllowlistImpl } from "../../../extensions/slack/src/resolve-users.js"; -import { sendMessageSlack as sendMessageSlackImpl } from "../../../extensions/slack/src/send.js"; +} from "../../../extensions/slack/runtime-api.js"; +import { monitorSlackProvider as monitorSlackProviderImpl } from "../../../extensions/slack/runtime-api.js"; +import { probeSlack as probeSlackImpl } from "../../../extensions/slack/runtime-api.js"; +import { resolveSlackChannelAllowlist as resolveSlackChannelAllowlistImpl } from "../../../extensions/slack/runtime-api.js"; +import { resolveSlackUserAllowlist as resolveSlackUserAllowlistImpl } from "../../../extensions/slack/runtime-api.js"; +import { sendMessageSlack as sendMessageSlackImpl } from "../../../extensions/slack/runtime-api.js"; import { handleSlackAction as handleSlackActionImpl } from "../../agents/tools/slack-actions.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; diff --git a/src/plugins/runtime/runtime-slack.ts b/src/plugins/runtime/runtime-slack.ts index 30742195ad6..9f1cab0f094 100644 --- a/src/plugins/runtime/runtime-slack.ts +++ b/src/plugins/runtime/runtime-slack.ts @@ -1,60 +1,38 @@ -import { createLazyRuntimeMethod, createLazyRuntimeSurface } from "../../shared/lazy-runtime.js"; +import { + createLazyRuntimeMethodBinder, + createLazyRuntimeSurface, +} from "../../shared/lazy-runtime.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; -type RuntimeSlackOps = typeof import("./runtime-slack-ops.runtime.js").runtimeSlackOps; - const loadRuntimeSlackOps = createLazyRuntimeSurface( () => import("./runtime-slack-ops.runtime.js"), ({ runtimeSlackOps }) => runtimeSlackOps, ); -const listDirectoryGroupsLiveLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.listDirectoryGroupsLive); +const bindSlackRuntimeMethod = createLazyRuntimeMethodBinder(loadRuntimeSlackOps); -const listDirectoryPeersLiveLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.listDirectoryPeersLive); - -const probeSlackLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.probeSlack); - -const resolveChannelAllowlistLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.resolveChannelAllowlist); - -const resolveUserAllowlistLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.resolveUserAllowlist); - -const sendMessageSlackLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.sendMessageSlack); - -const monitorSlackProviderLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.monitorSlackProvider); - -const handleSlackActionLazy = createLazyRuntimeMethod< - RuntimeSlackOps, - Parameters, - ReturnType ->(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.handleSlackAction); +const listDirectoryGroupsLiveLazy = bindSlackRuntimeMethod( + (runtimeSlackOps) => runtimeSlackOps.listDirectoryGroupsLive, +); +const listDirectoryPeersLiveLazy = bindSlackRuntimeMethod( + (runtimeSlackOps) => runtimeSlackOps.listDirectoryPeersLive, +); +const probeSlackLazy = bindSlackRuntimeMethod((runtimeSlackOps) => runtimeSlackOps.probeSlack); +const resolveChannelAllowlistLazy = bindSlackRuntimeMethod( + (runtimeSlackOps) => runtimeSlackOps.resolveChannelAllowlist, +); +const resolveUserAllowlistLazy = bindSlackRuntimeMethod( + (runtimeSlackOps) => runtimeSlackOps.resolveUserAllowlist, +); +const sendMessageSlackLazy = bindSlackRuntimeMethod( + (runtimeSlackOps) => runtimeSlackOps.sendMessageSlack, +); +const monitorSlackProviderLazy = bindSlackRuntimeMethod( + (runtimeSlackOps) => runtimeSlackOps.monitorSlackProvider, +); +const handleSlackActionLazy = bindSlackRuntimeMethod( + (runtimeSlackOps) => runtimeSlackOps.handleSlackAction, +); export function createRuntimeSlack(): PluginRuntimeChannel["slack"] { return { diff --git a/src/plugins/runtime/runtime-telegram-ops.runtime.ts b/src/plugins/runtime/runtime-telegram-ops.runtime.ts index b8b915e6065..dcd3fa05dec 100644 --- a/src/plugins/runtime/runtime-telegram-ops.runtime.ts +++ b/src/plugins/runtime/runtime-telegram-ops.runtime.ts @@ -1,6 +1,6 @@ -import { auditTelegramGroupMembership as auditTelegramGroupMembershipImpl } from "../../../extensions/telegram/src/audit.js"; -import { monitorTelegramProvider as monitorTelegramProviderImpl } from "../../../extensions/telegram/src/monitor.js"; -import { probeTelegram as probeTelegramImpl } from "../../../extensions/telegram/src/probe.js"; +import { auditTelegramGroupMembership as auditTelegramGroupMembershipImpl } from "../../../extensions/telegram/runtime-api.js"; +import { monitorTelegramProvider as monitorTelegramProviderImpl } from "../../../extensions/telegram/runtime-api.js"; +import { probeTelegram as probeTelegramImpl } from "../../../extensions/telegram/runtime-api.js"; import { deleteMessageTelegram as deleteMessageTelegramImpl, editMessageReplyMarkupTelegram as editMessageReplyMarkupTelegramImpl, @@ -11,7 +11,7 @@ import { sendPollTelegram as sendPollTelegramImpl, sendTypingTelegram as sendTypingTelegramImpl, unpinMessageTelegram as unpinMessageTelegramImpl, -} from "../../../extensions/telegram/src/send.js"; +} from "../../../extensions/telegram/runtime-api.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeTelegramOps = Pick< diff --git a/src/plugins/runtime/runtime-telegram.ts b/src/plugins/runtime/runtime-telegram.ts index b83df21670f..74b4de7e48e 100644 --- a/src/plugins/runtime/runtime-telegram.ts +++ b/src/plugins/runtime/runtime-telegram.ts @@ -1,108 +1,58 @@ -import { collectTelegramUnmentionedGroupIds } from "../../../extensions/telegram/src/audit.js"; -import { telegramMessageActions } from "../../../extensions/telegram/src/channel-actions.js"; +import { collectTelegramUnmentionedGroupIds } from "../../../extensions/telegram/runtime-api.js"; +import { telegramMessageActions } from "../../../extensions/telegram/runtime-api.js"; import { setTelegramThreadBindingIdleTimeoutBySessionKey, setTelegramThreadBindingMaxAgeBySessionKey, -} from "../../../extensions/telegram/src/thread-bindings.js"; -import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js"; -import { createLazyRuntimeMethod, createLazyRuntimeSurface } from "../../shared/lazy-runtime.js"; +} from "../../../extensions/telegram/runtime-api.js"; +import { resolveTelegramToken } from "../../../extensions/telegram/runtime-api.js"; +import { + createLazyRuntimeMethodBinder, + createLazyRuntimeSurface, +} from "../../shared/lazy-runtime.js"; import { createTelegramTypingLease } from "./runtime-telegram-typing.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; -type RuntimeTelegramOps = typeof import("./runtime-telegram-ops.runtime.js").runtimeTelegramOps; - const loadRuntimeTelegramOps = createLazyRuntimeSurface( () => import("./runtime-telegram-ops.runtime.js"), ({ runtimeTelegramOps }) => runtimeTelegramOps, ); -const auditGroupMembershipLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.auditGroupMembership); +const bindTelegramRuntimeMethod = createLazyRuntimeMethodBinder(loadRuntimeTelegramOps); -const probeTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.probeTelegram); - -const sendMessageTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.sendMessageTelegram); - -const sendPollTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.sendPollTelegram); - -const monitorTelegramProviderLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.monitorTelegramProvider); - -const sendTypingTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.typing.pulse); - -const editMessageTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->( - loadRuntimeTelegramOps, +const auditGroupMembershipLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.auditGroupMembership, +); +const probeTelegramLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.probeTelegram, +); +const sendMessageTelegramLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.sendMessageTelegram, +); +const sendPollTelegramLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.sendPollTelegram, +); +const monitorTelegramProviderLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.monitorTelegramProvider, +); +const sendTypingTelegramLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.typing.pulse, +); +const editMessageTelegramLazy = bindTelegramRuntimeMethod( (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.editMessage, ); - -const editMessageReplyMarkupTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->( - loadRuntimeTelegramOps, +const editMessageReplyMarkupTelegramLazy = bindTelegramRuntimeMethod( (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.editReplyMarkup, ); - -const deleteMessageTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->( - loadRuntimeTelegramOps, +const deleteMessageTelegramLazy = bindTelegramRuntimeMethod( (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.deleteMessage, ); - -const renameForumTopicTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->( - loadRuntimeTelegramOps, +const renameForumTopicTelegramLazy = bindTelegramRuntimeMethod( (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.renameTopic, ); - -const pinMessageTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->( - loadRuntimeTelegramOps, +const pinMessageTelegramLazy = bindTelegramRuntimeMethod( (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.pinMessage, ); - -const unpinMessageTelegramLazy = createLazyRuntimeMethod< - RuntimeTelegramOps, - Parameters, - ReturnType ->( - loadRuntimeTelegramOps, +const unpinMessageTelegramLazy = bindTelegramRuntimeMethod( (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.unpinMessage, ); diff --git a/src/plugins/runtime/runtime-whatsapp-login-tool.ts b/src/plugins/runtime/runtime-whatsapp-login-tool.ts index 811619b9099..094e47c9a1d 100644 --- a/src/plugins/runtime/runtime-whatsapp-login-tool.ts +++ b/src/plugins/runtime/runtime-whatsapp-login-tool.ts @@ -1 +1 @@ -export { createWhatsAppLoginTool as createRuntimeWhatsAppLoginTool } from "../../../extensions/whatsapp/src/agent-tools-login.js"; +export { createWhatsAppLoginTool as createRuntimeWhatsAppLoginTool } from "../../../extensions/whatsapp/runtime-api.js"; diff --git a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts index 2760db7311d..baef795d478 100644 --- a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts +++ b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts @@ -1,4 +1,4 @@ -import { loginWeb as loginWebImpl } from "../../../extensions/whatsapp/src/login.js"; +import { loginWeb as loginWebImpl } from "../../../extensions/whatsapp/runtime-api.js"; import type { PluginRuntime } from "./types.js"; type RuntimeWhatsAppLogin = Pick; diff --git a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts index 71aa83ce9ac..91fcba6fd39 100644 --- a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts +++ b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts @@ -1,7 +1,7 @@ import { sendMessageWhatsApp as sendMessageWhatsAppImpl, sendPollWhatsApp as sendPollWhatsAppImpl, -} from "../../../extensions/whatsapp/src/send.js"; +} from "../../../extensions/whatsapp/runtime-api.js"; import type { PluginRuntime } from "./types.js"; type RuntimeWhatsAppOutbound = Pick< diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts index 63871bc08f8..5ca70688471 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -1,20 +1,18 @@ -import { getActiveWebListener } from "../../../extensions/whatsapp/src/active-listener.js"; +import { getActiveWebListener } from "../../../extensions/whatsapp/runtime-api.js"; import { getWebAuthAgeMs, logoutWeb, logWebSelfId, readWebSelfId, webAuthExists, -} from "../../../extensions/whatsapp/src/auth-store.js"; -import { createLazyRuntimeMethod, createLazyRuntimeSurface } from "../../shared/lazy-runtime.js"; +} from "../../../extensions/whatsapp/runtime-api.js"; +import { + createLazyRuntimeMethodBinder, + createLazyRuntimeSurface, +} from "../../shared/lazy-runtime.js"; import { createRuntimeWhatsAppLoginTool } from "./runtime-whatsapp-login-tool.js"; import type { PluginRuntime } from "./types.js"; -type RuntimeWhatsAppOutbound = - typeof import("./runtime-whatsapp-outbound.runtime.js").runtimeWhatsAppOutbound; -type RuntimeWhatsAppLogin = - typeof import("./runtime-whatsapp-login.runtime.js").runtimeWhatsAppLogin; - const loadWebOutbound = createLazyRuntimeSurface( () => import("./runtime-whatsapp-outbound.runtime.js"), ({ runtimeWhatsAppOutbound }) => runtimeWhatsAppOutbound, @@ -25,23 +23,18 @@ const loadWebLogin = createLazyRuntimeSurface( ({ runtimeWhatsAppLogin }) => runtimeWhatsAppLogin, ); -const sendMessageWhatsAppLazy = createLazyRuntimeMethod< - RuntimeWhatsAppOutbound, - Parameters, - ReturnType ->(loadWebOutbound, (runtimeWhatsAppOutbound) => runtimeWhatsAppOutbound.sendMessageWhatsApp); +const bindWhatsAppOutboundMethod = createLazyRuntimeMethodBinder(loadWebOutbound); +const bindWhatsAppLoginMethod = createLazyRuntimeMethodBinder(loadWebLogin); -const sendPollWhatsAppLazy = createLazyRuntimeMethod< - RuntimeWhatsAppOutbound, - Parameters, - ReturnType ->(loadWebOutbound, (runtimeWhatsAppOutbound) => runtimeWhatsAppOutbound.sendPollWhatsApp); - -const loginWebLazy = createLazyRuntimeMethod< - RuntimeWhatsAppLogin, - Parameters, - ReturnType ->(loadWebLogin, (runtimeWhatsAppLogin) => runtimeWhatsAppLogin.loginWeb); +const sendMessageWhatsAppLazy = bindWhatsAppOutboundMethod( + (runtimeWhatsAppOutbound) => runtimeWhatsAppOutbound.sendMessageWhatsApp, +); +const sendPollWhatsAppLazy = bindWhatsAppOutboundMethod( + (runtimeWhatsAppOutbound) => runtimeWhatsAppOutbound.sendPollWhatsApp, +); +const loginWebLazy = bindWhatsAppLoginMethod( + (runtimeWhatsAppLogin) => runtimeWhatsAppLogin.loginWeb, +); const startWebLoginWithQrLazy: PluginRuntime["channel"]["whatsapp"]["startWebLoginWithQr"] = async ( ...args @@ -71,7 +64,7 @@ const handleWhatsAppActionLazy: PluginRuntime["channel"]["whatsapp"]["handleWhat }; let webLoginQrPromise: Promise< - typeof import("../../../extensions/whatsapp/src/login-qr.js") + typeof import("../../../extensions/whatsapp/login-qr-api.js") > | null = null; let webChannelPromise: Promise | null = null; let whatsappActionsPromise: Promise< @@ -79,7 +72,7 @@ let whatsappActionsPromise: Promise< > | null = null; function loadWebLoginQr() { - webLoginQrPromise ??= import("../../../extensions/whatsapp/src/login-qr.js"); + webLoginQrPromise ??= import("../../../extensions/whatsapp/login-qr-api.js"); return webLoginQrPromise; } diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 1b0c21044a8..ee50b7dd02a 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -87,29 +87,29 @@ export type PluginRuntimeChannel = { shouldHandleTextCommands: typeof import("../../auto-reply/commands-registry.js").shouldHandleTextCommands; }; discord: { - messageActions: typeof import("../../../extensions/discord/src/channel-actions.js").discordMessageActions; - auditChannelPermissions: typeof import("../../../extensions/discord/src/audit.js").auditDiscordChannelPermissions; - listDirectoryGroupsLive: typeof import("../../../extensions/discord/src/directory-live.js").listDiscordDirectoryGroupsLive; - listDirectoryPeersLive: typeof import("../../../extensions/discord/src/directory-live.js").listDiscordDirectoryPeersLive; - probeDiscord: typeof import("../../../extensions/discord/src/probe.js").probeDiscord; - resolveChannelAllowlist: typeof import("../../../extensions/discord/src/resolve-channels.js").resolveDiscordChannelAllowlist; - resolveUserAllowlist: typeof import("../../../extensions/discord/src/resolve-users.js").resolveDiscordUserAllowlist; - sendComponentMessage: typeof import("../../../extensions/discord/src/send.js").sendDiscordComponentMessage; - sendMessageDiscord: typeof import("../../../extensions/discord/src/send.js").sendMessageDiscord; - sendPollDiscord: typeof import("../../../extensions/discord/src/send.js").sendPollDiscord; - monitorDiscordProvider: typeof import("../../../extensions/discord/src/monitor.js").monitorDiscordProvider; + messageActions: typeof import("../../../extensions/discord/runtime-api.js").discordMessageActions; + auditChannelPermissions: typeof import("../../../extensions/discord/runtime-api.js").auditDiscordChannelPermissions; + listDirectoryGroupsLive: typeof import("../../../extensions/discord/runtime-api.js").listDiscordDirectoryGroupsLive; + listDirectoryPeersLive: typeof import("../../../extensions/discord/runtime-api.js").listDiscordDirectoryPeersLive; + probeDiscord: typeof import("../../../extensions/discord/runtime-api.js").probeDiscord; + resolveChannelAllowlist: typeof import("../../../extensions/discord/runtime-api.js").resolveDiscordChannelAllowlist; + resolveUserAllowlist: typeof import("../../../extensions/discord/runtime-api.js").resolveDiscordUserAllowlist; + sendComponentMessage: typeof import("../../../extensions/discord/runtime-api.js").sendDiscordComponentMessage; + sendMessageDiscord: typeof import("../../../extensions/discord/runtime-api.js").sendMessageDiscord; + sendPollDiscord: typeof import("../../../extensions/discord/runtime-api.js").sendPollDiscord; + monitorDiscordProvider: typeof import("../../../extensions/discord/runtime-api.js").monitorDiscordProvider; threadBindings: { - getManager: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").getThreadBindingManager; - resolveIdleTimeoutMs: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").resolveThreadBindingIdleTimeoutMs; - resolveInactivityExpiresAt: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").resolveThreadBindingInactivityExpiresAt; - resolveMaxAgeMs: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").resolveThreadBindingMaxAgeMs; - resolveMaxAgeExpiresAt: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").resolveThreadBindingMaxAgeExpiresAt; - setIdleTimeoutBySessionKey: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").setThreadBindingIdleTimeoutBySessionKey; - setMaxAgeBySessionKey: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").setThreadBindingMaxAgeBySessionKey; - unbindBySessionKey: typeof import("../../../extensions/discord/src/monitor/thread-bindings.js").unbindThreadBindingsBySessionKey; + getManager: typeof import("../../../extensions/discord/runtime-api.js").getThreadBindingManager; + resolveIdleTimeoutMs: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingIdleTimeoutMs; + resolveInactivityExpiresAt: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingInactivityExpiresAt; + resolveMaxAgeMs: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingMaxAgeMs; + resolveMaxAgeExpiresAt: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingMaxAgeExpiresAt; + setIdleTimeoutBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").setThreadBindingIdleTimeoutBySessionKey; + setMaxAgeBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").setThreadBindingMaxAgeBySessionKey; + unbindBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").unbindThreadBindingsBySessionKey; }; typing: { - pulse: typeof import("../../../extensions/discord/src/send.js").sendTypingDiscord; + pulse: typeof import("../../../extensions/discord/runtime-api.js").sendTypingDiscord; start: (params: { channelId: string; accountId?: string; @@ -121,39 +121,39 @@ export type PluginRuntimeChannel = { }>; }; conversationActions: { - editMessage: typeof import("../../../extensions/discord/src/send.js").editMessageDiscord; - deleteMessage: typeof import("../../../extensions/discord/src/send.js").deleteMessageDiscord; - pinMessage: typeof import("../../../extensions/discord/src/send.js").pinMessageDiscord; - unpinMessage: typeof import("../../../extensions/discord/src/send.js").unpinMessageDiscord; - createThread: typeof import("../../../extensions/discord/src/send.js").createThreadDiscord; - editChannel: typeof import("../../../extensions/discord/src/send.js").editChannelDiscord; + editMessage: typeof import("../../../extensions/discord/runtime-api.js").editMessageDiscord; + deleteMessage: typeof import("../../../extensions/discord/runtime-api.js").deleteMessageDiscord; + pinMessage: typeof import("../../../extensions/discord/runtime-api.js").pinMessageDiscord; + unpinMessage: typeof import("../../../extensions/discord/runtime-api.js").unpinMessageDiscord; + createThread: typeof import("../../../extensions/discord/runtime-api.js").createThreadDiscord; + editChannel: typeof import("../../../extensions/discord/runtime-api.js").editChannelDiscord; }; }; slack: { - listDirectoryGroupsLive: typeof import("../../../extensions/slack/src/directory-live.js").listSlackDirectoryGroupsLive; - listDirectoryPeersLive: typeof import("../../../extensions/slack/src/directory-live.js").listSlackDirectoryPeersLive; - probeSlack: typeof import("../../../extensions/slack/src/probe.js").probeSlack; - resolveChannelAllowlist: typeof import("../../../extensions/slack/src/resolve-channels.js").resolveSlackChannelAllowlist; - resolveUserAllowlist: typeof import("../../../extensions/slack/src/resolve-users.js").resolveSlackUserAllowlist; - sendMessageSlack: typeof import("../../../extensions/slack/src/send.js").sendMessageSlack; - monitorSlackProvider: typeof import("../../../extensions/slack/src/index.js").monitorSlackProvider; + listDirectoryGroupsLive: typeof import("../../../extensions/slack/runtime-api.js").listSlackDirectoryGroupsLive; + listDirectoryPeersLive: typeof import("../../../extensions/slack/runtime-api.js").listSlackDirectoryPeersLive; + probeSlack: typeof import("../../../extensions/slack/runtime-api.js").probeSlack; + resolveChannelAllowlist: typeof import("../../../extensions/slack/runtime-api.js").resolveSlackChannelAllowlist; + resolveUserAllowlist: typeof import("../../../extensions/slack/runtime-api.js").resolveSlackUserAllowlist; + sendMessageSlack: typeof import("../../../extensions/slack/runtime-api.js").sendMessageSlack; + monitorSlackProvider: typeof import("../../../extensions/slack/runtime-api.js").monitorSlackProvider; handleSlackAction: typeof import("../../agents/tools/slack-actions.js").handleSlackAction; }; telegram: { - auditGroupMembership: typeof import("../../../extensions/telegram/src/audit.js").auditTelegramGroupMembership; - collectUnmentionedGroupIds: typeof import("../../../extensions/telegram/src/audit.js").collectTelegramUnmentionedGroupIds; - probeTelegram: typeof import("../../../extensions/telegram/src/probe.js").probeTelegram; - resolveTelegramToken: typeof import("../../../extensions/telegram/src/token.js").resolveTelegramToken; - sendMessageTelegram: typeof import("../../../extensions/telegram/src/send.js").sendMessageTelegram; - sendPollTelegram: typeof import("../../../extensions/telegram/src/send.js").sendPollTelegram; - monitorTelegramProvider: typeof import("../../../extensions/telegram/src/monitor.js").monitorTelegramProvider; - messageActions: typeof import("../../../extensions/telegram/src/channel-actions.js").telegramMessageActions; + auditGroupMembership: typeof import("../../../extensions/telegram/runtime-api.js").auditTelegramGroupMembership; + collectUnmentionedGroupIds: typeof import("../../../extensions/telegram/runtime-api.js").collectTelegramUnmentionedGroupIds; + probeTelegram: typeof import("../../../extensions/telegram/runtime-api.js").probeTelegram; + resolveTelegramToken: typeof import("../../../extensions/telegram/runtime-api.js").resolveTelegramToken; + sendMessageTelegram: typeof import("../../../extensions/telegram/runtime-api.js").sendMessageTelegram; + sendPollTelegram: typeof import("../../../extensions/telegram/runtime-api.js").sendPollTelegram; + monitorTelegramProvider: typeof import("../../../extensions/telegram/runtime-api.js").monitorTelegramProvider; + messageActions: typeof import("../../../extensions/telegram/runtime-api.js").telegramMessageActions; threadBindings: { - setIdleTimeoutBySessionKey: typeof import("../../../extensions/telegram/src/thread-bindings.js").setTelegramThreadBindingIdleTimeoutBySessionKey; - setMaxAgeBySessionKey: typeof import("../../../extensions/telegram/src/thread-bindings.js").setTelegramThreadBindingMaxAgeBySessionKey; + setIdleTimeoutBySessionKey: typeof import("../../../extensions/telegram/runtime-api.js").setTelegramThreadBindingIdleTimeoutBySessionKey; + setMaxAgeBySessionKey: typeof import("../../../extensions/telegram/runtime-api.js").setTelegramThreadBindingMaxAgeBySessionKey; }; typing: { - pulse: typeof import("../../../extensions/telegram/src/send.js").sendTypingTelegram; + pulse: typeof import("../../../extensions/telegram/runtime-api.js").sendTypingTelegram; start: (params: { to: string; accountId?: string; @@ -166,8 +166,8 @@ export type PluginRuntimeChannel = { }>; }; conversationActions: { - editMessage: typeof import("../../../extensions/telegram/src/send.js").editMessageTelegram; - editReplyMarkup: typeof import("../../../extensions/telegram/src/send.js").editMessageReplyMarkupTelegram; + editMessage: typeof import("../../../extensions/telegram/runtime-api.js").editMessageTelegram; + editReplyMarkup: typeof import("../../../extensions/telegram/runtime-api.js").editMessageReplyMarkupTelegram; clearReplyMarkup: ( chatIdInput: string | number, messageIdInput: string | number, @@ -180,35 +180,35 @@ export type PluginRuntimeChannel = { cfg?: ReturnType; }, ) => Promise<{ ok: true; messageId: string; chatId: string }>; - deleteMessage: typeof import("../../../extensions/telegram/src/send.js").deleteMessageTelegram; - renameTopic: typeof import("../../../extensions/telegram/src/send.js").renameForumTopicTelegram; - pinMessage: typeof import("../../../extensions/telegram/src/send.js").pinMessageTelegram; - unpinMessage: typeof import("../../../extensions/telegram/src/send.js").unpinMessageTelegram; + deleteMessage: typeof import("../../../extensions/telegram/runtime-api.js").deleteMessageTelegram; + renameTopic: typeof import("../../../extensions/telegram/runtime-api.js").renameForumTopicTelegram; + pinMessage: typeof import("../../../extensions/telegram/runtime-api.js").pinMessageTelegram; + unpinMessage: typeof import("../../../extensions/telegram/runtime-api.js").unpinMessageTelegram; }; }; signal: { - probeSignal: typeof import("../../../extensions/signal/src/probe.js").probeSignal; - sendMessageSignal: typeof import("../../../extensions/signal/src/send.js").sendMessageSignal; - monitorSignalProvider: typeof import("../../../extensions/signal/src/index.js").monitorSignalProvider; + probeSignal: typeof import("../../../extensions/signal/runtime-api.js").probeSignal; + sendMessageSignal: typeof import("../../../extensions/signal/runtime-api.js").sendMessageSignal; + monitorSignalProvider: typeof import("../../../extensions/signal/runtime-api.js").monitorSignalProvider; messageActions: typeof import("../../channels/plugins/actions/signal.js").signalMessageActions; }; imessage: { - monitorIMessageProvider: typeof import("../../../extensions/imessage/src/monitor.js").monitorIMessageProvider; - probeIMessage: typeof import("../../../extensions/imessage/src/probe.js").probeIMessage; - sendMessageIMessage: typeof import("../../../extensions/imessage/src/send.js").sendMessageIMessage; + monitorIMessageProvider: typeof import("../../../extensions/imessage/runtime-api.js").monitorIMessageProvider; + probeIMessage: typeof import("../../../extensions/imessage/runtime-api.js").probeIMessage; + sendMessageIMessage: typeof import("../../../extensions/imessage/runtime-api.js").sendMessageIMessage; }; whatsapp: { - getActiveWebListener: typeof import("../../../extensions/whatsapp/src/active-listener.js").getActiveWebListener; - getWebAuthAgeMs: typeof import("../../../extensions/whatsapp/src/auth-store.js").getWebAuthAgeMs; - logoutWeb: typeof import("../../../extensions/whatsapp/src/auth-store.js").logoutWeb; - logWebSelfId: typeof import("../../../extensions/whatsapp/src/auth-store.js").logWebSelfId; - readWebSelfId: typeof import("../../../extensions/whatsapp/src/auth-store.js").readWebSelfId; - webAuthExists: typeof import("../../../extensions/whatsapp/src/auth-store.js").webAuthExists; - sendMessageWhatsApp: typeof import("../../../extensions/whatsapp/src/send.js").sendMessageWhatsApp; - sendPollWhatsApp: typeof import("../../../extensions/whatsapp/src/send.js").sendPollWhatsApp; - loginWeb: typeof import("../../../extensions/whatsapp/src/login.js").loginWeb; - startWebLoginWithQr: typeof import("../../../extensions/whatsapp/src/login-qr.js").startWebLoginWithQr; - waitForWebLogin: typeof import("../../../extensions/whatsapp/src/login-qr.js").waitForWebLogin; + getActiveWebListener: typeof import("../../../extensions/whatsapp/runtime-api.js").getActiveWebListener; + getWebAuthAgeMs: typeof import("../../../extensions/whatsapp/runtime-api.js").getWebAuthAgeMs; + logoutWeb: typeof import("../../../extensions/whatsapp/runtime-api.js").logoutWeb; + logWebSelfId: typeof import("../../../extensions/whatsapp/runtime-api.js").logWebSelfId; + readWebSelfId: typeof import("../../../extensions/whatsapp/runtime-api.js").readWebSelfId; + webAuthExists: typeof import("../../../extensions/whatsapp/runtime-api.js").webAuthExists; + sendMessageWhatsApp: typeof import("../../../extensions/whatsapp/runtime-api.js").sendMessageWhatsApp; + sendPollWhatsApp: typeof import("../../../extensions/whatsapp/runtime-api.js").sendPollWhatsApp; + loginWeb: typeof import("../../../extensions/whatsapp/runtime-api.js").loginWeb; + startWebLoginWithQr: typeof import("../../../extensions/whatsapp/login-qr-api.js").startWebLoginWithQr; + waitForWebLogin: typeof import("../../../extensions/whatsapp/login-qr-api.js").waitForWebLogin; monitorWebChannel: typeof import("../../channels/web/index.js").monitorWebChannel; handleWhatsAppAction: typeof import("../../agents/tools/whatsapp-actions.js").handleWhatsAppAction; createLoginTool: typeof import("./runtime-whatsapp-login-tool.js").createRuntimeWhatsAppLoginTool; diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index e5951a1ce57..2ca6f6c035a 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -39,7 +39,7 @@ export type PluginRuntimeCore = { formatNativeDependencyHint: typeof import("./native-deps.js").formatNativeDependencyHint; }; media: { - loadWebMedia: typeof import("../../../extensions/whatsapp/src/media.js").loadWebMedia; + loadWebMedia: typeof import("../../../extensions/whatsapp/runtime-api.js").loadWebMedia; detectMime: typeof import("../../media/mime.js").detectMime; mediaKindFromMime: typeof import("../../media/constants.js").mediaKindFromMime; isVoiceCompatibleAudio: typeof import("../../media/audio.js").isVoiceCompatibleAudio; diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 245e8dd1274..aa1118ecf92 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -8,6 +8,8 @@ export type { RuntimeLogger }; export type SubagentRunParams = { sessionKey: string; message: string; + provider?: string; + model?: string; extraSystemPrompt?: string; lane?: string; deliver?: boolean; diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 3c7bc35cba6..d16db23da4b 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const loadConfigMock = vi.fn(); const loadOpenClawPluginsMock = vi.fn(); let buildPluginStatusReport: typeof import("./status.js").buildPluginStatusReport; +let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport; +let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports; vi.mock("../config/config.js", () => ({ loadConfig: () => loadConfigMock(), @@ -32,14 +34,22 @@ describe("buildPluginStatusReport", () => { diagnostics: [], channels: [], providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], tools: [], hooks: [], + typedHooks: [], + channelSetups: [], + httpRoutes: [], gatewayHandlers: {}, cliRegistrars: [], services: [], commands: [], }); - ({ buildPluginStatusReport } = await import("./status.js")); + ({ buildAllPluginInspectReports, buildPluginInspectReport, buildPluginStatusReport } = + await import("./status.js")); }); it("forwards an explicit env to plugin loading", () => { @@ -59,4 +69,192 @@ describe("buildPluginStatusReport", () => { }), ); }); + + it("builds an inspect report with capability shape and policy", () => { + loadConfigMock.mockReturnValue({ + plugins: { + entries: { + google: { + hooks: { allowPromptInjection: false }, + subagent: { + allowModelOverride: true, + allowedModels: ["openai/gpt-5.4"], + }, + }, + }, + }, + }); + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "google", + name: "Google", + description: "Google provider plugin", + source: "/tmp/google/index.ts", + origin: "bundled", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: ["google"], + speechProviderIds: [], + mediaUnderstandingProviderIds: ["google"], + imageGenerationProviderIds: ["google"], + webSearchProviderIds: ["google"], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }, + ], + diagnostics: [{ level: "warn", pluginId: "google", message: "watch this seam" }], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [ + { + pluginId: "google", + hookName: "before_agent_start", + handler: () => undefined, + source: "/tmp/google/index.ts", + }, + ], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + }); + + const inspect = buildPluginInspectReport({ id: "google" }); + + expect(inspect).not.toBeNull(); + expect(inspect?.shape).toBe("hybrid-capability"); + expect(inspect?.capabilityMode).toBe("hybrid"); + expect(inspect?.capabilities.map((entry) => entry.kind)).toEqual([ + "text-inference", + "media-understanding", + "image-generation", + "web-search", + ]); + expect(inspect?.usesLegacyBeforeAgentStart).toBe(true); + expect(inspect?.policy).toEqual({ + allowPromptInjection: false, + allowModelOverride: true, + allowedModels: ["openai/gpt-5.4"], + hasAllowedModelsConfig: true, + }); + expect(inspect?.diagnostics).toEqual([ + { level: "warn", pluginId: "google", message: "watch this seam" }, + ]); + }); + + it("builds inspect reports for every loaded plugin", () => { + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "lca", + name: "LCA", + description: "Legacy hook plugin", + source: "/tmp/lca/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 1, + configSchema: false, + }, + { + id: "microsoft", + name: "Microsoft", + description: "Hybrid capability plugin", + source: "/tmp/microsoft/index.ts", + origin: "bundled", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: ["microsoft"], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: ["microsoft"], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [ + { + pluginId: "lca", + events: ["message"], + entry: { + hook: { + name: "legacy", + handler: () => undefined, + }, + }, + }, + ], + typedHooks: [ + { + pluginId: "lca", + hookName: "before_agent_start", + handler: () => undefined, + source: "/tmp/lca/index.ts", + }, + ], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + }); + + const inspect = buildAllPluginInspectReports(); + + expect(inspect.map((entry) => entry.plugin.id)).toEqual(["lca", "microsoft"]); + expect(inspect.map((entry) => entry.shape)).toEqual(["hook-only", "hybrid-capability"]); + expect(inspect[0]?.usesLegacyBeforeAgentStart).toBe(true); + expect(inspect[1]?.capabilities.map((entry) => entry.kind)).toEqual([ + "text-inference", + "web-search", + ]); + }); }); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 65c48203eb8..09a75e02516 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -2,14 +2,67 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { normalizePluginsConfig } from "./config-state.js"; import { loadOpenClawPlugins } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; import type { PluginRegistry } from "./registry.js"; +import type { PluginDiagnostic, PluginHookName } from "./types.js"; export type PluginStatusReport = PluginRegistry & { workspaceDir?: string; }; +export type PluginCapabilityKind = + | "text-inference" + | "speech" + | "media-understanding" + | "image-generation" + | "web-search" + | "channel"; + +export type PluginInspectShape = + | "hook-only" + | "plain-capability" + | "hybrid-capability" + | "non-capability"; + +export type PluginInspectReport = { + workspaceDir?: string; + plugin: PluginRegistry["plugins"][number]; + shape: PluginInspectShape; + capabilityMode: "none" | "plain" | "hybrid"; + capabilityCount: number; + capabilities: Array<{ + kind: PluginCapabilityKind; + ids: string[]; + }>; + typedHooks: Array<{ + name: PluginHookName; + priority?: number; + }>; + customHooks: Array<{ + name: string; + events: string[]; + }>; + tools: Array<{ + names: string[]; + optional: boolean; + }>; + commands: string[]; + cliCommands: string[]; + services: string[]; + gatewayMethods: string[]; + httpRouteCount: number; + diagnostics: PluginDiagnostic[]; + policy: { + allowPromptInjection?: boolean; + allowModelOverride?: boolean; + allowedModels: string[]; + hasAllowedModelsConfig: boolean; + }; + usesLegacyBeforeAgentStart: boolean; +}; + const log = createSubsystemLogger("plugins"); export function buildPluginStatusReport(params?: { @@ -36,3 +89,152 @@ export function buildPluginStatusReport(params?: { ...registry, }; } + +function buildCapabilityEntries(plugin: PluginRegistry["plugins"][number]) { + return [ + { kind: "text-inference" as const, ids: plugin.providerIds }, + { kind: "speech" as const, ids: plugin.speechProviderIds }, + { kind: "media-understanding" as const, ids: plugin.mediaUnderstandingProviderIds }, + { kind: "image-generation" as const, ids: plugin.imageGenerationProviderIds }, + { kind: "web-search" as const, ids: plugin.webSearchProviderIds }, + { kind: "channel" as const, ids: plugin.channelIds }, + ].filter((entry) => entry.ids.length > 0); +} + +function deriveInspectShape(params: { + capabilityCount: number; + typedHookCount: number; + customHookCount: number; + toolCount: number; + commandCount: number; + cliCount: number; + serviceCount: number; + gatewayMethodCount: number; + httpRouteCount: number; +}): PluginInspectShape { + if (params.capabilityCount > 1) { + return "hybrid-capability"; + } + if (params.capabilityCount === 1) { + return "plain-capability"; + } + const hasOnlyHooks = + params.typedHookCount + params.customHookCount > 0 && + params.toolCount === 0 && + params.commandCount === 0 && + params.cliCount === 0 && + params.serviceCount === 0 && + params.gatewayMethodCount === 0 && + params.httpRouteCount === 0; + if (hasOnlyHooks) { + return "hook-only"; + } + return "non-capability"; +} + +export function buildPluginInspectReport(params: { + id: string; + config?: ReturnType; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + report?: PluginStatusReport; +}): PluginInspectReport | null { + const config = params.config ?? loadConfig(); + const report = + params.report ?? + buildPluginStatusReport({ + config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + const plugin = report.plugins.find((entry) => entry.id === params.id || entry.name === params.id); + if (!plugin) { + return null; + } + + const capabilities = buildCapabilityEntries(plugin); + const typedHooks = report.typedHooks + .filter((entry) => entry.pluginId === plugin.id) + .map((entry) => ({ + name: entry.hookName, + priority: entry.priority, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + const customHooks = report.hooks + .filter((entry) => entry.pluginId === plugin.id) + .map((entry) => ({ + name: entry.entry.hook.name, + events: [...entry.events].sort(), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + const tools = report.tools + .filter((entry) => entry.pluginId === plugin.id) + .map((entry) => ({ + names: [...entry.names], + optional: entry.optional, + })); + const diagnostics = report.diagnostics.filter((entry) => entry.pluginId === plugin.id); + const policyEntry = normalizePluginsConfig(config.plugins).entries[plugin.id]; + const capabilityCount = capabilities.length; + + return { + workspaceDir: report.workspaceDir, + plugin, + shape: deriveInspectShape({ + capabilityCount, + typedHookCount: typedHooks.length, + customHookCount: customHooks.length, + toolCount: tools.length, + commandCount: plugin.commands.length, + cliCount: plugin.cliCommands.length, + serviceCount: plugin.services.length, + gatewayMethodCount: plugin.gatewayMethods.length, + httpRouteCount: plugin.httpRoutes, + }), + capabilityMode: capabilityCount === 0 ? "none" : capabilityCount === 1 ? "plain" : "hybrid", + capabilityCount, + capabilities, + typedHooks, + customHooks, + tools, + commands: [...plugin.commands], + cliCommands: [...plugin.cliCommands], + services: [...plugin.services], + gatewayMethods: [...plugin.gatewayMethods], + httpRouteCount: plugin.httpRoutes, + diagnostics, + policy: { + allowPromptInjection: policyEntry?.hooks?.allowPromptInjection, + allowModelOverride: policyEntry?.subagent?.allowModelOverride, + allowedModels: [...(policyEntry?.subagent?.allowedModels ?? [])], + hasAllowedModelsConfig: policyEntry?.subagent?.hasAllowedModelsConfig === true, + }, + usesLegacyBeforeAgentStart: typedHooks.some((entry) => entry.name === "before_agent_start"), + }; +} + +export function buildAllPluginInspectReports(params?: { + config?: ReturnType; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + report?: PluginStatusReport; +}): PluginInspectReport[] { + const config = params?.config ?? loadConfig(); + const report = + params?.report ?? + buildPluginStatusReport({ + config, + workspaceDir: params?.workspaceDir, + env: params?.env, + }); + + return report.plugins + .map((plugin) => + buildPluginInspectReport({ + id: plugin.id, + config, + report, + }), + ) + .filter((entry): entry is PluginInspectReport => entry !== null); +} diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 6deb59669f1..ae5b2d116b4 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -246,6 +246,18 @@ export type ProviderCatalogContext = { apiKey: string | undefined; discoveryApiKey?: string; }; + resolveProviderAuth: ( + providerId?: string, + options?: { + oauthMarker?: string; + }, + ) => { + apiKey: string | undefined; + discoveryApiKey?: string; + mode: "api_key" | "oauth" | "token" | "none"; + source: "env" | "profile" | "none"; + profileId?: string; + }; }; export type ProviderCatalogResult = @@ -940,6 +952,8 @@ export type PluginConversationBindingRequestParams = { detachHint?: string; }; +export type PluginConversationBindingResolutionDecision = "allow-once" | "allow-always" | "deny"; + export type PluginConversationBinding = { bindingId: string; pluginId: string; @@ -970,6 +984,24 @@ export type PluginConversationBindingRequestResult = message: string; }; +export type PluginConversationBindingResolvedEvent = { + status: "approved" | "denied"; + binding?: PluginConversationBinding; + decision: PluginConversationBindingResolutionDecision; + request: { + summary?: string; + detachHint?: string; + requestedBySenderId?: string; + conversation: { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + threadId?: string | number; + }; + }; +}; + /** * Result returned by a plugin command handler. */ @@ -1256,6 +1288,9 @@ export type OpenClawPluginApi = { registerImageGenerationProvider: (provider: ImageGenerationProviderPlugin) => void; registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void; registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void; + onConversationBindingResolved: ( + handler: (event: PluginConversationBindingResolvedEvent) => void | Promise, + ) => void; /** * Register a custom command that bypasses the LLM agent. * Plugin commands are processed before built-in commands and before agent invocation. diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index e3c21e8d7ef..7e93ab7ba50 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -20,6 +20,8 @@ vi.mock("./bundled-sources.js", () => ({ resolveBundledPluginSources: (...args: unknown[]) => resolveBundledPluginSourcesMock(...args), })); +const { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } = await import("./update.js"); + describe("updateNpmInstalledPlugins", () => { beforeEach(() => { installPluginFromNpmSpecMock.mockReset(); @@ -36,7 +38,6 @@ describe("updateNpmInstalledPlugins", () => { extensions: ["index.ts"], }); - const { updateNpmInstalledPlugins } = await import("./update.js"); await updateNpmInstalledPlugins({ config: { plugins: { @@ -71,7 +72,6 @@ describe("updateNpmInstalledPlugins", () => { extensions: ["index.ts"], }); - const { updateNpmInstalledPlugins } = await import("./update.js"); await updateNpmInstalledPlugins({ config: { plugins: { @@ -104,7 +104,6 @@ describe("updateNpmInstalledPlugins", () => { error: "Package not found on npm: @openclaw/missing.", }); - const { updateNpmInstalledPlugins } = await import("./update.js"); const result = await updateNpmInstalledPlugins({ config: { plugins: { @@ -137,7 +136,6 @@ describe("updateNpmInstalledPlugins", () => { error: "unsupported npm spec: github:evil/evil", }); - const { updateNpmInstalledPlugins } = await import("./update.js"); const result = await updateNpmInstalledPlugins({ config: { plugins: { @@ -172,7 +170,6 @@ describe("updateNpmInstalledPlugins", () => { extensions: ["index.ts"], }); - const { updateNpmInstalledPlugins } = await import("./update.js"); const result = await updateNpmInstalledPlugins({ config: { plugins: { @@ -231,7 +228,6 @@ describe("updateNpmInstalledPlugins", () => { marketplacePlugin: "claude-bundle", }); - const { updateNpmInstalledPlugins } = await import("./update.js"); const result = await updateNpmInstalledPlugins({ config: { plugins: { @@ -280,7 +276,6 @@ describe("updateNpmInstalledPlugins", () => { marketplacePlugin: "claude-bundle", }); - const { updateNpmInstalledPlugins } = await import("./update.js"); const result = await updateNpmInstalledPlugins({ config: { plugins: { @@ -330,7 +325,6 @@ describe("syncPluginsForUpdateChannel", () => { ]), ); - const { syncPluginsForUpdateChannel } = await import("./update.js"); const result = await syncPluginsForUpdateChannel({ channel: "beta", config: { @@ -369,7 +363,6 @@ describe("syncPluginsForUpdateChannel", () => { ]), ); - const { syncPluginsForUpdateChannel } = await import("./update.js"); const result = await syncPluginsForUpdateChannel({ channel: "beta", config: { @@ -402,7 +395,6 @@ describe("syncPluginsForUpdateChannel", () => { resolveBundledPluginSourcesMock.mockReturnValue(new Map()); const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv; - const { syncPluginsForUpdateChannel } = await import("./update.js"); await syncPluginsForUpdateChannel({ channel: "beta", config: {}, @@ -434,7 +426,6 @@ describe("syncPluginsForUpdateChannel", () => { const previousHome = process.env.HOME; process.env.HOME = "/tmp/process-home"; try { - const { syncPluginsForUpdateChannel } = await import("./update.js"); const result = await syncPluginsForUpdateChannel({ channel: "beta", env: { diff --git a/src/plugins/wired-hooks-compaction.test.ts b/src/plugins/wired-hooks-compaction.test.ts index f8ce4d0a668..1fc258d4cef 100644 --- a/src/plugins/wired-hooks-compaction.test.ts +++ b/src/plugins/wired-hooks-compaction.test.ts @@ -39,11 +39,20 @@ describe("compaction hook wiring", () => { function createCompactionEndCtx(params: { runId: string; messages?: unknown[]; + sessionFile?: string; + sessionKey?: string; compactionCount?: number; withRetryHooks?: boolean; }) { return { - params: { runId: params.runId, session: { messages: params.messages ?? [] } }, + params: { + runId: params.runId, + sessionKey: params.sessionKey, + session: { + messages: params.messages ?? [], + sessionFile: params.sessionFile, + }, + }, state: { compactionInFlight: true }, log: { debug: vi.fn(), warn: vi.fn() }, maybeResolveCompactionWait: vi.fn(), @@ -107,6 +116,8 @@ describe("compaction hook wiring", () => { const ctx = createCompactionEndCtx({ runId: "r2", messages: [1, 2], + sessionFile: "/tmp/session.jsonl", + sessionKey: "agent:main:web-xyz", compactionCount: 1, }); @@ -122,13 +133,16 @@ describe("compaction hook wiring", () => { expect(hookMocks.runner.runAfterCompaction).toHaveBeenCalledTimes(1); const afterCalls = hookMocks.runner.runAfterCompaction.mock.calls as unknown as Array< - [unknown] + [unknown, unknown] >; const event = afterCalls[0]?.[0] as - | { messageCount?: number; compactedCount?: number } + | { messageCount?: number; compactedCount?: number; sessionFile?: string } | undefined; expect(event?.messageCount).toBe(2); expect(event?.compactedCount).toBe(1); + expect(event?.sessionFile).toBe("/tmp/session.jsonl"); + const hookCtx = afterCalls[0]?.[1] as { sessionKey?: string } | undefined; + expect(hookCtx?.sessionKey).toBe("agent:main:web-xyz"); expect(ctx.incrementCompactionCount).toHaveBeenCalledTimes(1); expect(ctx.maybeResolveCompactionWait).toHaveBeenCalledTimes(1); expect(hookMocks.emitAgentEvent).toHaveBeenCalledWith({ diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index dedc789773c..6a8e72f6f2e 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -171,6 +171,48 @@ function expectNoFinding(res: SecurityAuditReport, checkId: string): void { expect(hasFinding(res, checkId)).toBe(false); } +async function expectSeverityByExposureCases(params: { + checkId: string; + cases: Array<{ + name: string; + cfg: OpenClawConfig; + expectedSeverity: "warn" | "critical"; + }>; +}) { + await Promise.all( + params.cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + expect(hasFinding(res, params.checkId, testCase.expectedSeverity), testCase.name).toBe(true); + }), + ); +} + +async function runChannelSecurityAudit( + cfg: OpenClawConfig, + plugins: ChannelPlugin[], +): Promise { + return runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: true, + plugins, + }); +} + +async function runInstallMetadataAudit( + cfg: OpenClawConfig, + stateDir: string, +): Promise { + return runSecurityAudit({ + config: cfg, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + execDockerRawFn: execDockerRawUnavailable, + }); +} + describe("security audit", () => { let fixtureRoot = ""; let caseId = 0; @@ -208,6 +250,17 @@ describe("security audit", () => { ); }; + const runSharedExtensionsAudit = async (config: OpenClawConfig) => { + return runSecurityAudit({ + config, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir: sharedExtensionsStateDir, + configPath: path.join(sharedExtensionsStateDir, "openclaw.json"), + execDockerRawFn: execDockerRawUnavailable, + }); + }; + const createSharedCodeSafetyFixture = async () => { const stateDir = await makeTmpDir("audit-scanner-shared"); const workspaceDir = path.join(stateDir, "workspace"); @@ -295,132 +348,131 @@ description: test skill expect(summary?.detail).toContain("trust model: personal assistant"); }); - it("flags non-loopback bind without auth as critical", async () => { - // Clear env tokens so resolveGatewayAuth defaults to mode=none - const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; - const prevPassword = process.env.OPENCLAW_GATEWAY_PASSWORD; - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - - try { - const cfg: OpenClawConfig = { - gateway: { - bind: "lan", - auth: {}, - }, - }; - - const res = await audit(cfg); - - expect(hasFinding(res, "gateway.bind_no_auth", "critical")).toBe(true); - } finally { - // Restore env - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; - } - if (prevPassword === undefined) { - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - } else { - process.env.OPENCLAW_GATEWAY_PASSWORD = prevPassword; - } - } - }); - - it("does not flag non-loopback bind without auth when gateway password uses SecretRef", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "lan", - auth: { - password: { - source: "env", - provider: "default", - id: "OPENCLAW_GATEWAY_PASSWORD", - }, - }, - }, - }; - - const res = await audit(cfg, { env: {} }); - expectNoFinding(res, "gateway.bind_no_auth"); - }); - - it("does not flag missing gateway auth when read-only scrubbed config omits unavailable auth SecretRefs", async () => { - const sourceConfig: OpenClawConfig = { - gateway: { - bind: "lan", - auth: { - token: { - source: "env", - provider: "default", - id: "OPENCLAW_GATEWAY_TOKEN", - }, - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - }; - const resolvedConfig: OpenClawConfig = { - gateway: { - bind: "lan", - auth: {}, - }, - secrets: sourceConfig.secrets, - }; - - const res = await runSecurityAudit({ - config: resolvedConfig, - sourceConfig, - env: {}, - includeFilesystem: false, - includeChannelSecurity: false, - }); - - expectNoFinding(res, "gateway.bind_no_auth"); - }); - - it("evaluates gateway auth rate-limit warning based on configuration", async () => { - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - expectWarn: boolean; - }> = [ + it("evaluates gateway auth presence and rate-limit guardrails", async () => { + const cases = [ { - name: "no rate limit", - cfg: { - gateway: { - bind: "lan", - auth: { token: "secret" }, - }, - }, - expectWarn: true, - }, - { - name: "rate limit configured", - cfg: { - gateway: { - bind: "lan", - auth: { - token: "secret", - rateLimit: { maxAttempts: 10, windowMs: 60_000, lockoutMs: 300_000 }, + name: "flags non-loopback bind without auth as critical", + run: async () => + withEnvAsync( + { + OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_GATEWAY_PASSWORD: undefined, }, - }, + async () => + audit({ + gateway: { + bind: "lan", + auth: {}, + }, + }), + ), + assert: (res: SecurityAuditReport) => { + expect(hasFinding(res, "gateway.bind_no_auth", "critical")).toBe(true); }, - expectWarn: false, }, - ]; - await Promise.all( - cases.map(async (testCase) => { - const res = await audit(testCase.cfg, { env: {} }); - expect(hasFinding(res, "gateway.auth_no_rate_limit", "warn"), testCase.name).toBe( - testCase.expectWarn, - ); - }), - ); + { + name: "does not flag non-loopback bind without auth when gateway password uses SecretRef", + run: async () => + audit( + { + gateway: { + bind: "lan", + auth: { + password: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_PASSWORD", + }, + }, + }, + }, + { env: {} }, + ), + assert: (res: SecurityAuditReport) => { + expectNoFinding(res, "gateway.bind_no_auth"); + }, + }, + { + name: "does not flag missing gateway auth when read-only scrubbed config omits unavailable auth SecretRefs", + run: async () => { + const sourceConfig: OpenClawConfig = { + gateway: { + bind: "lan", + auth: { + token: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + const resolvedConfig: OpenClawConfig = { + gateway: { + bind: "lan", + auth: {}, + }, + secrets: sourceConfig.secrets, + }; + + return runSecurityAudit({ + config: resolvedConfig, + sourceConfig, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + }, + assert: (res: SecurityAuditReport) => { + expectNoFinding(res, "gateway.bind_no_auth"); + }, + }, + { + name: "warns when auth has no rate limit", + run: async () => + audit( + { + gateway: { + bind: "lan", + auth: { token: "secret" }, + }, + }, + { env: {} }, + ), + assert: (res: SecurityAuditReport) => { + expect(hasFinding(res, "gateway.auth_no_rate_limit", "warn")).toBe(true); + }, + }, + { + name: "does not warn when auth rate limit is configured", + run: async () => + audit( + { + gateway: { + bind: "lan", + auth: { + token: "secret", + rateLimit: { maxAttempts: 10, windowMs: 60_000, lockoutMs: 300_000 }, + }, + }, + }, + { env: {} }, + ), + assert: (res: SecurityAuditReport) => { + expectNoFinding(res, "gateway.auth_no_rate_limit"); + }, + }, + ] as const; + + for (const testCase of cases) { + const res = await testCase.run(); + testCase.assert(res); + } }); it("scores dangerous gateway.tools.allow over HTTP by exposure", async () => { @@ -600,52 +652,64 @@ description: test skill ); }); - it("warns for risky safeBinTrustedDirs entries", async () => { + it("evaluates safeBinTrustedDirs risk findings", async () => { const riskyGlobalTrustedDirs = process.platform === "win32" ? [String.raw`C:\Users\ci-user\bin`, String.raw`C:\Users\ci-user\.local\bin`] : ["/usr/local/bin", "/tmp/openclaw-safe-bins"]; - const cfg: OpenClawConfig = { - tools: { - exec: { - safeBinTrustedDirs: riskyGlobalTrustedDirs, - }, - }, - agents: { - list: [ - { - id: "ops", - tools: { - exec: { - safeBinTrustedDirs: ["./relative-bin-dir"], - }, + const cases = [ + { + name: "warns for risky global and relative trusted dirs", + cfg: { + tools: { + exec: { + safeBinTrustedDirs: riskyGlobalTrustedDirs, }, }, - ], - }, - }; - - const res = await audit(cfg); - const finding = res.findings.find( - (f) => f.checkId === "tools.exec.safe_bin_trusted_dirs_risky", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain(riskyGlobalTrustedDirs[0]); - expect(finding?.detail).toContain(riskyGlobalTrustedDirs[1]); - expect(finding?.detail).toContain("agents.list.ops.tools.exec"); - }); - - it("does not warn for non-risky absolute safeBinTrustedDirs entries", async () => { - const cfg: OpenClawConfig = { - tools: { - exec: { - safeBinTrustedDirs: ["/usr/libexec"], + agents: { + list: [ + { + id: "ops", + tools: { + exec: { + safeBinTrustedDirs: ["./relative-bin-dir"], + }, + }, + }, + ], + }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + const finding = res.findings.find( + (f) => f.checkId === "tools.exec.safe_bin_trusted_dirs_risky", + ); + expect(finding?.severity).toBe("warn"); + expect(finding?.detail).toContain(riskyGlobalTrustedDirs[0]); + expect(finding?.detail).toContain(riskyGlobalTrustedDirs[1]); + expect(finding?.detail).toContain("agents.list.ops.tools.exec"); }, }, - }; + { + name: "ignores non-risky absolute dirs", + cfg: { + tools: { + exec: { + safeBinTrustedDirs: ["/usr/libexec"], + }, + }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expectNoFinding(res, "tools.exec.safe_bin_trusted_dirs_risky"); + }, + }, + ] as const; - const res = await audit(cfg); - expectNoFinding(res, "tools.exec.safe_bin_trusted_dirs_risky"); + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + testCase.assert(res); + }), + ); }); it("evaluates loopback control UI and logging exposure findings", async () => { @@ -700,199 +764,254 @@ description: test skill ); }); - it("treats Windows ACL-only perms as secure", async () => { - const tmp = await makeTmpDir("win"); - const stateDir = path.join(tmp, "state"); - await fs.mkdir(stateDir, { recursive: true }); - const configPath = path.join(stateDir, "openclaw.json"); - await fs.writeFile(configPath, "{}\n", "utf-8"); - - const user = "DESKTOP-TEST\\Tester"; - const execIcacls = async (_cmd: string, args: string[]) => ({ - stdout: `${args[0]} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`, - stderr: "", - }); - - const res = await runSecurityAudit({ - config: {}, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - platform: "win32", - env: windowsAuditEnv, - execIcacls, - execDockerRawFn: execDockerRawUnavailable, - }); - - const forbidden = new Set([ - "fs.state_dir.perms_world_writable", - "fs.state_dir.perms_group_writable", - "fs.state_dir.perms_readable", - "fs.config.perms_writable", - "fs.config.perms_world_readable", - "fs.config.perms_group_readable", - ]); - for (const id of forbidden) { - expect(res.findings.some((f) => f.checkId === id)).toBe(false); - } - }); - - it("flags Windows ACLs when Users can read the state dir", async () => { - const tmp = await makeTmpDir("win-open"); - const stateDir = path.join(tmp, "state"); - await fs.mkdir(stateDir, { recursive: true }); - const configPath = path.join(stateDir, "openclaw.json"); - await fs.writeFile(configPath, "{}\n", "utf-8"); - - const user = "DESKTOP-TEST\\Tester"; - const execIcacls = async (_cmd: string, args: string[]) => { - const target = args[0]; - if (target === stateDir) { - return { - stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(RX)\n ${user}:(F)\n`, + it("evaluates Windows ACL-derived filesystem findings", async () => { + const cases = [ + { + name: "treats Windows ACL-only perms as secure", + label: "win", + execIcacls: async (_cmd: string, args: string[]) => ({ + stdout: `${args[0]} NT AUTHORITY\\SYSTEM:(F)\n DESKTOP-TEST\\Tester:(F)\n`, stderr: "", - }; - } - return { - stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`, - stderr: "", - }; - }; + }), + assert: (res: SecurityAuditReport) => { + const forbidden = new Set([ + "fs.state_dir.perms_world_writable", + "fs.state_dir.perms_group_writable", + "fs.state_dir.perms_readable", + "fs.config.perms_writable", + "fs.config.perms_world_readable", + "fs.config.perms_group_readable", + ]); + for (const id of forbidden) { + expect( + res.findings.some((f) => f.checkId === id), + id, + ).toBe(false); + } + }, + }, + { + name: "flags Windows ACLs when Users can read the state dir", + label: "win-open", + execIcacls: async (_cmd: string, args: string[]) => { + const target = args[0]; + if (target.endsWith(`${path.sep}state`)) { + return { + stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(RX)\n DESKTOP-TEST\\Tester:(F)\n`, + stderr: "", + }; + } + return { + stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n DESKTOP-TEST\\Tester:(F)\n`, + stderr: "", + }; + }, + assert: (res: SecurityAuditReport) => { + expect( + res.findings.some( + (f) => f.checkId === "fs.state_dir.perms_readable" && f.severity === "warn", + ), + ).toBe(true); + }, + }, + ] as const; - const res = await runSecurityAudit({ - config: {}, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - platform: "win32", - env: windowsAuditEnv, - execIcacls, - execDockerRawFn: execDockerRawUnavailable, - }); + await Promise.all( + cases.map(async (testCase) => { + const tmp = await makeTmpDir(testCase.label); + const stateDir = path.join(tmp, "state"); + await fs.mkdir(stateDir, { recursive: true }); + const configPath = path.join(stateDir, "openclaw.json"); + await fs.writeFile(configPath, "{}\n", "utf-8"); - expect( - res.findings.some( - (f) => f.checkId === "fs.state_dir.perms_readable" && f.severity === "warn", - ), - ).toBe(true); - }); + const res = await runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + platform: "win32", + env: windowsAuditEnv, + execIcacls: testCase.execIcacls, + execDockerRawFn: execDockerRawUnavailable, + }); - it("warns when sandbox browser containers have missing or stale hash labels", async () => { - const { stateDir, configPath } = await createFilesystemAuditFixture("browser-hash-labels"); - - const execDockerRawFn = (async (args: string[]) => { - if (args[0] === "ps") { - return { - stdout: Buffer.from("openclaw-sbx-browser-old\nopenclaw-sbx-browser-missing-hash\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-old") { - return { - stdout: Buffer.from("abc123\tepoch-v0\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-missing-hash") { - return { - stdout: Buffer.from("\t\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - return { - stdout: Buffer.alloc(0), - stderr: Buffer.from("not found"), - code: 1, - }; - }) as NonNullable; - - const res = await runSecurityAudit({ - config: {}, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - execDockerRawFn, - }); - - expect(hasFinding(res, "sandbox.browser_container.hash_label_missing", "warn")).toBe(true); - expect(hasFinding(res, "sandbox.browser_container.hash_epoch_stale", "warn")).toBe(true); - const staleEpoch = res.findings.find( - (f) => f.checkId === "sandbox.browser_container.hash_epoch_stale", + testCase.assert(res); + }), ); - expect(staleEpoch?.detail).toContain("openclaw-sbx-browser-old"); }); - it("skips sandbox browser hash label checks when docker inspect is unavailable", async () => { - const { stateDir, configPath } = await createFilesystemAuditFixture("browser-hash-labels-skip"); + it("evaluates sandbox browser findings", async () => { + const cases = [ + { + name: "warns when sandbox browser containers have missing or stale hash labels", + run: async () => { + const { stateDir, configPath } = + await createFilesystemAuditFixture("browser-hash-labels"); + return runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + execDockerRawFn: (async (args: string[]) => { + if (args[0] === "ps") { + return { + stdout: Buffer.from( + "openclaw-sbx-browser-old\nopenclaw-sbx-browser-missing-hash\n", + ), + stderr: Buffer.alloc(0), + code: 0, + }; + } + if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-old") { + return { + stdout: Buffer.from("abc123\tepoch-v0\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-missing-hash") { + return { + stdout: Buffer.from("\t\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + return { + stdout: Buffer.alloc(0), + stderr: Buffer.from("not found"), + code: 1, + }; + }) as NonNullable, + }); + }, + assert: (res: SecurityAuditReport) => { + expect(hasFinding(res, "sandbox.browser_container.hash_label_missing", "warn")).toBe( + true, + ); + expect(hasFinding(res, "sandbox.browser_container.hash_epoch_stale", "warn")).toBe(true); + const staleEpoch = res.findings.find( + (f) => f.checkId === "sandbox.browser_container.hash_epoch_stale", + ); + expect(staleEpoch?.detail).toContain("openclaw-sbx-browser-old"); + }, + }, + { + name: "skips sandbox browser hash label checks when docker inspect is unavailable", + run: async () => { + const { stateDir, configPath } = await createFilesystemAuditFixture( + "browser-hash-labels-skip", + ); + return runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + execDockerRawFn: (async () => { + throw new Error("spawn docker ENOENT"); + }) as NonNullable, + }); + }, + assert: (res: SecurityAuditReport) => { + expect(hasFinding(res, "sandbox.browser_container.hash_label_missing")).toBe(false); + expect(hasFinding(res, "sandbox.browser_container.hash_epoch_stale")).toBe(false); + }, + }, + { + name: "flags sandbox browser containers with non-loopback published ports", + run: async () => { + const { stateDir, configPath } = await createFilesystemAuditFixture( + "browser-non-loopback-publish", + ); + return runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + execDockerRawFn: (async (args: string[]) => { + if (args[0] === "ps") { + return { + stdout: Buffer.from("openclaw-sbx-browser-exposed\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-exposed") { + return { + stdout: Buffer.from("hash123\t2026-02-21-novnc-auth-default\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + if (args[0] === "port" && args.at(-1) === "openclaw-sbx-browser-exposed") { + return { + stdout: Buffer.from("6080/tcp -> 0.0.0.0:49101\n9222/tcp -> 127.0.0.1:49100\n"), + stderr: Buffer.alloc(0), + code: 0, + }; + } + return { + stdout: Buffer.alloc(0), + stderr: Buffer.from("not found"), + code: 1, + }; + }) as NonNullable, + }); + }, + assert: (res: SecurityAuditReport) => { + expect( + hasFinding(res, "sandbox.browser_container.non_loopback_publish", "critical"), + ).toBe(true); + }, + }, + { + name: "warns when bridge network omits cdpSourceRange", + run: async () => + audit({ + agents: { + defaults: { + sandbox: { + mode: "all", + browser: { enabled: true, network: "bridge" }, + }, + }, + }, + }), + assert: (res: SecurityAuditReport) => { + const finding = res.findings.find( + (f) => f.checkId === "sandbox.browser_cdp_bridge_unrestricted", + ); + expect(finding?.severity).toBe("warn"); + expect(finding?.detail).toContain("agents.defaults.sandbox.browser"); + }, + }, + { + name: "does not warn for dedicated default browser network", + run: async () => + audit({ + agents: { + defaults: { + sandbox: { + mode: "all", + browser: { enabled: true }, + }, + }, + }, + }), + assert: (res: SecurityAuditReport) => { + expect(hasFinding(res, "sandbox.browser_cdp_bridge_unrestricted")).toBe(false); + }, + }, + ] as const; - const execDockerRawFn = (async () => { - throw new Error("spawn docker ENOENT"); - }) as NonNullable; - - const res = await runSecurityAudit({ - config: {}, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - execDockerRawFn, - }); - - expect(hasFinding(res, "sandbox.browser_container.hash_label_missing")).toBe(false); - expect(hasFinding(res, "sandbox.browser_container.hash_epoch_stale")).toBe(false); - }); - - it("flags sandbox browser containers with non-loopback published ports", async () => { - const { stateDir, configPath } = await createFilesystemAuditFixture( - "browser-non-loopback-publish", - ); - - const execDockerRawFn = (async (args: string[]) => { - if (args[0] === "ps") { - return { - stdout: Buffer.from("openclaw-sbx-browser-exposed\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-exposed") { - return { - stdout: Buffer.from("hash123\t2026-02-21-novnc-auth-default\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (args[0] === "port" && args.at(-1) === "openclaw-sbx-browser-exposed") { - return { - stdout: Buffer.from("6080/tcp -> 0.0.0.0:49101\n9222/tcp -> 127.0.0.1:49100\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - return { - stdout: Buffer.alloc(0), - stderr: Buffer.from("not found"), - code: 1, - }; - }) as NonNullable; - - const res = await runSecurityAudit({ - config: {}, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - execDockerRawFn, - }); - - expect(hasFinding(res, "sandbox.browser_container.non_loopback_publish", "critical")).toBe( - true, + await Promise.all( + cases.map(async (testCase) => { + const res = await testCase.run(); + testCase.assert(res); + }), ); }); @@ -929,69 +1048,81 @@ description: test skill expect(res.findings.some((f) => f.checkId === "fs.config.perms_group_readable")).toBe(false); }); - it("warns when workspace skill files resolve outside workspace root", async () => { - if (isWindows) { - return; + it("evaluates workspace skill path escape findings", async () => { + const cases = [ + { + name: "warns when workspace skill files resolve outside workspace root", + supported: !isWindows, + setup: async () => { + const tmp = await makeTmpDir("workspace-skill-symlink-escape"); + const stateDir = path.join(tmp, "state"); + const workspaceDir = path.join(tmp, "workspace"); + const outsideDir = path.join(tmp, "outside"); + await fs.mkdir(stateDir, { recursive: true, mode: 0o700 }); + await fs.mkdir(path.join(workspaceDir, "skills", "leak"), { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + + const outsideSkillPath = path.join(outsideDir, "SKILL.md"); + await fs.writeFile(outsideSkillPath, "# outside\n", "utf-8"); + await fs.symlink(outsideSkillPath, path.join(workspaceDir, "skills", "leak", "SKILL.md")); + + return { stateDir, workspaceDir, outsideSkillPath }; + }, + assert: ( + res: SecurityAuditReport, + fixture: { stateDir: string; workspaceDir: string; outsideSkillPath?: string }, + ) => { + const finding = res.findings.find((f) => f.checkId === "skills.workspace.symlink_escape"); + expect(finding?.severity).toBe("warn"); + expect(fixture.outsideSkillPath).toBeTruthy(); + expect(finding?.detail).toContain(fixture.outsideSkillPath ?? ""); + }, + }, + { + name: "does not warn for workspace skills that stay inside workspace root", + supported: true, + setup: async () => { + const tmp = await makeTmpDir("workspace-skill-in-root"); + const stateDir = path.join(tmp, "state"); + const workspaceDir = path.join(tmp, "workspace"); + await fs.mkdir(stateDir, { recursive: true, mode: 0o700 }); + await fs.mkdir(path.join(workspaceDir, "skills", "safe"), { recursive: true }); + await fs.writeFile( + path.join(workspaceDir, "skills", "safe", "SKILL.md"), + "# in workspace\n", + "utf-8", + ); + return { stateDir, workspaceDir }; + }, + assert: (res: SecurityAuditReport) => { + expectNoFinding(res, "skills.workspace.symlink_escape"); + }, + }, + ] as const; + + for (const testCase of cases) { + if (!testCase.supported) { + continue; + } + + const fixture = await testCase.setup(); + const configPath = path.join(fixture.stateDir, "openclaw.json"); + await fs.writeFile(configPath, "{}\n", "utf-8"); + if (!isWindows) { + await fs.chmod(configPath, 0o600); + } + + const res = await runSecurityAudit({ + config: { agents: { defaults: { workspace: fixture.workspaceDir } } }, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir: fixture.stateDir, + configPath, + execDockerRawFn: execDockerRawUnavailable, + }); + + testCase.assert(res, fixture); } - - const tmp = await makeTmpDir("workspace-skill-symlink-escape"); - const stateDir = path.join(tmp, "state"); - const workspaceDir = path.join(tmp, "workspace"); - const outsideDir = path.join(tmp, "outside"); - await fs.mkdir(stateDir, { recursive: true, mode: 0o700 }); - await fs.mkdir(path.join(workspaceDir, "skills", "leak"), { recursive: true }); - await fs.mkdir(outsideDir, { recursive: true }); - - const outsideSkillPath = path.join(outsideDir, "SKILL.md"); - await fs.writeFile(outsideSkillPath, "# outside\n", "utf-8"); - await fs.symlink(outsideSkillPath, path.join(workspaceDir, "skills", "leak", "SKILL.md")); - - const configPath = path.join(stateDir, "openclaw.json"); - await fs.writeFile(configPath, "{}\n", "utf-8"); - await fs.chmod(configPath, 0o600); - - const res = await runSecurityAudit({ - config: { agents: { defaults: { workspace: workspaceDir } } }, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - execDockerRawFn: execDockerRawUnavailable, - }); - - const finding = res.findings.find((f) => f.checkId === "skills.workspace.symlink_escape"); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain(outsideSkillPath); - }); - - it("does not warn for workspace skills that stay inside workspace root", async () => { - const tmp = await makeTmpDir("workspace-skill-in-root"); - const stateDir = path.join(tmp, "state"); - const workspaceDir = path.join(tmp, "workspace"); - await fs.mkdir(stateDir, { recursive: true, mode: 0o700 }); - await fs.mkdir(path.join(workspaceDir, "skills", "safe"), { recursive: true }); - await fs.writeFile( - path.join(workspaceDir, "skills", "safe", "SKILL.md"), - "# in workspace\n", - "utf-8", - ); - - const configPath = path.join(stateDir, "openclaw.json"); - await fs.writeFile(configPath, "{}\n", "utf-8"); - if (!isWindows) { - await fs.chmod(configPath, 0o600); - } - - const res = await runSecurityAudit({ - config: { agents: { defaults: { workspace: workspaceDir } } }, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - execDockerRawFn: execDockerRawUnavailable, - }); - - expect(res.findings.some((f) => f.checkId === "skills.workspace.symlink_escape")).toBe(false); }); it("scores small-model risk by tool/sandbox exposure", async () => { @@ -1036,12 +1167,8 @@ description: test skill ); }); - it("checks sandbox docker mode-off findings with/without agent override", async () => { - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - expectedPresent: boolean; - }> = [ + it("evaluates sandbox docker config findings", async () => { + const cases = [ { name: "mode off with docker config only", cfg: { @@ -1053,8 +1180,8 @@ description: test skill }, }, }, - }, - expectedPresent: true, + } as OpenClawConfig, + expectedFindings: [{ checkId: "sandbox.docker_config_mode_off" }], }, { name: "agent enables sandbox mode", @@ -1068,203 +1195,134 @@ description: test skill }, list: [{ id: "ops", sandbox: { mode: "all" } }], }, - }, - expectedPresent: false, + } as OpenClawConfig, + expectedFindings: [], + expectedAbsent: ["sandbox.docker_config_mode_off"], }, - ]; - await Promise.all( - cases.map(async (testCase) => { - const res = await audit(testCase.cfg); - expect(hasFinding(res, "sandbox.docker_config_mode_off"), testCase.name).toBe( - testCase.expectedPresent, - ); - }), - ); - }); - - it("flags dangerous sandbox docker config (binds/network/seccomp/apparmor)", async () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - sandbox: { - mode: "all", - docker: { - binds: ["/etc/passwd:/mnt/passwd:ro", "/run:/run"], - network: "host", - seccompProfile: "unconfined", - apparmorProfile: "unconfined", - }, - }, - }, - }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "sandbox.dangerous_bind_mount", severity: "critical" }), - expect.objectContaining({ - checkId: "sandbox.dangerous_network_mode", - severity: "critical", - }), - expect.objectContaining({ - checkId: "sandbox.dangerous_seccomp_profile", - severity: "critical", - }), - expect.objectContaining({ - checkId: "sandbox.dangerous_apparmor_profile", - severity: "critical", - }), - ]), - ); - }); - - it("flags container namespace join network mode in sandbox config", async () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - sandbox: { - mode: "all", - docker: { - network: "container:peer", - }, - }, - }, - }, - }; - const res = await audit(cfg); - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "sandbox.dangerous_network_mode", - severity: "critical", - title: "Dangerous network mode in sandbox config", - }), - ]), - ); - }); - - it("checks sandbox browser bridge-network restrictions", async () => { - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - expectedPresent: boolean; - expectedSeverity?: "warn"; - detailIncludes?: string; - }> = [ { - name: "bridge without cdpSourceRange", + name: "dangerous binds, host network, seccomp, and apparmor", cfg: { agents: { defaults: { sandbox: { mode: "all", - browser: { enabled: true, network: "bridge" }, + docker: { + binds: ["/etc/passwd:/mnt/passwd:ro", "/run:/run"], + network: "host", + seccompProfile: "unconfined", + apparmorProfile: "unconfined", + }, }, }, }, - }, - expectedPresent: true, - expectedSeverity: "warn", - detailIncludes: "agents.defaults.sandbox.browser", + } as OpenClawConfig, + expectedFindings: [ + { checkId: "sandbox.dangerous_bind_mount", severity: "critical" }, + { checkId: "sandbox.dangerous_network_mode", severity: "critical" }, + { checkId: "sandbox.dangerous_seccomp_profile", severity: "critical" }, + { checkId: "sandbox.dangerous_apparmor_profile", severity: "critical" }, + ], }, { - name: "dedicated default network", + name: "container namespace join network mode", cfg: { agents: { defaults: { sandbox: { mode: "all", - browser: { enabled: true }, + docker: { + network: "container:peer", + }, }, }, }, - }, - expectedPresent: false, + } as OpenClawConfig, + expectedFindings: [ + { + checkId: "sandbox.dangerous_network_mode", + severity: "critical", + title: "Dangerous network mode in sandbox config", + }, + ], }, - ]; + ] as const; + await Promise.all( cases.map(async (testCase) => { const res = await audit(testCase.cfg); - const finding = res.findings.find( - (f) => f.checkId === "sandbox.browser_cdp_bridge_unrestricted", - ); - expect(Boolean(finding), testCase.name).toBe(testCase.expectedPresent); - if (testCase.expectedPresent) { - expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity); - if (testCase.detailIncludes) { - expect(finding?.detail, testCase.name).toContain(testCase.detailIncludes); - } + if (testCase.expectedFindings.length > 0) { + expect(res.findings, testCase.name).toEqual( + expect.arrayContaining( + testCase.expectedFindings.map((finding) => expect.objectContaining(finding)), + ), + ); + } + const expectedAbsent = "expectedAbsent" in testCase ? testCase.expectedAbsent : []; + for (const checkId of expectedAbsent) { + expect(hasFinding(res, checkId), `${testCase.name}:${checkId}`).toBe(false); } }), ); }); - it("flags ineffective gateway.nodes.denyCommands entries", async () => { - const cfg: OpenClawConfig = { - gateway: { - nodes: { - denyCommands: ["system.*", "system.runx"], - }, + it("evaluates ineffective gateway.nodes.denyCommands entries", async () => { + const cases = [ + { + name: "flags ineffective gateway.nodes.denyCommands entries", + cfg: { + gateway: { + nodes: { + denyCommands: ["system.*", "system.runx"], + }, + }, + } satisfies OpenClawConfig, + detailIncludes: ["system.*", "system.runx", "did you mean", "system.run"], }, - }; + { + name: "suggests prefix-matching commands for unknown denyCommands entries", + cfg: { + gateway: { + nodes: { + denyCommands: ["system.run.prep"], + }, + }, + } satisfies OpenClawConfig, + detailIncludes: ["system.run.prep", "did you mean", "system.run.prepare"], + }, + { + name: "keeps unknown denyCommands entries without suggestions when no close command exists", + cfg: { + gateway: { + nodes: { + denyCommands: ["zzzzzzzzzzzzzz"], + }, + }, + } satisfies OpenClawConfig, + detailIncludes: ["zzzzzzzzzzzzzz"], + detailExcludes: ["did you mean"], + }, + ] as const; - const res = await audit(cfg); - - const finding = res.findings.find( - (f) => f.checkId === "gateway.nodes.deny_commands_ineffective", + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + const finding = res.findings.find( + (f) => f.checkId === "gateway.nodes.deny_commands_ineffective", + ); + expect(finding?.severity, testCase.name).toBe("warn"); + for (const text of testCase.detailIncludes) { + expect(finding?.detail, `${testCase.name}:${text}`).toContain(text); + } + const detailExcludes = "detailExcludes" in testCase ? testCase.detailExcludes : []; + for (const text of detailExcludes) { + expect(finding?.detail, `${testCase.name}:${text}`).not.toContain(text); + } + }), ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("system.*"); - expect(finding?.detail).toContain("system.runx"); - expect(finding?.detail).toContain("did you mean"); - expect(finding?.detail).toContain("system.run"); }); - it("suggests prefix-matching commands for unknown denyCommands entries", async () => { - const cfg: OpenClawConfig = { - gateway: { - nodes: { - denyCommands: ["system.run.prep"], - }, - }, - }; - - const res = await audit(cfg); - const finding = res.findings.find( - (f) => f.checkId === "gateway.nodes.deny_commands_ineffective", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("system.run.prep"); - expect(finding?.detail).toContain("did you mean"); - expect(finding?.detail).toContain("system.run.prepare"); - }); - - it("keeps unknown denyCommands entries without suggestions when no close command exists", async () => { - const cfg: OpenClawConfig = { - gateway: { - nodes: { - denyCommands: ["zzzzzzzzzzzzzz"], - }, - }, - }; - - const res = await audit(cfg); - const finding = res.findings.find( - (f) => f.checkId === "gateway.nodes.deny_commands_ineffective", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("zzzzzzzzzzzzzz"); - expect(finding?.detail).not.toContain("did you mean"); - }); - - it("scores dangerous gateway.nodes.allowCommands by exposure", async () => { - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - expectedSeverity: "warn" | "critical"; - }> = [ + it("evaluates dangerous gateway.nodes.allowCommands findings", async () => { + const cases = [ { name: "loopback gateway", cfg: { @@ -1272,8 +1330,8 @@ description: test skill bind: "loopback", nodes: { allowCommands: ["camera.snap", "screen.record"] }, }, - }, - expectedSeverity: "warn", + } as OpenClawConfig, + expectedSeverity: "warn" as const, }, { name: "lan-exposed gateway", @@ -1282,38 +1340,46 @@ description: test skill bind: "lan", nodes: { allowCommands: ["camera.snap", "screen.record"] }, }, - }, - expectedSeverity: "critical", + } as OpenClawConfig, + expectedSeverity: "critical" as const, }, - ]; + { + name: "denied again suppresses dangerous allowCommands finding", + cfg: { + gateway: { + nodes: { + allowCommands: ["camera.snap", "screen.record"], + denyCommands: ["camera.snap", "screen.record"], + }, + }, + } as OpenClawConfig, + expectedAbsent: true, + }, + ] as const; await Promise.all( cases.map(async (testCase) => { const res = await audit(testCase.cfg); + if ("expectedAbsent" in testCase && testCase.expectedAbsent) { + expectNoFinding(res, "gateway.nodes.allow_commands_dangerous"); + return; + } + const expectedSeverity = + "expectedSeverity" in testCase ? testCase.expectedSeverity : undefined; + if (!expectedSeverity) { + return; + } + const finding = res.findings.find( (f) => f.checkId === "gateway.nodes.allow_commands_dangerous", ); - expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity); + expect(finding?.severity, testCase.name).toBe(expectedSeverity); expect(finding?.detail, testCase.name).toContain("camera.snap"); expect(finding?.detail, testCase.name).toContain("screen.record"); }), ); }); - it("does not flag dangerous allowCommands entries when denied again", async () => { - const cfg: OpenClawConfig = { - gateway: { - nodes: { - allowCommands: ["camera.snap", "screen.record"], - denyCommands: ["camera.snap", "screen.record"], - }, - }, - }; - - const res = await audit(cfg); - expectNoFinding(res, "gateway.nodes.allow_commands_dangerous"); - }); - it("flags agent profile overrides when global tools.profile is minimal", async () => { const cfg: OpenClawConfig = { tools: { @@ -1348,205 +1414,212 @@ description: test skill expectFinding(res, "tools.elevated.allowFrom.whatsapp.wildcard", "critical"); }); - it("flags browser control without auth when browser is enabled", async () => { - const cfg: OpenClawConfig = { - gateway: { - controlUi: { enabled: false }, - auth: {}, - }, - browser: { - enabled: true, - }, - }; - - const res = await audit(cfg, { env: {} }); - - expectFinding(res, "browser.control_no_auth", "critical"); - }); - - it("does not flag browser control auth when gateway token is configured", async () => { - const cfg: OpenClawConfig = { - gateway: { - controlUi: { enabled: false }, - auth: { token: "very-long-browser-token-0123456789" }, - }, - browser: { - enabled: true, - }, - }; - - const res = await audit(cfg, { env: {} }); - - expectNoFinding(res, "browser.control_no_auth"); - }); - - it("does not flag browser control auth when gateway password uses SecretRef", async () => { - const cfg: OpenClawConfig = { - gateway: { - controlUi: { enabled: false }, - auth: { - password: { - source: "env", - provider: "default", - id: "OPENCLAW_GATEWAY_PASSWORD", + it.each([ + { + name: "flags browser control without auth when browser is enabled", + cfg: { + gateway: { + controlUi: { enabled: false }, + auth: {}, + }, + browser: { + enabled: true, + }, + } satisfies OpenClawConfig, + expectedFinding: { checkId: "browser.control_no_auth", severity: "critical" }, + }, + { + name: "does not flag browser control auth when gateway token is configured", + cfg: { + gateway: { + controlUi: { enabled: false }, + auth: { token: "very-long-browser-token-0123456789" }, + }, + browser: { + enabled: true, + }, + } satisfies OpenClawConfig, + expectedNoFinding: "browser.control_no_auth", + }, + { + name: "does not flag browser control auth when gateway password uses SecretRef", + cfg: { + gateway: { + controlUi: { enabled: false }, + auth: { + password: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_PASSWORD", + }, }, }, - }, - browser: { - enabled: true, - }, - }; - - const res = await audit(cfg, { env: {} }); - expectNoFinding(res, "browser.control_no_auth"); - }); - - it("warns when remote CDP uses HTTP", async () => { - const cfg: OpenClawConfig = { - browser: { - profiles: { - remote: { cdpUrl: "http://example.com:9222", color: "#0066CC" }, + browser: { + enabled: true, }, - }, - }; - - const res = await audit(cfg); - - expectFinding(res, "browser.remote_cdp_http", "warn"); - }); - - it("warns when remote CDP targets a private/internal host", async () => { - const cfg: OpenClawConfig = { - browser: { - profiles: { - remote: { - cdpUrl: - "http://169.254.169.254:9222/json/version?token=supersecrettokenvalue1234567890", - color: "#0066CC", + } satisfies OpenClawConfig, + expectedNoFinding: "browser.control_no_auth", + }, + { + name: "warns when remote CDP uses HTTP", + cfg: { + browser: { + profiles: { + remote: { cdpUrl: "http://example.com:9222", color: "#0066CC" }, }, }, + } satisfies OpenClawConfig, + expectedFinding: { checkId: "browser.remote_cdp_http", severity: "warn" }, + }, + { + name: "warns when remote CDP targets a private/internal host", + cfg: { + browser: { + profiles: { + remote: { + cdpUrl: + "http://169.254.169.254:9222/json/version?token=supersecrettokenvalue1234567890", + color: "#0066CC", + }, + }, + }, + } satisfies OpenClawConfig, + expectedFinding: { + checkId: "browser.remote_cdp_private_host", + severity: "warn", + detail: expect.stringContaining("token=supers…7890"), }, - }; + }, + ])("$name", async (testCase) => { + const res = await audit(testCase.cfg, { env: {} }); - const res = await audit(cfg); - - expectFinding(res, "browser.remote_cdp_private_host", "warn"); - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "browser.remote_cdp_private_host", - detail: expect.stringContaining("token=supers…7890"), - }), - ]), - ); + if (testCase.expectedFinding) { + expect(res.findings).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), + ); + } + if (testCase.expectedNoFinding) { + expectNoFinding(res, testCase.expectedNoFinding); + } }); - it("warns when control UI allows insecure auth", async () => { - const cfg: OpenClawConfig = { - gateway: { - controlUi: { allowInsecureAuth: true }, - }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ + it("warns on insecure or dangerous flags", async () => { + const cases = [ + { + name: "control UI allows insecure auth", + cfg: { + gateway: { + controlUi: { allowInsecureAuth: true }, + }, + } satisfies OpenClawConfig, + expectedFinding: { checkId: "gateway.control_ui.insecure_auth", severity: "warn", - }), - expect.objectContaining({ - checkId: "config.insecure_or_dangerous_flags", - severity: "warn", - detail: expect.stringContaining("gateway.controlUi.allowInsecureAuth=true"), - }), - ]), - ); - }); - - it("warns when control UI device auth is disabled", async () => { - const cfg: OpenClawConfig = { - gateway: { - controlUi: { dangerouslyDisableDeviceAuth: true }, + }, + expectedDangerousDetails: ["gateway.controlUi.allowInsecureAuth=true"], }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ + { + name: "control UI device auth is disabled", + cfg: { + gateway: { + controlUi: { dangerouslyDisableDeviceAuth: true }, + }, + } satisfies OpenClawConfig, + expectedFinding: { checkId: "gateway.control_ui.device_auth_disabled", severity: "critical", - }), - expect.objectContaining({ - checkId: "config.insecure_or_dangerous_flags", - severity: "warn", - detail: expect.stringContaining("gateway.controlUi.dangerouslyDisableDeviceAuth=true"), - }), - ]), - ); - }); - - it("warns when insecure/dangerous debug flags are enabled", async () => { - const cfg: OpenClawConfig = { - hooks: { - gmail: { allowUnsafeExternalContent: true }, - mappings: [{ allowUnsafeExternalContent: true }], - }, - tools: { - exec: { - applyPatch: { - workspaceOnly: false, - }, }, + expectedDangerousDetails: ["gateway.controlUi.dangerouslyDisableDeviceAuth=true"], }, - }; + { + name: "generic insecure debug flags", + cfg: { + hooks: { + gmail: { allowUnsafeExternalContent: true }, + mappings: [{ allowUnsafeExternalContent: true }], + }, + tools: { + exec: { + applyPatch: { + workspaceOnly: false, + }, + }, + }, + } satisfies OpenClawConfig, + expectedDangerousDetails: [ + "hooks.gmail.allowUnsafeExternalContent=true", + "hooks.mappings[0].allowUnsafeExternalContent=true", + "tools.exec.applyPatch.workspaceOnly=false", + ], + }, + ] as const; - const res = await audit(cfg); - const finding = res.findings.find((f) => f.checkId === "config.insecure_or_dangerous_flags"); - - expect(finding).toBeTruthy(); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("hooks.gmail.allowUnsafeExternalContent=true"); - expect(finding?.detail).toContain("hooks.mappings[0].allowUnsafeExternalContent=true"); - expect(finding?.detail).toContain("tools.exec.applyPatch.workspaceOnly=false"); + for (const testCase of cases) { + const res = await audit(testCase.cfg); + if ("expectedFinding" in testCase) { + expect(res.findings, testCase.name).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), + ); + } + const finding = res.findings.find((f) => f.checkId === "config.insecure_or_dangerous_flags"); + expect(finding, testCase.name).toBeTruthy(); + expect(finding?.severity, testCase.name).toBe("warn"); + for (const detail of testCase.expectedDangerousDetails) { + expect(finding?.detail, `${testCase.name}:${detail}`).toContain(detail); + } + } }); - it("flags non-loopback Control UI without allowed origins", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "lan", - auth: { mode: "token", token: "very-long-browser-token-0123456789" }, + it.each([ + { + name: "flags non-loopback Control UI without allowed origins", + cfg: { + gateway: { + bind: "lan", + auth: { mode: "token", token: "very-long-browser-token-0123456789" }, + }, + } satisfies OpenClawConfig, + expectedFinding: { + checkId: "gateway.control_ui.allowed_origins_required", + severity: "critical", }, - }; - - const res = await audit(cfg); - expectFinding(res, "gateway.control_ui.allowed_origins_required", "critical"); - }); - - it("flags wildcard Control UI origins by exposure level", async () => { - const loopbackCfg: OpenClawConfig = { - gateway: { - bind: "loopback", - controlUi: { allowedOrigins: ["*"] }, + }, + { + name: "flags wildcard Control UI origins by exposure level on loopback", + cfg: { + gateway: { + bind: "loopback", + controlUi: { allowedOrigins: ["*"] }, + }, + } satisfies OpenClawConfig, + expectedFinding: { + checkId: "gateway.control_ui.allowed_origins_wildcard", + severity: "warn", }, - }; - const exposedCfg: OpenClawConfig = { - gateway: { - bind: "lan", - auth: { mode: "token", token: "very-long-browser-token-0123456789" }, - controlUi: { allowedOrigins: ["*"] }, + }, + { + name: "flags wildcard Control UI origins by exposure level when exposed", + cfg: { + gateway: { + bind: "lan", + auth: { mode: "token", token: "very-long-browser-token-0123456789" }, + controlUi: { allowedOrigins: ["*"] }, + }, + } satisfies OpenClawConfig, + expectedFinding: { + checkId: "gateway.control_ui.allowed_origins_wildcard", + severity: "critical", }, - }; - - const loopback = await audit(loopbackCfg); - const exposed = await audit(exposedCfg); - - expectFinding(loopback, "gateway.control_ui.allowed_origins_wildcard", "warn"); - expectFinding(exposed, "gateway.control_ui.allowed_origins_wildcard", "critical"); - expectNoFinding(exposed, "gateway.control_ui.allowed_origins_required"); + expectedNoFinding: "gateway.control_ui.allowed_origins_required", + }, + ])("$name", async (testCase) => { + const res = await audit(testCase.cfg); + expect(res.findings).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), + ); + if (testCase.expectedNoFinding) { + expectNoFinding(res, testCase.expectedNoFinding); + } }); it("flags dangerous host-header origin fallback and suppresses missing allowed-origins finding", async () => { @@ -1569,51 +1642,56 @@ description: test skill ); }); - it("warns when Feishu doc tool is enabled because create can grant requester access", async () => { - const cfg: OpenClawConfig = { - channels: { - feishu: { - appId: "cli_test", - appSecret: "secret_test", // pragma: allowlist secret - }, - }, - }; - - const res = await audit(cfg); - expectFinding(res, "channels.feishu.doc_owner_open_id", "warn"); - }); - - it("treats Feishu SecretRef appSecret as configured for doc tool risk detection", async () => { - const cfg: OpenClawConfig = { - channels: { - feishu: { - appId: "cli_test", - appSecret: { - source: "env", - provider: "default", - id: "FEISHU_APP_SECRET", + it.each([ + { + name: "warns when Feishu doc tool is enabled because create can grant requester access", + cfg: { + channels: { + feishu: { + appId: "cli_test", + appSecret: "secret_test", // pragma: allowlist secret }, }, - }, - }; - - const res = await audit(cfg); - expectFinding(res, "channels.feishu.doc_owner_open_id", "warn"); - }); - - it("does not warn for Feishu doc grant risk when doc tools are disabled", async () => { - const cfg: OpenClawConfig = { - channels: { - feishu: { - appId: "cli_test", - appSecret: "secret_test", // pragma: allowlist secret - tools: { doc: false }, + } satisfies OpenClawConfig, + expectedFinding: "channels.feishu.doc_owner_open_id", + }, + { + name: "treats Feishu SecretRef appSecret as configured for doc tool risk detection", + cfg: { + channels: { + feishu: { + appId: "cli_test", + appSecret: { + source: "env", + provider: "default", + id: "FEISHU_APP_SECRET", + }, + }, }, - }, - }; - - const res = await audit(cfg); - expectNoFinding(res, "channels.feishu.doc_owner_open_id"); + } satisfies OpenClawConfig, + expectedFinding: "channels.feishu.doc_owner_open_id", + }, + { + name: "does not warn for Feishu doc grant risk when doc tools are disabled", + cfg: { + channels: { + feishu: { + appId: "cli_test", + appSecret: "secret_test", // pragma: allowlist secret + tools: { doc: false }, + }, + }, + } satisfies OpenClawConfig, + expectedNoFinding: "channels.feishu.doc_owner_open_id", + }, + ])("$name", async (testCase) => { + const res = await audit(testCase.cfg); + if (testCase.expectedFinding) { + expectFinding(res, testCase.expectedFinding, "warn"); + } + if (testCase.expectedNoFinding) { + expectNoFinding(res, testCase.expectedNoFinding); + } }); it("scores X-Real-IP fallback risk by gateway exposure", async () => { @@ -1688,15 +1766,10 @@ description: test skill }, ]; - await Promise.all( - cases.map(async (testCase) => { - const res = await audit(testCase.cfg); - expect( - hasFinding(res, "gateway.real_ip_fallback_enabled", testCase.expectedSeverity), - testCase.name, - ).toBe(true); - }), - ); + await expectSeverityByExposureCases({ + checkId: "gateway.real_ip_fallback_enabled", + cases, + }); }); it("scores mDNS full mode risk by gateway bind mode", async () => { @@ -1739,15 +1812,10 @@ description: test skill }, ]; - await Promise.all( - cases.map(async (testCase) => { - const res = await audit(testCase.cfg); - expect( - hasFinding(res, "discovery.mdns_full_mode", testCase.expectedSeverity), - testCase.name, - ).toBe(true); - }), - ); + await expectSeverityByExposureCases({ + checkId: "discovery.mdns_full_mode", + cases, + }); }); it("evaluates trusted-proxy auth guardrails", async () => { @@ -1891,130 +1959,281 @@ description: test skill ); }); - it("flags Discord native commands without a guild user allowlist", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "t", - groupPolicy: "allowlist", - guilds: { - "123": { - channels: { - general: { allow: true }, + it("evaluates Discord native command allowlist findings", async () => { + const cases = [ + { + name: "flags missing guild user allowlists", + cfg: { + channels: { + discord: { + enabled: true, + token: "t", + groupPolicy: "allowlist", + guilds: { + "123": { + channels: { + general: { allow: true }, + }, }, }, }, }, - }, - }; + } as OpenClawConfig, + expectFinding: true, + }, + { + name: "does not flag when dm.allowFrom includes a Discord snowflake id", + cfg: { + channels: { + discord: { + enabled: true, + token: "t", + dm: { allowFrom: ["387380367612706819"] }, + groupPolicy: "allowlist", + guilds: { + "123": { + channels: { + general: { allow: true }, + }, + }, + }, + }, + }, + } as OpenClawConfig, + expectFinding: false, + }, + ] as const; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], + for (const testCase of cases) { + await withChannelSecurityStateDir(async () => { + const res = await runSecurityAudit({ + config: testCase.cfg, + includeFilesystem: false, + includeChannelSecurity: true, + plugins: [discordPlugin], + }); + + expect( + res.findings.some( + (finding) => finding.checkId === "channels.discord.commands.native.no_allowlists", + ), + testCase.name, + ).toBe(testCase.expectFinding); }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.discord.commands.native.no_allowlists", - severity: "warn", - }), - ]), - ); - }); + } }); - it("keeps channel security findings when SecretRef credentials are configured but unavailable", async () => { - await withChannelSecurityStateDir(async () => { - const sourceConfig: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, - groupPolicy: "allowlist", - guilds: { - "123": { - channels: { - general: { allow: true }, + it("keeps source-configured channel security findings when resolved inspection is incomplete", async () => { + const cases = [ + { + name: "discord SecretRef configured but unavailable", + sourceConfig: { + channels: { + discord: { + enabled: true, + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + groupPolicy: "allowlist", + guilds: { + "123": { + channels: { + general: { allow: true }, + }, }, }, }, }, - }, - }; - const resolvedConfig: OpenClawConfig = { - channels: { - discord: { - enabled: true, - groupPolicy: "allowlist", - guilds: { - "123": { - channels: { - general: { allow: true }, + } as OpenClawConfig, + resolvedConfig: { + channels: { + discord: { + enabled: true, + groupPolicy: "allowlist", + guilds: { + "123": { + channels: { + general: { allow: true }, + }, }, }, }, }, - }, - }; - - const inspectableDiscordPlugin = stubChannelPlugin({ - id: "discord", - label: "Discord", - inspectAccount: (cfg) => { - const channel = cfg.channels?.discord ?? {}; - const token = channel.token; - return { - accountId: "default", - enabled: true, - configured: - Boolean(token) && - typeof token === "object" && - !Array.isArray(token) && - "source" in token, - token: "", - tokenSource: - Boolean(token) && - typeof token === "object" && - !Array.isArray(token) && - "source" in token - ? "config" - : "none", - tokenStatus: - Boolean(token) && - typeof token === "object" && - !Array.isArray(token) && - "source" in token - ? "configured_unavailable" - : "missing", - config: channel, - }; - }, - resolveAccount: (cfg) => ({ config: cfg.channels?.discord ?? {} }), - isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), - }); - - const res = await runSecurityAudit({ - config: resolvedConfig, - sourceConfig, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [inspectableDiscordPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.discord.commands.native.no_allowlists", - severity: "warn", + } as OpenClawConfig, + plugin: () => + stubChannelPlugin({ + id: "discord", + label: "Discord", + inspectAccount: (cfg) => { + const channel = cfg.channels?.discord ?? {}; + const token = channel.token; + return { + accountId: "default", + enabled: true, + configured: + Boolean(token) && + typeof token === "object" && + !Array.isArray(token) && + "source" in token, + token: "", + tokenSource: + Boolean(token) && + typeof token === "object" && + !Array.isArray(token) && + "source" in token + ? "config" + : "none", + tokenStatus: + Boolean(token) && + typeof token === "object" && + !Array.isArray(token) && + "source" in token + ? "configured_unavailable" + : "missing", + config: channel, + }; + }, + resolveAccount: (cfg) => ({ config: cfg.channels?.discord ?? {} }), + isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), }), - ]), - ); - }); + expectedCheckId: "channels.discord.commands.native.no_allowlists", + }, + { + name: "slack resolved inspection only exposes signingSecret status", + sourceConfig: { + channels: { + slack: { + enabled: true, + mode: "http", + groupPolicy: "open", + slashCommand: { enabled: true }, + }, + }, + } as OpenClawConfig, + resolvedConfig: { + channels: { + slack: { + enabled: true, + mode: "http", + groupPolicy: "open", + slashCommand: { enabled: true }, + }, + }, + } as OpenClawConfig, + plugin: (sourceConfig: OpenClawConfig) => + stubChannelPlugin({ + id: "slack", + label: "Slack", + inspectAccount: (cfg) => { + const channel = cfg.channels?.slack ?? {}; + if (cfg === sourceConfig) { + return { + accountId: "default", + enabled: false, + configured: true, + mode: "http", + botTokenSource: "config", + botTokenStatus: "configured_unavailable", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret + config: channel, + }; + } + return { + accountId: "default", + enabled: true, + configured: true, + mode: "http", + botTokenSource: "config", + botTokenStatus: "available", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "available", // pragma: allowlist secret + config: channel, + }; + }, + resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }), + isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), + }), + expectedCheckId: "channels.slack.commands.slash.no_allowlists", + }, + { + name: "slack source config still wins when resolved inspection is unconfigured", + sourceConfig: { + channels: { + slack: { + enabled: true, + mode: "http", + groupPolicy: "open", + slashCommand: { enabled: true }, + }, + }, + } as OpenClawConfig, + resolvedConfig: { + channels: { + slack: { + enabled: true, + mode: "http", + groupPolicy: "open", + slashCommand: { enabled: true }, + }, + }, + } as OpenClawConfig, + plugin: (sourceConfig: OpenClawConfig) => + stubChannelPlugin({ + id: "slack", + label: "Slack", + inspectAccount: (cfg) => { + const channel = cfg.channels?.slack ?? {}; + if (cfg === sourceConfig) { + return { + accountId: "default", + enabled: true, + configured: true, + mode: "http", + botTokenSource: "config", + botTokenStatus: "configured_unavailable", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret + config: channel, + }; + } + return { + accountId: "default", + enabled: true, + configured: false, + mode: "http", + botTokenSource: "config", + botTokenStatus: "available", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "missing", // pragma: allowlist secret + config: channel, + }; + }, + resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }), + isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), + }), + expectedCheckId: "channels.slack.commands.slash.no_allowlists", + }, + ] as const; + + for (const testCase of cases) { + await withChannelSecurityStateDir(async () => { + const res = await runSecurityAudit({ + config: testCase.resolvedConfig, + sourceConfig: testCase.sourceConfig, + includeFilesystem: false, + includeChannelSecurity: true, + plugins: [testCase.plugin(testCase.sourceConfig)], + }); + + expect(res.findings, testCase.name).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: testCase.expectedCheckId, + severity: "warn", + }), + ]), + ); + }); + } }); it("adds a read-only resolution warning when channel account resolveAccount throws", async () => { @@ -2051,202 +2270,16 @@ description: test skill expect(finding?.detail).toContain("missing SecretRef"); }); - it("keeps Slack HTTP slash-command findings when resolved inspection only exposes signingSecret status", async () => { - await withChannelSecurityStateDir(async () => { - const sourceConfig: OpenClawConfig = { - channels: { - slack: { - enabled: true, - mode: "http", - groupPolicy: "open", - slashCommand: { enabled: true }, - }, - }, - }; - const resolvedConfig: OpenClawConfig = { - channels: { - slack: { - enabled: true, - mode: "http", - groupPolicy: "open", - slashCommand: { enabled: true }, - }, - }, - }; - - const inspectableSlackPlugin = stubChannelPlugin({ - id: "slack", - label: "Slack", - inspectAccount: (cfg) => { - const channel = cfg.channels?.slack ?? {}; - if (cfg === sourceConfig) { - return { - accountId: "default", - enabled: false, - configured: true, - mode: "http", - botTokenSource: "config", - botTokenStatus: "configured_unavailable", - signingSecretSource: "config", // pragma: allowlist secret - signingSecretStatus: "configured_unavailable", // pragma: allowlist secret - config: channel, - }; - } - return { - accountId: "default", - enabled: true, - configured: true, - mode: "http", - botTokenSource: "config", - botTokenStatus: "available", - signingSecretSource: "config", // pragma: allowlist secret - signingSecretStatus: "available", // pragma: allowlist secret - config: channel, - }; - }, - resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }), - isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), - }); - - const res = await runSecurityAudit({ - config: resolvedConfig, - sourceConfig, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [inspectableSlackPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.slack.commands.slash.no_allowlists", - severity: "warn", - }), - ]), - ); - }); - }); - - it("keeps source-configured Slack HTTP findings when resolved inspection is unconfigured", async () => { - await withChannelSecurityStateDir(async () => { - const sourceConfig: OpenClawConfig = { - channels: { - slack: { - enabled: true, - mode: "http", - groupPolicy: "open", - slashCommand: { enabled: true }, - }, - }, - }; - const resolvedConfig: OpenClawConfig = { - channels: { - slack: { - enabled: true, - mode: "http", - groupPolicy: "open", - slashCommand: { enabled: true }, - }, - }, - }; - - const inspectableSlackPlugin = stubChannelPlugin({ - id: "slack", - label: "Slack", - inspectAccount: (cfg) => { - const channel = cfg.channels?.slack ?? {}; - if (cfg === sourceConfig) { - return { - accountId: "default", - enabled: true, - configured: true, - mode: "http", - botTokenSource: "config", - botTokenStatus: "configured_unavailable", - signingSecretSource: "config", // pragma: allowlist secret - signingSecretStatus: "configured_unavailable", // pragma: allowlist secret - config: channel, - }; - } - return { - accountId: "default", - enabled: true, - configured: false, - mode: "http", - botTokenSource: "config", - botTokenStatus: "available", - signingSecretSource: "config", // pragma: allowlist secret - signingSecretStatus: "missing", // pragma: allowlist secret - config: channel, - }; - }, - resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }), - isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), - }); - - const res = await runSecurityAudit({ - config: resolvedConfig, - sourceConfig, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [inspectableSlackPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.slack.commands.slash.no_allowlists", - severity: "warn", - }), - ]), - ); - }); - }); - - it("does not flag Discord slash commands when dm.allowFrom includes a Discord snowflake id", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "t", - dm: { allowFrom: ["387380367612706819"] }, - groupPolicy: "allowlist", - guilds: { - "123": { - channels: { - general: { allow: true }, - }, - }, - }, - }, - }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], - }); - - expect(res.findings).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.discord.commands.native.no_allowlists", - }), - ]), - ); - }); - }); - - it("warns when Discord allowlists contain name-based entries", async () => { - await withChannelSecurityStateDir(async (tmp) => { - await fs.writeFile( - path.join(tmp, "credentials", "discord-allowFrom.json"), - JSON.stringify({ version: 1, allowFrom: ["team.owner"] }), - ); - const cfg: OpenClawConfig = { + it.each([ + { + name: "warns when Discord allowlists contain name-based entries", + setup: async (tmp: string) => { + await fs.writeFile( + path.join(tmp, "credentials", "discord-allowFrom.json"), + JSON.stringify({ version: 1, allowFrom: ["team.owner"] }), + ); + }, + cfg: { channels: { discord: { enabled: true, @@ -2264,35 +2297,20 @@ description: test skill }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], - }); - - const finding = res.findings.find( - (entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries", - ); - expect(finding).toBeDefined(); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("channels.discord.allowFrom:Alice#1234"); - expect(finding?.detail).toContain("channels.discord.guilds.123.users:trusted.operator"); - expect(finding?.detail).toContain( + } satisfies OpenClawConfig, + plugins: [discordPlugin], + expectNameBasedSeverity: "warn", + detailIncludes: [ + "channels.discord.allowFrom:Alice#1234", + "channels.discord.guilds.123.users:trusted.operator", "channels.discord.guilds.123.channels.general.users:security-team", - ); - expect(finding?.detail).toContain( "~/.openclaw/credentials/discord-allowFrom.json:team.owner", - ); - expect(finding?.detail).not.toContain("<@123456789012345678>"); - }); - }); - - it("marks Discord name-based allowlists as break-glass when dangerous matching is enabled", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + ], + detailExcludes: ["<@123456789012345678>"], + }, + { + name: "marks Discord name-based allowlists as break-glass when dangerous matching is enabled", + cfg: { channels: { discord: { enabled: true, @@ -2301,35 +2319,18 @@ description: test skill allowFrom: ["Alice#1234"], }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], - }); - - const finding = res.findings.find( - (entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries", - ); - expect(finding).toBeDefined(); - expect(finding?.severity).toBe("info"); - expect(finding?.detail).toContain("out-of-scope"); - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", - severity: "info", - }), - ]), - ); - }); - }); - - it("audits non-default Discord accounts for dangerous name matching", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + } satisfies OpenClawConfig, + plugins: [discordPlugin], + expectNameBasedSeverity: "info", + detailIncludes: ["out-of-scope"], + expectFindingMatch: { + checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", + severity: "info", + }, + }, + { + name: "audits non-default Discord accounts for dangerous name matching", + cfg: { channels: { discord: { enabled: true, @@ -2343,24 +2344,101 @@ description: test skill }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", - title: expect.stringContaining("(account: beta)"), - severity: "info", - }), - ]), + } satisfies OpenClawConfig, + plugins: [discordPlugin], + expectNoNameBasedFinding: true, + expectFindingMatch: { + checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", + title: expect.stringContaining("(account: beta)"), + severity: "info", + }, + }, + { + name: "audits name-based allowlists on non-default Discord accounts", + cfg: { + channels: { + discord: { + enabled: true, + token: "t", + accounts: { + alpha: { + token: "a", + allowFrom: ["123456789012345678"], + }, + beta: { + token: "b", + allowFrom: ["Alice#1234"], + }, + }, + }, + }, + } satisfies OpenClawConfig, + plugins: [discordPlugin], + expectNameBasedSeverity: "warn", + detailIncludes: ["channels.discord.accounts.beta.allowFrom:Alice#1234"], + }, + { + name: "does not warn when Discord allowlists use ID-style entries only", + cfg: { + channels: { + discord: { + enabled: true, + token: "t", + allowFrom: [ + "123456789012345678", + "<@223456789012345678>", + "user:323456789012345678", + "discord:423456789012345678", + "pk:member-123", + ], + guilds: { + "123": { + users: ["523456789012345678", "<@623456789012345678>", "pk:member-456"], + channels: { + general: { + users: ["723456789012345678", "user:823456789012345678"], + }, + }, + }, + }, + }, + }, + } satisfies OpenClawConfig, + plugins: [discordPlugin], + expectNoNameBasedFinding: true, + }, + ])("$name", async (testCase) => { + await withChannelSecurityStateDir(async (tmp) => { + await testCase.setup?.(tmp); + const res = await runChannelSecurityAudit(testCase.cfg, testCase.plugins); + const nameBasedFinding = res.findings.find( + (entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries", ); + + if (testCase.expectNoNameBasedFinding) { + expect(nameBasedFinding).toBeUndefined(); + } else if ( + testCase.expectNameBasedSeverity || + testCase.detailIncludes?.length || + testCase.detailExcludes?.length + ) { + expect(nameBasedFinding).toBeDefined(); + if (testCase.expectNameBasedSeverity) { + expect(nameBasedFinding?.severity).toBe(testCase.expectNameBasedSeverity); + } + for (const snippet of testCase.detailIncludes ?? []) { + expect(nameBasedFinding?.detail).toContain(snippet); + } + for (const snippet of testCase.detailExcludes ?? []) { + expect(nameBasedFinding?.detail).not.toContain(snippet); + } + } + + if (testCase.expectFindingMatch) { + expect(res.findings).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectFindingMatch)]), + ); + } }); }); @@ -2409,45 +2487,10 @@ description: test skill }); }); - it("audits name-based allowlists on non-default Discord accounts", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "t", - accounts: { - alpha: { - token: "a", - allowFrom: ["123456789012345678"], - }, - beta: { - token: "b", - allowFrom: ["Alice#1234"], - }, - }, - }, - }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], - }); - - const finding = res.findings.find( - (entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries", - ); - expect(finding).toBeDefined(); - expect(finding?.detail).toContain("channels.discord.accounts.beta.allowFrom:Alice#1234"); - }); - }); - - it("warns when Zalouser group routing contains mutable group entries", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + it.each([ + { + name: "warns when Zalouser group routing contains mutable group entries", + cfg: { channels: { zalouser: { enabled: true, @@ -2457,28 +2500,14 @@ description: test skill }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [zalouserPlugin], - }); - - const finding = res.findings.find( - (entry) => entry.checkId === "channels.zalouser.groups.mutable_entries", - ); - expect(finding).toBeDefined(); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("channels.zalouser.groups:Ops Room"); - expect(finding?.detail).not.toContain("group:g-123"); - }); - }); - - it("marks Zalouser mutable group routing as break-glass when dangerous matching is enabled", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + } satisfies OpenClawConfig, + expectedSeverity: "warn", + detailIncludes: ["channels.zalouser.groups:Ops Room"], + detailExcludes: ["group:g-123"], + }, + { + name: "marks Zalouser mutable group routing as break-glass when dangerous matching is enabled", + cfg: { channels: { zalouser: { enabled: true, @@ -2488,78 +2517,41 @@ description: test skill }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [zalouserPlugin], - }); - + } satisfies OpenClawConfig, + expectedSeverity: "info", + detailIncludes: ["out-of-scope"], + expectFindingMatch: { + checkId: "channels.zalouser.allowFrom.dangerous_name_matching_enabled", + severity: "info", + }, + }, + ])("$name", async (testCase) => { + await withChannelSecurityStateDir(async () => { + const res = await runChannelSecurityAudit(testCase.cfg, [zalouserPlugin]); const finding = res.findings.find( (entry) => entry.checkId === "channels.zalouser.groups.mutable_entries", ); + expect(finding).toBeDefined(); - expect(finding?.severity).toBe("info"); - expect(finding?.detail).toContain("out-of-scope"); - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.zalouser.allowFrom.dangerous_name_matching_enabled", - severity: "info", - }), - ]), - ); + expect(finding?.severity).toBe(testCase.expectedSeverity); + for (const snippet of testCase.detailIncludes) { + expect(finding?.detail).toContain(snippet); + } + for (const snippet of testCase.detailExcludes ?? []) { + expect(finding?.detail).not.toContain(snippet); + } + if (testCase.expectFindingMatch) { + expect(res.findings).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectFindingMatch)]), + ); + } }); }); - it("does not warn when Discord allowlists use ID-style entries only", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "t", - allowFrom: [ - "123456789012345678", - "<@223456789012345678>", - "user:323456789012345678", - "discord:423456789012345678", - "pk:member-123", - ], - guilds: { - "123": { - users: ["523456789012345678", "<@623456789012345678>", "pk:member-456"], - channels: { - general: { - users: ["723456789012345678", "user:823456789012345678"], - }, - }, - }, - }, - }, - }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], - }); - - expect(res.findings).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "channels.discord.allowFrom.name_based_entries" }), - ]), - ); - }); - }); - - it("flags Discord slash commands when access-group enforcement is disabled and no users allowlist exists", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + it.each([ + { + name: "flags Discord slash commands when access-group enforcement is disabled and no users allowlist exists", + cfg: { commands: { useAccessGroups: false }, channels: { discord: { @@ -2575,29 +2567,16 @@ description: test skill }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [discordPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.discord.commands.native.unrestricted", - severity: "critical", - }), - ]), - ); - }); - }); - - it("flags Slack slash commands without a channel users allowlist", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + } satisfies OpenClawConfig, + plugins: [discordPlugin], + expectedFinding: { + checkId: "channels.discord.commands.native.unrestricted", + severity: "critical", + }, + }, + { + name: "flags Slack slash commands without a channel users allowlist", + cfg: { channels: { slack: { enabled: true, @@ -2607,29 +2586,16 @@ description: test skill slashCommand: { enabled: true }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [slackPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.slack.commands.slash.no_allowlists", - severity: "warn", - }), - ]), - ); - }); - }); - - it("flags Slack slash commands when access-group enforcement is disabled", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + } satisfies OpenClawConfig, + plugins: [slackPlugin], + expectedFinding: { + checkId: "channels.slack.commands.slash.no_allowlists", + severity: "warn", + }, + }, + { + name: "flags Slack slash commands when access-group enforcement is disabled", + cfg: { commands: { useAccessGroups: false }, channels: { slack: { @@ -2640,29 +2606,16 @@ description: test skill slashCommand: { enabled: true }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [slackPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.slack.commands.slash.useAccessGroups_off", - severity: "critical", - }), - ]), - ); - }); - }); - - it("flags Telegram group commands without a sender allowlist", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + } satisfies OpenClawConfig, + plugins: [slackPlugin], + expectedFinding: { + checkId: "channels.slack.commands.slash.useAccessGroups_off", + severity: "critical", + }, + }, + { + name: "flags Telegram group commands without a sender allowlist", + cfg: { channels: { telegram: { enabled: true, @@ -2671,29 +2624,16 @@ description: test skill groups: { "-100123": {} }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [telegramPlugin], - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.telegram.groups.allowFrom.missing", - severity: "critical", - }), - ]), - ); - }); - }); - - it("warns when Telegram allowFrom entries are non-numeric (legacy @username configs)", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { + } satisfies OpenClawConfig, + plugins: [telegramPlugin], + expectedFinding: { + checkId: "channels.telegram.groups.allowFrom.missing", + severity: "critical", + }, + }, + { + name: "warns when Telegram allowFrom entries are non-numeric (legacy @username configs)", + cfg: { channels: { telegram: { enabled: true, @@ -2703,22 +2643,19 @@ description: test skill groups: { "-100123": {} }, }, }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [telegramPlugin], - }); + } satisfies OpenClawConfig, + plugins: [telegramPlugin], + expectedFinding: { + checkId: "channels.telegram.allowFrom.invalid_entries", + severity: "warn", + }, + }, + ])("$name", async (testCase) => { + await withChannelSecurityStateDir(async () => { + const res = await runChannelSecurityAudit(testCase.cfg, testCase.plugins); expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "channels.telegram.allowFrom.invalid_entries", - severity: "warn", - }), - ]), + expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), ); }); }); @@ -2807,209 +2744,182 @@ description: test skill ); }); - it("warns when hooks token looks short", async () => { - const cfg: OpenClawConfig = { - hooks: { enabled: true, token: "short" }, - }; - - const res = await audit(cfg); - - expectFinding(res, "hooks.token_too_short", "warn"); - }); - - it("flags hooks token reuse of the gateway env token as critical", async () => { - const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; - process.env.OPENCLAW_GATEWAY_TOKEN = "shared-gateway-token-1234567890"; - const cfg: OpenClawConfig = { - hooks: { enabled: true, token: "shared-gateway-token-1234567890" }, - }; - - try { - const res = await audit(cfg); - expectFinding(res, "hooks.token_reuse_gateway_token", "critical"); - } finally { - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; - } - } - }); - - it("warns when hooks.defaultSessionKey is unset", async () => { - const cfg: OpenClawConfig = { - hooks: { enabled: true, token: "shared-gateway-token-1234567890" }, - }; - - const res = await audit(cfg); - - expectFinding(res, "hooks.default_session_key_unset", "warn"); - }); - - it("scores unrestricted hooks.allowedAgentIds by gateway exposure", async () => { - const baseHooks = { + it("evaluates hooks ingress auth and routing findings", async () => { + const unrestrictedBaseHooks = { enabled: true, token: "shared-gateway-token-1234567890", defaultSessionKey: "hook:ingress", } satisfies NonNullable; - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - expectedSeverity: "warn" | "critical"; - }> = [ - { - name: "local exposure", - cfg: { hooks: baseHooks }, - expectedSeverity: "warn", - }, - { - name: "remote exposure", - cfg: { gateway: { bind: "lan" }, hooks: baseHooks }, - expectedSeverity: "critical", - }, - ]; - await Promise.all( - cases.map(async (testCase) => { - const res = await audit(testCase.cfg); - expect( - hasFinding(res, "hooks.allowed_agent_ids_unrestricted", testCase.expectedSeverity), - testCase.name, - ).toBe(true); - }), - ); - }); - - it("treats wildcard hooks.allowedAgentIds as unrestricted routing", async () => { - const res = await audit({ - hooks: { - enabled: true, - token: "shared-gateway-token-1234567890", - defaultSessionKey: "hook:ingress", - allowedAgentIds: ["*"], - }, - }); - - expectFinding(res, "hooks.allowed_agent_ids_unrestricted", "warn"); - }); - - it("scores hooks request sessionKey override by gateway exposure", async () => { - const baseHooks = { - enabled: true, - token: "shared-gateway-token-1234567890", - defaultSessionKey: "hook:ingress", + const requestSessionKeyHooks = { + ...unrestrictedBaseHooks, allowRequestSessionKey: true, } satisfies NonNullable; - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - expectedSeverity: "warn" | "critical"; - expectsPrefixesMissing?: boolean; - }> = [ + const cases = [ { - name: "local exposure", - cfg: { hooks: baseHooks }, - expectedSeverity: "warn", - expectsPrefixesMissing: true, + name: "warns when hooks token looks short", + cfg: { + hooks: { enabled: true, token: "short" }, + } satisfies OpenClawConfig, + expectedFinding: "hooks.token_too_short", + expectedSeverity: "warn" as const, }, { - name: "remote exposure", - cfg: { gateway: { bind: "lan" }, hooks: baseHooks }, - expectedSeverity: "critical", + name: "flags hooks token reuse of the gateway env token as critical", + cfg: { + hooks: { enabled: true, token: "shared-gateway-token-1234567890" }, + } satisfies OpenClawConfig, + env: { + OPENCLAW_GATEWAY_TOKEN: "shared-gateway-token-1234567890", + }, + expectedFinding: "hooks.token_reuse_gateway_token", + expectedSeverity: "critical" as const, }, - ]; + { + name: "warns when hooks.defaultSessionKey is unset", + cfg: { + hooks: { enabled: true, token: "shared-gateway-token-1234567890" }, + } satisfies OpenClawConfig, + expectedFinding: "hooks.default_session_key_unset", + expectedSeverity: "warn" as const, + }, + { + name: "treats wildcard hooks.allowedAgentIds as unrestricted routing", + cfg: { + hooks: { + enabled: true, + token: "shared-gateway-token-1234567890", + defaultSessionKey: "hook:ingress", + allowedAgentIds: ["*"], + }, + } satisfies OpenClawConfig, + expectedFinding: "hooks.allowed_agent_ids_unrestricted", + expectedSeverity: "warn" as const, + }, + { + name: "scores unrestricted hooks.allowedAgentIds by local exposure", + cfg: { hooks: unrestrictedBaseHooks } satisfies OpenClawConfig, + expectedFinding: "hooks.allowed_agent_ids_unrestricted", + expectedSeverity: "warn" as const, + }, + { + name: "scores unrestricted hooks.allowedAgentIds by remote exposure", + cfg: { gateway: { bind: "lan" }, hooks: unrestrictedBaseHooks } satisfies OpenClawConfig, + expectedFinding: "hooks.allowed_agent_ids_unrestricted", + expectedSeverity: "critical" as const, + }, + { + name: "scores hooks request sessionKey override by local exposure", + cfg: { hooks: requestSessionKeyHooks } satisfies OpenClawConfig, + expectedFinding: "hooks.request_session_key_enabled", + expectedSeverity: "warn" as const, + expectedExtraFinding: { + checkId: "hooks.request_session_key_prefixes_missing", + severity: "warn" as const, + }, + }, + { + name: "scores hooks request sessionKey override by remote exposure", + cfg: { + gateway: { bind: "lan" }, + hooks: requestSessionKeyHooks, + } satisfies OpenClawConfig, + expectedFinding: "hooks.request_session_key_enabled", + expectedSeverity: "critical" as const, + }, + ] as const; + await Promise.all( cases.map(async (testCase) => { - const res = await audit(testCase.cfg); - expect( - hasFinding(res, "hooks.request_session_key_enabled", testCase.expectedSeverity), - testCase.name, - ).toBe(true); - if (testCase.expectsPrefixesMissing) { - expect(hasFinding(res, "hooks.request_session_key_prefixes_missing", "warn")).toBe(true); + const env = "env" in testCase ? testCase.env : undefined; + const res = await audit(testCase.cfg, env ? { env } : undefined); + expectFinding(res, testCase.expectedFinding, testCase.expectedSeverity); + if ("expectedExtraFinding" in testCase) { + expectFinding( + res, + testCase.expectedExtraFinding.checkId, + testCase.expectedExtraFinding.severity, + ); } }), ); }); - it("scores gateway HTTP no-auth findings by exposure", async () => { - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - expectedSeverity: "warn" | "critical"; - detailIncludes?: string[]; - }> = [ - { - name: "loopback no-auth", - cfg: { - gateway: { - bind: "loopback", - auth: { mode: "none" }, - http: { endpoints: { chatCompletions: { enabled: true } } }, + it.each([ + { + name: "scores loopback gateway HTTP no-auth as warn", + cfg: { + gateway: { + bind: "loopback", + auth: { mode: "none" }, + http: { endpoints: { chatCompletions: { enabled: true } } }, + }, + } satisfies OpenClawConfig, + expectedFinding: { checkId: "gateway.http.no_auth", severity: "warn" }, + detailIncludes: ["/tools/invoke", "/v1/chat/completions"], + auditOptions: { env: {} }, + }, + { + name: "scores remote gateway HTTP no-auth as critical", + cfg: { + gateway: { + bind: "lan", + auth: { mode: "none" }, + http: { endpoints: { responses: { enabled: true } } }, + }, + } satisfies OpenClawConfig, + expectedFinding: { checkId: "gateway.http.no_auth", severity: "critical" }, + auditOptions: { env: {} }, + }, + { + name: "does not report gateway.http.no_auth when auth mode is token", + cfg: { + gateway: { + bind: "loopback", + auth: { mode: "token", token: "secret" }, + http: { + endpoints: { + chatCompletions: { enabled: true }, + responses: { enabled: true }, + }, }, }, - expectedSeverity: "warn", - detailIncludes: ["/tools/invoke", "/v1/chat/completions"], - }, - { - name: "remote no-auth", - cfg: { - gateway: { - bind: "lan", - auth: { mode: "none" }, - http: { endpoints: { responses: { enabled: true } } }, + } satisfies OpenClawConfig, + expectedNoFinding: "gateway.http.no_auth", + auditOptions: { env: {} }, + }, + { + name: "reports HTTP API session-key override surfaces when enabled", + cfg: { + gateway: { + http: { + endpoints: { + chatCompletions: { enabled: true }, + responses: { enabled: true }, + }, }, }, - expectedSeverity: "critical", - }, - ]; + } satisfies OpenClawConfig, + expectedFinding: { checkId: "gateway.http.session_key_override_enabled", severity: "info" }, + }, + ])("$name", async (testCase) => { + const res = await audit(testCase.cfg, testCase.auditOptions); - await Promise.all( - cases.map(async (testCase) => { - const res = await audit(testCase.cfg, { env: {} }); - expectFinding(res, "gateway.http.no_auth", testCase.expectedSeverity); - if (testCase.detailIncludes) { - const finding = res.findings.find((entry) => entry.checkId === "gateway.http.no_auth"); - for (const text of testCase.detailIncludes) { - expect(finding?.detail, `${testCase.name}:${text}`).toContain(text); - } + if (testCase.expectedFinding) { + expect(res.findings).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), + ); + if (testCase.detailIncludes) { + const finding = res.findings.find( + (entry) => entry.checkId === testCase.expectedFinding?.checkId, + ); + for (const text of testCase.detailIncludes) { + expect(finding?.detail, `${testCase.name}:${text}`).toContain(text); } - }), - ); - }); - - it("does not report gateway.http.no_auth when auth mode is token", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "loopback", - auth: { mode: "token", token: "secret" }, - http: { - endpoints: { - chatCompletions: { enabled: true }, - responses: { enabled: true }, - }, - }, - }, - }; - - const res = await audit(cfg, { env: {} }); - expectNoFinding(res, "gateway.http.no_auth"); - }); - - it("reports HTTP API session-key override surfaces when enabled", async () => { - const cfg: OpenClawConfig = { - gateway: { - http: { - endpoints: { - chatCompletions: { enabled: true }, - responses: { enabled: true }, - }, - }, - }, - }; - - const res = await audit(cfg); - - expectFinding(res, "gateway.http.session_key_override_enabled", "info"); + } + } + if (testCase.expectedNoFinding) { + expectNoFinding(res, testCase.expectedNoFinding); + } }); it("warns when state/config look like a synced folder", async () => { @@ -3084,515 +2994,485 @@ description: test skill ); }); - it("flags extensions without plugins.allow", async () => { - const prevDiscordToken = process.env.DISCORD_BOT_TOKEN; - const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; - const prevSlackBotToken = process.env.SLACK_BOT_TOKEN; - const prevSlackAppToken = process.env.SLACK_APP_TOKEN; - delete process.env.DISCORD_BOT_TOKEN; - delete process.env.TELEGRAM_BOT_TOKEN; - delete process.env.SLACK_BOT_TOKEN; - delete process.env.SLACK_APP_TOKEN; - const stateDir = sharedExtensionsStateDir; + it("evaluates install metadata findings", async () => { + const cases = [ + { + name: "warns on unpinned npm install specs and missing integrity metadata", + run: async () => + runInstallMetadataAudit( + { + plugins: { + installs: { + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call", + }, + }, + }, + hooks: { + internal: { + installs: { + "test-hooks": { + source: "npm", + spec: "@openclaw/test-hooks", + }, + }, + }, + }, + } satisfies OpenClawConfig, + sharedInstallMetadataStateDir, + ), + expectedPresent: [ + "plugins.installs_unpinned_npm_specs", + "plugins.installs_missing_integrity", + "hooks.installs_unpinned_npm_specs", + "hooks.installs_missing_integrity", + ], + }, + { + name: "does not warn on pinned npm install specs with integrity metadata", + run: async () => + runInstallMetadataAudit( + { + plugins: { + installs: { + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call@1.2.3", + integrity: "sha512-plugin", + }, + }, + }, + hooks: { + internal: { + installs: { + "test-hooks": { + source: "npm", + spec: "@openclaw/test-hooks@1.2.3", + integrity: "sha512-hook", + }, + }, + }, + }, + } satisfies OpenClawConfig, + sharedInstallMetadataStateDir, + ), + expectedAbsent: [ + "plugins.installs_unpinned_npm_specs", + "plugins.installs_missing_integrity", + "hooks.installs_unpinned_npm_specs", + "hooks.installs_missing_integrity", + ], + }, + { + name: "warns when install records drift from installed package versions", + run: async () => { + const tmp = await makeTmpDir("install-version-drift"); + const stateDir = path.join(tmp, "state"); + const pluginDir = path.join(stateDir, "extensions", "voice-call"); + const hookDir = path.join(stateDir, "hooks", "test-hooks"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.mkdir(hookDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, "package.json"), + JSON.stringify({ name: "@openclaw/voice-call", version: "9.9.9" }), + "utf-8", + ); + await fs.writeFile( + path.join(hookDir, "package.json"), + JSON.stringify({ name: "@openclaw/test-hooks", version: "8.8.8" }), + "utf-8", + ); - try { - const cfg: OpenClawConfig = {}; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); + return runInstallMetadataAudit( + { + plugins: { + installs: { + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call@1.2.3", + integrity: "sha512-plugin", + resolvedVersion: "1.2.3", + }, + }, + }, + hooks: { + internal: { + installs: { + "test-hooks": { + source: "npm", + spec: "@openclaw/test-hooks@1.2.3", + integrity: "sha512-hook", + resolvedVersion: "1.2.3", + }, + }, + }, + }, + }, + stateDir, + ); + }, + expectedPresent: ["plugins.installs_version_drift", "hooks.installs_version_drift"], + }, + ] as const; - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "plugins.extensions_no_allowlist", severity: "warn" }), - ]), - ); - } finally { - if (prevDiscordToken == null) { - delete process.env.DISCORD_BOT_TOKEN; - } else { - process.env.DISCORD_BOT_TOKEN = prevDiscordToken; + for (const testCase of cases) { + const res = await testCase.run(); + const expectedPresent = "expectedPresent" in testCase ? testCase.expectedPresent : []; + for (const checkId of expectedPresent) { + expect(hasFinding(res, checkId, "warn"), `${testCase.name}:${checkId}`).toBe(true); } - if (prevTelegramToken == null) { - delete process.env.TELEGRAM_BOT_TOKEN; - } else { - process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; - } - if (prevSlackBotToken == null) { - delete process.env.SLACK_BOT_TOKEN; - } else { - process.env.SLACK_BOT_TOKEN = prevSlackBotToken; - } - if (prevSlackAppToken == null) { - delete process.env.SLACK_APP_TOKEN; - } else { - process.env.SLACK_APP_TOKEN = prevSlackAppToken; + const expectedAbsent = "expectedAbsent" in testCase ? testCase.expectedAbsent : []; + for (const checkId of expectedAbsent) { + expect(hasFinding(res, checkId), `${testCase.name}:${checkId}`).toBe(false); } } }); - it("warns on unpinned npm install specs and missing integrity metadata", async () => { - const cfg: OpenClawConfig = { - plugins: { - installs: { - "voice-call": { - source: "npm", - spec: "@openclaw/voice-call", - }, + it("evaluates extension tool reachability findings", async () => { + const cases = [ + { + name: "flags extensions without plugins.allow", + cfg: {} satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "plugins.extensions_no_allowlist", + severity: "warn", + }), + ]), + ); }, }, - hooks: { - internal: { - installs: { - "test-hooks": { - source: "npm", - spec: "@openclaw/test-hooks", + { + name: "flags enabled extensions when tool policy can expose plugin tools", + cfg: { + plugins: { allow: ["some-plugin"] }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "plugins.tools_reachable_permissive_policy", + severity: "warn", + }), + ]), + ); + }, + }, + { + name: "does not flag plugin tool reachability when profile is restrictive", + cfg: { + plugins: { allow: ["some-plugin"] }, + tools: { profile: "coding" }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect( + res.findings.some((f) => f.checkId === "plugins.tools_reachable_permissive_policy"), + ).toBe(false); + }, + }, + { + name: "flags unallowlisted extensions as critical when native skill commands are exposed", + cfg: { + channels: { + discord: { enabled: true, token: "t" }, + }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "plugins.extensions_no_allowlist", + severity: "critical", + }), + ]), + ); + }, + }, + { + name: "treats SecretRef channel credentials as configured for extension allowlist severity", + cfg: { + channels: { + discord: { + enabled: true, + token: { + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + } as unknown as string, }, }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "plugins.extensions_no_allowlist", + severity: "critical", + }), + ]), + ); }, }, - }; + ] as const; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir: sharedInstallMetadataStateDir, - configPath: path.join(sharedInstallMetadataStateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); - - expect(hasFinding(res, "plugins.installs_unpinned_npm_specs", "warn")).toBe(true); - expect(hasFinding(res, "plugins.installs_missing_integrity", "warn")).toBe(true); - expect(hasFinding(res, "hooks.installs_unpinned_npm_specs", "warn")).toBe(true); - expect(hasFinding(res, "hooks.installs_missing_integrity", "warn")).toBe(true); - }); - - it("does not warn on pinned npm install specs with integrity metadata", async () => { - const cfg: OpenClawConfig = { - plugins: { - installs: { - "voice-call": { - source: "npm", - spec: "@openclaw/voice-call@1.2.3", - integrity: "sha512-plugin", - }, - }, + await withEnvAsync( + { + DISCORD_BOT_TOKEN: undefined, + TELEGRAM_BOT_TOKEN: undefined, + SLACK_BOT_TOKEN: undefined, + SLACK_APP_TOKEN: undefined, }, - hooks: { - internal: { - installs: { - "test-hooks": { - source: "npm", - spec: "@openclaw/test-hooks@1.2.3", - integrity: "sha512-hook", - }, - }, - }, - }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir: sharedInstallMetadataStateDir, - configPath: path.join(sharedInstallMetadataStateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); - - expect(hasFinding(res, "plugins.installs_unpinned_npm_specs")).toBe(false); - expect(hasFinding(res, "plugins.installs_missing_integrity")).toBe(false); - expect(hasFinding(res, "hooks.installs_unpinned_npm_specs")).toBe(false); - expect(hasFinding(res, "hooks.installs_missing_integrity")).toBe(false); - }); - - it("warns when install records drift from installed package versions", async () => { - const tmp = await makeTmpDir("install-version-drift"); - const stateDir = path.join(tmp, "state"); - const pluginDir = path.join(stateDir, "extensions", "voice-call"); - const hookDir = path.join(stateDir, "hooks", "test-hooks"); - await fs.mkdir(pluginDir, { recursive: true }); - await fs.mkdir(hookDir, { recursive: true }); - await fs.writeFile( - path.join(pluginDir, "package.json"), - JSON.stringify({ name: "@openclaw/voice-call", version: "9.9.9" }), - "utf-8", - ); - await fs.writeFile( - path.join(hookDir, "package.json"), - JSON.stringify({ name: "@openclaw/test-hooks", version: "8.8.8" }), - "utf-8", - ); - - const cfg: OpenClawConfig = { - plugins: { - installs: { - "voice-call": { - source: "npm", - spec: "@openclaw/voice-call@1.2.3", - integrity: "sha512-plugin", - resolvedVersion: "1.2.3", - }, - }, - }, - hooks: { - internal: { - installs: { - "test-hooks": { - source: "npm", - spec: "@openclaw/test-hooks@1.2.3", - integrity: "sha512-hook", - resolvedVersion: "1.2.3", - }, - }, - }, - }, - }; - - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); - - expect(hasFinding(res, "plugins.installs_version_drift", "warn")).toBe(true); - expect(hasFinding(res, "hooks.installs_version_drift", "warn")).toBe(true); - }); - - it("flags enabled extensions when tool policy can expose plugin tools", async () => { - const stateDir = sharedExtensionsStateDir; - - const cfg: OpenClawConfig = { - plugins: { allow: ["some-plugin"] }, - }; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "plugins.tools_reachable_permissive_policy", - severity: "warn", - }), - ]), - ); - }); - - it("does not flag plugin tool reachability when profile is restrictive", async () => { - const stateDir = sharedExtensionsStateDir; - - const cfg: OpenClawConfig = { - plugins: { allow: ["some-plugin"] }, - tools: { profile: "coding" }, - }; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); - - expect( - res.findings.some((f) => f.checkId === "plugins.tools_reachable_permissive_policy"), - ).toBe(false); - }); - - it("flags unallowlisted extensions as critical when native skill commands are exposed", async () => { - const prevDiscordToken = process.env.DISCORD_BOT_TOKEN; - delete process.env.DISCORD_BOT_TOKEN; - const stateDir = sharedExtensionsStateDir; - - try { - const cfg: OpenClawConfig = { - channels: { - discord: { enabled: true, token: "t" }, - }, - }; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "plugins.extensions_no_allowlist", - severity: "critical", + async () => { + await Promise.all( + cases.map(async (testCase) => { + const res = await runSharedExtensionsAudit(testCase.cfg); + testCase.assert(res); }), - ]), - ); - } finally { - if (prevDiscordToken == null) { - delete process.env.DISCORD_BOT_TOKEN; - } else { - process.env.DISCORD_BOT_TOKEN = prevDiscordToken; - } - } + ); + }, + ); }); - it("treats SecretRef channel credentials as configured for extension allowlist severity", async () => { - const prevDiscordToken = process.env.DISCORD_BOT_TOKEN; - delete process.env.DISCORD_BOT_TOKEN; - const stateDir = sharedExtensionsStateDir; - - try { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: { - source: "env", - provider: "default", - id: "DISCORD_BOT_TOKEN", - } as unknown as string, - }, - }, - }; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - execDockerRawFn: execDockerRawUnavailable, - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "plugins.extensions_no_allowlist", - severity: "critical", + it("evaluates code-safety findings", async () => { + const cases = [ + { + name: "does not scan plugin code safety findings when deep audit is disabled", + run: async () => + runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + deep: false, + stateDir: sharedCodeSafetyStateDir, + execDockerRawFn: execDockerRawUnavailable, }), - ]), - ); - } finally { - if (prevDiscordToken == null) { - delete process.env.DISCORD_BOT_TOKEN; - } else { - process.env.DISCORD_BOT_TOKEN = prevDiscordToken; - } - } - }); - - it("does not scan plugin code safety findings when deep audit is disabled", async () => { - const cfg: OpenClawConfig = {}; - const nonDeepRes = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - deep: false, - stateDir: sharedCodeSafetyStateDir, - execDockerRawFn: execDockerRawUnavailable, - }); - expect(nonDeepRes.findings.some((f) => f.checkId === "plugins.code_safety")).toBe(false); - - // Deep-mode positive coverage lives in the detailed plugin+skills code-safety test below. - }); - - it("reports detailed code-safety issues for both plugins and skills", async () => { - const cfg: OpenClawConfig = { - agents: { defaults: { workspace: sharedCodeSafetyWorkspaceDir } }, - }; - const [pluginFindings, skillFindings] = await Promise.all([ - collectPluginsCodeSafetyFindings({ stateDir: sharedCodeSafetyStateDir }), - collectInstalledSkillsCodeSafetyFindings({ cfg, stateDir: sharedCodeSafetyStateDir }), - ]); - - const pluginFinding = pluginFindings.find( - (finding) => finding.checkId === "plugins.code_safety" && finding.severity === "critical", - ); - expect(pluginFinding).toBeDefined(); - expect(pluginFinding?.detail).toContain("dangerous-exec"); - expect(pluginFinding?.detail).toMatch(/\.hidden[\\/]+index\.js:\d+/); - - const skillFinding = skillFindings.find( - (finding) => finding.checkId === "skills.code_safety" && finding.severity === "critical", - ); - expect(skillFinding).toBeDefined(); - expect(skillFinding?.detail).toContain("dangerous-exec"); - expect(skillFinding?.detail).toMatch(/runner\.js:\d+/); - }); - - it("flags plugin extension entry path traversal in deep audit", async () => { - const tmpDir = await makeTmpDir("audit-scanner-escape"); - const pluginDir = path.join(tmpDir, "extensions", "escape-plugin"); - await fs.mkdir(pluginDir, { recursive: true }); - await fs.writeFile( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "escape-plugin", - openclaw: { extensions: ["../outside.js"] }, - }), - ); - await fs.writeFile(path.join(pluginDir, "index.js"), "export {};"); - - const findings = await collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); - expect(findings.some((f) => f.checkId === "plugins.code_safety.entry_escape")).toBe(true); - }); - - it("reports scan_failed when plugin code scanner throws during deep audit", async () => { - const scanSpy = vi - .spyOn(skillScanner, "scanDirectoryWithSummary") - .mockRejectedValueOnce(new Error("boom")); - - const tmpDir = await makeTmpDir("audit-scanner-throws"); - try { - const pluginDir = path.join(tmpDir, "extensions", "scanfail-plugin"); - await fs.mkdir(pluginDir, { recursive: true }); - await fs.writeFile( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "scanfail-plugin", - openclaw: { extensions: ["index.js"] }, - }), - ); - await fs.writeFile(path.join(pluginDir, "index.js"), "export {};"); - - const findings = await collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); - expect(findings.some((f) => f.checkId === "plugins.code_safety.scan_failed")).toBe(true); - } finally { - scanSpy.mockRestore(); - } - }); - - it("flags open groupPolicy when tools.elevated is enabled", async () => { - const cfg: OpenClawConfig = { - tools: { elevated: { enabled: true, allowFrom: { whatsapp: ["+1"] } } }, - channels: { whatsapp: { groupPolicy: "open" } }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "security.exposure.open_groups_with_elevated", - severity: "critical", - }), - ]), - ); - }); - - it("flags open groupPolicy when runtime/filesystem tools are exposed without guards", async () => { - const cfg: OpenClawConfig = { - channels: { whatsapp: { groupPolicy: "open" } }, - tools: { elevated: { enabled: false } }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "security.exposure.open_groups_with_runtime_or_fs", - severity: "critical", - }), - ]), - ); - }); - - it("does not flag runtime/filesystem exposure for open groups when sandbox mode is all", async () => { - const cfg: OpenClawConfig = { - channels: { whatsapp: { groupPolicy: "open" } }, - tools: { - elevated: { enabled: false }, - profile: "coding", - }, - agents: { - defaults: { - sandbox: { mode: "all" }, + assert: (result: SecurityAuditReport) => { + expect(result.findings.some((f) => f.checkId === "plugins.code_safety")).toBe(false); }, }, - }; + { + name: "reports detailed code-safety issues for both plugins and skills", + run: async () => { + const cfg: OpenClawConfig = { + agents: { defaults: { workspace: sharedCodeSafetyWorkspaceDir } }, + }; + const [pluginFindings, skillFindings] = await Promise.all([ + collectPluginsCodeSafetyFindings({ stateDir: sharedCodeSafetyStateDir }), + collectInstalledSkillsCodeSafetyFindings({ cfg, stateDir: sharedCodeSafetyStateDir }), + ]); + return { pluginFindings, skillFindings }; + }, + assert: ( + result: Awaited> extends never + ? never + : { + pluginFindings: Awaited>; + skillFindings: Awaited>; + }, + ) => { + const pluginFinding = result.pluginFindings.find( + (finding) => + finding.checkId === "plugins.code_safety" && finding.severity === "critical", + ); + expect(pluginFinding).toBeDefined(); + expect(pluginFinding?.detail).toContain("dangerous-exec"); + expect(pluginFinding?.detail).toMatch(/\.hidden[\\/]+index\.js:\d+/); - const res = await audit(cfg); - - expect( - res.findings.some((f) => f.checkId === "security.exposure.open_groups_with_runtime_or_fs"), - ).toBe(false); - }); - - it("does not flag runtime/filesystem exposure for open groups when runtime is denied and fs is workspace-only", async () => { - const cfg: OpenClawConfig = { - channels: { whatsapp: { groupPolicy: "open" } }, - tools: { - elevated: { enabled: false }, - profile: "coding", - deny: ["group:runtime"], - fs: { workspaceOnly: true }, + const skillFinding = result.skillFindings.find( + (finding) => + finding.checkId === "skills.code_safety" && finding.severity === "critical", + ); + expect(skillFinding).toBeDefined(); + expect(skillFinding?.detail).toContain("dangerous-exec"); + expect(skillFinding?.detail).toMatch(/runner\.js:\d+/); + }, }, - }; + { + name: "flags plugin extension entry path traversal in deep audit", + run: async () => { + const tmpDir = await makeTmpDir("audit-scanner-escape"); + const pluginDir = path.join(tmpDir, "extensions", "escape-plugin"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "escape-plugin", + openclaw: { extensions: ["../outside.js"] }, + }), + ); + await fs.writeFile(path.join(pluginDir, "index.js"), "export {};"); + return collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); + }, + assert: (findings: Awaited>) => { + expect(findings.some((f) => f.checkId === "plugins.code_safety.entry_escape")).toBe(true); + }, + }, + { + name: "reports scan_failed when plugin code scanner throws during deep audit", + run: async () => { + const scanSpy = vi + .spyOn(skillScanner, "scanDirectoryWithSummary") + .mockRejectedValueOnce(new Error("boom")); + try { + const tmpDir = await makeTmpDir("audit-scanner-throws"); + const pluginDir = path.join(tmpDir, "extensions", "scanfail-plugin"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "scanfail-plugin", + openclaw: { extensions: ["index.js"] }, + }), + ); + await fs.writeFile(path.join(pluginDir, "index.js"), "export {};"); + return await collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); + } finally { + scanSpy.mockRestore(); + } + }, + assert: (findings: Awaited>) => { + expect(findings.some((f) => f.checkId === "plugins.code_safety.scan_failed")).toBe(true); + }, + }, + ] as const; - const res = await audit(cfg); - - expect( - res.findings.some((f) => f.checkId === "security.exposure.open_groups_with_runtime_or_fs"), - ).toBe(false); + for (const testCase of cases) { + const result = await testCase.run(); + testCase.assert(result as never); + } }); - it("warns when config heuristics suggest a likely multi-user setup", async () => { - const cfg: OpenClawConfig = { - channels: { - discord: { - groupPolicy: "allowlist", - guilds: { - "1234567890": { - channels: { - "7777777777": { allow: true }, + it("evaluates trust-model exposure findings", async () => { + const cases = [ + { + name: "flags open groupPolicy when tools.elevated is enabled", + cfg: { + tools: { elevated: { enabled: true, allowFrom: { whatsapp: ["+1"] } } }, + channels: { whatsapp: { groupPolicy: "open" } }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "security.exposure.open_groups_with_elevated", + severity: "critical", + }), + ]), + ); + }, + }, + { + name: "flags open groupPolicy when runtime/filesystem tools are exposed without guards", + cfg: { + channels: { whatsapp: { groupPolicy: "open" } }, + tools: { elevated: { enabled: false } }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "security.exposure.open_groups_with_runtime_or_fs", + severity: "critical", + }), + ]), + ); + }, + }, + { + name: "does not flag runtime/filesystem exposure for open groups when sandbox mode is all", + cfg: { + channels: { whatsapp: { groupPolicy: "open" } }, + tools: { + elevated: { enabled: false }, + profile: "coding", + }, + agents: { + defaults: { + sandbox: { mode: "all" }, + }, + }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect( + res.findings.some( + (f) => f.checkId === "security.exposure.open_groups_with_runtime_or_fs", + ), + ).toBe(false); + }, + }, + { + name: "does not flag runtime/filesystem exposure for open groups when runtime is denied and fs is workspace-only", + cfg: { + channels: { whatsapp: { groupPolicy: "open" } }, + tools: { + elevated: { enabled: false }, + profile: "coding", + deny: ["group:runtime"], + fs: { workspaceOnly: true }, + }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expect( + res.findings.some( + (f) => f.checkId === "security.exposure.open_groups_with_runtime_or_fs", + ), + ).toBe(false); + }, + }, + { + name: "warns when config heuristics suggest a likely multi-user setup", + cfg: { + channels: { + discord: { + groupPolicy: "allowlist", + guilds: { + "1234567890": { + channels: { + "7777777777": { allow: true }, + }, + }, }, }, }, + tools: { elevated: { enabled: false } }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + const finding = res.findings.find( + (f) => f.checkId === "security.trust_model.multi_user_heuristic", + ); + expect(finding?.severity).toBe("warn"); + expect(finding?.detail).toContain( + 'channels.discord.groupPolicy="allowlist" with configured group targets', + ); + expect(finding?.detail).toContain("personal-assistant"); + expect(finding?.remediation).toContain('agents.defaults.sandbox.mode="all"'); }, }, - tools: { elevated: { enabled: false } }, - }; - - const res = await audit(cfg); - const finding = res.findings.find( - (f) => f.checkId === "security.trust_model.multi_user_heuristic", - ); - - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain( - 'channels.discord.groupPolicy="allowlist" with configured group targets', - ); - expect(finding?.detail).toContain("personal-assistant"); - expect(finding?.remediation).toContain('agents.defaults.sandbox.mode="all"'); - }); - - it("does not warn for multi-user heuristic when no shared-user signals are configured", async () => { - const cfg: OpenClawConfig = { - channels: { - discord: { - groupPolicy: "allowlist", + { + name: "does not warn for multi-user heuristic when no shared-user signals are configured", + cfg: { + channels: { + discord: { + groupPolicy: "allowlist", + }, + }, + tools: { elevated: { enabled: false } }, + } satisfies OpenClawConfig, + assert: (res: SecurityAuditReport) => { + expectNoFinding(res, "security.trust_model.multi_user_heuristic"); }, }, - tools: { elevated: { enabled: false } }, - }; + ] as const; - const res = await audit(cfg); - - expectNoFinding(res, "security.trust_model.multi_user_heuristic"); + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + testCase.assert(res); + }), + ); }); describe("maybeProbeGateway auth selection", () => { @@ -3621,28 +3501,28 @@ description: test skill return probeEnv; }; - it("applies token precedence across local/remote gateway modes", async () => { + it("applies gateway auth precedence across local/remote modes", async () => { const cases: Array<{ name: string; cfg: OpenClawConfig; - env?: { token?: string }; - expectedToken: string; + env?: { token?: string; password?: string }; + expectedAuth: { token?: string; password?: string }; }> = [ { name: "uses local auth when gateway.mode is local", cfg: { gateway: { mode: "local", auth: { token: "local-token-abc123" } } }, - expectedToken: "local-token-abc123", + expectedAuth: { token: "local-token-abc123" }, }, { name: "prefers env token over local config token", cfg: { gateway: { mode: "local", auth: { token: "local-token" } } }, env: { token: "env-token" }, - expectedToken: "env-token", + expectedAuth: { token: "env-token" }, }, { name: "uses local auth when gateway.mode is undefined (default)", cfg: { gateway: { auth: { token: "default-local-token" } } }, - expectedToken: "default-local-token", + expectedAuth: { token: "default-local-token" }, }, { name: "uses remote auth when gateway.mode is remote with URL", @@ -3653,7 +3533,7 @@ description: test skill remote: { url: "wss://remote.example.com:18789", token: "remote-token-xyz789" }, }, }, - expectedToken: "remote-token-xyz789", + expectedAuth: { token: "remote-token-xyz789" }, }, { name: "ignores env token when gateway.mode is remote", @@ -3665,7 +3545,7 @@ description: test skill }, }, env: { token: "env-token" }, - expectedToken: "remote-token", + expectedAuth: { token: "remote-token" }, }, { name: "falls back to local auth when gateway.mode is remote but URL is missing", @@ -3676,31 +3556,8 @@ description: test skill remote: { token: "remote-token-should-not-use" }, }, }, - expectedToken: "fallback-local-token", + expectedAuth: { token: "fallback-local-token" }, }, - ]; - - await Promise.all( - cases.map(async (testCase) => { - const { probeGatewayFn, getAuth } = makeProbeCapture(); - await audit(testCase.cfg, { - deep: true, - deepTimeoutMs: 50, - probeGatewayFn, - env: makeProbeEnv(testCase.env), - }); - expect(getAuth()?.token, testCase.name).toBe(testCase.expectedToken); - }), - ); - }); - - it("applies password precedence for remote gateways", async () => { - const cases: Array<{ - name: string; - cfg: OpenClawConfig; - env?: { password?: string }; - expectedPassword: string; - }> = [ { name: "uses remote password when env is unset", cfg: { @@ -3709,7 +3566,7 @@ description: test skill remote: { url: "wss://remote.example.com:18789", password: "remote-pass" }, }, }, - expectedPassword: "remote-pass", + expectedAuth: { password: "remote-pass" }, }, { name: "prefers env password over remote password", @@ -3720,7 +3577,7 @@ description: test skill }, }, env: { password: "env-pass" }, - expectedPassword: "env-pass", + expectedAuth: { password: "env-pass" }, }, ]; @@ -3733,7 +3590,7 @@ description: test skill probeGatewayFn, env: makeProbeEnv(testCase.env), }); - expect(getAuth()?.password, testCase.name).toBe(testCase.expectedPassword); + expect(getAuth(), testCase.name).toEqual(testCase.expectedAuth); }), ); }); diff --git a/src/security/audit.ts b/src/security/audit.ts index ba809a1714c..8eacad4649e 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -512,9 +512,9 @@ function collectGatewayConfigFindings( severity: exposed ? "critical" : "warn", title: "Control UI allowed origins contains wildcard", detail: - 'gateway.controlUi.allowedOrigins includes "*" which effectively disables origin allowlisting for Control UI/WebChat requests.', + 'gateway.controlUi.allowedOrigins includes "*" which means allow any browser origin for Control UI/WebChat requests. This disables origin allowlisting and should be treated as an intentional allow-all policy.', remediation: - "Replace wildcard origins with explicit trusted origins (for example https://control.example.com).", + 'Replace wildcard origins with explicit trusted origins (for example https://control.example.com). Do not use "*" outside tightly controlled local testing.', }); } if (dangerouslyAllowHostHeaderOriginFallback) { diff --git a/src/shared/lazy-runtime.ts b/src/shared/lazy-runtime.ts index 3edaa865f50..23f6a6039de 100644 --- a/src/shared/lazy-runtime.ts +++ b/src/shared/lazy-runtime.ts @@ -9,6 +9,21 @@ export function createLazyRuntimeSurface( }; } +/** Cache the raw dynamically imported runtime module behind a stable loader. */ +export function createLazyRuntimeModule( + importer: () => Promise, +): () => Promise { + return createLazyRuntimeSurface(importer, (module) => module); +} + +/** Cache a single named runtime export without repeating a custom selector closure per caller. */ +export function createLazyRuntimeNamedExport( + importer: () => Promise, + key: TKey, +): () => Promise { + return createLazyRuntimeSurface(importer, (module) => module[key]); +} + export function createLazyRuntimeMethod( load: () => Promise, select: (surface: TSurface) => (...args: TArgs) => TResult, @@ -19,3 +34,11 @@ export function createLazyRuntimeMethod(load: () => Promise) { + return function ( + select: (surface: TSurface) => (...args: TArgs) => TResult, + ): (...args: TArgs) => Promise> { + return createLazyRuntimeMethod(load, select); + }; +} diff --git a/src/shared/pid-alive.test.ts b/src/shared/pid-alive.test.ts index 88066f1a794..70eaaadc5a5 100644 --- a/src/shared/pid-alive.test.ts +++ b/src/shared/pid-alive.test.ts @@ -77,17 +77,27 @@ describe("isPidAlive", () => { }); describe("getProcessStartTime", () => { - it("returns a number on Linux for the current process", async () => { - // Simulate a realistic /proc//stat line - const fakeStat = `${process.pid} (node) S 1 ${process.pid} ${process.pid} 0 -1 4194304 12345 0 0 0 100 50 0 0 20 0 8 0 98765 123456789 5000 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`; + it("parses linux /proc stat start times and rejects malformed variants", async () => { + const fakeStatPrefix = "42 (node) S 1 42 42 0 -1 4194304 12345 0 0 0 100 50 0 0 20 0 8 0 "; + const fakeStatSuffix = + " 123456789 5000 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0"; mockProcReads({ - [`/proc/${process.pid}/stat`]: fakeStat, + [`/proc/${process.pid}/stat`]: `${process.pid} (node) S 1 ${process.pid} ${process.pid} 0 -1 4194304 12345 0 0 0 100 50 0 0 20 0 8 0 98765 123456789 5000 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`, + "/proc/42/stat": `${fakeStatPrefix}55555${fakeStatSuffix}`, + "/proc/43/stat": "43 node S malformed", + "/proc/44/stat": `44 (My App (v2)) S 1 44 44 0 -1 4194304 0 0 0 0 0 0 0 0 20 0 1 0 66666 0 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`, + "/proc/45/stat": `${fakeStatPrefix}-1${fakeStatSuffix}`, + "/proc/46/stat": `${fakeStatPrefix}1.5${fakeStatSuffix}`, }); await withLinuxProcessPlatform(async () => { const { getProcessStartTime: fresh } = await import("./pid-alive.js"); - const starttime = fresh(process.pid); - expect(starttime).toBe(98765); + expect(fresh(process.pid)).toBe(98765); + expect(fresh(42)).toBe(55555); + expect(fresh(43)).toBeNull(); + expect(fresh(44)).toBe(66666); + expect(fresh(45)).toBeNull(); + expect(fresh(46)).toBeNull(); }); }); @@ -107,41 +117,4 @@ describe("getProcessStartTime", () => { expect(getProcessStartTime(Number.NaN)).toBeNull(); expect(getProcessStartTime(Number.POSITIVE_INFINITY)).toBeNull(); }); - - it("returns null for malformed /proc stat content", async () => { - mockProcReads({ - "/proc/42/stat": "42 node S malformed", - }); - await withLinuxProcessPlatform(async () => { - const { getProcessStartTime: fresh } = await import("./pid-alive.js"); - expect(fresh(42)).toBeNull(); - }); - }); - - it("handles comm fields containing spaces and parentheses", async () => { - // comm field with spaces and nested parens: "(My App (v2))" - const fakeStat = `42 (My App (v2)) S 1 42 42 0 -1 4194304 0 0 0 0 0 0 0 0 20 0 1 0 55555 0 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`; - mockProcReads({ - "/proc/42/stat": fakeStat, - }); - await withLinuxProcessPlatform(async () => { - const { getProcessStartTime: fresh } = await import("./pid-alive.js"); - expect(fresh(42)).toBe(55555); - }); - }); - - it("returns null for negative or non-integer start times", async () => { - const fakeStatPrefix = "42 (node) S 1 42 42 0 -1 4194304 12345 0 0 0 100 50 0 0 20 0 8 0 "; - const fakeStatSuffix = - " 123456789 5000 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0"; - mockProcReads({ - "/proc/42/stat": `${fakeStatPrefix}-1${fakeStatSuffix}`, - "/proc/43/stat": `${fakeStatPrefix}1.5${fakeStatSuffix}`, - }); - await withLinuxProcessPlatform(async () => { - const { getProcessStartTime: fresh } = await import("./pid-alive.js"); - expect(fresh(42)).toBeNull(); - expect(fresh(43)).toBeNull(); - }); - }); }); diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 6ecf718f895..d2ebbc45933 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -35,6 +35,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl cliRegistrars: [], services: [], commands: [], + conversationBindingResolvedHandlers: [], diagnostics: [], }); diff --git a/src/test-utils/imessage-test-plugin.ts b/src/test-utils/imessage-test-plugin.ts index 201ad3f9897..62362fe5712 100644 --- a/src/test-utils/imessage-test-plugin.ts +++ b/src/test-utils/imessage-test-plugin.ts @@ -1,4 +1,4 @@ -import { normalizeIMessageHandle } from "../../extensions/imessage/src/targets.js"; +import { normalizeIMessageHandle } from "../../extensions/imessage/api.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"; diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index 05e902ef20c..ade83c0b30a 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -596,49 +596,54 @@ describe("tts", () => { messages: { tts: {} }, }; - it("defaults to the official OpenAI endpoint", () => { - withEnv({ OPENAI_TTS_BASE_URL: undefined }, () => { - const config = resolveTtsConfig(baseCfg); - expect(config.openai.baseUrl).toBe("https://api.openai.com/v1"); - }); - }); - - it("picks up OPENAI_TTS_BASE_URL env var when no config baseUrl is set", () => { - withEnv({ OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" }, () => { - const config = resolveTtsConfig(baseCfg); - expect(config.openai.baseUrl).toBe("http://localhost:8880/v1"); - }); - }); - - it("config baseUrl takes precedence over env var", () => { - const cfg: OpenClawConfig = { - ...baseCfg, - messages: { - tts: { openai: { baseUrl: "http://my-server:9000/v1" } }, + it("resolves openai.baseUrl from config/env with config precedence and slash trimming", () => { + for (const testCase of [ + { + name: "default endpoint", + cfg: baseCfg, + env: { OPENAI_TTS_BASE_URL: undefined }, + expected: "https://api.openai.com/v1", }, - }; - withEnv({ OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" }, () => { - const config = resolveTtsConfig(cfg); - expect(config.openai.baseUrl).toBe("http://my-server:9000/v1"); - }); - }); - - it("strips trailing slashes from the resolved baseUrl", () => { - const cfg: OpenClawConfig = { - ...baseCfg, - messages: { - tts: { openai: { baseUrl: "http://my-server:9000/v1///" } }, + { + name: "env override", + cfg: baseCfg, + env: { OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" }, + expected: "http://localhost:8880/v1", }, - }; - const config = resolveTtsConfig(cfg); - expect(config.openai.baseUrl).toBe("http://my-server:9000/v1"); - }); - - it("strips trailing slashes from env var baseUrl", () => { - withEnv({ OPENAI_TTS_BASE_URL: "http://localhost:8880/v1/" }, () => { - const config = resolveTtsConfig(baseCfg); - expect(config.openai.baseUrl).toBe("http://localhost:8880/v1"); - }); + { + name: "config wins over env", + cfg: { + ...baseCfg, + messages: { + tts: { openai: { baseUrl: "http://my-server:9000/v1" } }, + }, + } as OpenClawConfig, + env: { OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" }, + expected: "http://my-server:9000/v1", + }, + { + name: "config slash trimming", + cfg: { + ...baseCfg, + messages: { + tts: { openai: { baseUrl: "http://my-server:9000/v1///" } }, + }, + } as OpenClawConfig, + env: { OPENAI_TTS_BASE_URL: undefined }, + expected: "http://my-server:9000/v1", + }, + { + name: "env slash trimming", + cfg: baseCfg, + env: { OPENAI_TTS_BASE_URL: "http://localhost:8880/v1/" }, + expected: "http://localhost:8880/v1", + }, + ] as const) { + withEnv(testCase.env, () => { + const config = resolveTtsConfig(testCase.cfg); + expect(config.openai.baseUrl, testCase.name).toBe(testCase.expected); + }); + } }); }); @@ -678,12 +683,13 @@ describe("tts", () => { }); } - it("omits instructions for unsupported speech models", async () => { - await expectTelephonyInstructions("tts-1", undefined); - }); - - it("includes instructions for gpt-4o-mini-tts", async () => { - await expectTelephonyInstructions("gpt-4o-mini-tts", "Speak warmly"); + it("only includes instructions for supported telephony models", async () => { + for (const testCase of [ + { model: "tts-1", expectedInstructions: undefined }, + { model: "gpt-4o-mini-tts", expectedInstructions: "Speak warmly" }, + ] as const) { + await expectTelephonyInstructions(testCase.model, testCase.expectedInstructions); + } }); }); @@ -769,31 +775,36 @@ describe("tts", () => { } }); - it("skips auto-TTS in tagged mode unless a tts tag is present", async () => { - await withMockedAutoTtsFetch(async (fetchMock) => { - const payload = { text: "Hello world" }; - const result = await maybeApplyTtsToPayload({ - payload, - cfg: taggedCfg, - kind: "final", - }); - - expect(result).toBe(payload); - expect(fetchMock).not.toHaveBeenCalled(); - }); - }); - - it("runs auto-TTS in tagged mode when tags are present", async () => { - await withMockedAutoTtsFetch(async (fetchMock) => { - const result = await maybeApplyTtsToPayload({ + it("respects tagged-mode auto-TTS gating", async () => { + for (const testCase of [ + { + name: "plain text is skipped", + payload: { text: "Hello world" }, + expectedFetchCalls: 0, + expectSamePayload: true, + }, + { + name: "tagged text is synthesized", payload: { text: "[[tts:text]]Hello world[[/tts:text]]" }, - cfg: taggedCfg, - kind: "final", - }); + expectedFetchCalls: 1, + expectSamePayload: false, + }, + ] as const) { + await withMockedAutoTtsFetch(async (fetchMock) => { + const result = await maybeApplyTtsToPayload({ + payload: testCase.payload, + cfg: taggedCfg, + kind: "final", + }); - expect(result.mediaUrl).toBeDefined(); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); + expect(fetchMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedFetchCalls); + if (testCase.expectSamePayload) { + expect(result, testCase.name).toBe(testCase.payload); + } else { + expect(result.mediaUrl, testCase.name).toBeDefined(); + } + }); + } }); }); }); diff --git a/test/helpers/extensions/discord-provider.test-support.ts b/test/helpers/extensions/discord-provider.test-support.ts index ca1d1fd0894..2c8ad988d04 100644 --- a/test/helpers/extensions/discord-provider.test-support.ts +++ b/test/helpers/extensions/discord-provider.test-support.ts @@ -255,6 +255,7 @@ export const baseConfig = (): OpenClawConfig => }) as OpenClawConfig; vi.mock("@buape/carbon", () => { + class Command {} class ReadyListener {} class RateLimitError extends Error { status = 429; @@ -292,7 +293,7 @@ vi.mock("@buape/carbon", () => { return clientGetPluginMock(name); } } - return { Client, RateLimitError, ReadyListener }; + return { Client, Command, RateLimitError, ReadyListener }; }); vi.mock("@buape/carbon/gateway", () => ({ @@ -377,23 +378,23 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async () => { }; }); -vi.mock("../discord/src/accounts.js", () => ({ +vi.mock("../../../extensions/discord/src/accounts.js", () => ({ resolveDiscordAccount: resolveDiscordAccountMock, })); -vi.mock("../discord/src/probe.js", () => ({ +vi.mock("../../../extensions/discord/src/probe.js", () => ({ fetchDiscordApplicationId: async () => "app-1", })); -vi.mock("../discord/src/token.js", () => ({ +vi.mock("../../../extensions/discord/src/token.js", () => ({ normalizeDiscordToken: (value?: string) => value, })); -vi.mock("../discord/src/voice/command.js", () => ({ +vi.mock("../../../extensions/discord/src/voice/command.js", () => ({ createDiscordVoiceCommand: () => ({ name: "voice-command" }), })); -vi.mock("../discord/src/monitor/agent-components.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/agent-components.js", () => ({ createAgentComponentButton: () => ({ id: "btn" }), createAgentSelectMenu: () => ({ id: "menu" }), createDiscordComponentButton: () => ({ id: "btn2" }), @@ -405,15 +406,15 @@ vi.mock("../discord/src/monitor/agent-components.js", () => ({ createDiscordComponentUserSelect: () => ({ id: "user" }), })); -vi.mock("../discord/src/monitor/auto-presence.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/auto-presence.js", () => ({ createDiscordAutoPresenceController: createDiscordAutoPresenceControllerMock, })); -vi.mock("../discord/src/monitor/commands.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/commands.js", () => ({ resolveDiscordSlashCommandConfig: () => ({ ephemeral: false }), })); -vi.mock("../discord/src/monitor/exec-approvals.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/exec-approvals.js", () => ({ createExecApprovalButton: () => ({ id: "exec-approval" }), DiscordExecApprovalHandler: class DiscordExecApprovalHandler { async start() { @@ -425,11 +426,11 @@ vi.mock("../discord/src/monitor/exec-approvals.js", () => ({ }, })); -vi.mock("../discord/src/monitor/gateway-plugin.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/gateway-plugin.js", () => ({ createDiscordGatewayPlugin: () => ({ id: "gateway-plugin" }), })); -vi.mock("../discord/src/monitor/listeners.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/listeners.js", () => ({ DiscordMessageListener: class DiscordMessageListener {}, DiscordPresenceListener: class DiscordPresenceListener {}, DiscordReactionListener: class DiscordReactionListener {}, @@ -438,34 +439,34 @@ vi.mock("../discord/src/monitor/listeners.js", () => ({ registerDiscordListener: vi.fn(), })); -vi.mock("../discord/src/monitor/message-handler.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/message-handler.js", () => ({ createDiscordMessageHandler: createDiscordMessageHandlerMock, })); -vi.mock("../discord/src/monitor/native-command.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/native-command.js", () => ({ createDiscordCommandArgFallbackButton: () => ({ id: "arg-fallback" }), createDiscordModelPickerFallbackButton: () => ({ id: "model-fallback-btn" }), createDiscordModelPickerFallbackSelect: () => ({ id: "model-fallback-select" }), createDiscordNativeCommand: createDiscordNativeCommandMock, })); -vi.mock("../discord/src/monitor/presence.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/presence.js", () => ({ resolveDiscordPresenceUpdate: () => undefined, })); -vi.mock("../discord/src/monitor/provider.allowlist.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/provider.allowlist.js", () => ({ resolveDiscordAllowlistConfig: resolveDiscordAllowlistConfigMock, })); -vi.mock("../discord/src/monitor/provider.lifecycle.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/provider.lifecycle.js", () => ({ runDiscordGatewayLifecycle: monitorLifecycleMock, })); -vi.mock("../discord/src/monitor/rest-fetch.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/rest-fetch.js", () => ({ resolveDiscordRestFetch: () => async () => undefined, })); -vi.mock("../discord/src/monitor/thread-bindings.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/thread-bindings.js", () => ({ createNoopThreadBindingManager: createNoopThreadBindingManagerMock, createThreadBindingManager: createThreadBindingManagerMock, reconcileAcpThreadBindingsOnStartup: reconcileAcpThreadBindingsOnStartupMock, diff --git a/test/helpers/extensions/plugin-api.ts b/test/helpers/extensions/plugin-api.ts index bb94c326ee8..ee1e97178a8 100644 --- a/test/helpers/extensions/plugin-api.ts +++ b/test/helpers/extensions/plugin-api.ts @@ -20,6 +20,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi registerImageGenerationProvider() {}, registerWebSearchProvider() {}, registerInteractiveHandler() {}, + onConversationBindingResolved() {}, registerCommand() {}, registerContextEngine() {}, resolvePath(input: string) { diff --git a/extensions/telegram/src/bot-native-commands.plugin-command-test-support.ts b/test/helpers/extensions/telegram-plugin-command.ts similarity index 93% rename from extensions/telegram/src/bot-native-commands.plugin-command-test-support.ts rename to test/helpers/extensions/telegram-plugin-command.ts index b4a47b728e4..dec0046de1f 100644 --- a/extensions/telegram/src/bot-native-commands.plugin-command-test-support.ts +++ b/test/helpers/extensions/telegram-plugin-command.ts @@ -6,7 +6,7 @@ export const pluginCommandMocks = { executePluginCommand: vi.fn(async () => ({ text: "ok" })), }; -vi.mock("../../../src/plugins/commands.js", () => ({ +vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({ getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, matchPluginCommand: pluginCommandMocks.matchPluginCommand, executePluginCommand: pluginCommandMocks.executePluginCommand,