diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7266469c4a2..c7bacc8504f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -273,7 +273,9 @@ jobs: use-sticky-disk: "false" - name: Run changed extension tests - run: pnpm test:extension ${{ matrix.extension }} + env: + OPENCLAW_CHANGED_EXTENSION: ${{ matrix.extension }} + run: pnpm test:extension "$OPENCLAW_CHANGED_EXTENSION" # Types, lint, and format check. check: @@ -302,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 @@ -328,9 +330,40 @@ jobs: - name: Smoke test CLI launcher status json run: node openclaw.mjs status --json --timeout 1 + - name: Smoke test built bundled plugin singleton + run: pnpm test:build:singleton + - 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] @@ -458,30 +491,30 @@ jobs: run: pre-commit run --all-files detect-private-key - name: Audit changed GitHub workflows with zizmor + env: + BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} run: | set -euo pipefail - BASE="$( - python - <<'PY' - import json - import os + if [ -z "${BASE_SHA:-}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then + echo "No usable base SHA detected; skipping zizmor." + exit 0 + fi - with open(os.environ["GITHUB_EVENT_PATH"], "r", encoding="utf-8") as fh: - event = json.load(fh) + if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + echo "Base SHA ${BASE_SHA} is unavailable; skipping zizmor." + exit 0 + fi - if os.environ["GITHUB_EVENT_NAME"] == "push": - print(event["before"]) - else: - print(event["pull_request"]["base"]["sha"]) - PY - )" - - mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml') + mapfile -t workflow_files < <( + git diff --name-only "${BASE_SHA}" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml' + ) if [ "${#workflow_files[@]}" -eq 0 ]; then echo "No workflow changes detected; skipping zizmor." exit 0 fi + printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}" pre-commit run zizmor --files "${workflow_files[@]}" - name: Audit production dependencies diff --git a/.gitignore b/.gitignore index a0da79d14ef..c46954af2ef 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ pnpm-lock.yaml bun.lock bun.lockb coverage +__openclaw_vitest__/ __pycache__/ *.pyc .tsbuildinfo diff --git a/CHANGELOG.md b/CHANGELOG.md index 34afa1bc61d..4592c1ae307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman. +- Gateway/docs: clarify that empty URL input allowlists are treated as unset, document `allowUrl: false` as the deny-all switch, and add regression coverage for the normalization path. - Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only. - Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode. - Web tools/Firecrawl: add Firecrawl as an `onboard`/configure search provider via a bundled plugin, expose explicit `firecrawl_search` and `firecrawl_scrape` tools, and align core `web_fetch` fallback behavior with Firecrawl base-URL/env fallback plus guarded endpoint fetches. @@ -31,13 +32,19 @@ Docs: https://docs.openclaw.ai - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. - Browser/existing-session: support `browser.profiles..userDataDir` so Chrome DevTools MCP can attach to Brave, Edge, and other Chromium-based browsers through their own user data directories. (#48170) Thanks @velvet-shark. - Skills/prompt budget: preserve all registered skills via a compact catalog fallback before dropping entries when the full prompt format exceeds `maxSkillsPromptChars`. (#47553) Thanks @snese. +- Models/OpenAI: add native forward-compat support for `gpt-5.4-mini` and `gpt-5.4-nano` in the OpenAI provider catalog, runtime resolution, and reasoning capability gates. Thanks @vincentkoc. - 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. +- CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant. ### Breaking - Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc. - Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root. +- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly. ### Fixes @@ -98,6 +105,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. @@ -108,16 +118,28 @@ 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 - Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus. +- 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. +- Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob. ## 2026.3.13 @@ -196,6 +218,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 @@ -292,6 +315,9 @@ Docs: https://docs.openclaw.ai - Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman. - Agents/Ollama overflow: rewrite Ollama `prompt too long` API payloads through the normal context-overflow sanitizer so embedded sessions keep the friendly overflow copy and auto-compaction trigger. (#34019) thanks @lishuaigit. +- 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 ### Security @@ -433,6 +459,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 @@ -620,6 +647,7 @@ Docs: https://docs.openclaw.ai - Control UI/markdown fallback regression coverage: add explicit regression assertions for parser-error fallback behavior so malformed markdown no longer risks reintroducing hard-crash rendering paths in future markdown/parser upgrades. (#36445) Thanks @BinHPdev. - Web UI/config form: treat `additionalProperties: true` object schemas as editable map entries instead of unsupported fields so Accounts-style maps stay editable in form mode. (#35380, supersedes #32072) Thanks @stakeswky and @liuxiaopai-ai. - Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread `message.reply` routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune. +- macOS/tray menu: keep injected sessions and device rows below the controls section so toggles and action buttons stay visible even when many sessions are active. (#38079) Thanks @bernesto. - Feishu/group mention detection: carry startup-probed bot display names through monitor dispatch so `requireMention` checks compare against current bot identity instead of stale config names, fixing missed `@bot` handling in groups while preserving multi-bot false-positive guards. (#36317, #34271) Thanks @liuxiaopai-ai. - Security/dependency audit: patch transitive Hono vulnerabilities by pinning `hono` to `4.12.5` and `@hono/node-server` to `1.19.10` in production resolution paths. Thanks @shakkernerd. - Security/dependency audit: bump `tar` to `7.5.10` (from `7.5.9`) to address the high-severity hardlink path traversal advisory (`GHSA-qffp-2rhf-9h96`). Thanks @shakkernerd. @@ -1221,7 +1249,7 @@ Docs: https://docs.openclaw.ai - Signal/Sync message null-handling: treat `syncMessage` presence (including `null`) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin. - Infra/fs-safe: sanitize directory-read failures so raw `EISDIR` text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo. -## Unreleased +## 2026.2.27 ### Changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 14a9b3c8bcd..9e487f254cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ Welcome to the lobster tank! šŸ¦ž - **Christoph Nakazawa** - JS Infra - GitHub: [@cpojer](https://github.com/cpojer) Ā· X: [@cnakazawa](https://x.com/cnakazawa) -- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI +- **Gustavo Madeira Santana** - Multi-agents, CLI, Performance, Plugins, Matrix - GitHub: [@gumadeiras](https://github.com/gumadeiras) Ā· X: [@gumadeiras](https://x.com/gumadeiras) - **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams diff --git a/README.md b/README.md index 418e2a070af..1c836da84ee 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,7 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) Ā· [Docker ### [Discord](https://docs.openclaw.ai/channels/discord) -- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins). +- Set `DISCORD_BOT_TOKEN` or `channels.discord.token`. - Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed. ```json5 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/OpenClaw/LaunchAgentManager.swift b/apps/macos/Sources/OpenClaw/LaunchAgentManager.swift index af318b330d4..004d575d5d5 100644 --- a/apps/macos/Sources/OpenClaw/LaunchAgentManager.swift +++ b/apps/macos/Sources/OpenClaw/LaunchAgentManager.swift @@ -26,7 +26,12 @@ enum LaunchAgentManager { } private static func writePlist(bundlePath: String) { - let plist = """ + let plist = self.plistContents(bundlePath: bundlePath) + try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8) + } + + static func plistContents(bundlePath: String) -> String { + """ @@ -41,8 +46,6 @@ enum LaunchAgentManager { \(FileManager().homeDirectoryForCurrentUser.path) RunAtLoad - KeepAlive - EnvironmentVariables PATH @@ -55,7 +58,6 @@ enum LaunchAgentManager { """ - try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8) } @discardableResult diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift index eb6271d0a8c..9f667cc6239 100644 --- a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift +++ b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift @@ -1099,38 +1099,33 @@ extension MenuSessionsInjector { // MARK: - Width + placement private func findInsertIndex(in menu: NSMenu) -> Int? { - // Insert right before the separator above "Send Heartbeats". - if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { - if let sepIdx = menu.items[..= 1 { return 1 } - return menu.items.count + self.findDynamicSectionInsertIndex(in: menu) } private func findNodesInsertIndex(in menu: NSMenu) -> Int? { - if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { - if let sepIdx = menu.items[.. Int? { + // Keep controls and action buttons visible by inserting dynamic rows at the + // built-in footer boundary, not by matching localized menu item titles. + if let footerSeparatorIndex = menu.items.lastIndex(where: { item in + item.isSeparatorItem && !self.isInjectedItem(item) + }) { + return footerSeparatorIndex } - if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) { - return sepIdx + if let firstBaseItemIndex = menu.items.firstIndex(where: { !self.isInjectedItem($0) }) { + return min(firstBaseItemIndex + 1, menu.items.count) } - if menu.items.count >= 1 { return 1 } return menu.items.count } + private func isInjectedItem(_ item: NSMenuItem) -> Bool { + item.tag == self.tag || item.tag == self.nodesTag + } + private func initialWidth(for menu: NSMenu) -> CGFloat { if let openWidth = self.menuOpenWidth { return max(300, openWidth) @@ -1236,5 +1231,13 @@ extension MenuSessionsInjector { func injectForTesting(into menu: NSMenu) { self.inject(into: menu) } + + func testingFindInsertIndex(in menu: NSMenu) -> Int? { + self.findInsertIndex(in: menu) + } + + func testingFindNodesInsertIndex(in menu: NSMenu) -> Int? { + self.findNodesInsertIndex(in: menu) + } } #endif diff --git a/apps/macos/Sources/OpenClaw/NodeServiceManager.swift b/apps/macos/Sources/OpenClaw/NodeServiceManager.swift index 7a9da5925f8..18f500bd359 100644 --- a/apps/macos/Sources/OpenClaw/NodeServiceManager.swift +++ b/apps/macos/Sources/OpenClaw/NodeServiceManager.swift @@ -6,7 +6,7 @@ enum NodeServiceManager { static func start() async -> String? { let result = await self.runServiceCommandResult( - ["node", "start"], + ["start"], timeout: 20, quiet: false) if let error = self.errorMessage(from: result, treatNotLoadedAsError: true) { @@ -18,7 +18,7 @@ enum NodeServiceManager { static func stop() async -> String? { let result = await self.runServiceCommandResult( - ["node", "stop"], + ["stop"], timeout: 15, quiet: false) if let error = self.errorMessage(from: result, treatNotLoadedAsError: false) { @@ -30,6 +30,14 @@ enum NodeServiceManager { } extension NodeServiceManager { + private static func serviceCommand(_ args: [String]) -> [String] { + CommandResolver.openclawCommand( + subcommand: "node", + extraArgs: self.withJsonFlag(args), + // Service management must always run locally, even if remote mode is configured. + configRoot: ["gateway": ["mode": "local"]]) + } + private struct CommandResult { let success: Bool let payload: Data? @@ -52,11 +60,7 @@ extension NodeServiceManager { timeout: Double, quiet: Bool) async -> CommandResult { - let command = CommandResolver.openclawCommand( - subcommand: "service", - extraArgs: self.withJsonFlag(args), - // Service management must always run locally, even if remote mode is configured. - configRoot: ["gateway": ["mode": "local"]]) + let command = self.serviceCommand(args) var env = ProcessInfo.processInfo.environment env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) @@ -136,3 +140,11 @@ extension NodeServiceManager { TextSummarySupport.summarizeLastLine(text) } } + +#if DEBUG +extension NodeServiceManager { + static func _testServiceCommand(_ args: [String]) -> [String] { + self.serviceCommand(args) + } +} +#endif 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/macos/Tests/OpenClawIPCTests/LaunchAgentManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/LaunchAgentManagerTests.swift new file mode 100644 index 00000000000..c9a17d57577 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/LaunchAgentManagerTests.swift @@ -0,0 +1,19 @@ +import Foundation +import Testing +@testable import OpenClaw + +struct LaunchAgentManagerTests { + @Test func `launch at login plist does not keep app alive after manual quit`() throws { + let plist = LaunchAgentManager.plistContents(bundlePath: "/Applications/OpenClaw.app") + let data = try #require(plist.data(using: .utf8)) + let object = try #require( + PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] + ) + + #expect(object["RunAtLoad"] as? Bool == true) + #expect(object["KeepAlive"] == nil) + + let args = try #require(object["ProgramArguments"] as? [String]) + #expect(args == ["/Applications/OpenClaw.app/Contents/MacOS/OpenClaw"]) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift index 186675f1eea..b1d01b9650e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift @@ -5,7 +5,26 @@ import Testing @Suite(.serialized) @MainActor struct MenuSessionsInjectorTests { - @Test func `injects disconnected message`() { + @Test func anchorsDynamicRowsBelowControlsAndActions() throws { + let injector = MenuSessionsInjector() + + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Open Chat", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: "")) + + let footerSeparatorIndex = try #require(menu.items.lastIndex(where: { $0.isSeparatorItem })) + #expect(injector.testingFindInsertIndex(in: menu) == footerSeparatorIndex) + #expect(injector.testingFindNodesInsertIndex(in: menu) == footerSeparatorIndex) + } + + @Test func injectsDisconnectedMessage() { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(false) injector.setTestingSnapshot(nil, errorText: nil) @@ -19,7 +38,7 @@ struct MenuSessionsInjectorTests { #expect(menu.items.contains { $0.tag == 9_415_557 }) } - @Test func `injects session rows`() { + @Test func injectsSessionRows() throws { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(true) @@ -88,10 +107,22 @@ struct MenuSessionsInjectorTests { menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: "")) injector.injectForTesting(into: menu) #expect(menu.items.contains { $0.tag == 9_415_557 }) #expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem }) + let sendHeartbeatsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Send Heartbeats" })) + let openDashboardIndex = try #require(menu.items.firstIndex(where: { $0.title == "Open Dashboard" })) + let firstInjectedIndex = try #require(menu.items.firstIndex(where: { $0.tag == 9_415_557 })) + let settingsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Settings…" })) + #expect(sendHeartbeatsIndex < firstInjectedIndex) + #expect(openDashboardIndex < firstInjectedIndex) + #expect(firstInjectedIndex < settingsIndex) } @Test func `cost usage submenu does not use injector delegate`() { diff --git a/apps/macos/Tests/OpenClawIPCTests/NodeServiceManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodeServiceManagerTests.swift new file mode 100644 index 00000000000..df49a82e223 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/NodeServiceManagerTests.swift @@ -0,0 +1,19 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct NodeServiceManagerTests { + @Test func `builds node service commands with current CLI shape`() throws { + let tmp = try makeTempDirForTests() + CommandResolver.setProjectRoot(tmp.path) + + let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw") + try makeExecutableForTests(at: openclawPath) + + let start = NodeServiceManager._testServiceCommand(["start"]) + #expect(start == [openclawPath.path, "node", "start", "--json"]) + + let stop = NodeServiceManager._testServiceCommand(["stop"]) + #expect(stop == [openclawPath.path, "node", "stop", "--json"]) + } +} 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 65688a7fc7a..dabe2cf9837 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -1754,6 +1754,58 @@ "help": "Delay style for block replies (\"off\", \"natural\", \"custom\").", "hasChildren": false }, + { + "path": "agents.defaults.imageGenerationModel", + "kind": "core", + "type": [ + "object", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "agents.defaults.imageGenerationModel.fallbacks", + "kind": "core", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "media", + "reliability" + ], + "label": "Image Generation Model Fallbacks", + "help": "Ordered fallback image-generation models (provider/model).", + "hasChildren": true + }, + { + "path": "agents.defaults.imageGenerationModel.fallbacks.*", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "agents.defaults.imageGenerationModel.primary", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "media" + ], + "label": "Image Generation Model", + "help": "Optional image-generation model (provider/model) used by the shared image generation capability.", + "hasChildren": false + }, { "path": "agents.defaults.imageMaxDimensionPx", "kind": "core", @@ -11733,6 +11785,116 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.discord.accounts.*.voice.tts.microsoft", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.discord.accounts.*.voice.tts.microsoft.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.accounts.*.voice.tts.microsoft.lang", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.accounts.*.voice.tts.microsoft.outputFormat", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.accounts.*.voice.tts.microsoft.pitch", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.accounts.*.voice.tts.microsoft.proxy", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.accounts.*.voice.tts.microsoft.rate", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.accounts.*.voice.tts.microsoft.saveSubtitles", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.accounts.*.voice.tts.microsoft.timeoutMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.accounts.*.voice.tts.microsoft.voice", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.accounts.*.voice.tts.microsoft.volume", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.discord.accounts.*.voice.tts.mode", "kind": "channel", @@ -11961,11 +12123,6 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "elevenlabs", - "openai", - "edge" - ], "deprecated": false, "sensitive": false, "tags": [], @@ -14698,6 +14855,116 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.discord.voice.tts.microsoft", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.discord.voice.tts.microsoft.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.voice.tts.microsoft.lang", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.voice.tts.microsoft.outputFormat", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.voice.tts.microsoft.pitch", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.voice.tts.microsoft.proxy", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.voice.tts.microsoft.rate", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.voice.tts.microsoft.saveSubtitles", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.voice.tts.microsoft.timeoutMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.voice.tts.microsoft.voice", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.voice.tts.microsoft.volume", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.discord.voice.tts.mode", "kind": "channel", @@ -14926,11 +15193,6 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "elevenlabs", - "openai", - "edge" - ], "deprecated": false, "sensitive": false, "tags": [], @@ -38002,6 +38264,20 @@ "help": "Allow /debug chat command for runtime-only overrides (default: false).", "hasChildren": false }, + { + "path": "commands.mcp", + "kind": "core", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Allow /mcp", + "help": "Allow /mcp chat command to manage OpenClaw MCP server config under mcp.servers (default: false).", + "hasChildren": false + }, { "path": "commands.native", "kind": "core", @@ -38098,6 +38374,20 @@ "help": "Optional secret used to HMAC hash owner IDs when ownerDisplay=hash. Prefer env substitution.", "hasChildren": false }, + { + "path": "commands.plugins", + "kind": "core", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Allow /plugins", + "help": "Allow /plugins chat command to list discovered plugins and toggle plugin enablement in config (default: false).", + "hasChildren": false + }, { "path": "commands.restart", "kind": "core", @@ -39636,7 +39926,7 @@ "network" ], "label": "OpenAI Chat Completions Allow Image URLs", - "help": "Allow server-side URL fetches for `image_url` parts (default: false; data URIs remain supported).", + "help": "Allow server-side URL fetches for `image_url` parts (default: false; data URIs remain supported). Set this to `false` to disable URL fetching entirely.", "hasChildren": false }, { @@ -39701,7 +39991,7 @@ "network" ], "label": "OpenAI Chat Completions Image URL Allowlist", - "help": "Optional hostname allowlist for `image_url` URL fetches; supports exact hosts and `*.example.com` wildcards.", + "help": "Optional hostname allowlist for `image_url` URL fetches; supports exact hosts and `*.example.com` wildcards. Empty or omitted lists mean no hostname allowlist restriction.", "hasChildren": true }, { @@ -42004,6 +42294,137 @@ "help": "Sensitive redaction mode: \"off\" disables built-in masking, while \"tools\" redacts sensitive tool/config payload fields. Keep \"tools\" in shared logs unless you have isolated secure log sinks.", "hasChildren": false }, + { + "path": "mcp", + "kind": "core", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "MCP", + "help": "Global MCP server definitions managed by OpenClaw. Embedded Pi and other runtime adapters can consume these servers without storing them inside Pi-owned project settings.", + "hasChildren": true + }, + { + "path": "mcp.servers", + "kind": "core", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "MCP Servers", + "help": "Named MCP server definitions. OpenClaw stores them in its own config and runtime adapters decide which transports are supported at execution time.", + "hasChildren": true + }, + { + "path": "mcp.servers.*", + "kind": "core", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "mcp.servers.*.*", + "kind": "core", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "mcp.servers.*.args", + "kind": "core", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "mcp.servers.*.args.*", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "mcp.servers.*.command", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "mcp.servers.*.cwd", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "mcp.servers.*.env", + "kind": "core", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "mcp.servers.*.env.*", + "kind": "core", + "type": [ + "boolean", + "number", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "mcp.servers.*.url", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "mcp.servers.*.workingDirectory", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "media", "kind": "core", @@ -43560,6 +43981,116 @@ "tags": [], "hasChildren": false }, + { + "path": "messages.tts.microsoft", + "kind": "core", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "messages.tts.microsoft.enabled", + "kind": "core", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "messages.tts.microsoft.lang", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "messages.tts.microsoft.outputFormat", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "messages.tts.microsoft.pitch", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "messages.tts.microsoft.proxy", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "messages.tts.microsoft.rate", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "messages.tts.microsoft.saveSubtitles", + "kind": "core", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "messages.tts.microsoft.timeoutMs", + "kind": "core", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "messages.tts.microsoft.voice", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "messages.tts.microsoft.volume", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "messages.tts.mode", "kind": "core", @@ -43786,11 +44317,6 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "elevenlabs", - "openai", - "edge" - ], "deprecated": false, "sensitive": false, "tags": [], @@ -44768,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", @@ -45034,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", @@ -45103,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", @@ -45172,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", @@ -45241,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", @@ -45310,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", @@ -45379,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", @@ -45448,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", @@ -45517,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", @@ -45600,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", @@ -45669,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", @@ -46075,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", @@ -46144,6 +47294,179 @@ "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", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/elevenlabs-speech", + "help": "OpenClaw ElevenLabs speech plugin (plugin: elevenlabs)", + "hasChildren": true + }, + { + "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 + }, + { + "path": "plugins.entries.elevenlabs.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/elevenlabs-speech", + "hasChildren": false + }, + { + "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 + }, + { + "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 + }, + { + "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", @@ -46213,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", @@ -46282,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", @@ -46351,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", @@ -46420,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", @@ -46489,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", @@ -46558,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", @@ -46627,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", @@ -46696,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", @@ -46766,7 +48505,7 @@ "hasChildren": false }, { - "path": "plugins.entries.kimi-coding", + "path": "plugins.entries.kilocode.subagent", "kind": "plugin", "type": "object", "required": false, @@ -46775,12 +48514,50 @@ "tags": [ "advanced" ], - "label": "@openclaw/kimi-coding-provider", - "help": "OpenClaw Kimi Coding provider plugin (plugin: kimi-coding)", + "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-coding.config", + "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", "type": "object", "required": false, @@ -46789,12 +48566,26 @@ "tags": [ "advanced" ], - "label": "@openclaw/kimi-coding-provider Config", - "help": "Plugin-defined config payload for kimi-coding.", + "label": "@openclaw/kimi-provider", + "help": "OpenClaw Kimi provider plugin (plugin: kimi)", + "hasChildren": true + }, + { + "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 }, { - "path": "plugins.entries.kimi-coding.enabled", + "path": "plugins.entries.kimi.enabled", "kind": "plugin", "type": "boolean", "required": false, @@ -46803,11 +48594,11 @@ "tags": [ "advanced" ], - "label": "Enable @openclaw/kimi-coding-provider", + "label": "Enable @openclaw/kimi-provider", "hasChildren": false }, { - "path": "plugins.entries.kimi-coding.hooks", + "path": "plugins.entries.kimi.hooks", "kind": "plugin", "type": "object", "required": false, @@ -46821,7 +48612,7 @@ "hasChildren": true }, { - "path": "plugins.entries.kimi-coding.hooks.allowPromptInjection", + "path": "plugins.entries.kimi.hooks.allowPromptInjection", "kind": "plugin", "type": "boolean", "required": false, @@ -46834,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", @@ -46903,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", @@ -47042,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", @@ -47111,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", @@ -47180,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", @@ -47249,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", @@ -47318,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", @@ -47516,6 +49671,179 @@ "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", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/microsoft-speech", + "help": "OpenClaw Microsoft speech plugin (plugin: microsoft)", + "hasChildren": true + }, + { + "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 + }, + { + "path": "plugins.entries.microsoft.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/microsoft-speech", + "hasChildren": false + }, + { + "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 + }, + { + "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 + }, + { + "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", @@ -47585,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", @@ -47654,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", @@ -47723,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", @@ -47792,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", @@ -47861,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", @@ -47930,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", @@ -47999,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", @@ -48068,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", @@ -48137,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", @@ -48206,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", @@ -48275,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", @@ -48358,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", @@ -48413,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", @@ -48482,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", @@ -48718,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", @@ -48787,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", @@ -48856,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", @@ -48925,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", @@ -48994,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", @@ -49063,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", @@ -49132,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", @@ -49201,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", @@ -49270,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", @@ -49339,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", @@ -49408,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", @@ -49477,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", @@ -49584,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", @@ -49653,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", @@ -49722,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", @@ -49791,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", @@ -49860,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", @@ -49929,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", @@ -49998,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", @@ -51184,11 +55228,6 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "openai", - "elevenlabs", - "edge" - ], "deprecated": false, "sensitive": false, "tags": [ @@ -51196,7 +55235,7 @@ "media" ], "label": "TTS Provider Override", - "help": "Deep-merges with messages.tts (Edge is ignored for calls).", + "help": "Deep-merges with messages.tts (Microsoft is ignored for calls).", "hasChildren": false }, { @@ -51428,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", @@ -51497,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", @@ -51566,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", @@ -51635,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", @@ -51704,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", @@ -51773,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", @@ -51842,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", @@ -51911,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 d8d82d7bb7a..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":5104} +{"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} @@ -150,6 +150,10 @@ {"recordType":"path","path":"agents.defaults.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Human Delay Max (ms)","help":"Maximum delay in ms for custom humanDelay (default: 2500).","hasChildren":false} {"recordType":"path","path":"agents.defaults.humanDelay.minMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Human Delay Min (ms)","help":"Minimum delay in ms for custom humanDelay (default: 800).","hasChildren":false} {"recordType":"path","path":"agents.defaults.humanDelay.mode","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Human Delay Mode","help":"Delay style for block replies (\"off\", \"natural\", \"custom\").","hasChildren":false} +{"recordType":"path","path":"agents.defaults.imageGenerationModel","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"agents.defaults.imageGenerationModel.fallbacks","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["media","reliability"],"label":"Image Generation Model Fallbacks","help":"Ordered fallback image-generation models (provider/model).","hasChildren":true} +{"recordType":"path","path":"agents.defaults.imageGenerationModel.fallbacks.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"agents.defaults.imageGenerationModel.primary","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["media"],"label":"Image Generation Model","help":"Optional image-generation model (provider/model) used by the shared image generation capability.","hasChildren":false} {"recordType":"path","path":"agents.defaults.imageMaxDimensionPx","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["media","performance"],"label":"Image Max Dimension (px)","help":"Max image side length in pixels when sanitizing transcript/tool-result image payloads (default: 1200).","hasChildren":false} {"recordType":"path","path":"agents.defaults.imageModel","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.defaults.imageModel.fallbacks","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["media","models","reliability"],"label":"Image Model Fallbacks","help":"Ordered fallback image models (provider/model).","hasChildren":true} @@ -1047,6 +1051,17 @@ {"recordType":"path","path":"channels.discord.accounts.*.voice.tts.elevenlabs.voiceSettings.useSpeakerBoost","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.accounts.*.voice.tts.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.accounts.*.voice.tts.maxTextLength","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.microsoft","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.microsoft.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.microsoft.lang","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.microsoft.outputFormat","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.microsoft.pitch","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.microsoft.proxy","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.microsoft.rate","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.microsoft.saveSubtitles","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.microsoft.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.microsoft.voice","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.microsoft.volume","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.accounts.*.voice.tts.mode","kind":"channel","type":"string","required":false,"enumValues":["final","all"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.accounts.*.voice.tts.modelOverrides","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.discord.accounts.*.voice.tts.modelOverrides.allowModelId","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -1068,7 +1083,7 @@ {"recordType":"path","path":"channels.discord.accounts.*.voice.tts.openai.speed","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.accounts.*.voice.tts.openai.voice","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.accounts.*.voice.tts.prefsPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.provider","kind":"channel","type":"string","required":false,"enumValues":["elevenlabs","openai","edge"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.provider","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.accounts.*.voice.tts.summaryModel","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.accounts.*.voice.tts.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -1302,6 +1317,17 @@ {"recordType":"path","path":"channels.discord.voice.tts.elevenlabs.voiceSettings.useSpeakerBoost","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.voice.tts.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.voice.tts.maxTextLength","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.voice.tts.microsoft","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.discord.voice.tts.microsoft.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.voice.tts.microsoft.lang","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.voice.tts.microsoft.outputFormat","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.voice.tts.microsoft.pitch","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.voice.tts.microsoft.proxy","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.voice.tts.microsoft.rate","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.voice.tts.microsoft.saveSubtitles","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.voice.tts.microsoft.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.voice.tts.microsoft.voice","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.voice.tts.microsoft.volume","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.voice.tts.mode","kind":"channel","type":"string","required":false,"enumValues":["final","all"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.voice.tts.modelOverrides","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.discord.voice.tts.modelOverrides.allowModelId","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -1323,7 +1349,7 @@ {"recordType":"path","path":"channels.discord.voice.tts.openai.speed","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.voice.tts.openai.voice","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.voice.tts.prefsPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.discord.voice.tts.provider","kind":"channel","type":"string","required":false,"enumValues":["elevenlabs","openai","edge"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.voice.tts.provider","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.voice.tts.summaryModel","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.voice.tts.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Feishu","help":"飞书/Lark enterprise messaging.","hasChildren":true} @@ -3431,12 +3457,14 @@ {"recordType":"path","path":"commands.bashForegroundMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Bash Foreground Window (ms)","help":"How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).","hasChildren":false} {"recordType":"path","path":"commands.config","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Allow /config","help":"Allow /config chat command to read/write config on disk (default: false).","hasChildren":false} {"recordType":"path","path":"commands.debug","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Allow /debug","help":"Allow /debug chat command for runtime-only overrides (default: false).","hasChildren":false} +{"recordType":"path","path":"commands.mcp","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Allow /mcp","help":"Allow /mcp chat command to manage OpenClaw MCP server config under mcp.servers (default: false).","hasChildren":false} {"recordType":"path","path":"commands.native","kind":"core","type":["boolean","string"],"required":true,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Native Commands","help":"Registers native slash/menu commands with channels that support command registration (Discord, Slack, Telegram). Keep enabled for discoverability unless you intentionally run text-only command workflows.","hasChildren":false} {"recordType":"path","path":"commands.nativeSkills","kind":"core","type":["boolean","string"],"required":true,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Native Skill Commands","help":"Registers native skill commands so users can invoke skills directly from provider command menus where supported. Keep aligned with your skill policy so exposed commands match what operators expect.","hasChildren":false} {"recordType":"path","path":"commands.ownerAllowFrom","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Command Owners","help":"Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.","hasChildren":true} {"recordType":"path","path":"commands.ownerAllowFrom.*","kind":"core","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"commands.ownerDisplay","kind":"core","type":"string","required":true,"enumValues":["raw","hash"],"defaultValue":"raw","deprecated":false,"sensitive":false,"tags":["access"],"label":"Owner ID Display","help":"Controls how owner IDs are rendered in the system prompt. Allowed values: raw, hash. Default: raw.","hasChildren":false} {"recordType":"path","path":"commands.ownerDisplaySecret","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["access","auth","security"],"label":"Owner ID Hash Secret","help":"Optional secret used to HMAC hash owner IDs when ownerDisplay=hash. Prefer env substitution.","hasChildren":false} +{"recordType":"path","path":"commands.plugins","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Allow /plugins","help":"Allow /plugins chat command to list discovered plugins and toggle plugin enablement in config (default: false).","hasChildren":false} {"recordType":"path","path":"commands.restart","kind":"core","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Allow Restart","help":"Allow /restart and gateway restart tool actions (default: true).","hasChildren":false} {"recordType":"path","path":"commands.text","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Text Commands","help":"Enables text-command parsing in chat input in addition to native command surfaces where available. Keep this enabled for compatibility across channels that do not support native command registration.","hasChildren":false} {"recordType":"path","path":"commands.useAccessGroups","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Use Access Groups","help":"Enforce access-group allowlists/policies for commands.","hasChildren":false} @@ -3551,11 +3579,11 @@ {"recordType":"path","path":"gateway.http.endpoints.chatCompletions.images","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["media","network"],"label":"OpenAI Chat Completions Image Limits","help":"Image fetch/validation controls for OpenAI-compatible `image_url` parts.","hasChildren":true} {"recordType":"path","path":"gateway.http.endpoints.chatCompletions.images.allowedMimes","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","media","network"],"label":"OpenAI Chat Completions Image MIME Allowlist","help":"Allowed MIME types for `image_url` parts (case-insensitive list).","hasChildren":true} {"recordType":"path","path":"gateway.http.endpoints.chatCompletions.images.allowedMimes.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"gateway.http.endpoints.chatCompletions.images.allowUrl","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access","media","network"],"label":"OpenAI Chat Completions Allow Image URLs","help":"Allow server-side URL fetches for `image_url` parts (default: false; data URIs remain supported).","hasChildren":false} +{"recordType":"path","path":"gateway.http.endpoints.chatCompletions.images.allowUrl","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access","media","network"],"label":"OpenAI Chat Completions Allow Image URLs","help":"Allow server-side URL fetches for `image_url` parts (default: false; data URIs remain supported). Set this to `false` to disable URL fetching entirely.","hasChildren":false} {"recordType":"path","path":"gateway.http.endpoints.chatCompletions.images.maxBytes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["media","network","performance"],"label":"OpenAI Chat Completions Image Max Bytes","help":"Max bytes per fetched/decoded `image_url` image (default: 10MB).","hasChildren":false} {"recordType":"path","path":"gateway.http.endpoints.chatCompletions.images.maxRedirects","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["media","network","performance","storage"],"label":"OpenAI Chat Completions Image Max Redirects","help":"Max HTTP redirects allowed when fetching `image_url` URLs (default: 3).","hasChildren":false} {"recordType":"path","path":"gateway.http.endpoints.chatCompletions.images.timeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["media","network","performance"],"label":"OpenAI Chat Completions Image Timeout (ms)","help":"Timeout in milliseconds for `image_url` URL fetches (default: 10000).","hasChildren":false} -{"recordType":"path","path":"gateway.http.endpoints.chatCompletions.images.urlAllowlist","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","media","network"],"label":"OpenAI Chat Completions Image URL Allowlist","help":"Optional hostname allowlist for `image_url` URL fetches; supports exact hosts and `*.example.com` wildcards.","hasChildren":true} +{"recordType":"path","path":"gateway.http.endpoints.chatCompletions.images.urlAllowlist","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","media","network"],"label":"OpenAI Chat Completions Image URL Allowlist","help":"Optional hostname allowlist for `image_url` URL fetches; supports exact hosts and `*.example.com` wildcards. Empty or omitted lists mean no hostname allowlist restriction.","hasChildren":true} {"recordType":"path","path":"gateway.http.endpoints.chatCompletions.images.urlAllowlist.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"gateway.http.endpoints.chatCompletions.maxBodyBytes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","performance"],"label":"OpenAI Chat Completions Max Body Bytes","help":"Max request body size in bytes for `/v1/chat/completions` (default: 20MB).","hasChildren":false} {"recordType":"path","path":"gateway.http.endpoints.chatCompletions.maxImageParts","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["media","network","performance"],"label":"OpenAI Chat Completions Max Image Parts","help":"Max number of `image_url` parts accepted from the latest user message (default: 8).","hasChildren":false} @@ -3737,6 +3765,18 @@ {"recordType":"path","path":"logging.redactPatterns","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["observability","privacy"],"label":"Custom Redaction Patterns","help":"Additional custom redact regex patterns applied to log output before emission/storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.","hasChildren":true} {"recordType":"path","path":"logging.redactPatterns.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"logging.redactSensitive","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["observability","privacy"],"label":"Sensitive Data Redaction Mode","help":"Sensitive redaction mode: \"off\" disables built-in masking, while \"tools\" redacts sensitive tool/config payload fields. Keep \"tools\" in shared logs unless you have isolated secure log sinks.","hasChildren":false} +{"recordType":"path","path":"mcp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"MCP","help":"Global MCP server definitions managed by OpenClaw. Embedded Pi and other runtime adapters can consume these servers without storing them inside Pi-owned project settings.","hasChildren":true} +{"recordType":"path","path":"mcp.servers","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"MCP Servers","help":"Named MCP server definitions. OpenClaw stores them in its own config and runtime adapters decide which transports are supported at execution time.","hasChildren":true} +{"recordType":"path","path":"mcp.servers.*","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"mcp.servers.*.*","kind":"core","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"mcp.servers.*.args","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"mcp.servers.*.args.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"mcp.servers.*.command","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"mcp.servers.*.cwd","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"mcp.servers.*.env","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"mcp.servers.*.env.*","kind":"core","type":["boolean","number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"mcp.servers.*.url","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"mcp.servers.*.workingDirectory","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"media","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Media","help":"Top-level media behavior shared across providers and tools that handle inbound files. Keep defaults unless you need stable filenames for external processing pipelines or longer-lived inbound media retention.","hasChildren":true} {"recordType":"path","path":"media.preserveFilenames","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Preserve Media Filenames","help":"When enabled, uploaded media keeps its original filename instead of a generated temp-safe name. Turn this on when downstream automations depend on stable names, and leave off to reduce accidental filename leakage.","hasChildren":false} {"recordType":"path","path":"media.ttlHours","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Media Retention TTL (hours)","help":"Optional retention window in hours for persisted inbound media cleanup across the full media tree. Leave unset to preserve legacy behavior, or set values like 24 (1 day) or 168 (7 days) when you want automatic cleanup.","hasChildren":false} @@ -3867,6 +3907,17 @@ {"recordType":"path","path":"messages.tts.elevenlabs.voiceSettings.useSpeakerBoost","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"messages.tts.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"messages.tts.maxTextLength","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"messages.tts.microsoft","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"messages.tts.microsoft.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"messages.tts.microsoft.lang","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"messages.tts.microsoft.outputFormat","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"messages.tts.microsoft.pitch","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"messages.tts.microsoft.proxy","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"messages.tts.microsoft.rate","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"messages.tts.microsoft.saveSubtitles","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"messages.tts.microsoft.timeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"messages.tts.microsoft.voice","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"messages.tts.microsoft.volume","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"messages.tts.mode","kind":"core","type":"string","required":false,"enumValues":["final","all"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"messages.tts.modelOverrides","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"messages.tts.modelOverrides.allowModelId","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3888,7 +3939,7 @@ {"recordType":"path","path":"messages.tts.openai.speed","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"messages.tts.openai.voice","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"messages.tts.prefsPath","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"messages.tts.provider","kind":"core","type":"string","required":false,"enumValues":["elevenlabs","openai","edge"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"messages.tts.provider","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"messages.tts.summaryModel","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"messages.tts.timeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"meta","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Metadata","help":"Metadata fields automatically maintained by OpenClaw to record write/version history for this config file. Keep these values system-managed and avoid manual edits unless debugging migration history.","hasChildren":true} @@ -3969,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} @@ -3989,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} @@ -4062,66 +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.kimi-coding","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kimi-coding-provider","help":"OpenClaw Kimi Coding provider plugin (plugin: kimi-coding)","hasChildren":true} -{"recordType":"path","path":"plugins.entries.kimi-coding.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kimi-coding-provider Config","help":"Plugin-defined config payload for kimi-coding.","hasChildren":false} -{"recordType":"path","path":"plugins.entries.kimi-coding.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/kimi-coding-provider","hasChildren":false} -{"recordType":"path","path":"plugins.entries.kimi-coding.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-coding.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} @@ -4134,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} @@ -4168,76 +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} @@ -4255,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} @@ -4318,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} @@ -4449,7 +4770,7 @@ {"recordType":"path","path":"plugins.entries.voice-call.config.tts.openai.speed","kind":"plugin","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"plugins.entries.voice-call.config.tts.openai.voice","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced","media"],"label":"OpenAI TTS Voice","hasChildren":false} {"recordType":"path","path":"plugins.entries.voice-call.config.tts.prefsPath","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"plugins.entries.voice-call.config.tts.provider","kind":"plugin","type":"string","required":false,"enumValues":["openai","elevenlabs","edge"],"deprecated":false,"sensitive":false,"tags":["advanced","media"],"label":"TTS Provider Override","help":"Deep-merges with messages.tts (Edge is ignored for calls).","hasChildren":false} +{"recordType":"path","path":"plugins.entries.voice-call.config.tts.provider","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced","media"],"label":"TTS Provider Override","help":"Deep-merges with messages.tts (Microsoft is ignored for calls).","hasChildren":false} {"recordType":"path","path":"plugins.entries.voice-call.config.tts.summaryModel","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"plugins.entries.voice-call.config.tts.timeoutMs","kind":"plugin","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"plugins.entries.voice-call.config.tunnel","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -4469,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/discord.md b/docs/channels/discord.md index 2b2266c4c83..0f7b6ac7074 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -96,8 +96,10 @@ You will need to create a new application with a bot, add the bot to your server Your Discord bot token is a secret (like a password). Set it on the machine running OpenClaw before messaging your agent. ```bash -openclaw config set channels.discord.token '"YOUR_BOT_TOKEN"' --json -openclaw config set channels.discord.enabled true --json +export DISCORD_BOT_TOKEN="YOUR_BOT_TOKEN" +openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN --dry-run +openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN +openclaw config set channels.discord.enabled true --strict-json openclaw gateway ``` @@ -121,7 +123,11 @@ openclaw gateway channels: { discord: { enabled: true, - token: "YOUR_BOT_TOKEN", + token: { + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }, }, }, } @@ -133,7 +139,7 @@ openclaw gateway DISCORD_BOT_TOKEN=... ``` - SecretRef values are also supported for `channels.discord.token` (env/file/exec providers). See [Secrets Management](/gateway/secrets). + Plaintext `token` values are supported. SecretRef values are also supported for `channels.discord.token` across env/file/exec providers. See [Secrets Management](/gateway/secrets). 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/config.md b/docs/cli/config.md index fa0d62e8511..72ba3af0c9d 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -7,9 +7,9 @@ title: "config" # `openclaw config` -Config helpers: get/set/unset/validate values by path and print the active -config file. Run without a subcommand to open -the configure wizard (same as `openclaw configure`). +Config helpers for non-interactive edits in `openclaw.json`: get/set/unset/validate +values by path and print the active config file. Run without a subcommand to +open the configure wizard (same as `openclaw configure`). ## Examples @@ -19,7 +19,10 @@ openclaw config get browser.executablePath openclaw config set browser.executablePath "/usr/bin/google-chrome" openclaw config set agents.defaults.heartbeat.every "2h" openclaw config set agents.list[0].tools.exec.node "node-id-or-name" +openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN +openclaw config set secrets.providers.vaultfile --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json openclaw config unset tools.web.search.apiKey +openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN --dry-run openclaw config validate openclaw config validate --json ``` @@ -51,6 +54,230 @@ openclaw config set gateway.port 19001 --strict-json openclaw config set channels.whatsapp.groups '["*"]' --strict-json ``` +## `config set` modes + +`openclaw config set` supports four assignment styles: + +1. Value mode: `openclaw config set ` +2. SecretRef builder mode: + +```bash +openclaw config set channels.discord.token \ + --ref-provider default \ + --ref-source env \ + --ref-id DISCORD_BOT_TOKEN +``` + +3. Provider builder mode (`secrets.providers.` path only): + +```bash +openclaw config set secrets.providers.vault \ + --provider-source exec \ + --provider-command /usr/local/bin/openclaw-vault \ + --provider-arg read \ + --provider-arg openai/api-key \ + --provider-timeout-ms 5000 +``` + +4. Batch mode (`--batch-json` or `--batch-file`): + +```bash +openclaw config set --batch-json '[ + { + "path": "secrets.providers.default", + "provider": { "source": "env" } + }, + { + "path": "channels.discord.token", + "ref": { "source": "env", "provider": "default", "id": "DISCORD_BOT_TOKEN" } + } +]' +``` + +```bash +openclaw config set --batch-file ./config-set.batch.json --dry-run +``` + +Batch parsing always uses the batch payload (`--batch-json`/`--batch-file`) as the source of truth. +`--strict-json` / `--json` do not change batch parsing behavior. + +JSON path/value mode remains supported for both SecretRefs and providers: + +```bash +openclaw config set channels.discord.token \ + '{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}' \ + --strict-json + +openclaw config set secrets.providers.vaultfile \ + '{"source":"file","path":"/etc/openclaw/secrets.json","mode":"json"}' \ + --strict-json +``` + +## Provider Builder Flags + +Provider builder targets must use `secrets.providers.` as the path. + +Common flags: + +- `--provider-source ` +- `--provider-timeout-ms ` (`file`, `exec`) + +Env provider (`--provider-source env`): + +- `--provider-allowlist ` (repeatable) + +File provider (`--provider-source file`): + +- `--provider-path ` (required) +- `--provider-mode ` +- `--provider-max-bytes ` + +Exec provider (`--provider-source exec`): + +- `--provider-command ` (required) +- `--provider-arg ` (repeatable) +- `--provider-no-output-timeout-ms ` +- `--provider-max-output-bytes ` +- `--provider-json-only` +- `--provider-env ` (repeatable) +- `--provider-pass-env ` (repeatable) +- `--provider-trusted-dir ` (repeatable) +- `--provider-allow-insecure-path` +- `--provider-allow-symlink-command` + +Hardened exec provider example: + +```bash +openclaw config set secrets.providers.vault \ + --provider-source exec \ + --provider-command /usr/local/bin/openclaw-vault \ + --provider-arg read \ + --provider-arg openai/api-key \ + --provider-json-only \ + --provider-pass-env VAULT_TOKEN \ + --provider-trusted-dir /usr/local/bin \ + --provider-timeout-ms 5000 +``` + +## Dry run + +Use `--dry-run` to validate changes without writing `openclaw.json`. + +```bash +openclaw config set channels.discord.token \ + --ref-provider default \ + --ref-source env \ + --ref-id DISCORD_BOT_TOKEN \ + --dry-run + +openclaw config set channels.discord.token \ + --ref-provider default \ + --ref-source env \ + --ref-id DISCORD_BOT_TOKEN \ + --dry-run \ + --json + +openclaw config set channels.discord.token \ + --ref-provider vault \ + --ref-source exec \ + --ref-id discord/token \ + --dry-run \ + --allow-exec +``` + +Dry-run behavior: + +- Builder mode: runs SecretRef resolvability checks for changed refs/providers. +- JSON mode (`--strict-json`, `--json`, or batch mode): runs schema validation plus SecretRef resolvability checks. +- Exec SecretRef checks are skipped by default during dry-run to avoid command side effects. +- Use `--allow-exec` with `--dry-run` to opt in to exec SecretRef checks (this may execute provider commands). +- `--allow-exec` is dry-run only and errors if used without `--dry-run`. + +`--dry-run --json` prints a machine-readable report: + +- `ok`: whether dry-run passed +- `operations`: number of assignments evaluated +- `checks`: whether schema/resolvability checks ran +- `checks.resolvabilityComplete`: whether resolvability checks ran to completion (false when exec refs are skipped) +- `refsChecked`: number of refs actually resolved during dry-run +- `skippedExecRefs`: number of exec refs skipped because `--allow-exec` was not set +- `errors`: structured schema/resolvability failures when `ok=false` + +### JSON Output Shape + +```json5 +{ + ok: boolean, + operations: number, + configPath: string, + inputModes: ["value" | "json" | "builder", ...], + checks: { + schema: boolean, + resolvability: boolean, + resolvabilityComplete: boolean, + }, + refsChecked: number, + skippedExecRefs: number, + errors?: [ + { + kind: "schema" | "resolvability", + message: string, + ref?: string, // present for resolvability errors + }, + ], +} +``` + +Success example: + +```json +{ + "ok": true, + "operations": 1, + "configPath": "~/.openclaw/openclaw.json", + "inputModes": ["builder"], + "checks": { + "schema": false, + "resolvability": true, + "resolvabilityComplete": true + }, + "refsChecked": 1, + "skippedExecRefs": 0 +} +``` + +Failure example: + +```json +{ + "ok": false, + "operations": 1, + "configPath": "~/.openclaw/openclaw.json", + "inputModes": ["builder"], + "checks": { + "schema": false, + "resolvability": true, + "resolvabilityComplete": true + }, + "refsChecked": 1, + "skippedExecRefs": 0, + "errors": [ + { + "kind": "resolvability", + "message": "Error: Environment variable \"MISSING_TEST_SECRET\" is not set.", + "ref": "env:default:MISSING_TEST_SECRET" + } + ] +} +``` + +If dry-run fails: + +- `config schema validation failed`: your post-change config shape is invalid; fix path/value or provider/ref object shape. +- `SecretRef assignment(s) could not be resolved`: referenced provider/ref currently cannot resolve (missing env var, invalid file pointer, exec provider failure, or provider/source mismatch). +- `Dry run note: skipped exec SecretRef resolvability check(s)`: dry-run skipped exec refs; rerun with `--allow-exec` if you need exec resolvability validation. +- For batch mode, fix failing entries and rerun `--dry-run` before writing. + ## Subcommands - `config file`: Print the active config file path (resolved from `OPENCLAW_CONFIG_PATH` or default location). diff --git a/docs/cli/index.md b/docs/cli/index.md index 8700655c766..a247a4085de 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -101,6 +101,8 @@ openclaw [--dev] [--profile ] get set unset + file + validate completion doctor dashboard @@ -283,7 +285,7 @@ Note: plugins can add additional top-level commands (for example `openclaw voice Manage extensions and their config: - `openclaw plugins list` — discover plugins (use `--json` for machine output). -- `openclaw plugins info ` — show details for a plugin. +- `openclaw plugins inspect ` — show details for a plugin (`info` is an alias). - `openclaw plugins install ` — install a plugin (or add a plugin path to `plugins.load.paths`). - `openclaw plugins marketplace list ` — list marketplace entries before install. - `openclaw plugins enable ` / `disable ` — toggle `plugins.entries..enabled`. @@ -393,7 +395,15 @@ subcommand launches the wizard. Subcommands: - `config get `: print a config value (dot/bracket path). -- `config set `: set a value (JSON5 or raw string). +- `config set`: supports four assignment modes: + - value mode: `config set ` (JSON5-or-string parsing) + - SecretRef builder mode: `config set --ref-provider --ref-source --ref-id ` + - provider builder mode: `config set secrets.providers. --provider-source ...` + - batch mode: `config set --batch-json ''` or `config set --batch-file ` +- `config set --dry-run`: validate assignments without writing `openclaw.json` (exec SecretRef checks are skipped by default). +- `config set --allow-exec --dry-run`: opt in to exec SecretRef dry-run checks (may execute provider commands). +- `config set --dry-run --json`: emit machine-readable dry-run output (checks + completeness signal, operations, refs checked/skipped, errors). +- `config set --strict-json`: require JSON5 parsing for path/value input. `--json` remains a legacy alias for strict parsing outside dry-run output mode. - `config unset `: remove a value. - `config file`: print the active config file path. - `config validate`: validate the current config against the schema without starting the gateway. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 5e551a9c64f..6a137137af1 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -21,7 +21,7 @@ Related: ```bash openclaw plugins list -openclaw plugins info +openclaw plugins inspect openclaw plugins enable openclaw plugins disable openclaw plugins uninstall @@ -31,6 +31,8 @@ openclaw plugins update --all openclaw plugins marketplace list ``` +`info` is an alias for `inspect`. + Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to activate them. @@ -148,3 +150,26 @@ marketplace installs. When a stored integrity hash exists and the fetched artifact hash changes, OpenClaw prints a warning and asks for confirmation before proceeding. Use global `--yes` to bypass prompts in CI/non-interactive runs. + +### Inspect + +```bash +openclaw plugins inspect +openclaw plugins inspect --json +``` + +Deep introspection for a single plugin. Shows identity, load status, source, +plugin shape, registered capabilities, hooks, tools, commands, services, +gateway methods, HTTP routes, policy flags, diagnostics, and install metadata. + +Plugin shape is derived from actual registration behavior: + +- **plain-capability** — one capability type registered +- **hybrid-capability** — multiple capability types registered +- **hook-only** — only hooks, no capabilities or surfaces +- **non-capability** — tools/commands/services but no capabilities + +The `--json` flag outputs a machine-readable report suitable for scripting and +auditing. + +`info` is an alias for `inspect`. 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/compaction.md b/docs/concepts/compaction.md index 73f6372c3f7..5640fa51a35 100644 --- a/docs/concepts/compaction.md +++ b/docs/concepts/compaction.md @@ -97,6 +97,17 @@ compaction and can run alongside it. See [OpenAI provider](/providers/openai) for model params and overrides. +## Custom context engines + +Compaction behavior is owned by the active +[context engine](/concepts/context-engine). The legacy engine uses the built-in +summarization described above. Plugin engines (selected via +`plugins.slots.contextEngine`) can implement any compaction strategy — DAG +summaries, vector retrieval, incremental condensation, etc. + +When a plugin engine sets `ownsCompaction: true`, OpenClaw delegates all +compaction decisions to the engine and does not run built-in auto-compaction. + ## Tips - Use `/compact` when sessions feel stale or context is bloated. diff --git a/docs/concepts/context-engine.md b/docs/concepts/context-engine.md new file mode 100644 index 00000000000..87d5e87d85b --- /dev/null +++ b/docs/concepts/context-engine.md @@ -0,0 +1,250 @@ +--- +summary: "Context engine: pluggable context assembly, compaction, and subagent lifecycle" +read_when: + - You want to understand how OpenClaw assembles model context + - You are switching between the legacy engine and a plugin engine + - You are building a context engine plugin +title: "Context Engine" +--- + +# Context Engine + +A **context engine** controls how OpenClaw builds model context for each run. +It decides which messages to include, how to summarize older history, and how +to manage context across subagent boundaries. + +OpenClaw ships with a built-in `legacy` engine. Plugins can register +alternative engines that replace the entire context pipeline. + +## Quick start + +Check which engine is active: + +```bash +openclaw doctor +# or inspect config directly: +cat ~/.openclaw/openclaw.json | jq '.plugins.slots.contextEngine' +``` + +### Installing a context engine plugin + +Context engine plugins are installed like any other OpenClaw plugin. Install +first, then select the engine in the slot: + +```bash +# Install from npm +openclaw plugins install @martian-engineering/lossless-claw + +# Or install from a local path (for development) +openclaw plugins install -l ./my-context-engine +``` + +Then enable the plugin and select it as the active engine in your config: + +```json5 +// openclaw.json +{ + plugins: { + slots: { + contextEngine: "lossless-claw", // must match the plugin's registered engine id + }, + entries: { + "lossless-claw": { + enabled: true, + // Plugin-specific config goes here (see the plugin's docs) + }, + }, + }, +} +``` + +Restart the gateway after installing and configuring. + +To switch back to the built-in engine, set `contextEngine` to `"legacy"` (or +remove the key entirely — `"legacy"` is the default). + +## How it works + +Every time OpenClaw runs a model prompt, the context engine participates at +four lifecycle points: + +1. **Ingest** — called when a new message is added to the session. The engine + can store or index the message in its own data store. +2. **Assemble** — called before each model run. The engine returns an ordered + set of messages (and an optional `systemPromptAddition`) that fit within + the token budget. +3. **Compact** — called when the context window is full, or when the user runs + `/compact`. The engine summarizes older history to free space. +4. **After turn** — called after a run completes. The engine can persist state, + trigger background compaction, or update indexes. + +### Subagent lifecycle (optional) + +OpenClaw currently calls one subagent lifecycle hook: + +- **onSubagentEnded** — clean up when a subagent session completes or is swept. + +The `prepareSubagentSpawn` hook is part of the interface for future use, but +the runtime does not invoke it yet. + +### System prompt addition + +The `assemble` method can return a `systemPromptAddition` string. OpenClaw +prepends this to the system prompt for the run. This lets engines inject +dynamic recall guidance, retrieval instructions, or context-aware hints +without requiring static workspace files. + +## The legacy engine + +The built-in `legacy` engine preserves OpenClaw's original behavior: + +- **Ingest**: no-op (the session manager handles message persistence directly). +- **Assemble**: pass-through (the existing sanitize → validate → limit pipeline + in the runtime handles context assembly). +- **Compact**: delegates to the built-in summarization compaction, which creates + a single summary of older messages and keeps recent messages intact. +- **After turn**: no-op. + +The legacy engine does not register tools or provide a `systemPromptAddition`. + +When no `plugins.slots.contextEngine` is set (or it's set to `"legacy"`), this +engine is used automatically. + +## Plugin engines + +A plugin can register a context engine using the plugin API: + +```ts +export default function register(api) { + api.registerContextEngine("my-engine", () => ({ + info: { + id: "my-engine", + name: "My Context Engine", + ownsCompaction: true, + }, + + async ingest({ sessionId, message, isHeartbeat }) { + // Store the message in your data store + return { ingested: true }; + }, + + async assemble({ sessionId, messages, tokenBudget }) { + // Return messages that fit the budget + return { + messages: buildContext(messages, tokenBudget), + estimatedTokens: countTokens(messages), + systemPromptAddition: "Use lcm_grep to search history...", + }; + }, + + async compact({ sessionId, force }) { + // Summarize older context + return { ok: true, compacted: true }; + }, + })); +} +``` + +Then enable it in config: + +```json5 +{ + plugins: { + slots: { + contextEngine: "my-engine", + }, + entries: { + "my-engine": { + enabled: true, + }, + }, + }, +} +``` + +### The ContextEngine interface + +Required members: + +| Member | Kind | Purpose | +| ------------------ | -------- | -------------------------------------------------------- | +| `info` | Property | Engine id, name, version, and whether it owns compaction | +| `ingest(params)` | Method | Store a single message | +| `assemble(params)` | Method | Build context for a model run (returns `AssembleResult`) | +| `compact(params)` | Method | Summarize/reduce context | + +`assemble` returns an `AssembleResult` with: + +- `messages` — the ordered messages to send to the model. +- `estimatedTokens` (required, `number`) — the engine's estimate of total + tokens in the assembled context. OpenClaw uses this for compaction threshold + decisions and diagnostic reporting. +- `systemPromptAddition` (optional, `string`) — prepended to the system prompt. + +Optional members: + +| Member | Kind | Purpose | +| ------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------- | +| `bootstrap(params)` | Method | Initialize engine state for a session. Called once when the engine first sees a session (e.g., import history). | +| `ingestBatch(params)` | Method | Ingest a completed turn as a batch. Called after a run completes, with all messages from that turn at once. | +| `afterTurn(params)` | Method | Post-run lifecycle work (persist state, trigger background compaction). | +| `prepareSubagentSpawn(params)` | Method | Set up shared state for a child session. | +| `onSubagentEnded(params)` | Method | Clean up after a subagent ends. | +| `dispose()` | Method | Release resources. Called during gateway shutdown or plugin reload — not per-session. | + +### ownsCompaction + +When `info.ownsCompaction` is `true`, the engine manages its own compaction +lifecycle. OpenClaw will not trigger the built-in auto-compaction; instead it +delegates entirely to the engine's `compact()` method. The engine may also +run compaction proactively in `afterTurn()`. + +When `false` or unset, OpenClaw's built-in auto-compaction logic runs +alongside the engine. + +## Configuration reference + +```json5 +{ + plugins: { + slots: { + // Select the active context engine. Default: "legacy". + // Set to a plugin id to use a plugin engine. + contextEngine: "legacy", + }, + }, +} +``` + +The slot is exclusive at run time — only one registered context engine is +resolved for a given run or compaction operation. Other enabled +`kind: "context-engine"` plugins can still load and run their registration +code; `plugins.slots.contextEngine` only selects which registered engine id +OpenClaw resolves when it needs a context engine. + +## Relationship to compaction and memory + +- **Compaction** is one responsibility of the context engine. The legacy engine + delegates to OpenClaw's built-in summarization. Plugin engines can implement + any compaction strategy (DAG summaries, vector retrieval, etc.). +- **Memory plugins** (`plugins.slots.memory`) are separate from context engines. + Memory plugins provide search/retrieval; context engines control what the + model sees. They can work together — a context engine might use memory + plugin data during assembly. +- **Session pruning** (trimming old tool results in-memory) still runs + regardless of which context engine is active. + +## Tips + +- Use `openclaw doctor` to verify your engine is loading correctly. +- If switching engines, existing sessions continue with their current history. + The new engine takes over for future runs. +- Engine errors are logged and surfaced in diagnostics. If a plugin engine + fails to register or the selected engine id cannot be resolved, OpenClaw + does not fall back automatically; runs fail until you fix the plugin or + switch `plugins.slots.contextEngine` back to `"legacy"`. +- For development, use `openclaw plugins install -l ./my-engine` to link a + local plugin directory without copying. + +See also: [Compaction](/concepts/compaction), [Context](/concepts/context), +[Plugins](/tools/plugin), [Plugin manifest](/plugins/manifest). diff --git a/docs/concepts/context.md b/docs/concepts/context.md index abc5e5af47c..d5316ea8bf8 100644 --- a/docs/concepts/context.md +++ b/docs/concepts/context.md @@ -157,7 +157,8 @@ By default, OpenClaw uses the built-in `legacy` context engine for assembly and compaction. If you install a plugin that provides `kind: "context-engine"` and select it with `plugins.slots.contextEngine`, OpenClaw delegates context assembly, `/compact`, and related subagent context lifecycle hooks to that -engine instead. +engine instead. See [Context Engine](/concepts/context-engine) for the full +pluggable interface, lifecycle hooks, and configuration. ## What `/context` actually reports diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 6adbb5d0f26..f5a73d7256e 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -32,6 +32,10 @@ For model selection rules, see [/concepts/models](/concepts/models). `supportsXHighThinking`, `resolveDefaultThinkingLevel`, `isModernModelRef`, `prepareRuntimeAuth`, `resolveUsageAuth`, and `fetchUsageSnapshot`. +- Note: provider runtime `capabilities` is shared runner metadata (provider + family, transcript/tooling quirks, transport/cache hints). It is not the + same as the [public capability model](/tools/plugin#public-capability-model) + which describes what a plugin registers (text inference, speech, etc.). ## Plugin-owned provider behavior 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/docs.json b/docs/docs.json index 42852e724c9..3d9e18ab759 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -59,6 +59,10 @@ "source": "/compaction", "destination": "/concepts/compaction" }, + { + "source": "/context-engine", + "destination": "/concepts/context-engine" + }, { "source": "/cron", "destination": "/cron-jobs" @@ -952,6 +956,7 @@ "concepts/agent-loop", "concepts/system-prompt", "concepts/context", + "concepts/context-engine", "concepts/agent-workspace", "concepts/oauth" ] diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 9767f2db674..5627f93395d 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -434,7 +434,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. nodeManager: "npm", }, entries: { - "nano-banana-pro": { + "image-lab": { enabled: true, apiKey: "GEMINI_KEY_HERE", env: { GEMINI_API_KEY: "GEMINI_KEY_HERE" }, diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ee823da9cac..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. @@ -2371,7 +2372,7 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio nodeManager: "npm", // npm | pnpm | yarn }, entries: { - "nano-banana-pro": { + "image-lab": { apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string env: { GEMINI_API_KEY: "GEMINI_KEY_HERE" }, }, @@ -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. @@ -2612,6 +2615,8 @@ See [Plugins](/tools/plugin). - `gateway.http.endpoints.responses.maxUrlParts` - `gateway.http.endpoints.responses.files.urlAllowlist` - `gateway.http.endpoints.responses.images.urlAllowlist` + Empty allowlists are treated as unset; use `gateway.http.endpoints.responses.files.allowUrl=false` + and/or `gateway.http.endpoints.responses.images.allowUrl=false` to disable URL fetching. - Optional response hardening header: - `gateway.http.securityHeaders.strictTransportSecurity` (set only for HTTPS origins you control; see [Trusted Proxy Auth](/gateway/trusted-proxy-auth#tls-termination-and-hsts)) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 3ead49f6817..d15efb3384b 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -597,11 +597,11 @@ Rules: }, skills: { entries: { - "nano-banana-pro": { + "image-lab": { apiKey: { source: "file", provider: "filemain", - id: "/skills/entries/nano-banana-pro/apiKey", + id: "/skills/entries/image-lab/apiKey", }, }, }, diff --git a/docs/gateway/openresponses-http-api.md b/docs/gateway/openresponses-http-api.md index fa86f912ef5..8305da62ee5 100644 --- a/docs/gateway/openresponses-http-api.md +++ b/docs/gateway/openresponses-http-api.md @@ -144,6 +144,8 @@ URL fetch defaults: - Optional hostname allowlists are supported per input type (`files.urlAllowlist`, `images.urlAllowlist`). - Exact host: `"cdn.example.com"` - Wildcard subdomains: `"*.assets.example.com"` (does not match apex) + - Empty or omitted allowlists mean no hostname allowlist restriction. +- To disable URL-based fetches entirely, set `files.allowUrl: false` and/or `images.allowUrl: false`. ## File + image limits (config) diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 7741707a62b..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. @@ -568,6 +572,8 @@ tool calls. Reduce the blast radius by: - For OpenResponses URL inputs (`input_file` / `input_image`), set tight `gateway.http.endpoints.responses.files.urlAllowlist` and `gateway.http.endpoints.responses.images.urlAllowlist`, and keep `maxUrlParts` low. + Empty allowlists are treated as unset; use `files.allowUrl: false` / `images.allowUrl: false` + if you want to disable URL fetching entirely. - Enabling sandboxing and strict tool allowlists for any agent that touches untrusted input. - Keeping secrets out of prompts; pass them via env/config on the gateway host instead. diff --git a/docs/help/testing.md b/docs/help/testing.md index f3315fa6faa..0d14f507bc9 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -52,6 +52,17 @@ Think of the suites as ā€œincreasing realismā€ (and increasing flakiness/cost): - Runs in CI - No real keys required - Should be fast and stable +- Embedded runner note: + - When you change message-tool discovery inputs or compaction runtime context, + keep both levels of coverage. + - Add focused helper regressions for pure routing/normalization seams. + - Also keep the embedded runner integration suites healthy: + `src/agents/pi-embedded-runner/compact.hooks.test.ts`, + `src/agents/pi-embedded-runner/run.overflow-compaction.test.ts`, and + `src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts`. + - Those suites verify that scoped ids and compaction behavior still flow + through the real `run.ts` / `compact.ts` paths; helper-only tests are not a + sufficient substitute for those seams. - Pool note: - OpenClaw uses Vitest `vmForks` on Node 22, 23, and 24 for faster unit shards. - On Node 25+, OpenClaw automatically falls back to regular `forks` until the repo is re-validated there. @@ -360,14 +371,29 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local - Enable: `BYTEPLUS_API_KEY=... BYTEPLUS_LIVE_TEST=1 pnpm test:live src/agents/byteplus.live.test.ts` - Optional model override: `BYTEPLUS_CODING_MODEL=ark-code-latest` -## Google image generation live +## Image generation live -- Test: `src/image-generation/providers/google.live.test.ts` -- Enable: `GOOGLE_LIVE_TEST=1 pnpm test:live src/image-generation/providers/google.live.test.ts` -- Key source: `GEMINI_API_KEY` or `GOOGLE_API_KEY` -- Optional overrides: - - `GOOGLE_IMAGE_GENERATION_MODEL=gemini-3.1-flash-image-preview` - - `GOOGLE_IMAGE_BASE_URL=https://generativelanguage.googleapis.com/v1beta` +- Test: `src/image-generation/runtime.live.test.ts` +- Command: `pnpm test:live src/image-generation/runtime.live.test.ts` +- Scope: + - Enumerates every registered image-generation provider plugin + - Loads missing provider env vars from your login shell (`~/.profile`) before probing + - Uses live/env API keys ahead of stored auth profiles by default, so stale test keys in `auth-profiles.json` do not mask real shell credentials + - Skips providers with no usable auth/profile/model + - Runs the stock image-generation variants through the shared runtime capability: + - `google:flash-generate` + - `google:pro-generate` + - `google:pro-edit` + - `openai:default-generate` +- Current bundled providers covered: + - `openai` + - `google` +- Optional narrowing: + - `OPENCLAW_LIVE_IMAGE_GENERATION_PROVIDERS="openai,google"` + - `OPENCLAW_LIVE_IMAGE_GENERATION_MODELS="openai/gpt-image-1,google/gemini-3.1-flash-image-preview"` + - `OPENCLAW_LIVE_IMAGE_GENERATION_CASES="google:flash-generate,google:pro-edit"` +- Optional auth behavior: + - `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to force profile-store auth and ignore env-only overrides ## Docker runners (optional ā€œworks in Linuxā€ checks) diff --git a/docs/pi.md b/docs/pi.md index 2689b480963..f12c687906c 100644 --- a/docs/pi.md +++ b/docs/pi.md @@ -119,19 +119,24 @@ src/agents/ │ ā”œā”€ā”€ browser-tool.ts │ ā”œā”€ā”€ canvas-tool.ts │ ā”œā”€ā”€ cron-tool.ts -│ ā”œā”€ā”€ discord-actions*.ts │ ā”œā”€ā”€ gateway-tool.ts │ ā”œā”€ā”€ image-tool.ts │ ā”œā”€ā”€ message-tool.ts │ ā”œā”€ā”€ nodes-tool.ts │ ā”œā”€ā”€ session*.ts -│ ā”œā”€ā”€ slack-actions.ts -│ ā”œā”€ā”€ telegram-actions.ts │ ā”œā”€ā”€ web-*.ts -│ └── whatsapp-actions.ts +│ └── ... └── ... ``` +Channel-specific message action runtimes now live in the plugin-owned extension +directories instead of under `src/agents/tools`, for example: + +- `extensions/discord/src/actions/runtime*.ts` +- `extensions/slack/src/action-runtime.ts` +- `extensions/telegram/src/action-runtime.ts` +- `extensions/whatsapp/src/action-runtime.ts` + ## Core Integration Flow ### 1. Running an Embedded Agent diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index bc6bc49e5a0..82a5605e099 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -19,7 +19,7 @@ Today that means three closely related ecosystems: - Cursor bundles OpenClaw shows all of them as `Format: bundle` in `openclaw plugins list`. -Verbose output and `openclaw plugins info ` also show the subtype +Verbose output and `openclaw plugins inspect ` also show the subtype (`codex`, `claude`, or `cursor`). Related: @@ -141,7 +141,7 @@ diagnostics/info output, but OpenClaw does not run them yet: ## Capability reporting -`openclaw plugins info ` shows bundle capabilities from the normalized +`openclaw plugins inspect ` shows bundle capabilities from the normalized bundle record. Supported capabilities are loaded quietly. Unsupported capabilities produce a @@ -269,7 +269,7 @@ openclaw plugins install ./my-cursor-bundle openclaw plugins install ./my-bundle.tgz openclaw plugins marketplace list openclaw plugins install @ -openclaw plugins info my-bundle +openclaw plugins inspect my-bundle ``` If the directory is a native OpenClaw plugin/package, the native install path @@ -284,7 +284,7 @@ sources; after resolution, the normal install rules still apply. ### Bundle is detected but capabilities do not run -Check `openclaw plugins info `. +Check `openclaw plugins inspect `. If the capability is listed but OpenClaw says it is not wired yet, that is a real product limit, not a broken install. diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 5ef77b9ef68..0db89ec5df9 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -32,6 +32,7 @@ Every native OpenClaw plugin **must** ship a `openclaw.plugin.json` file in the plugin errors and block config validation. See the full plugin system guide: [Plugins](/tools/plugin). +For the public capability model: [Capability model](/tools/plugin#public-capability-model). ## Required fields @@ -54,8 +55,8 @@ Required keys: Optional keys: - `kind` (string): plugin kind (examples: `"memory"`, `"context-engine"`). -- `channels` (array): channel ids registered by this plugin (example: `["matrix"]`). -- `providers` (array): provider ids registered by this plugin. +- `channels` (array): channel ids registered by this plugin (channel capability; example: `["matrix"]`). +- `providers` (array): provider ids registered by this plugin (text inference capability). - `providerAuthEnvVars` (object): auth env vars keyed by provider id. Use this when OpenClaw should resolve provider credentials from env without loading plugin runtime first. diff --git a/docs/refactor/plugin-sdk.md b/docs/refactor/plugin-sdk.md index 5a630982a97..edf79de266d 100644 --- a/docs/refactor/plugin-sdk.md +++ b/docs/refactor/plugin-sdk.md @@ -213,7 +213,33 @@ Notes: Related docs: [Plugins](/tools/plugin), [Channels](/channels/index), [Configuration](/gateway/configuration). -## Implemented channel-owned seams +## Capability plan alignment + +The plugin SDK refactor now aligns with the public capability model documented +in [Plugins](/tools/plugin#public-capability-model). + +Key decisions: + +- Capabilities are the public plugin model. Registration is explicit and typed. +- Legacy hook-only plugins remain supported without migration. +- Plugin shapes (plain-capability, hybrid-capability, hook-only, non-capability) + are classified from actual registration behavior. +- `openclaw plugins inspect` provides canonical deep introspection for any + loaded plugin, showing shape, capabilities, hooks, tools, and diagnostics. +- Export boundary: export capabilities, not implementation convenience. Trim + non-contract helper exports. + +Required test matrix for the capability model: + +- hook-only legacy plugin fixture +- plain capability plugin fixture +- hybrid capability plugin fixture +- real-world legacy hook-style plugin fixture +- `before_agent_start` still works +- typed hooks remain additive +- capability usage and plugin shape are inspectable + +## Implemented channel-owned capabilities Recent refactor work widened the channel plugin contract so core can stop owning channel-specific UX and routing behavior: @@ -234,5 +260,5 @@ channel-specific UX and routing behavior: config mutation/removal - `allowlist.supportsScope`: channel-owned allowlist scope advertisement -These hooks should be preferred over new `channel === "discord"` / `telegram` -branches in shared core flows. +These capabilities should be preferred over new `channel === "discord"` / +`telegram` branches in shared core flows. diff --git a/docs/tools/capability-cookbook.md b/docs/tools/capability-cookbook.md index 5cfc94ef3c0..f439c362e89 100644 --- a/docs/tools/capability-cookbook.md +++ b/docs/tools/capability-cookbook.md @@ -1,7 +1,7 @@ --- summary: "Cookbook for adding a new shared capability to OpenClaw" read_when: - - Adding a new core capability and plugin seam + - Adding a new core capability and plugin registration surface - Deciding whether code belongs in core, a vendor plugin, or a feature plugin - Wiring a new runtime helper for channels or tools title: "Capability Cookbook" diff --git a/docs/tools/index.md b/docs/tools/index.md index deb42b0d76a..f5eb956f13e 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -400,6 +400,31 @@ Notes: - Only available when `agents.defaults.imageModel` is configured (primary or fallbacks), or when an implicit image model can be inferred from your default model + configured auth (best-effort pairing). - Uses the image model directly (independent of the main chat model). +### `image_generate` + +Generate one or more images with the configured or inferred image-generation model. + +Core parameters: + +- `action` (optional: `generate` or `list`; default `generate`) +- `prompt` (required) +- `image` or `images` (optional reference image path/URL for edit mode) +- `model` (optional provider/model override) +- `size` (optional size hint) +- `resolution` (optional `1K|2K|4K` hint) +- `count` (optional, `1-4`, default `1`) + +Notes: + +- 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). +- Google-backed flows support reference-image edits plus explicit `1K|2K|4K` resolution hints. +- When editing and `resolution` is omitted, OpenClaw infers a draft/final resolution from the input image size. +- This is the built-in replacement for the old sample `nano-banana-pro` skill workflow. Use `agents.defaults.imageGenerationModel`, not `skills.entries`, for stock image generation. + ### `pdf` Analyze one or more PDF documents. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index b0eec032bcf..4dc95ae4fe6 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -69,6 +69,97 @@ 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. + +## Public capability model + +Capabilities are the public plugin model. Every native OpenClaw plugin +registers against one or more capability types: + +| Capability | Registration method | Example plugins | +| ------------------- | --------------------------------------------- | ------------------------- | +| Text inference | `api.registerProvider(...)` | `openai`, `anthropic` | +| Speech | `api.registerSpeechProvider(...)` | `elevenlabs`, `microsoft` | +| Media understanding | `api.registerMediaUnderstandingProvider(...)` | `openai`, `google` | +| Image generation | `api.registerImageGenerationProvider(...)` | `openai`, `google` | +| Web search | `api.registerWebSearchProvider(...)` | `google` | +| Channel / messaging | `api.registerChannel(...)` | `msteams`, `matrix` | + +A plugin that registers zero capabilities but provides hooks, tools, or +services is a **legacy hook-only** plugin. That shape is still fully supported. + +### Plugin shapes + +OpenClaw classifies every loaded plugin into a shape based on its actual +registration behavior (not just static metadata): + +- **plain-capability** — registers exactly one capability type (for example a + provider-only plugin like `mistral`) +- **hybrid-capability** — registers multiple capability types (for example + `openai` owns text inference, speech, media understanding, and image + generation) +- **hook-only** — registers only hooks (typed or custom), no capabilities, + tools, commands, or services +- **non-capability** — registers tools, commands, services, or routes but no + capabilities + +Use `openclaw plugins inspect ` to see a plugin's shape and capability +breakdown. See [CLI reference](/cli/plugins#inspect) for details. + +### Capability labels + +Plugin capabilities use two stability labels: + +- `public` — stable, documented, and safe to depend on +- `experimental` — may change between releases + +### Legacy hooks + +The `before_agent_start` hook remains supported as a compatibility path for +hook-only plugins. Legacy real-world plugins still depend on it. + +Direction: + +- keep it working +- document it as legacy +- prefer `before_model_resolve` for model/provider override work +- prefer `before_prompt_build` for prompt mutation work +- remove only after real usage drops and fixture coverage proves migration safety + ## Architecture OpenClaw's plugin system has four layers: @@ -97,6 +188,53 @@ The important design boundary: That split lets OpenClaw validate config, explain missing/disabled plugins, and build UI/schema hints before the full runtime is active. +### Channel plugins and the shared message tool + +Channel plugins do not need to register a separate send/edit/react tool for +normal chat actions. OpenClaw keeps one shared `message` tool in core, and +channel plugins own the channel-specific discovery and execution behind it. + +The current boundary is: + +- core owns the shared `message` tool host, prompt wiring, session/thread + bookkeeping, and execution dispatch +- channel plugins own scoped action discovery, capability discovery, and any + channel-specific schema fragments +- channel plugins execute the final action through their action adapter + +For channel plugins, the preferred SDK surface is +`ChannelMessageActionAdapter.describeMessageTool(...)`. That unified discovery +call lets a plugin return its visible actions, capabilities, and schema +contributions together so those pieces do not drift apart. + +Core passes runtime scope into that discovery step. Important fields include: + +- `accountId` +- `currentChannelId` +- `currentThreadTs` +- `currentMessageId` +- `sessionKey` +- `sessionId` +- `agentId` +- trusted inbound `requesterSenderId` + +That matters for context-sensitive plugins. A channel can hide or expose +message actions based on the active account, current room/thread/message, or +trusted requester identity without hardcoding channel-specific branches in the +core `message` tool. + +This is why embedded-runner routing changes are still plugin work: the runner is +responsible for forwarding the current chat/session identity into the plugin +discovery boundary so the shared `message` tool exposes the right channel-owned +surface for the current turn. + +For channel-owned execution helpers, bundled plugins should keep the execution +runtime inside their own extension modules. Core no longer owns the Discord, +Slack, Telegram, or WhatsApp message-action runtimes under `src/agents/tools`. +`agent-runtime` still re-exports the Discord and Telegram helpers for backward +compatibility, but we do not publish separate `plugin-sdk/*-action-runtime` +subpaths and new plugins should import their own local runtime code directly. + ## Capability ownership model OpenClaw treats a native plugin as the ownership boundary for a **company** or a @@ -384,18 +522,24 @@ Native OpenClaw plugins are **TypeScript modules** loaded at runtime via jiti. **Config validation does not execute plugin code**; it uses the plugin manifest and JSON Schema instead. See [Plugin manifest](/plugins/manifest). -Native OpenClaw plugins can register: +Native OpenClaw plugins can register capabilities and surfaces: -- Gateway RPC methods -- Gateway HTTP routes +**Capabilities** (public plugin model): + +- Text inference providers (model catalogs, auth, runtime hooks) +- Speech providers +- Media understanding providers +- Image generation providers +- Web search providers +- Channel / messaging connectors + +**Surfaces** (supporting infrastructure): + +- Gateway RPC methods and HTTP routes - Agent tools - CLI commands -- Speech providers -- Web search providers - Background services - Context engines -- Provider auth flows and model catalogs -- Provider runtime hooks for dynamic model ids, transport normalization, capability metadata, stream wrapping, cache TTL policy, missing-auth hints, built-in model suppression, catalog augmentation, runtime auth exchange, and usage/billing auth + snapshot resolution - Optional config validation - **Skills** (by listing `skills` directories in the plugin manifest) - **Auto-reply commands** (execute without invoking the AI agent) @@ -460,6 +604,49 @@ Bad plugin contracts are: When in doubt, raise the abstraction level: define the capability first, then let plugins plug into it. +## Export boundary + +OpenClaw exports capabilities, not implementation convenience. + +Keep capability registration public. Trim non-contract helper exports: + +- bundled-plugin-specific helper subpaths +- runtime plumbing subpaths not intended as public API +- vendor-specific convenience helpers +- setup/onboarding helpers that are implementation details + +## Plugin inspection + +Use `openclaw plugins inspect ` for deep plugin introspection. This is the +canonical command for understanding a plugin's shape and registration behavior. + +```bash +openclaw plugins inspect openai +openclaw plugins inspect openai --json +``` + +The inspect report shows: + +- identity, load status, source, and root +- plugin shape (plain-capability, hybrid-capability, hook-only, non-capability) +- capability mode and registered capabilities +- hooks (typed and custom), tools, commands, services +- channel registration +- config policy flags +- diagnostics +- whether the plugin uses the legacy `before_agent_start` hook +- install metadata + +Classification comes from actual registration behavior, not just static +metadata. + +Summary commands remain summary-focused: + +- `plugins list` — compact inventory +- `plugins status` — operational summary +- `doctor` — issue-focused diagnostics +- `plugins inspect` — deep detail + ## Provider runtime hooks Provider plugins now have two layers: @@ -471,7 +658,7 @@ Provider plugins now have two layers: - runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `formatApiKey`, `refreshOAuth`, `buildAuthDoctorHint`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `isBinaryThinking`, `supportsXHighThinking`, `resolveDefaultThinkingLevel`, `isModernModelRef`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` OpenClaw still owns the generic agent loop, failover, transcript handling, and -tool policy. These hooks are the seam for provider-specific behavior without +tool policy. These hooks are the extension surface for provider-specific behavior without needing a whole custom inference transport. Use manifest `providerAuthEnvVars` when the provider has env-based credentials @@ -862,6 +1049,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 +1136,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 entry points 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 entry point 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. @@ -1418,7 +1637,7 @@ Example: ```bash openclaw plugins list -openclaw plugins info +openclaw plugins inspect openclaw plugins install # copy a local file/dir into ~/.openclaw/extensions/ openclaw plugins install ./extensions/voice-call # relative path ok openclaw plugins install ./plugin.tgz # install from a local tarball @@ -1488,7 +1707,7 @@ Recommended sequence: lifecycle, channel-facing semantics, and runtime helper shape. 2. add typed plugin registration/runtime surfaces Extend `OpenClawPluginApi` and/or `api.runtime` with the smallest useful - typed seam. + typed capability surface. 3. wire core + channel/feature consumers Channels and feature plugins should consume the new capability through core, not by importing a vendor implementation directly. @@ -1678,8 +1897,8 @@ Plugins can register **model providers** so users can run OAuth or API-key setup inside OpenClaw, surface provider setup in onboarding/model-pickers, and contribute implicit provider discovery. -Provider plugins are the modular extension seam for model-provider setup. They -are not just "OAuth helpers" anymore. +Provider plugins are the modular extension surface for model-provider setup. +They are not just "OAuth helpers" anymore. ### Provider plugin lifecycle diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index 589d464bb13..697cb46dad6 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -24,7 +24,7 @@ All skills-related configuration lives under `skills` in `~/.openclaw/openclaw.j nodeManager: "npm", // npm | pnpm | yarn | bun (Gateway runtime still Node; bun not recommended) }, entries: { - "nano-banana-pro": { + "image-lab": { enabled: true, apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string env: { @@ -38,6 +38,10 @@ All skills-related configuration lives under `skills` in `~/.openclaw/openclaw.j } ``` +For built-in image generation/editing, prefer `agents.defaults.imageGenerationModel` +plus the core `image_generate` tool. `skills.entries.*` is only for custom or +third-party skill workflows. + ## Fields - `allowBundled`: optional allowlist for **bundled** skills only. When set, only diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 05369677b89..5b91d79af59 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -81,8 +81,8 @@ that up as `/skills` on the next session. ```markdown --- -name: nano-banana-pro -description: Generate or edit images via Gemini 3 Pro Image +name: image-lab +description: Generate or edit images via a provider-backed image workflow --- ``` @@ -109,8 +109,8 @@ OpenClaw **filters skills at load time** using `metadata` (single-line JSON): ```markdown --- -name: nano-banana-pro -description: Generate or edit images via Gemini 3 Pro Image +name: image-lab +description: Generate or edit images via a provider-backed image workflow metadata: { "openclaw": @@ -194,7 +194,7 @@ Bundled/managed skills can be toggled and supplied with env values: { skills: { entries: { - "nano-banana-pro": { + "image-lab": { enabled: true, apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string env: { @@ -214,6 +214,10 @@ Bundled/managed skills can be toggled and supplied with env values: Note: if the skill name contains hyphens, quote the key (JSON5 allows quoted keys). +If you want stock image generation/editing inside OpenClaw itself, use the core +`image_generate` tool with `agents.defaults.imageGenerationModel` instead of a +bundled skill. Skill examples here are for custom or third-party workflows. + Config keys match the **skill name** by default. If a skill defines `metadata.openclaw.skillKey`, use that key under `skills.entries`. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index d35b245d814..9e156bb339a 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -242,7 +242,7 @@ http://localhost:5173/?gatewayUrl=wss://:18789#token= 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/amazon-bedrock/index.test.ts b/extensions/amazon-bedrock/index.test.ts index 61b33a0bc68..4afa67e3501 100644 --- a/extensions/amazon-bedrock/index.test.ts +++ b/extensions/amazon-bedrock/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { registerSingleProviderPlugin } from "../test-utils/plugin-registration.js"; +import { registerSingleProviderPlugin } from "../../test/helpers/extensions/plugin-registration.js"; import amazonBedrockPlugin from "./index.js"; describe("amazon-bedrock provider plugin", () => { diff --git a/extensions/amazon-bedrock/index.ts b/extensions/amazon-bedrock/index.ts index 33fa3a08d32..9158ab158d7 100644 --- a/extensions/amazon-bedrock/index.ts +++ b/extensions/amazon-bedrock/index.ts @@ -1,14 +1,13 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; const PROVIDER_ID = "amazon-bedrock"; const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; -const amazonBedrockPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Amazon Bedrock Provider", description: "Bundled Amazon Bedrock provider policy plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Amazon Bedrock", @@ -18,6 +17,4 @@ const amazonBedrockPlugin = { CLAUDE_46_MODEL_RE.test(modelId.trim()) ? "adaptive" : undefined, }); }, -}; - -export default amazonBedrockPlugin; +}); diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index 4cad353908b..78f5bf3c17a 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -1,8 +1,7 @@ import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; import { parseDurationMs } from "openclaw/plugin-sdk/cli-runtime"; import { - emptyPluginConfigSchema, - type OpenClawPluginApi, + definePluginEntry, type ProviderAuthContext, type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, @@ -312,12 +311,11 @@ async function runAnthropicSetupTokenNonInteractive(ctx: { }); } -const anthropicPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Anthropic Provider", description: "Bundled Anthropic provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Anthropic", @@ -399,6 +397,4 @@ const anthropicPlugin = { }); api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider); }, -}; - -export default anthropicPlugin; +}); 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.runtime.ts b/extensions/bluebubbles/src/actions.runtime.ts index 00d0bc00efd..6b4112547d1 100644 --- a/extensions/bluebubbles/src/actions.runtime.ts +++ b/extensions/bluebubbles/src/actions.runtime.ts @@ -15,87 +15,17 @@ import { sendMessageBlueBubbles as sendMessageBlueBubblesImpl, } from "./send.js"; -type SendBlueBubblesAttachment = typeof import("./attachments.js").sendBlueBubblesAttachment; -type AddBlueBubblesParticipant = typeof import("./chat.js").addBlueBubblesParticipant; -type EditBlueBubblesMessage = typeof import("./chat.js").editBlueBubblesMessage; -type LeaveBlueBubblesChat = typeof import("./chat.js").leaveBlueBubblesChat; -type RemoveBlueBubblesParticipant = typeof import("./chat.js").removeBlueBubblesParticipant; -type RenameBlueBubblesChat = typeof import("./chat.js").renameBlueBubblesChat; -type SetGroupIconBlueBubbles = typeof import("./chat.js").setGroupIconBlueBubbles; -type UnsendBlueBubblesMessage = typeof import("./chat.js").unsendBlueBubblesMessage; -type ResolveBlueBubblesMessageId = typeof import("./monitor.js").resolveBlueBubblesMessageId; -type SendBlueBubblesReaction = typeof import("./reactions.js").sendBlueBubblesReaction; -type ResolveChatGuidForTarget = typeof import("./send.js").resolveChatGuidForTarget; -type SendMessageBlueBubbles = typeof import("./send.js").sendMessageBlueBubbles; - -export function sendBlueBubblesAttachment( - ...args: Parameters -): ReturnType { - return sendBlueBubblesAttachmentImpl(...args); -} - -export function addBlueBubblesParticipant( - ...args: Parameters -): ReturnType { - return addBlueBubblesParticipantImpl(...args); -} - -export function editBlueBubblesMessage( - ...args: Parameters -): ReturnType { - return editBlueBubblesMessageImpl(...args); -} - -export function leaveBlueBubblesChat( - ...args: Parameters -): ReturnType { - return leaveBlueBubblesChatImpl(...args); -} - -export function removeBlueBubblesParticipant( - ...args: Parameters -): ReturnType { - return removeBlueBubblesParticipantImpl(...args); -} - -export function renameBlueBubblesChat( - ...args: Parameters -): ReturnType { - return renameBlueBubblesChatImpl(...args); -} - -export function setGroupIconBlueBubbles( - ...args: Parameters -): ReturnType { - return setGroupIconBlueBubblesImpl(...args); -} - -export function unsendBlueBubblesMessage( - ...args: Parameters -): ReturnType { - return unsendBlueBubblesMessageImpl(...args); -} - -export function resolveBlueBubblesMessageId( - ...args: Parameters -): ReturnType { - return resolveBlueBubblesMessageIdImpl(...args); -} - -export function sendBlueBubblesReaction( - ...args: Parameters -): ReturnType { - return sendBlueBubblesReactionImpl(...args); -} - -export function resolveChatGuidForTarget( - ...args: Parameters -): ReturnType { - return resolveChatGuidForTargetImpl(...args); -} - -export function sendMessageBlueBubbles( - ...args: Parameters -): ReturnType { - return sendMessageBlueBubblesImpl(...args); -} +export const blueBubblesActionsRuntime = { + sendBlueBubblesAttachment: sendBlueBubblesAttachmentImpl, + addBlueBubblesParticipant: addBlueBubblesParticipantImpl, + editBlueBubblesMessage: editBlueBubblesMessageImpl, + leaveBlueBubblesChat: leaveBlueBubblesChatImpl, + removeBlueBubblesParticipant: removeBlueBubblesParticipantImpl, + renameBlueBubblesChat: renameBlueBubblesChatImpl, + setGroupIconBlueBubbles: setGroupIconBlueBubblesImpl, + unsendBlueBubblesMessage: unsendBlueBubblesMessageImpl, + resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdImpl, + sendBlueBubblesReaction: sendBlueBubblesReactionImpl, + resolveChatGuidForTarget: resolveChatGuidForTargetImpl, + sendMessageBlueBubbles: sendMessageBlueBubblesImpl, +}; 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 4e6476afa3f..78cffcd2414 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -11,18 +11,21 @@ import { type ChannelMessageActionAdapter, type ChannelMessageActionName, } from "openclaw/plugin-sdk/bluebubbles"; +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 { + normalizeBlueBubblesHandle, + normalizeBlueBubblesMessagingTarget, + parseBlueBubblesTarget, +} from "./targets.js"; import type { BlueBubblesSendTarget } from "./types.js"; -let actionsRuntimePromise: Promise | null = null; - -function loadBlueBubblesActionsRuntime() { - actionsRuntimePromise ??= import("./actions.runtime.js"); - return actionsRuntimePromise; -} +const loadBlueBubblesActionsRuntime = createLazyRuntimeNamedExport( + () => import("./actions.runtime.js"), + "blueBubblesActionsRuntime", +); const providerId = "bluebubbles"; @@ -64,7 +67,7 @@ const PRIVATE_API_ACTIONS = new Set([ ]); export const bluebubblesMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { + listActions: ({ cfg, currentChannelId }) => { const account = resolveBlueBubblesAccount({ cfg: cfg }); if (!account.enabled || !account.configured) { return []; @@ -88,6 +91,22 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { actions.add(action); } } + const normalizedTarget = currentChannelId + ? normalizeBlueBubblesMessagingTarget(currentChannelId) + : undefined; + const lowered = normalizedTarget?.trim().toLowerCase() ?? ""; + const isGroupTarget = + lowered.startsWith("chat_guid:") || + lowered.startsWith("chat_id:") || + lowered.startsWith("chat_identifier:") || + lowered.startsWith("group:"); + if (!isGroupTarget) { + for (const action of BLUEBUBBLES_ACTION_NAMES) { + if ("groupOnly" in BLUEBUBBLES_ACTIONS[action] && BLUEBUBBLES_ACTIONS[action].groupOnly) { + actions.delete(action); + } + } + } return Array.from(actions); }, supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), diff --git a/extensions/bluebubbles/src/channel.runtime.ts b/extensions/bluebubbles/src/channel.runtime.ts index d318943d3f2..b8b4066c4cd 100644 --- a/extensions/bluebubbles/src/channel.runtime.ts +++ b/extensions/bluebubbles/src/channel.runtime.ts @@ -6,52 +6,14 @@ import { } from "./monitor.js"; import { probeBlueBubbles as probeBlueBubblesImpl } from "./probe.js"; import { sendMessageBlueBubbles as sendMessageBlueBubblesImpl } from "./send.js"; -import { blueBubblesSetupWizard as blueBubblesSetupWizardImpl } from "./setup-surface.js"; export type { BlueBubblesProbe } from "./probe.js"; -type SendBlueBubblesMedia = typeof import("./media-send.js").sendBlueBubblesMedia; -type ResolveBlueBubblesMessageId = typeof import("./monitor.js").resolveBlueBubblesMessageId; -type MonitorBlueBubblesProvider = typeof import("./monitor.js").monitorBlueBubblesProvider; -type ResolveWebhookPathFromConfig = typeof import("./monitor.js").resolveWebhookPathFromConfig; -type ProbeBlueBubbles = typeof import("./probe.js").probeBlueBubbles; -type SendMessageBlueBubbles = typeof import("./send.js").sendMessageBlueBubbles; -type BlueBubblesSetupWizard = typeof import("./setup-surface.js").blueBubblesSetupWizard; - -export function sendBlueBubblesMedia( - ...args: Parameters -): ReturnType { - return sendBlueBubblesMediaImpl(...args); -} - -export function resolveBlueBubblesMessageId( - ...args: Parameters -): ReturnType { - return resolveBlueBubblesMessageIdImpl(...args); -} - -export function monitorBlueBubblesProvider( - ...args: Parameters -): ReturnType { - return monitorBlueBubblesProviderImpl(...args); -} - -export function resolveWebhookPathFromConfig( - ...args: Parameters -): ReturnType { - return resolveWebhookPathFromConfigImpl(...args); -} - -export function probeBlueBubbles( - ...args: Parameters -): ReturnType { - return probeBlueBubblesImpl(...args); -} - -export function sendMessageBlueBubbles( - ...args: Parameters -): ReturnType { - return sendMessageBlueBubblesImpl(...args); -} - -export const blueBubblesSetupWizard: BlueBubblesSetupWizard = { ...blueBubblesSetupWizardImpl }; +export const blueBubblesChannelRuntime = { + sendBlueBubblesMedia: sendBlueBubblesMediaImpl, + resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdImpl, + monitorBlueBubblesProvider: monitorBlueBubblesProviderImpl, + resolveWebhookPathFromConfig: resolveWebhookPathFromConfigImpl, + probeBlueBubbles: probeBlueBubblesImpl, + sendMessageBlueBubbles: sendMessageBlueBubblesImpl, +}; diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 9550c1166ed..9d9e49e74ab 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -18,6 +18,7 @@ import { buildAccountScopedDmSecurityPolicy, collectOpenGroupPolicyRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { listBlueBubblesAccountIds, type ResolvedBlueBubblesAccount, @@ -37,12 +38,10 @@ import { parseBlueBubblesTarget, } from "./targets.js"; -let blueBubblesChannelRuntimePromise: Promise | null = null; - -function loadBlueBubblesChannelRuntime() { - blueBubblesChannelRuntimePromise ??= import("./channel.runtime.js"); - return blueBubblesChannelRuntimePromise; -} +const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport( + () => import("./channel.runtime.js"), + "blueBubblesChannelRuntime", +); const meta = { id: "bluebubbles", diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 1ba2e27f0b6..17467465d82 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import { fetchBlueBubblesHistory } from "./history.js"; import { resetBlueBubblesSelfChatCache } from "./monitor-self-chat-cache.js"; diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts index f6826ac510b..8d98b0c45eb 100644 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import { fetchBlueBubblesHistory } from "./history.js"; import { diff --git a/extensions/brave/index.ts b/extensions/brave/index.ts index f23c5d4d485..1692f2db03f 100644 --- a/extensions/brave/index.ts +++ b/extensions/brave/index.ts @@ -1,16 +1,15 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createPluginBackedWebSearchProvider, getTopLevelCredentialValue, setTopLevelCredentialValue, } from "openclaw/plugin-sdk/provider-web-search"; -const bravePlugin = { +export default definePluginEntry({ id: "brave", name: "Brave Plugin", description: "Bundled Brave plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ id: "brave", @@ -26,6 +25,4 @@ const bravePlugin = { }), ); }, -}; - -export default bravePlugin; +}); diff --git a/extensions/byteplus/index.ts b/extensions/byteplus/index.ts index 215ac1a1705..a89cc87f531 100644 --- a/extensions/byteplus/index.ts +++ b/extensions/byteplus/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard"; import { buildBytePlusCodingProvider, buildBytePlusProvider } from "./provider-catalog.js"; @@ -6,12 +6,11 @@ import { buildBytePlusCodingProvider, buildBytePlusProvider } from "./provider-c const PROVIDER_ID = "byteplus"; const BYTEPLUS_DEFAULT_MODEL_REF = "byteplus-plan/ark-code-latest"; -const byteplusPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "BytePlus Provider", description: "Bundled BytePlus provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "BytePlus", @@ -60,6 +59,4 @@ const byteplusPlugin = { }, }); }, -}; - -export default byteplusPlugin; +}); 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/cloudflare-ai-gateway/index.ts b/extensions/cloudflare-ai-gateway/index.ts index 6c3cda9d0d2..a0307d9d524 100644 --- a/extensions/cloudflare-ai-gateway/index.ts +++ b/extensions/cloudflare-ai-gateway/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { applyAuthProfileConfig, buildApiKeyCredential, @@ -84,12 +84,11 @@ async function resolveCloudflareGatewayMetadataInteractive(ctx: { return { accountId, gatewayId }; } -const cloudflareAiGatewayPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Cloudflare AI Gateway Provider", description: "Bundled Cloudflare AI Gateway provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Cloudflare AI Gateway", @@ -252,6 +251,4 @@ const cloudflareAiGatewayPlugin = { }, }); }, -}; - -export default cloudflareAiGatewayPlugin; +}); diff --git a/extensions/copilot-proxy/index.ts b/extensions/copilot-proxy/index.ts index 2c517d9c26c..cf71710db5c 100644 --- a/extensions/copilot-proxy/index.ts +++ b/extensions/copilot-proxy/index.ts @@ -1,6 +1,5 @@ import { - emptyPluginConfigSchema, - type OpenClawPluginApi, + definePluginEntry, type ProviderAuthContext, type ProviderAuthResult, } from "openclaw/plugin-sdk/copilot-proxy"; @@ -71,12 +70,11 @@ function buildModelDefinition(modelId: string) { }; } -const copilotProxyPlugin = { +export default definePluginEntry({ id: "copilot-proxy", name: "Copilot Proxy", description: "Local Copilot Proxy (VS Code LM) provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: "copilot-proxy", label: "Copilot Proxy", @@ -157,6 +155,4 @@ const copilotProxyPlugin = { }, }); }, -}; - -export default copilotProxyPlugin; +}); 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 7ba88842a7a..defd3b5c4c6 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -1,14 +1,15 @@ import os from "node:os"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/device-pair"; +import qrcode from "qrcode-terminal"; import { approveDevicePairing, + definePluginEntry, issueDeviceBootstrapToken, listDevicePairing, resolveGatewayBindUrl, runPluginCommandWithTimeout, resolveTailnetHostWithRunner, -} from "openclaw/plugin-sdk/device-pair"; -import qrcode from "qrcode-terminal"; + type OpenClawPluginApi, +} from "./api.js"; import { armPairNotifyOnce, formatPendingRequests, @@ -325,226 +326,233 @@ function formatSetupInstructions(): string { ].join("\n"); } -export default function register(api: OpenClawPluginApi) { - registerPairingNotifierService(api); +export default definePluginEntry({ + id: "device-pair", + name: "Device Pair", + description: "QR/bootstrap pairing helpers for OpenClaw devices", + register(api: OpenClawPluginApi) { + registerPairingNotifierService(api); - api.registerCommand({ - name: "pair", - description: "Generate setup codes and approve device pairing requests.", - acceptsArgs: true, - handler: async (ctx) => { - const args = ctx.args?.trim() ?? ""; - const tokens = args.split(/\s+/).filter(Boolean); - const action = tokens[0]?.toLowerCase() ?? ""; - api.logger.info?.( - `device-pair: /pair invoked channel=${ctx.channel} sender=${ctx.senderId ?? "unknown"} action=${ - action || "new" - }`, - ); + api.registerCommand({ + name: "pair", + description: "Generate setup codes and approve device pairing requests.", + acceptsArgs: true, + handler: async (ctx) => { + const args = ctx.args?.trim() ?? ""; + const tokens = args.split(/\s+/).filter(Boolean); + const action = tokens[0]?.toLowerCase() ?? ""; + api.logger.info?.( + `device-pair: /pair invoked channel=${ctx.channel} sender=${ctx.senderId ?? "unknown"} action=${ + action || "new" + }`, + ); - if (action === "status" || action === "pending") { - const list = await listDevicePairing(); - return { text: formatPendingRequests(list.pending) }; - } - - if (action === "notify") { - const notifyAction = tokens[1]?.trim().toLowerCase() ?? "status"; - return await handleNotifyCommand({ - api, - ctx, - action: notifyAction, - }); - } - - if (action === "approve") { - const requested = tokens[1]?.trim(); - const list = await listDevicePairing(); - if (list.pending.length === 0) { - return { text: "No pending device pairing requests." }; + if (action === "status" || action === "pending") { + const list = await listDevicePairing(); + return { text: formatPendingRequests(list.pending) }; } - let pending: (typeof list.pending)[number] | undefined; - if (requested) { - if (requested.toLowerCase() === "latest") { - pending = [...list.pending].toSorted((a, b) => (b.ts ?? 0) - (a.ts ?? 0))[0]; - } else { - pending = list.pending.find((entry) => entry.requestId === requested); + if (action === "notify") { + const notifyAction = tokens[1]?.trim().toLowerCase() ?? "status"; + return await handleNotifyCommand({ + api, + ctx, + action: notifyAction, + }); + } + + if (action === "approve") { + const requested = tokens[1]?.trim(); + const list = await listDevicePairing(); + if (list.pending.length === 0) { + return { text: "No pending device pairing requests." }; } - } else if (list.pending.length === 1) { - pending = list.pending[0]; - } else { + + let pending: (typeof list.pending)[number] | undefined; + if (requested) { + if (requested.toLowerCase() === "latest") { + pending = [...list.pending].toSorted((a, b) => (b.ts ?? 0) - (a.ts ?? 0))[0]; + } else { + pending = list.pending.find((entry) => entry.requestId === requested); + } + } else if (list.pending.length === 1) { + pending = list.pending[0]; + } else { + return { + text: + `${formatPendingRequests(list.pending)}\n\n` + + "Multiple pending requests found. Approve one explicitly:\n" + + "/pair approve \n" + + "Or approve the most recent:\n" + + "/pair approve latest", + }; + } + if (!pending) { + return { text: "Pairing request not found." }; + } + const approved = await approveDevicePairing(pending.requestId); + if (!approved) { + return { text: "Pairing request not found." }; + } + const label = approved.device.displayName?.trim() || approved.device.deviceId; + const platform = approved.device.platform?.trim(); + const platformLabel = platform ? ` (${platform})` : ""; + return { text: `āœ… Paired ${label}${platformLabel}.` }; + } + + const authLabelResult = resolveAuthLabel(api.config); + if (authLabelResult.error) { + return { text: `Error: ${authLabelResult.error}` }; + } + + const urlResult = await resolveGatewayUrl(api); + if (!urlResult.url) { + return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` }; + } + + const payload: SetupPayload = { + url: urlResult.url, + bootstrapToken: (await issueDeviceBootstrapToken()).token, + }; + + if (action === "qr") { + const setupCode = encodeSetupCode(payload); + const qrAscii = await renderQrAscii(setupCode); + const authLabel = authLabelResult.label ?? "auth"; + + const channel = ctx.channel; + const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || ""; + let autoNotifyArmed = false; + + if (channel === "telegram" && target) { + try { + autoNotifyArmed = await armPairNotifyOnce({ api, ctx }); + } catch (err) { + api.logger.warn?.( + `device-pair: failed to arm one-shot pairing notify (${String( + (err as Error)?.message ?? err, + )})`, + ); + } + } + + if (channel === "telegram" && target) { + try { + const send = api.runtime?.channel?.telegram?.sendMessageTelegram; + if (send) { + await send( + target, + ["Scan this QR code with the OpenClaw iOS app:", "", "```", qrAscii, "```"].join( + "\n", + ), + { + ...(ctx.messageThreadId != null + ? { messageThreadId: ctx.messageThreadId } + : {}), + ...(ctx.accountId ? { accountId: ctx.accountId } : {}), + }, + ); + return { + text: [ + `Gateway: ${payload.url}`, + `Auth: ${authLabel}`, + "", + autoNotifyArmed + ? "After scanning, wait here for the pairing request ping." + : "After scanning, come back here and run `/pair approve` to complete pairing.", + ...(autoNotifyArmed + ? [ + "I’ll auto-ping here when the pairing request arrives, then auto-disable.", + "If the ping does not arrive, run `/pair approve latest` manually.", + ] + : []), + ].join("\n"), + }; + } + } catch (err) { + api.logger.warn?.( + `device-pair: telegram QR send failed, falling back (${String( + (err as Error)?.message ?? err, + )})`, + ); + } + } + + // Render based on channel capability + api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`); + const infoLines = [ + `Gateway: ${payload.url}`, + `Auth: ${authLabel}`, + "", + autoNotifyArmed + ? "After scanning, wait here for the pairing request ping." + : "After scanning, run `/pair approve` to complete pairing.", + ...(autoNotifyArmed + ? [ + "I’ll auto-ping here when the pairing request arrives, then auto-disable.", + "If the ping does not arrive, run `/pair approve latest` manually.", + ] + : []), + ]; + + // WebUI + CLI/TUI: ASCII QR return { - text: - `${formatPendingRequests(list.pending)}\n\n` + - "Multiple pending requests found. Approve one explicitly:\n" + - "/pair approve \n" + - "Or approve the most recent:\n" + - "/pair approve latest", + text: [ + "Scan this QR code with the OpenClaw iOS app:", + "", + "```", + qrAscii, + "```", + "", + ...infoLines, + ].join("\n"), }; } - if (!pending) { - return { text: "Pairing request not found." }; - } - const approved = await approveDevicePairing(pending.requestId); - if (!approved) { - return { text: "Pairing request not found." }; - } - const label = approved.device.displayName?.trim() || approved.device.deviceId; - const platform = approved.device.platform?.trim(); - const platformLabel = platform ? ` (${platform})` : ""; - return { text: `āœ… Paired ${label}${platformLabel}.` }; - } - - const authLabelResult = resolveAuthLabel(api.config); - if (authLabelResult.error) { - return { text: `Error: ${authLabelResult.error}` }; - } - - const urlResult = await resolveGatewayUrl(api); - if (!urlResult.url) { - return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` }; - } - - const payload: SetupPayload = { - url: urlResult.url, - bootstrapToken: (await issueDeviceBootstrapToken()).token, - }; - - if (action === "qr") { - const setupCode = encodeSetupCode(payload); - const qrAscii = await renderQrAscii(setupCode); - const authLabel = authLabelResult.label ?? "auth"; const channel = ctx.channel; const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || ""; - let autoNotifyArmed = false; + const authLabel = authLabelResult.label ?? "auth"; if (channel === "telegram" && target) { try { - autoNotifyArmed = await armPairNotifyOnce({ api, ctx }); - } catch (err) { - api.logger.warn?.( - `device-pair: failed to arm one-shot pairing notify (${String( - (err as Error)?.message ?? err, - )})`, + const runtimeKeys = Object.keys(api.runtime ?? {}); + const channelKeys = Object.keys(api.runtime?.channel ?? {}); + api.logger.debug?.( + `device-pair: runtime keys=${runtimeKeys.join(",") || "none"} channel keys=${ + channelKeys.join(",") || "none" + }`, ); - } - } - - if (channel === "telegram" && target) { - try { const send = api.runtime?.channel?.telegram?.sendMessageTelegram; - if (send) { - await send( - target, - ["Scan this QR code with the OpenClaw iOS app:", "", "```", qrAscii, "```"].join( - "\n", - ), - { - ...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}), - ...(ctx.accountId ? { accountId: ctx.accountId } : {}), - }, + if (!send) { + throw new Error( + `telegram runtime unavailable (runtime keys: ${runtimeKeys.join(",")}; channel keys: ${channelKeys.join( + ",", + )})`, ); - return { - text: [ - `Gateway: ${payload.url}`, - `Auth: ${authLabel}`, - "", - autoNotifyArmed - ? "After scanning, wait here for the pairing request ping." - : "After scanning, come back here and run `/pair approve` to complete pairing.", - ...(autoNotifyArmed - ? [ - "I’ll auto-ping here when the pairing request arrives, then auto-disable.", - "If the ping does not arrive, run `/pair approve latest` manually.", - ] - : []), - ].join("\n"), - }; } + await send(target, formatSetupInstructions(), { + ...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}), + ...(ctx.accountId ? { accountId: ctx.accountId } : {}), + }); + api.logger.info?.( + `device-pair: telegram split send ok target=${target} account=${ctx.accountId ?? "none"} thread=${ + ctx.messageThreadId ?? "none" + }`, + ); + return { text: encodeSetupCode(payload) }; } catch (err) { api.logger.warn?.( - `device-pair: telegram QR send failed, falling back (${String( + `device-pair: telegram split send failed, falling back to single message (${String( (err as Error)?.message ?? err, )})`, ); } } - // Render based on channel capability - api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`); - const infoLines = [ - `Gateway: ${payload.url}`, - `Auth: ${authLabel}`, - "", - autoNotifyArmed - ? "After scanning, wait here for the pairing request ping." - : "After scanning, run `/pair approve` to complete pairing.", - ...(autoNotifyArmed - ? [ - "I’ll auto-ping here when the pairing request arrives, then auto-disable.", - "If the ping does not arrive, run `/pair approve latest` manually.", - ] - : []), - ]; - - // WebUI + CLI/TUI: ASCII QR return { - text: [ - "Scan this QR code with the OpenClaw iOS app:", - "", - "```", - qrAscii, - "```", - "", - ...infoLines, - ].join("\n"), + text: formatSetupReply(payload, authLabel), }; - } - - const channel = ctx.channel; - const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || ""; - const authLabel = authLabelResult.label ?? "auth"; - - if (channel === "telegram" && target) { - try { - const runtimeKeys = Object.keys(api.runtime ?? {}); - const channelKeys = Object.keys(api.runtime?.channel ?? {}); - api.logger.debug?.( - `device-pair: runtime keys=${runtimeKeys.join(",") || "none"} channel keys=${ - channelKeys.join(",") || "none" - }`, - ); - const send = api.runtime?.channel?.telegram?.sendMessageTelegram; - if (!send) { - throw new Error( - `telegram runtime unavailable (runtime keys: ${runtimeKeys.join(",")}; channel keys: ${channelKeys.join( - ",", - )})`, - ); - } - await send(target, formatSetupInstructions(), { - ...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}), - ...(ctx.accountId ? { accountId: ctx.accountId } : {}), - }); - api.logger.info?.( - `device-pair: telegram split send ok target=${target} account=${ctx.accountId ?? "none"} thread=${ - ctx.messageThreadId ?? "none" - }`, - ); - return { text: encodeSetupCode(payload) }; - } catch (err) { - api.logger.warn?.( - `device-pair: telegram split send failed, falling back to single message (${String( - (err as Error)?.message ?? err, - )})`, - ); - } - } - - return { - text: formatSetupReply(payload, authLabel), - }; - }, - }); -} + }, + }); + }, +}); 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/diagnostics-otel/api.ts b/extensions/diagnostics-otel/api.ts new file mode 100644 index 00000000000..01d7aed8989 --- /dev/null +++ b/extensions/diagnostics-otel/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/diagnostics-otel"; diff --git a/extensions/diagnostics-otel/index.ts b/extensions/diagnostics-otel/index.ts index a6ab6c133b6..15b6aee404e 100644 --- a/extensions/diagnostics-otel/index.ts +++ b/extensions/diagnostics-otel/index.ts @@ -1,15 +1,11 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diagnostics-otel"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/diagnostics-otel"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createDiagnosticsOtelService } from "./src/service.js"; -const plugin = { +export default definePluginEntry({ id: "diagnostics-otel", name: "Diagnostics OpenTelemetry", description: "Export diagnostics events to OpenTelemetry", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerService(createDiagnosticsOtelService()); }, -}; - -export default plugin; +}); diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index d310b227be3..c8d08d07f1b 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -98,18 +98,16 @@ vi.mock("@opentelemetry/semantic-conventions", () => ({ ATTR_SERVICE_NAME: "service.name", })); -vi.mock("openclaw/plugin-sdk/diagnostics-otel", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/diagnostics-otel", - ); +vi.mock("../api.js", async () => { + const actual = await vi.importActual("../api.js"); return { ...actual, registerLogTransport: registerLogTransportMock, }; }); -import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk/diagnostics-otel"; -import { emitDiagnosticEvent } from "openclaw/plugin-sdk/diagnostics-otel"; +import type { OpenClawPluginServiceContext } from "../api.js"; +import { emitDiagnosticEvent } from "../api.js"; import { createDiagnosticsOtelService } from "./service.js"; const OTEL_TEST_STATE_DIR = "/tmp/openclaw-diagnostics-otel-test"; diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index b7224d034dd..2516b4c52e3 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -9,15 +9,8 @@ import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; import { NodeSDK } from "@opentelemetry/sdk-node"; import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; -import type { - DiagnosticEventPayload, - OpenClawPluginService, -} from "openclaw/plugin-sdk/diagnostics-otel"; -import { - onDiagnosticEvent, - redactSensitiveText, - registerLogTransport, -} from "openclaw/plugin-sdk/diagnostics-otel"; +import type { DiagnosticEventPayload, OpenClawPluginService } from "../api.js"; +import { onDiagnosticEvent, redactSensitiveText, registerLogTransport } from "../api.js"; const DEFAULT_SERVICE_NAME = "openclaw"; 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 b1ade0c6a09..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-utils/mock-http-response.js"; -import { createTestPluginApi } from "../test-utils/plugin-api.js"; +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.test.ts b/extensions/diffs/src/http.test.ts index eed9abd77d8..e35d847597b 100644 --- a/extensions/diffs/src/http.test.ts +++ b/extensions/diffs/src/http.test.ts @@ -1,6 +1,6 @@ import type { IncomingMessage } from "node:http"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createMockServerResponse } from "../../test-utils/mock-http-response.js"; +import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js"; import { createDiffsHttpHandler } from "./http.js"; import { DiffArtifactStore } from "./store.js"; import { createDiffStoreHarness } from "./test-helpers.js"; 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 2f845727274..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-utils/plugin-api.js"; +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..938b03d9c4a --- /dev/null +++ b/extensions/discord/runtime-api.ts @@ -0,0 +1,17 @@ +export * from "./src/audit.js"; +export * from "./src/actions/runtime.js"; +export * from "./src/actions/runtime.moderation-shared.js"; +export * from "./src/actions/runtime.shared.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/actions/handle-action.guild-admin.ts b/extensions/discord/src/actions/handle-action.guild-admin.ts index 0f6075384a5..e63d00f23ec 100644 --- a/extensions/discord/src/actions/handle-action.guild-admin.ts +++ b/extensions/discord/src/actions/handle-action.guild-admin.ts @@ -5,12 +5,12 @@ import { readStringArrayParam, readStringParam, } from "openclaw/plugin-sdk/agent-runtime"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; +import { handleDiscordAction } from "./runtime.js"; import { isDiscordModerationAction, readDiscordModerationCommand, -} from "openclaw/plugin-sdk/agent-runtime"; -import { handleDiscordAction } from "openclaw/plugin-sdk/agent-runtime"; -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; +} from "./runtime.moderation-shared.js"; type Ctx = Pick< ChannelMessageActionContext, diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index d23b078292a..0fca934e86f 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -4,8 +4,6 @@ import { readStringArrayParam, readStringParam, } from "openclaw/plugin-sdk/agent-runtime"; -import { readDiscordParentIdParam } from "openclaw/plugin-sdk/agent-runtime"; -import { handleDiscordAction } from "openclaw/plugin-sdk/agent-runtime"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; @@ -13,6 +11,8 @@ import { normalizeInteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; import { buildDiscordInteractiveComponents } from "../shared-interactive.js"; import { resolveDiscordChannelId } from "../targets.js"; import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; +import { handleDiscordAction } from "./runtime.js"; +import { readDiscordParentIdParam } from "./runtime.shared.js"; const providerId = "discord"; diff --git a/src/agents/tools/discord-actions-guild.ts b/extensions/discord/src/actions/runtime.guild.ts similarity index 78% rename from src/agents/tools/discord-actions-guild.ts rename to extensions/discord/src/actions/runtime.guild.ts index fa427d87650..5b3ed54dc83 100644 --- a/src/agents/tools/discord-actions-guild.ts +++ b/extensions/discord/src/actions/runtime.guild.ts @@ -1,5 +1,14 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; +import { + type ActionGate, + jsonResult, + parseAvailableTags, + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { getPresence } from "../monitor/presence-cache.js"; import { addRoleDiscord, createChannelDiscord, @@ -19,17 +28,29 @@ import { setChannelPermissionDiscord, uploadEmojiDiscord, uploadStickerDiscord, -} from "../../plugin-sdk/discord.js"; -import { getPresence } from "../../plugin-sdk/discord.js"; -import { - type ActionGate, - jsonResult, - parseAvailableTags, - readNumberParam, - readStringArrayParam, - readStringParam, -} from "./common.js"; -import { readDiscordParentIdParam } from "./discord-actions-shared.js"; +} from "../send.js"; +import { readDiscordParentIdParam } from "./runtime.shared.js"; + +export const discordGuildActionRuntime = { + addRoleDiscord, + createChannelDiscord, + createScheduledEventDiscord, + deleteChannelDiscord, + editChannelDiscord, + fetchChannelInfoDiscord, + fetchMemberInfoDiscord, + fetchRoleInfoDiscord, + fetchVoiceStatusDiscord, + listGuildChannelsDiscord, + listGuildEmojisDiscord, + listScheduledEventsDiscord, + moveChannelDiscord, + removeChannelPermissionDiscord, + removeRoleDiscord, + setChannelPermissionDiscord, + uploadEmojiDiscord, + uploadStickerDiscord, +}; type DiscordRoleMutation = (params: { guildId: string; @@ -85,8 +106,8 @@ export async function handleDiscordGuildAction( required: true, }); const member = accountId - ? await fetchMemberInfoDiscord(guildId, userId, { accountId }) - : await fetchMemberInfoDiscord(guildId, userId); + ? await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, { accountId }) + : await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId); const presence = getPresence(accountId, userId); const activities = presence?.activities ?? undefined; const status = presence?.status ?? undefined; @@ -100,8 +121,8 @@ export async function handleDiscordGuildAction( required: true, }); const roles = accountId - ? await fetchRoleInfoDiscord(guildId, { accountId }) - : await fetchRoleInfoDiscord(guildId); + ? await discordGuildActionRuntime.fetchRoleInfoDiscord(guildId, { accountId }) + : await discordGuildActionRuntime.fetchRoleInfoDiscord(guildId); return jsonResult({ ok: true, roles }); } case "emojiList": { @@ -112,8 +133,8 @@ export async function handleDiscordGuildAction( required: true, }); const emojis = accountId - ? await listGuildEmojisDiscord(guildId, { accountId }) - : await listGuildEmojisDiscord(guildId); + ? await discordGuildActionRuntime.listGuildEmojisDiscord(guildId, { accountId }) + : await discordGuildActionRuntime.listGuildEmojisDiscord(guildId); return jsonResult({ ok: true, emojis }); } case "emojiUpload": { @@ -129,7 +150,7 @@ export async function handleDiscordGuildAction( }); const roleIds = readStringArrayParam(params, "roleIds"); const emoji = accountId - ? await uploadEmojiDiscord( + ? await discordGuildActionRuntime.uploadEmojiDiscord( { guildId, name, @@ -138,7 +159,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await uploadEmojiDiscord({ + : await discordGuildActionRuntime.uploadEmojiDiscord({ guildId, name, mediaUrl, @@ -162,7 +183,7 @@ export async function handleDiscordGuildAction( required: true, }); const sticker = accountId - ? await uploadStickerDiscord( + ? await discordGuildActionRuntime.uploadStickerDiscord( { guildId, name, @@ -172,7 +193,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await uploadStickerDiscord({ + : await discordGuildActionRuntime.uploadStickerDiscord({ guildId, name, description, @@ -185,14 +206,22 @@ export async function handleDiscordGuildAction( if (!isActionEnabled("roles", false)) { throw new Error("Discord role changes are disabled."); } - await runRoleMutation({ accountId, values: params, mutate: addRoleDiscord }); + await runRoleMutation({ + accountId, + values: params, + mutate: discordGuildActionRuntime.addRoleDiscord, + }); return jsonResult({ ok: true }); } case "roleRemove": { if (!isActionEnabled("roles", false)) { throw new Error("Discord role changes are disabled."); } - await runRoleMutation({ accountId, values: params, mutate: removeRoleDiscord }); + await runRoleMutation({ + accountId, + values: params, + mutate: discordGuildActionRuntime.removeRoleDiscord, + }); return jsonResult({ ok: true }); } case "channelInfo": { @@ -203,8 +232,8 @@ export async function handleDiscordGuildAction( required: true, }); const channel = accountId - ? await fetchChannelInfoDiscord(channelId, { accountId }) - : await fetchChannelInfoDiscord(channelId); + ? await discordGuildActionRuntime.fetchChannelInfoDiscord(channelId, { accountId }) + : await discordGuildActionRuntime.fetchChannelInfoDiscord(channelId); return jsonResult({ ok: true, channel }); } case "channelList": { @@ -215,8 +244,8 @@ export async function handleDiscordGuildAction( required: true, }); const channels = accountId - ? await listGuildChannelsDiscord(guildId, { accountId }) - : await listGuildChannelsDiscord(guildId); + ? await discordGuildActionRuntime.listGuildChannelsDiscord(guildId, { accountId }) + : await discordGuildActionRuntime.listGuildChannelsDiscord(guildId); return jsonResult({ ok: true, channels }); } case "voiceStatus": { @@ -230,8 +259,10 @@ export async function handleDiscordGuildAction( required: true, }); const voice = accountId - ? await fetchVoiceStatusDiscord(guildId, userId, { accountId }) - : await fetchVoiceStatusDiscord(guildId, userId); + ? await discordGuildActionRuntime.fetchVoiceStatusDiscord(guildId, userId, { + accountId, + }) + : await discordGuildActionRuntime.fetchVoiceStatusDiscord(guildId, userId); return jsonResult({ ok: true, voice }); } case "eventList": { @@ -242,8 +273,8 @@ export async function handleDiscordGuildAction( required: true, }); const events = accountId - ? await listScheduledEventsDiscord(guildId, { accountId }) - : await listScheduledEventsDiscord(guildId); + ? await discordGuildActionRuntime.listScheduledEventsDiscord(guildId, { accountId }) + : await discordGuildActionRuntime.listScheduledEventsDiscord(guildId); return jsonResult({ ok: true, events }); } case "eventCreate": { @@ -274,8 +305,10 @@ export async function handleDiscordGuildAction( privacy_level: 2, }; const event = accountId - ? await createScheduledEventDiscord(guildId, payload, { accountId }) - : await createScheduledEventDiscord(guildId, payload); + ? await discordGuildActionRuntime.createScheduledEventDiscord(guildId, payload, { + accountId, + }) + : await discordGuildActionRuntime.createScheduledEventDiscord(guildId, payload); return jsonResult({ ok: true, event }); } case "channelCreate": { @@ -290,7 +323,7 @@ export async function handleDiscordGuildAction( const position = readNumberParam(params, "position", { integer: true }); const nsfw = params.nsfw as boolean | undefined; const channel = accountId - ? await createChannelDiscord( + ? await discordGuildActionRuntime.createChannelDiscord( { guildId, name, @@ -302,7 +335,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await createChannelDiscord({ + : await discordGuildActionRuntime.createChannelDiscord({ guildId, name, type: type ?? undefined, @@ -348,8 +381,8 @@ export async function handleDiscordGuildAction( availableTags, }; const channel = accountId - ? await editChannelDiscord(editPayload, { accountId }) - : await editChannelDiscord(editPayload); + ? await discordGuildActionRuntime.editChannelDiscord(editPayload, { accountId }) + : await discordGuildActionRuntime.editChannelDiscord(editPayload); return jsonResult({ ok: true, channel }); } case "channelDelete": { @@ -360,8 +393,8 @@ export async function handleDiscordGuildAction( required: true, }); const result = accountId - ? await deleteChannelDiscord(channelId, { accountId }) - : await deleteChannelDiscord(channelId); + ? await discordGuildActionRuntime.deleteChannelDiscord(channelId, { accountId }) + : await discordGuildActionRuntime.deleteChannelDiscord(channelId); return jsonResult(result); } case "channelMove": { @@ -375,7 +408,7 @@ export async function handleDiscordGuildAction( const parentId = readDiscordParentIdParam(params); const position = readNumberParam(params, "position", { integer: true }); if (accountId) { - await moveChannelDiscord( + await discordGuildActionRuntime.moveChannelDiscord( { guildId, channelId, @@ -385,7 +418,7 @@ export async function handleDiscordGuildAction( { accountId }, ); } else { - await moveChannelDiscord({ + await discordGuildActionRuntime.moveChannelDiscord({ guildId, channelId, parentId, @@ -402,7 +435,7 @@ export async function handleDiscordGuildAction( const name = readStringParam(params, "name", { required: true }); const position = readNumberParam(params, "position", { integer: true }); const channel = accountId - ? await createChannelDiscord( + ? await discordGuildActionRuntime.createChannelDiscord( { guildId, name, @@ -411,7 +444,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await createChannelDiscord({ + : await discordGuildActionRuntime.createChannelDiscord({ guildId, name, type: 4, @@ -429,7 +462,7 @@ export async function handleDiscordGuildAction( const name = readStringParam(params, "name"); const position = readNumberParam(params, "position", { integer: true }); const channel = accountId - ? await editChannelDiscord( + ? await discordGuildActionRuntime.editChannelDiscord( { channelId: categoryId, name: name ?? undefined, @@ -437,7 +470,7 @@ export async function handleDiscordGuildAction( }, { accountId }, ) - : await editChannelDiscord({ + : await discordGuildActionRuntime.editChannelDiscord({ channelId: categoryId, name: name ?? undefined, position: position ?? undefined, @@ -452,8 +485,8 @@ export async function handleDiscordGuildAction( required: true, }); const result = accountId - ? await deleteChannelDiscord(categoryId, { accountId }) - : await deleteChannelDiscord(categoryId); + ? await discordGuildActionRuntime.deleteChannelDiscord(categoryId, { accountId }) + : await discordGuildActionRuntime.deleteChannelDiscord(categoryId); return jsonResult(result); } case "channelPermissionSet": { @@ -468,7 +501,7 @@ export async function handleDiscordGuildAction( const allow = readStringParam(params, "allow"); const deny = readStringParam(params, "deny"); if (accountId) { - await setChannelPermissionDiscord( + await discordGuildActionRuntime.setChannelPermissionDiscord( { channelId, targetId, @@ -479,7 +512,7 @@ export async function handleDiscordGuildAction( { accountId }, ); } else { - await setChannelPermissionDiscord({ + await discordGuildActionRuntime.setChannelPermissionDiscord({ channelId, targetId, targetType, @@ -495,9 +528,11 @@ export async function handleDiscordGuildAction( } const { channelId, targetId } = readChannelPermissionTarget(params); if (accountId) { - await removeChannelPermissionDiscord(channelId, targetId, { accountId }); + await discordGuildActionRuntime.removeChannelPermissionDiscord(channelId, targetId, { + accountId, + }); } else { - await removeChannelPermissionDiscord(channelId, targetId); + await discordGuildActionRuntime.removeChannelPermissionDiscord(channelId, targetId); } return jsonResult({ ok: true }); } diff --git a/src/agents/tools/discord-actions-messaging.ts b/extensions/discord/src/actions/runtime.messaging.ts similarity index 71% rename from src/agents/tools/discord-actions-messaging.ts rename to extensions/discord/src/actions/runtime.messaging.ts index bad969ede80..92ef443cf44 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/extensions/discord/src/actions/runtime.messaging.ts @@ -1,7 +1,19 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; +import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; +import { withNormalizedTimestamp } from "../../../../src/agents/date-time.js"; +import { assertMediaNotDataUrl } from "../../../../src/agents/sandbox-paths.js"; +import { + type ActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringArrayParam, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { resolvePollMaxSelections } from "../../../../src/polls.js"; +import { readDiscordComponentSpec } from "../components.js"; import { createThreadDiscord, deleteMessageDiscord, @@ -23,20 +35,34 @@ import { sendStickerDiscord, sendVoiceMessageDiscord, unpinMessageDiscord, -} from "../../plugin-sdk/discord.js"; -import type { DiscordSendComponents, DiscordSendEmbeds } from "../../plugin-sdk/discord.js"; -import { readDiscordComponentSpec, resolveDiscordChannelId } from "../../plugin-sdk/discord.js"; -import { resolvePollMaxSelections } from "../../polls.js"; -import { withNormalizedTimestamp } from "../date-time.js"; -import { assertMediaNotDataUrl } from "../sandbox-paths.js"; -import { - type ActionGate, - jsonResult, - readNumberParam, - readReactionParams, - readStringArrayParam, - readStringParam, -} from "./common.js"; +} from "../send.js"; +import type { DiscordSendComponents, DiscordSendEmbeds } from "../send.shared.js"; +import { resolveDiscordChannelId } from "../targets.js"; + +export const discordMessagingActionRuntime = { + createThreadDiscord, + deleteMessageDiscord, + editMessageDiscord, + fetchChannelPermissionsDiscord, + fetchMessageDiscord, + fetchReactionsDiscord, + listPinsDiscord, + listThreadsDiscord, + pinMessageDiscord, + reactMessageDiscord, + readDiscordComponentSpec, + readMessagesDiscord, + removeOwnReactionsDiscord, + removeReactionDiscord, + resolveDiscordChannelId, + searchMessagesDiscord, + sendDiscordComponentMessage, + sendMessageDiscord, + sendPollDiscord, + sendStickerDiscord, + sendVoiceMessageDiscord, + unpinMessageDiscord, +}; function parseDiscordMessageLink(link: string) { const normalized = link.trim(); @@ -65,7 +91,7 @@ export async function handleDiscordMessagingAction( cfg?: OpenClawConfig, ): Promise> { const resolveChannelId = () => - resolveDiscordChannelId( + discordMessagingActionRuntime.resolveDiscordChannelId( readStringParam(params, "channelId", { required: true, }), @@ -95,28 +121,45 @@ export async function handleDiscordMessagingAction( }); if (remove) { if (accountId) { - await removeReactionDiscord(channelId, messageId, emoji, { + await discordMessagingActionRuntime.removeReactionDiscord(channelId, messageId, emoji, { ...cfgOptions, accountId, }); } else { - await removeReactionDiscord(channelId, messageId, emoji, cfgOptions); + await discordMessagingActionRuntime.removeReactionDiscord( + channelId, + messageId, + emoji, + cfgOptions, + ); } return jsonResult({ ok: true, removed: emoji }); } if (isEmpty) { const removed = accountId - ? await removeOwnReactionsDiscord(channelId, messageId, { ...cfgOptions, accountId }) - : await removeOwnReactionsDiscord(channelId, messageId, cfgOptions); + ? await discordMessagingActionRuntime.removeOwnReactionsDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.removeOwnReactionsDiscord( + channelId, + messageId, + cfgOptions, + ); return jsonResult({ ok: true, removed: removed.removed }); } if (accountId) { - await reactMessageDiscord(channelId, messageId, emoji, { + await discordMessagingActionRuntime.reactMessageDiscord(channelId, messageId, emoji, { ...cfgOptions, accountId, }); } else { - await reactMessageDiscord(channelId, messageId, emoji, cfgOptions); + await discordMessagingActionRuntime.reactMessageDiscord( + channelId, + messageId, + emoji, + cfgOptions, + ); } return jsonResult({ ok: true, added: emoji }); } @@ -129,11 +172,15 @@ export async function handleDiscordMessagingAction( required: true, }); const limit = readNumberParam(params, "limit"); - const reactions = await fetchReactionsDiscord(channelId, messageId, { - ...cfgOptions, - ...(accountId ? { accountId } : {}), - limit, - }); + const reactions = await discordMessagingActionRuntime.fetchReactionsDiscord( + channelId, + messageId, + { + ...cfgOptions, + ...(accountId ? { accountId } : {}), + limit, + }, + ); return jsonResult({ ok: true, reactions }); } case "sticker": { @@ -146,7 +193,7 @@ export async function handleDiscordMessagingAction( required: true, label: "stickerIds", }); - await sendStickerDiscord(to, stickerIds, { + await discordMessagingActionRuntime.sendStickerDiscord(to, stickerIds, { ...cfgOptions, ...(accountId ? { accountId } : {}), content, @@ -169,7 +216,7 @@ export async function handleDiscordMessagingAction( const allowMultiselect = readBooleanParam(params, "allowMultiselect"); const durationHours = readNumberParam(params, "durationHours"); const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect); - await sendPollDiscord( + await discordMessagingActionRuntime.sendPollDiscord( to, { question, options: answers, maxSelections, durationHours }, { ...cfgOptions, ...(accountId ? { accountId } : {}), content }, @@ -182,8 +229,11 @@ export async function handleDiscordMessagingAction( } const channelId = resolveChannelId(); const permissions = accountId - ? await fetchChannelPermissionsDiscord(channelId, { ...cfgOptions, accountId }) - : await fetchChannelPermissionsDiscord(channelId, cfgOptions); + ? await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(channelId, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(channelId, cfgOptions); return jsonResult({ ok: true, permissions }); } case "fetchMessage": { @@ -206,8 +256,11 @@ export async function handleDiscordMessagingAction( ); } const message = accountId - ? await fetchMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }) - : await fetchMessageDiscord(channelId, messageId, cfgOptions); + ? await discordMessagingActionRuntime.fetchMessageDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.fetchMessageDiscord(channelId, messageId, cfgOptions); return jsonResult({ ok: true, message: normalizeMessage(message), @@ -228,8 +281,11 @@ export async function handleDiscordMessagingAction( around: readStringParam(params, "around"), }; const messages = accountId - ? await readMessagesDiscord(channelId, query, { ...cfgOptions, accountId }) - : await readMessagesDiscord(channelId, query, cfgOptions); + ? await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, cfgOptions); return jsonResult({ ok: true, messages: messages.map((message) => normalizeMessage(message)), @@ -245,7 +301,7 @@ export async function handleDiscordMessagingAction( const rawComponents = params.components; const componentSpec = rawComponents && typeof rawComponents === "object" && !Array.isArray(rawComponents) - ? readDiscordComponentSpec(rawComponents) + ? discordMessagingActionRuntime.readDiscordComponentSpec(rawComponents) : null; const components: DiscordSendComponents | undefined = Array.isArray(rawComponents) || typeof rawComponents === "function" @@ -279,16 +335,20 @@ export async function handleDiscordMessagingAction( const payload = componentSpec.text ? componentSpec : { ...componentSpec, text: normalizedContent }; - const result = await sendDiscordComponentMessage(to, payload, { - ...cfgOptions, - ...(accountId ? { accountId } : {}), - silent, - replyTo: replyTo ?? undefined, - sessionKey: sessionKey ?? undefined, - agentId: agentId ?? undefined, - mediaUrl: mediaUrl ?? undefined, - filename: filename ?? undefined, - }); + const result = await discordMessagingActionRuntime.sendDiscordComponentMessage( + to, + payload, + { + ...cfgOptions, + ...(accountId ? { accountId } : {}), + silent, + replyTo: replyTo ?? undefined, + sessionKey: sessionKey ?? undefined, + agentId: agentId ?? undefined, + mediaUrl: mediaUrl ?? undefined, + filename: filename ?? undefined, + }, + ); return jsonResult({ ok: true, result, components: true }); } @@ -305,7 +365,7 @@ export async function handleDiscordMessagingAction( ); } assertMediaNotDataUrl(mediaUrl); - const result = await sendVoiceMessageDiscord(to, mediaUrl, { + const result = await discordMessagingActionRuntime.sendVoiceMessageDiscord(to, mediaUrl, { ...cfgOptions, ...(accountId ? { accountId } : {}), replyTo, @@ -314,7 +374,7 @@ export async function handleDiscordMessagingAction( return jsonResult({ ok: true, result, voiceMessage: true }); } - const result = await sendMessageDiscord(to, content ?? "", { + const result = await discordMessagingActionRuntime.sendMessageDiscord(to, content ?? "", { ...cfgOptions, ...(accountId ? { accountId } : {}), mediaUrl, @@ -338,8 +398,18 @@ export async function handleDiscordMessagingAction( required: true, }); const message = accountId - ? await editMessageDiscord(channelId, messageId, { content }, { ...cfgOptions, accountId }) - : await editMessageDiscord(channelId, messageId, { content }, cfgOptions); + ? await discordMessagingActionRuntime.editMessageDiscord( + channelId, + messageId, + { content }, + { ...cfgOptions, accountId }, + ) + : await discordMessagingActionRuntime.editMessageDiscord( + channelId, + messageId, + { content }, + cfgOptions, + ); return jsonResult({ ok: true, message }); } case "deleteMessage": { @@ -351,9 +421,12 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await deleteMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); + await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }); } else { - await deleteMessageDiscord(channelId, messageId, cfgOptions); + await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -375,8 +448,11 @@ export async function handleDiscordMessagingAction( appliedTags: appliedTags ?? undefined, }; const thread = accountId - ? await createThreadDiscord(channelId, payload, { ...cfgOptions, accountId }) - : await createThreadDiscord(channelId, payload, cfgOptions); + ? await discordMessagingActionRuntime.createThreadDiscord(channelId, payload, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.createThreadDiscord(channelId, payload, cfgOptions); return jsonResult({ ok: true, thread }); } case "threadList": { @@ -391,7 +467,7 @@ export async function handleDiscordMessagingAction( const before = readStringParam(params, "before"); const limit = readNumberParam(params, "limit"); const threads = accountId - ? await listThreadsDiscord( + ? await discordMessagingActionRuntime.listThreadsDiscord( { guildId, channelId, @@ -401,7 +477,7 @@ export async function handleDiscordMessagingAction( }, { ...cfgOptions, accountId }, ) - : await listThreadsDiscord( + : await discordMessagingActionRuntime.listThreadsDiscord( { guildId, channelId, @@ -423,13 +499,17 @@ export async function handleDiscordMessagingAction( }); const mediaUrl = readStringParam(params, "mediaUrl"); const replyTo = readStringParam(params, "replyTo"); - const result = await sendMessageDiscord(`channel:${channelId}`, content, { - ...cfgOptions, - ...(accountId ? { accountId } : {}), - mediaUrl, - mediaLocalRoots: options?.mediaLocalRoots, - replyTo, - }); + const result = await discordMessagingActionRuntime.sendMessageDiscord( + `channel:${channelId}`, + content, + { + ...cfgOptions, + ...(accountId ? { accountId } : {}), + mediaUrl, + mediaLocalRoots: options?.mediaLocalRoots, + replyTo, + }, + ); return jsonResult({ ok: true, result }); } case "pinMessage": { @@ -441,9 +521,12 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await pinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); + await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }); } else { - await pinMessageDiscord(channelId, messageId, cfgOptions); + await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -456,9 +539,12 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await unpinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); + await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, { + ...cfgOptions, + accountId, + }); } else { - await unpinMessageDiscord(channelId, messageId, cfgOptions); + await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -468,8 +554,11 @@ export async function handleDiscordMessagingAction( } const channelId = resolveChannelId(); const pins = accountId - ? await listPinsDiscord(channelId, { ...cfgOptions, accountId }) - : await listPinsDiscord(channelId, cfgOptions); + ? await discordMessagingActionRuntime.listPinsDiscord(channelId, { + ...cfgOptions, + accountId, + }) + : await discordMessagingActionRuntime.listPinsDiscord(channelId, cfgOptions); return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) }); } case "searchMessages": { @@ -490,7 +579,7 @@ export async function handleDiscordMessagingAction( const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])]; const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])]; const results = accountId - ? await searchMessagesDiscord( + ? await discordMessagingActionRuntime.searchMessagesDiscord( { guildId, content, @@ -500,7 +589,7 @@ export async function handleDiscordMessagingAction( }, { ...cfgOptions, accountId }, ) - : await searchMessagesDiscord( + : await discordMessagingActionRuntime.searchMessagesDiscord( { guildId, content, diff --git a/src/agents/tools/discord-actions-moderation-shared.ts b/extensions/discord/src/actions/runtime.moderation-shared.ts similarity index 94% rename from src/agents/tools/discord-actions-moderation-shared.ts rename to extensions/discord/src/actions/runtime.moderation-shared.ts index b2d9ec0ba99..7b6ef95d8f1 100644 --- a/src/agents/tools/discord-actions-moderation-shared.ts +++ b/extensions/discord/src/actions/runtime.moderation-shared.ts @@ -1,5 +1,5 @@ import { PermissionFlagsBits } from "discord-api-types/v10"; -import { readNumberParam, readStringParam } from "./common.js"; +import { readNumberParam, readStringParam } from "../../../../src/agents/tools/common.js"; export type DiscordModerationAction = "timeout" | "kick" | "ban"; diff --git a/src/agents/tools/discord-actions-moderation.authz.test.ts b/extensions/discord/src/actions/runtime.moderation.authz.test.ts similarity index 81% rename from src/agents/tools/discord-actions-moderation.authz.test.ts rename to extensions/discord/src/actions/runtime.moderation.authz.test.ts index d6b3651ca88..66d2a4ba9d8 100644 --- a/src/agents/tools/discord-actions-moderation.authz.test.ts +++ b/extensions/discord/src/actions/runtime.moderation.authz.test.ts @@ -1,25 +1,30 @@ import { PermissionFlagsBits } from "discord-api-types/v10"; -import { describe, expect, it, vi } from "vitest"; -import type { DiscordActionConfig } from "../../config/config.js"; -import { handleDiscordModerationAction } from "./discord-actions-moderation.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { + discordModerationActionRuntime, + handleDiscordModerationAction, +} from "./runtime.moderation.js"; -const discordSendMocks = vi.hoisted(() => ({ - banMemberDiscord: vi.fn(async () => ({ ok: true })), - kickMemberDiscord: vi.fn(async () => ({ ok: true })), - timeoutMemberDiscord: vi.fn(async () => ({ id: "user-1" })), - hasAnyGuildPermissionDiscord: vi.fn(async () => false), -})); - -const { banMemberDiscord, kickMemberDiscord, timeoutMemberDiscord, hasAnyGuildPermissionDiscord } = - discordSendMocks; - -vi.mock("../../../extensions/discord/src/send.js", () => ({ - ...discordSendMocks, -})); +const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime }; +const banMemberDiscord = vi.fn(async () => ({ ok: true })); +const kickMemberDiscord = vi.fn(async () => ({ ok: true })); +const timeoutMemberDiscord = vi.fn(async () => ({ id: "user-1" })); +const hasAnyGuildPermissionDiscord = vi.fn(async () => false); const enableAllActions = (_key: keyof DiscordActionConfig, _defaultValue = true) => true; describe("discord moderation sender authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(discordModerationActionRuntime, originalDiscordModerationActionRuntime, { + banMemberDiscord, + kickMemberDiscord, + timeoutMemberDiscord, + hasAnyGuildPermissionDiscord, + }); + }); + it("rejects ban when sender lacks BAN_MEMBERS", async () => { hasAnyGuildPermissionDiscord.mockResolvedValueOnce(false); diff --git a/src/agents/tools/discord-actions-moderation.ts b/extensions/discord/src/actions/runtime.moderation.ts similarity index 78% rename from src/agents/tools/discord-actions-moderation.ts rename to extensions/discord/src/actions/runtime.moderation.ts index 56d7a80d4c9..3278daa6532 100644 --- a/src/agents/tools/discord-actions-moderation.ts +++ b/extensions/discord/src/actions/runtime.moderation.ts @@ -1,17 +1,28 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; +import { + type ActionGate, + jsonResult, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; import { banMemberDiscord, hasAnyGuildPermissionDiscord, kickMemberDiscord, timeoutMemberDiscord, -} from "../../plugin-sdk/discord.js"; -import { type ActionGate, jsonResult, readStringParam } from "./common.js"; +} from "../send.js"; import { isDiscordModerationAction, readDiscordModerationCommand, requiredGuildPermissionForModerationAction, -} from "./discord-actions-moderation-shared.js"; +} from "./runtime.moderation-shared.js"; + +export const discordModerationActionRuntime = { + banMemberDiscord, + hasAnyGuildPermissionDiscord, + kickMemberDiscord, + timeoutMemberDiscord, +}; async function verifySenderModerationPermission(params: { guildId: string; @@ -23,7 +34,7 @@ async function verifySenderModerationPermission(params: { if (!params.senderUserId) { return; } - const hasPermission = await hasAnyGuildPermissionDiscord( + const hasPermission = await discordModerationActionRuntime.hasAnyGuildPermissionDiscord( params.guildId, params.senderUserId, [params.requiredPermission], @@ -57,7 +68,7 @@ export async function handleDiscordModerationAction( switch (command.action) { case "timeout": { const member = accountId - ? await timeoutMemberDiscord( + ? await discordModerationActionRuntime.timeoutMemberDiscord( { guildId: command.guildId, userId: command.userId, @@ -67,7 +78,7 @@ export async function handleDiscordModerationAction( }, { accountId }, ) - : await timeoutMemberDiscord({ + : await discordModerationActionRuntime.timeoutMemberDiscord({ guildId: command.guildId, userId: command.userId, durationMinutes: command.durationMinutes, @@ -78,7 +89,7 @@ export async function handleDiscordModerationAction( } case "kick": { if (accountId) { - await kickMemberDiscord( + await discordModerationActionRuntime.kickMemberDiscord( { guildId: command.guildId, userId: command.userId, @@ -87,7 +98,7 @@ export async function handleDiscordModerationAction( { accountId }, ); } else { - await kickMemberDiscord({ + await discordModerationActionRuntime.kickMemberDiscord({ guildId: command.guildId, userId: command.userId, reason: command.reason, @@ -97,7 +108,7 @@ export async function handleDiscordModerationAction( } case "ban": { if (accountId) { - await banMemberDiscord( + await discordModerationActionRuntime.banMemberDiscord( { guildId: command.guildId, userId: command.userId, @@ -107,7 +118,7 @@ export async function handleDiscordModerationAction( { accountId }, ); } else { - await banMemberDiscord({ + await discordModerationActionRuntime.banMemberDiscord({ guildId: command.guildId, userId: command.userId, reason: command.reason, diff --git a/src/agents/tools/discord-actions-presence.test.ts b/extensions/discord/src/actions/runtime.presence.test.ts similarity index 94% rename from src/agents/tools/discord-actions-presence.test.ts rename to extensions/discord/src/actions/runtime.presence.test.ts index dc8080666c6..7cc118150de 100644 --- a/src/agents/tools/discord-actions-presence.test.ts +++ b/extensions/discord/src/actions/runtime.presence.test.ts @@ -1,12 +1,9 @@ import type { GatewayPlugin } from "@buape/carbon/gateway"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - clearGateways, - registerGateway, -} from "../../../extensions/discord/src/monitor/gateway-registry.js"; -import type { DiscordActionConfig } from "../../config/config.js"; -import type { ActionGate } from "./common.js"; -import { handleDiscordPresenceAction } from "./discord-actions-presence.js"; +import type { ActionGate } from "../../../../src/agents/tools/common.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { clearGateways, registerGateway } from "../monitor/gateway-registry.js"; +import { handleDiscordPresenceAction } from "./runtime.presence.js"; const mockUpdatePresence = vi.fn(); diff --git a/src/agents/tools/discord-actions-presence.ts b/extensions/discord/src/actions/runtime.presence.ts similarity index 92% rename from src/agents/tools/discord-actions-presence.ts rename to extensions/discord/src/actions/runtime.presence.ts index 53c42829bb0..6d3a9f15bc2 100644 --- a/src/agents/tools/discord-actions-presence.ts +++ b/extensions/discord/src/actions/runtime.presence.ts @@ -1,8 +1,12 @@ import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { DiscordActionConfig } from "../../config/config.js"; -import { getGateway } from "../../plugin-sdk/discord.js"; -import { type ActionGate, jsonResult, readStringParam } from "./common.js"; +import { + type ActionGate, + jsonResult, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { getGateway } from "../monitor/gateway-registry.js"; const ACTIVITY_TYPE_MAP: Record = { playing: 0, diff --git a/src/agents/tools/discord-actions-shared.ts b/extensions/discord/src/actions/runtime.shared.ts similarity index 78% rename from src/agents/tools/discord-actions-shared.ts rename to extensions/discord/src/actions/runtime.shared.ts index 6f8283b5240..bd2ce7a08d6 100644 --- a/src/agents/tools/discord-actions-shared.ts +++ b/extensions/discord/src/actions/runtime.shared.ts @@ -1,4 +1,4 @@ -import { readStringParam } from "./common.js"; +import { readStringParam } from "../../../../src/agents/tools/common.js"; export function readDiscordParentIdParam( params: Record, diff --git a/src/agents/tools/discord-actions.test.ts b/extensions/discord/src/actions/runtime.test.ts similarity index 94% rename from src/agents/tools/discord-actions.test.ts rename to extensions/discord/src/actions/runtime.test.ts index c03cb2fdafa..8f11162f8f3 100644 --- a/src/agents/tools/discord-actions.test.ts +++ b/extensions/discord/src/actions/runtime.test.ts @@ -1,11 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { DiscordActionConfig, OpenClawConfig } from "../../config/config.js"; -import { handleDiscordGuildAction } from "./discord-actions-guild.js"; -import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; -import { handleDiscordModerationAction } from "./discord-actions-moderation.js"; -import { handleDiscordAction } from "./discord-actions.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { DiscordActionConfig } from "../../../../src/config/types.discord.js"; +import { discordGuildActionRuntime, handleDiscordGuildAction } from "./runtime.guild.js"; +import { handleDiscordAction } from "./runtime.js"; +import { + discordMessagingActionRuntime, + handleDiscordMessagingAction, +} from "./runtime.messaging.js"; +import { + discordModerationActionRuntime, + handleDiscordModerationAction, +} from "./runtime.moderation.js"; -const discordSendMocks = vi.hoisted(() => ({ +const originalDiscordMessagingActionRuntime = { ...discordMessagingActionRuntime }; +const originalDiscordGuildActionRuntime = { ...discordGuildActionRuntime }; +const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime }; + +const discordSendMocks = { banMemberDiscord: vi.fn(async () => ({})), createChannelDiscord: vi.fn(async () => ({ id: "new-channel", @@ -42,7 +53,7 @@ const discordSendMocks = vi.hoisted(() => ({ setChannelPermissionDiscord: vi.fn(async () => ({ ok: true })), timeoutMemberDiscord: vi.fn(async () => ({})), unpinMessageDiscord: vi.fn(async () => ({})), -})); +}; const { createChannelDiscord, @@ -67,21 +78,28 @@ const { timeoutMemberDiscord, } = discordSendMocks; -vi.mock("../../../extensions/discord/src/send.js", () => ({ - ...discordSendMocks, -})); - const enableAllActions = () => true; const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions"; const channelInfoEnabled = (key: keyof DiscordActionConfig) => key === "channelInfo"; const moderationEnabled = (key: keyof DiscordActionConfig) => key === "moderation"; -describe("handleDiscordMessagingAction", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); +beforeEach(() => { + vi.clearAllMocks(); + Object.assign( + discordMessagingActionRuntime, + originalDiscordMessagingActionRuntime, + discordSendMocks, + ); + Object.assign(discordGuildActionRuntime, originalDiscordGuildActionRuntime, discordSendMocks); + Object.assign( + discordModerationActionRuntime, + originalDiscordModerationActionRuntime, + discordSendMocks, + ); +}); +describe("handleDiscordMessagingAction", () => { it.each([ { name: "without account", diff --git a/src/agents/tools/discord-actions.ts b/extensions/discord/src/actions/runtime.ts similarity index 79% rename from src/agents/tools/discord-actions.ts rename to extensions/discord/src/actions/runtime.ts index b953e56cffd..7efa5a1536f 100644 --- a/src/agents/tools/discord-actions.ts +++ b/extensions/discord/src/actions/runtime.ts @@ -1,11 +1,11 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { createDiscordActionGate } from "../../plugin-sdk/discord.js"; -import { readStringParam } from "./common.js"; -import { handleDiscordGuildAction } from "./discord-actions-guild.js"; -import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; -import { handleDiscordModerationAction } from "./discord-actions-moderation.js"; -import { handleDiscordPresenceAction } from "./discord-actions-presence.js"; +import { readStringParam } from "../../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { createDiscordActionGate } from "../accounts.js"; +import { handleDiscordGuildAction } from "./runtime.guild.js"; +import { handleDiscordMessagingAction } from "./runtime.messaging.js"; +import { handleDiscordModerationAction } from "./runtime.moderation.js"; +import { handleDiscordPresenceAction } from "./runtime.presence.js"; const messagingActions = new Set([ "react", diff --git a/extensions/discord/src/api.test.ts b/extensions/discord/src/api.test.ts index 09e0863e137..11d15d5f59f 100644 --- a/extensions/discord/src/api.test.ts +++ b/extensions/discord/src/api.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js"; import { fetchDiscord } from "./api.js"; import { jsonResponse } from "./test-http-helpers.js"; diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index 21f24fd9553..960b08acdf6 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -1,115 +1,139 @@ import { + createLegacyMessageToolDiscoveryMethods, + createDiscordMessageToolComponentsSchema, createUnionActionGate, listTokenSourcedAccounts, } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageActionName, + ChannelMessageToolDiscovery, } from "openclaw/plugin-sdk/channel-runtime"; import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-runtime"; import { createDiscordActionGate, listEnabledDiscordAccounts } from "./accounts.js"; import { handleDiscordMessageAction } from "./actions/handle-action.js"; +function resolveDiscordActionDiscovery(cfg: Parameters[0]) { + const accounts = listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)); + if (accounts.length === 0) { + return null; + } + const unionGate = createUnionActionGate(accounts, (account) => + createDiscordActionGate({ + cfg, + accountId: account.accountId, + }), + ); + return { + isEnabled: (key: keyof DiscordActionConfig, defaultValue = true) => + unionGate(key, defaultValue), + }; +} + +function describeDiscordMessageTool({ + cfg, +}: Parameters< + NonNullable +>[0]): ChannelMessageToolDiscovery { + const discovery = resolveDiscordActionDiscovery(cfg); + if (!discovery) { + return { + actions: [], + capabilities: [], + schema: null, + }; + } + const actions = new Set(["send"]); + if (discovery.isEnabled("polls")) { + actions.add("poll"); + } + if (discovery.isEnabled("reactions")) { + actions.add("react"); + actions.add("reactions"); + actions.add("emoji-list"); + } + if (discovery.isEnabled("messages")) { + actions.add("read"); + actions.add("edit"); + actions.add("delete"); + } + if (discovery.isEnabled("pins")) { + actions.add("pin"); + actions.add("unpin"); + actions.add("list-pins"); + } + if (discovery.isEnabled("permissions")) { + actions.add("permissions"); + } + if (discovery.isEnabled("threads")) { + actions.add("thread-create"); + actions.add("thread-list"); + actions.add("thread-reply"); + } + if (discovery.isEnabled("search")) { + actions.add("search"); + } + if (discovery.isEnabled("stickers")) { + actions.add("sticker"); + } + if (discovery.isEnabled("memberInfo")) { + actions.add("member-info"); + } + if (discovery.isEnabled("roleInfo")) { + actions.add("role-info"); + } + if (discovery.isEnabled("emojiUploads")) { + actions.add("emoji-upload"); + } + if (discovery.isEnabled("stickerUploads")) { + actions.add("sticker-upload"); + } + if (discovery.isEnabled("roles", false)) { + actions.add("role-add"); + actions.add("role-remove"); + } + if (discovery.isEnabled("channelInfo")) { + actions.add("channel-info"); + actions.add("channel-list"); + } + if (discovery.isEnabled("channels")) { + actions.add("channel-create"); + actions.add("channel-edit"); + actions.add("channel-delete"); + actions.add("channel-move"); + actions.add("category-create"); + actions.add("category-edit"); + actions.add("category-delete"); + } + if (discovery.isEnabled("voiceStatus")) { + actions.add("voice-status"); + } + if (discovery.isEnabled("events")) { + actions.add("event-list"); + actions.add("event-create"); + } + if (discovery.isEnabled("moderation", false)) { + actions.add("timeout"); + actions.add("kick"); + actions.add("ban"); + } + if (discovery.isEnabled("presence", false)) { + actions.add("set-presence"); + } + return { + actions: Array.from(actions), + capabilities: ["interactive", "components"], + schema: { + properties: { + components: createDiscordMessageToolComponentsSchema(), + }, + }, + }; +} + export const discordMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { - const accounts = listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)); - if (accounts.length === 0) { - return []; - } - // Union of all accounts' action gates (any account enabling an action makes it available) - const gate = createUnionActionGate(accounts, (account) => - createDiscordActionGate({ - cfg, - accountId: account.accountId, - }), - ); - const isEnabled = (key: keyof DiscordActionConfig, defaultValue = true) => - gate(key, defaultValue); - const actions = new Set(["send"]); - if (isEnabled("polls")) { - actions.add("poll"); - } - if (isEnabled("reactions")) { - actions.add("react"); - actions.add("reactions"); - } - if (isEnabled("messages")) { - actions.add("read"); - actions.add("edit"); - actions.add("delete"); - } - if (isEnabled("pins")) { - actions.add("pin"); - actions.add("unpin"); - actions.add("list-pins"); - } - if (isEnabled("permissions")) { - actions.add("permissions"); - } - if (isEnabled("threads")) { - actions.add("thread-create"); - actions.add("thread-list"); - actions.add("thread-reply"); - } - if (isEnabled("search")) { - actions.add("search"); - } - if (isEnabled("stickers")) { - actions.add("sticker"); - } - if (isEnabled("memberInfo")) { - actions.add("member-info"); - } - if (isEnabled("roleInfo")) { - actions.add("role-info"); - } - if (isEnabled("reactions")) { - actions.add("emoji-list"); - } - if (isEnabled("emojiUploads")) { - actions.add("emoji-upload"); - } - if (isEnabled("stickerUploads")) { - actions.add("sticker-upload"); - } - if (isEnabled("roles", false)) { - actions.add("role-add"); - actions.add("role-remove"); - } - if (isEnabled("channelInfo")) { - actions.add("channel-info"); - actions.add("channel-list"); - } - if (isEnabled("channels")) { - actions.add("channel-create"); - actions.add("channel-edit"); - actions.add("channel-delete"); - actions.add("channel-move"); - actions.add("category-create"); - actions.add("category-edit"); - actions.add("category-delete"); - } - if (isEnabled("voiceStatus")) { - actions.add("voice-status"); - } - if (isEnabled("events")) { - actions.add("event-list"); - actions.add("event-create"); - } - if (isEnabled("moderation", false)) { - actions.add("timeout"); - actions.add("kick"); - actions.add("ban"); - } - if (isEnabled("presence", false)) { - actions.add("set-presence"); - } - return Array.from(actions); - }, - getCapabilities: ({ cfg }) => - listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)).length > 0 - ? (["interactive", "components"] as const) - : [], + describeMessageTool: describeDiscordMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeDiscordMessageTool), extractToolSend: ({ args }) => { const action = typeof args.action === "string" ? args.action.trim() : ""; if (action === "sendMessage") { 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..c555ff89382 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -33,17 +33,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"; @@ -76,10 +77,14 @@ function formatDiscordIntents(intents?: { } const discordMessageActions: ChannelMessageActionAdapter = { + describeMessageTool: (ctx) => + getDiscordRuntime().channel.discord.messageActions?.describeMessageTool?.(ctx) ?? null, listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [], getCapabilities: (ctx) => getDiscordRuntime().channel.discord.messageActions?.getCapabilities?.(ctx) ?? [], + getToolSchema: (ctx) => + getDiscordRuntime().channel.discord.messageActions?.getToolSchema?.(ctx) ?? null, extractToolSend: (ctx) => getDiscordRuntime().channel.discord.messageActions?.extractToolSend?.(ctx) ?? null, handleAction: async (ctx) => { @@ -488,11 +493,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 +520,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 +626,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 +667,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 +695,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/chunk.test.ts b/extensions/discord/src/chunk.test.ts index 69f5ec856ec..228871fe5d6 100644 --- a/extensions/discord/src/chunk.test.ts +++ b/extensions/discord/src/chunk.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { countLines, hasBalancedFences } from "../../test-utils/chunk-test-helpers.js"; +import { + countLines, + hasBalancedFences, +} from "../../../test/helpers/extensions/chunk-test-helpers.js"; import { chunkDiscordText, chunkDiscordTextWithMode } from "./chunk.js"; describe("chunkDiscordText", () => { diff --git a/extensions/discord/src/monitor.test.ts b/extensions/discord/src/monitor.test.ts index b3af666c35f..9836984d555 100644 --- a/extensions/discord/src/monitor.test.ts +++ b/extensions/discord/src/monitor.test.ts @@ -1,6 +1,6 @@ import { ChannelType, type Guild } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { typedCases } from "../../test-utils/typed-cases.js"; +import { typedCases } from "../../../test/helpers/extensions/typed-cases.js"; import { allowListMatches, buildDiscordMediaPayload, diff --git a/extensions/discord/src/monitor.tool-result.test-harness.ts b/extensions/discord/src/monitor.tool-result.test-harness.ts index fd4f67b0890..6d0405d756c 100644 --- a/extensions/discord/src/monitor.tool-result.test-harness.ts +++ b/extensions/discord/src/monitor.tool-result.test-harness.ts @@ -1,4 +1,4 @@ -import type { MockFn } from "openclaw/plugin-sdk/test-utils"; +import type { MockFn } from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; export const sendMock: MockFn = vi.fn(); 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.module-test-helpers.ts b/extensions/discord/src/monitor/message-handler.module-test-helpers.ts index adeaf7953e7..72327dfc608 100644 --- a/extensions/discord/src/monitor/message-handler.module-test-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.module-test-helpers.ts @@ -1,4 +1,4 @@ -import type { MockFn } from "openclaw/plugin-sdk/test-utils"; +import type { MockFn } from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; export const preflightDiscordMessageMock: MockFn = vi.fn(); 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.registry.test.ts b/extensions/discord/src/monitor/provider.registry.test.ts index 2187c851f69..5e092445065 100644 --- a/extensions/discord/src/monitor/provider.registry.test.ts +++ b/extensions/discord/src/monitor/provider.registry.test.ts @@ -5,7 +5,7 @@ import { baseRuntime, getProviderMonitorTestMocks, resetDiscordProviderMonitorMocks, -} from "./provider.test-support.js"; +} from "../../../../test/helpers/extensions/discord-provider.test-support.js"; const { createDiscordNativeCommandMock, clientHandleDeployRequestMock, monitorLifecycleMock } = getProviderMonitorTestMocks(); diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 14177aec001..8cda7cc90b3 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -9,7 +9,7 @@ import { getProviderMonitorTestMocks, mockResolvedDiscordAccountConfig, resetDiscordProviderMonitorMocks, -} from "./provider.test-support.js"; +} from "../../../../test/helpers/extensions/discord-provider.test-support.js"; const { clientConstructorOptionsMock, @@ -37,9 +37,15 @@ const { voiceRuntimeModuleLoadedMock, } = getProviderMonitorTestMocks(); -vi.mock("../../../../src/plugins/commands.js", () => ({ - getPluginCommandSpecs: getPluginCommandSpecsMock, -})); +vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/plugin-runtime", + ); + return { + ...actual, + getPluginCommandSpecs: getPluginCommandSpecsMock, + }; +}); vi.mock("../voice/manager.runtime.js", () => { voiceRuntimeModuleLoadedMock(); 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/resolve-channels.test.ts b/extensions/discord/src/resolve-channels.test.ts index f053fb97888..8fd06593923 100644 --- a/extensions/discord/src/resolve-channels.test.ts +++ b/extensions/discord/src/resolve-channels.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js"; import { resolveDiscordChannelAllowlist } from "./resolve-channels.js"; import { jsonResponse, urlToString } from "./test-http-helpers.js"; diff --git a/extensions/discord/src/resolve-users.test.ts b/extensions/discord/src/resolve-users.test.ts index f67b7289a59..080c312b856 100644 --- a/extensions/discord/src/resolve-users.test.ts +++ b/extensions/discord/src/resolve-users.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { jsonResponse, urlToString } from "./test-http-helpers.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/send.test-harness.ts b/extensions/discord/src/send.test-harness.ts index 8a2058772fc..c0069f99770 100644 --- a/extensions/discord/src/send.test-harness.ts +++ b/extensions/discord/src/send.test-harness.ts @@ -1,4 +1,4 @@ -import type { MockFn } from "openclaw/plugin-sdk/test-utils"; +import type { MockFn } from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; type DiscordWebMediaMockFactoryResult = { diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index 4b807f10a65..a05a9af65b1 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -16,7 +16,7 @@ import { type ChannelSetupDmPolicy, type ChannelSetupWizard, } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +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 66f7f8bbf4b..d27c7862c99 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -5,7 +5,7 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +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 92e248066af..7558b27394a 100644 --- a/extensions/discord/src/shared.ts +++ b/extensions/discord/src/shared.ts @@ -3,10 +3,12 @@ import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; -import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; -import { DiscordConfigSchema } from "../../../src/config/zod-schema.providers-core.js"; +import { + buildChannelConfigSchema, + DiscordConfigSchema, + getChatChannelMeta, + type ChannelPlugin, +} from "openclaw/plugin-sdk/discord-core"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index 9ba082144e6..a05db63043a 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -1,5 +1,9 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/discord"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getRequiredHookHandler, + registerHookHandlersForTest, +} from "../../../test/helpers/extensions/subagent-hooks.js"; import { registerDiscordSubagentHooks } from "./subagent-hooks.js"; type ThreadBindingRecord = { @@ -55,26 +59,10 @@ function registerHandlersForTest( }, }, ) { - const handlers = new Map unknown>(); - const api = { + return registerHookHandlersForTest({ config, - on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => { - handlers.set(hookName, handler); - }, - } as unknown as OpenClawPluginApi; - registerDiscordSubagentHooks(api); - return handlers; -} - -function getRequiredHandler( - handlers: Map unknown>, - hookName: string, -): (event: unknown, ctx: unknown) => unknown { - const handler = handlers.get(hookName); - if (!handler) { - throw new Error(`expected ${hookName} hook handler`); - } - return handler; + register: registerDiscordSubagentHooks, + }); } function resolveSubagentDeliveryTargetForTest(requesterOrigin: { @@ -84,7 +72,7 @@ function resolveSubagentDeliveryTargetForTest(requesterOrigin: { threadId?: string; }) { const handlers = registerHandlersForTest(); - const handler = getRequiredHandler(handlers, "subagent_delivery_target"); + const handler = getRequiredHookHandler(handlers, "subagent_delivery_target"); return handler( { childSessionKey: "agent:main:subagent:child", @@ -158,7 +146,7 @@ async function runSubagentSpawning( event = createSpawnEventWithoutThread(), ) { const handlers = registerHandlersForTest(config); - const handler = getRequiredHandler(handlers, "subagent_spawning"); + const handler = getRequiredHookHandler(handlers, "subagent_spawning"); return await handler(event, {}); } @@ -202,7 +190,7 @@ describe("discord subagent hook handlers", () => { it("binds thread routing on subagent_spawning", async () => { const handlers = registerHandlersForTest(); - const handler = getRequiredHandler(handlers, "subagent_spawning"); + const handler = getRequiredHookHandler(handlers, "subagent_spawning"); const result = await handler(createSpawnEvent(), {}); @@ -320,7 +308,7 @@ describe("discord subagent hook handlers", () => { it("unbinds thread routing on subagent_ended", () => { const handlers = registerHandlersForTest(); - const handler = getRequiredHandler(handlers, "subagent_ended"); + const handler = getRequiredHookHandler(handlers, "subagent_ended"); handler( { 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/elevenlabs/index.ts b/extensions/elevenlabs/index.ts index 034c56815c3..4d32eb4c532 100644 --- a/extensions/elevenlabs/index.ts +++ b/extensions/elevenlabs/index.ts @@ -1,14 +1,11 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { buildElevenLabsSpeechProvider } from "openclaw/plugin-sdk/speech"; -const elevenLabsPlugin = { +export default definePluginEntry({ id: "elevenlabs", name: "ElevenLabs Speech", description: "Bundled ElevenLabs speech provider", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerSpeechProvider(buildElevenLabsSpeechProvider()); }, -}; - -export default elevenLabsPlugin; +}); 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..1e18c0eea12 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, @@ -43,7 +45,6 @@ export { buildMentionedCardContent, type MentionTarget, } from "./src/mention.js"; -export { feishuPlugin } from "./src/channel.js"; export default defineChannelPluginEntry({ id: "feishu", diff --git a/extensions/feishu/src/bot.card-action.test.ts b/extensions/feishu/src/bot.card-action.test.ts index 2df1ce361a1..0dd3cf8730c 100644 --- a/extensions/feishu/src/bot.card-action.test.ts +++ b/extensions/feishu/src/bot.card-action.test.ts @@ -35,6 +35,54 @@ describe("Feishu Card Action Handler", () => { const cfg = {} as any; // Minimal mock const runtime = { log: vi.fn(), error: vi.fn() } as any; + function createCardActionEvent(params: { + token: string; + actionValue: Record; + chatId?: string; + openId?: string; + userId?: string; + unionId?: string; + }): FeishuCardActionEvent { + const openId = params.openId ?? "u123"; + const userId = params.userId ?? "uid1"; + return { + operator: { open_id: openId, user_id: userId, union_id: params.unionId ?? "un1" }, + token: params.token, + action: { + value: params.actionValue, + tag: "button", + }, + context: { open_id: openId, user_id: userId, chat_id: params.chatId ?? "chat1" }, + }; + } + + function createStructuredQuickActionEvent(params: { + token: string; + action: string; + command?: string; + chatId?: string; + chatType?: "group" | "p2p"; + operatorOpenId?: string; + actionOpenId?: string; + }): FeishuCardActionEvent { + return createCardActionEvent({ + token: params.token, + chatId: params.chatId, + openId: params.operatorOpenId, + actionValue: createFeishuCardInteractionEnvelope({ + k: "quick", + a: params.action, + ...(params.command ? { q: params.command } : {}), + c: { + u: params.actionOpenId ?? params.operatorOpenId ?? "u123", + h: params.chatId ?? "chat1", + t: params.chatType ?? "group", + e: Date.now() + 60_000, + }, + }), + }); + } + beforeEach(() => { vi.clearAllMocks(); resetProcessedFeishuCardActionTokensForTests(); @@ -85,20 +133,11 @@ describe("Feishu Card Action Handler", () => { }); it("routes quick command actions with operator and conversation context", async () => { - const event: FeishuCardActionEvent = { - operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + const event = createStructuredQuickActionEvent({ token: "tok3", - action: { - value: createFeishuCardInteractionEnvelope({ - k: "quick", - a: "feishu.quick_actions.help", - q: "/help", - c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, - }), - tag: "button", - }, - context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, - }; + action: "feishu.quick_actions.help", + command: "/help", + }); await handleFeishuCardAction({ cfg, event, runtime }); @@ -182,20 +221,11 @@ describe("Feishu Card Action Handler", () => { }); it("runs approval confirmation through the normal message path", async () => { - const event: FeishuCardActionEvent = { - operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + const event = createStructuredQuickActionEvent({ token: "tok5", - action: { - value: createFeishuCardInteractionEnvelope({ - k: "quick", - a: FEISHU_APPROVAL_CONFIRM_ACTION, - q: "/new", - c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, - }), - tag: "button", - }, - context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, - }; + action: FEISHU_APPROVAL_CONFIRM_ACTION, + command: "/new", + }); await handleFeishuCardAction({ cfg, event, runtime }); @@ -211,20 +241,15 @@ describe("Feishu Card Action Handler", () => { }); it("safely rejects stale structured actions", async () => { - const event: FeishuCardActionEvent = { - operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + const event = createCardActionEvent({ token: "tok6", - action: { - value: createFeishuCardInteractionEnvelope({ - k: "quick", - a: "feishu.quick_actions.help", - q: "/help", - c: { u: "u123", h: "chat1", t: "group", e: Date.now() - 1 }, - }), - tag: "button", - }, - context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, - }; + actionValue: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() - 1 }, + }), + }); await handleFeishuCardAction({ cfg, event, runtime }); @@ -238,20 +263,13 @@ describe("Feishu Card Action Handler", () => { }); it("safely rejects wrong-user structured actions", async () => { - const event: FeishuCardActionEvent = { - operator: { open_id: "u999", user_id: "uid1", union_id: "un1" }, + const event = createStructuredQuickActionEvent({ token: "tok7", - action: { - value: createFeishuCardInteractionEnvelope({ - k: "quick", - a: "feishu.quick_actions.help", - q: "/help", - c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, - }), - tag: "button", - }, - context: { open_id: "u999", user_id: "uid1", chat_id: "chat1" }, - }; + action: "feishu.quick_actions.help", + command: "/help", + operatorOpenId: "u999", + actionOpenId: "u123", + }); await handleFeishuCardAction({ cfg, event, runtime }); @@ -289,20 +307,13 @@ describe("Feishu Card Action Handler", () => { }); it("preserves p2p callbacks for DM quick actions", async () => { - const event: FeishuCardActionEvent = { - operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + const event = createStructuredQuickActionEvent({ token: "tok9", - action: { - value: createFeishuCardInteractionEnvelope({ - k: "quick", - a: "feishu.quick_actions.help", - q: "/help", - c: { u: "u123", h: "p2p-chat-1", t: "p2p", e: Date.now() + 60_000 }, - }), - tag: "button", - }, - context: { open_id: "u123", user_id: "uid1", chat_id: "p2p-chat-1" }, - }; + action: "feishu.quick_actions.help", + command: "/help", + chatId: "p2p-chat-1", + chatType: "p2p", + }); await handleFeishuCardAction({ cfg, event, runtime }); @@ -319,20 +330,11 @@ describe("Feishu Card Action Handler", () => { }); it("drops duplicate structured callback tokens", async () => { - const event: FeishuCardActionEvent = { - operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + const event = createStructuredQuickActionEvent({ token: "tok10", - action: { - value: createFeishuCardInteractionEnvelope({ - k: "quick", - a: "feishu.quick_actions.help", - q: "/help", - c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, - }), - tag: "button", - }, - context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, - }; + action: "feishu.quick_actions.help", + command: "/help", + }); await handleFeishuCardAction({ cfg, event, runtime }); await handleFeishuCardAction({ cfg, event, runtime }); @@ -341,20 +343,11 @@ describe("Feishu Card Action Handler", () => { }); it("releases a claimed token when dispatch fails so retries can succeed", async () => { - const event: FeishuCardActionEvent = { - operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + const event = createStructuredQuickActionEvent({ token: "tok11", - action: { - value: createFeishuCardInteractionEnvelope({ - k: "quick", - a: "feishu.quick_actions.help", - q: "/help", - c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, - }), - tag: "button", - }, - context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, - }; + action: "feishu.quick_actions.help", + command: "/help", + }); vi.mocked(handleFeishuMessage) .mockRejectedValueOnce(new Error("transient")) .mockResolvedValueOnce(undefined as never); diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index df787b0106a..0995632e3a1 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -1,6 +1,6 @@ import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import type { FeishuMessageEvent } from "./bot.js"; import { buildBroadcastSessionKey, @@ -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,10 +78,19 @@ vi.mock("./client.js", () => ({ createFeishuClient: mockCreateFeishuClient, })); -vi.mock("../../../src/acp/persistent-bindings.route.js", () => ({ - 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: () => ({ @@ -136,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({ @@ -216,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", @@ -266,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", @@ -303,7 +374,7 @@ describe("handleFeishuMessage ACP routing", () => { matchedBy: "binding.channel", }, } as any); - mockEnsureConfiguredAcpRouteReady.mockResolvedValue({ + mockEnsureConfiguredBindingRouteReady.mockResolvedValue({ ok: false, error: "runtime unavailable", } as any); @@ -431,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.runtime.ts b/extensions/feishu/src/channel.runtime.ts index c8a742942ea..ef13b721a4e 100644 --- a/extensions/feishu/src/channel.runtime.ts +++ b/extensions/feishu/src/channel.runtime.ts @@ -26,104 +26,22 @@ import { sendMessageFeishu as sendMessageFeishuImpl, } from "./send.js"; -type ListFeishuDirectoryGroupsLive = typeof import("./directory.js").listFeishuDirectoryGroupsLive; -type ListFeishuDirectoryPeersLive = typeof import("./directory.js").listFeishuDirectoryPeersLive; -type FeishuOutbound = typeof import("./outbound.js").feishuOutbound; -type CreatePinFeishu = typeof import("./pins.js").createPinFeishu; -type ListPinsFeishu = typeof import("./pins.js").listPinsFeishu; -type RemovePinFeishu = typeof import("./pins.js").removePinFeishu; -type ProbeFeishu = typeof import("./probe.js").probeFeishu; -type AddReactionFeishu = typeof import("./reactions.js").addReactionFeishu; -type ListReactionsFeishu = typeof import("./reactions.js").listReactionsFeishu; -type RemoveReactionFeishu = typeof import("./reactions.js").removeReactionFeishu; -type GetChatInfo = typeof import("./chat.js").getChatInfo; -type GetChatMembers = typeof import("./chat.js").getChatMembers; -type GetFeishuMemberInfo = typeof import("./chat.js").getFeishuMemberInfo; -type EditMessageFeishu = typeof import("./send.js").editMessageFeishu; -type GetMessageFeishu = typeof import("./send.js").getMessageFeishu; -type SendCardFeishu = typeof import("./send.js").sendCardFeishu; -type SendMessageFeishu = typeof import("./send.js").sendMessageFeishu; - -export function listFeishuDirectoryGroupsLive( - ...args: Parameters -): ReturnType { - return listFeishuDirectoryGroupsLiveImpl(...args); -} - -export function listFeishuDirectoryPeersLive( - ...args: Parameters -): ReturnType { - return listFeishuDirectoryPeersLiveImpl(...args); -} - -export const feishuOutbound: FeishuOutbound = { ...feishuOutboundImpl }; - -export function createPinFeishu(...args: Parameters): ReturnType { - return createPinFeishuImpl(...args); -} - -export function listPinsFeishu(...args: Parameters): ReturnType { - return listPinsFeishuImpl(...args); -} - -export function removePinFeishu(...args: Parameters): ReturnType { - return removePinFeishuImpl(...args); -} - -export function probeFeishu(...args: Parameters): ReturnType { - return probeFeishuImpl(...args); -} - -export function addReactionFeishu( - ...args: Parameters -): ReturnType { - return addReactionFeishuImpl(...args); -} - -export function listReactionsFeishu( - ...args: Parameters -): ReturnType { - return listReactionsFeishuImpl(...args); -} - -export function removeReactionFeishu( - ...args: Parameters -): ReturnType { - return removeReactionFeishuImpl(...args); -} - -export function getChatInfo(...args: Parameters): ReturnType { - return getChatInfoImpl(...args); -} - -export function getChatMembers(...args: Parameters): ReturnType { - return getChatMembersImpl(...args); -} - -export function getFeishuMemberInfo( - ...args: Parameters -): ReturnType { - return getFeishuMemberInfoImpl(...args); -} - -export function editMessageFeishu( - ...args: Parameters -): ReturnType { - return editMessageFeishuImpl(...args); -} - -export function getMessageFeishu( - ...args: Parameters -): ReturnType { - return getMessageFeishuImpl(...args); -} - -export function sendCardFeishu(...args: Parameters): ReturnType { - return sendCardFeishuImpl(...args); -} - -export function sendMessageFeishu( - ...args: Parameters -): ReturnType { - return sendMessageFeishuImpl(...args); -} +export const feishuChannelRuntime = { + listFeishuDirectoryGroupsLive: listFeishuDirectoryGroupsLiveImpl, + listFeishuDirectoryPeersLive: listFeishuDirectoryPeersLiveImpl, + feishuOutbound: { ...feishuOutboundImpl }, + createPinFeishu: createPinFeishuImpl, + listPinsFeishu: listPinsFeishuImpl, + removePinFeishu: removePinFeishuImpl, + probeFeishu: probeFeishuImpl, + addReactionFeishu: addReactionFeishuImpl, + listReactionsFeishu: listReactionsFeishuImpl, + removeReactionFeishu: removeReactionFeishuImpl, + getChatInfo: getChatInfoImpl, + getChatMembers: getChatMembersImpl, + getFeishuMemberInfo: getFeishuMemberInfoImpl, + editMessageFeishu: editMessageFeishuImpl, + getMessageFeishu: getMessageFeishuImpl, + sendCardFeishu: sendCardFeishuImpl, + sendMessageFeishu: sendMessageFeishuImpl, +}; diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index 826ca1c26fb..7c4ae5d877a 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -28,22 +28,28 @@ vi.mock("./client.js", () => ({ })); vi.mock("./channel.runtime.js", () => ({ - addReactionFeishu: addReactionFeishuMock, - createPinFeishu: createPinFeishuMock, - editMessageFeishu: editMessageFeishuMock, - getChatInfo: getChatInfoMock, - getChatMembers: getChatMembersMock, - getFeishuMemberInfo: getFeishuMemberInfoMock, - getMessageFeishu: getMessageFeishuMock, - listFeishuDirectoryGroupsLive: listFeishuDirectoryGroupsLiveMock, - listFeishuDirectoryPeersLive: listFeishuDirectoryPeersLiveMock, - listPinsFeishu: listPinsFeishuMock, - listReactionsFeishu: listReactionsFeishuMock, - probeFeishu: probeFeishuMock, - removePinFeishu: removePinFeishuMock, - removeReactionFeishu: removeReactionFeishuMock, - sendCardFeishu: sendCardFeishuMock, - sendMessageFeishu: sendMessageFeishuMock, + feishuChannelRuntime: { + addReactionFeishu: addReactionFeishuMock, + createPinFeishu: createPinFeishuMock, + editMessageFeishu: editMessageFeishuMock, + getChatInfo: getChatInfoMock, + getChatMembers: getChatMembersMock, + getFeishuMemberInfo: getFeishuMemberInfoMock, + getMessageFeishu: getMessageFeishuMock, + listFeishuDirectoryGroupsLive: listFeishuDirectoryGroupsLiveMock, + listFeishuDirectoryPeersLive: listFeishuDirectoryPeersLiveMock, + listPinsFeishu: listPinsFeishuMock, + listReactionsFeishu: listReactionsFeishuMock, + probeFeishu: probeFeishuMock, + removePinFeishu: removePinFeishuMock, + removeReactionFeishu: removeReactionFeishuMock, + sendCardFeishu: sendCardFeishuMock, + sendMessageFeishu: sendMessageFeishuMock, + feishuOutbound: { + sendText: vi.fn(), + sendMedia: vi.fn(), + }, + }, })); import { feishuPlugin } from "./channel.js"; diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 1964331e7e0..da5cd8e4382 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,6 +1,14 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; +import { + createLegacyMessageToolDiscoveryMethods, + createMessageToolCardSchema, +} from "openclaw/plugin-sdk/channel-runtime"; +import type { + ChannelMessageActionAdapter, + ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { buildChannelConfigSchema, @@ -12,6 +20,7 @@ import { PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk/feishu"; import type { ChannelMessageActionName } from "openclaw/plugin-sdk/feishu"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { resolveFeishuAccount, resolveFeishuCredentials, @@ -41,8 +50,59 @@ const meta: ChannelMeta = { order: 70, }; -async function loadFeishuChannelRuntime() { - return await import("./channel.runtime.js"); +const loadFeishuChannelRuntime = createLazyRuntimeNamedExport( + () => import("./channel.runtime.js"), + "feishuChannelRuntime", +); + +function describeFeishuMessageTool({ + cfg, +}: Parameters< + NonNullable +>[0]): ChannelMessageToolDiscovery { + const enabled = + cfg.channels?.feishu?.enabled !== false && + Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)); + if (listEnabledFeishuAccounts(cfg).length === 0) { + return { + actions: [], + capabilities: enabled ? ["cards"] : [], + schema: enabled + ? { + properties: { + card: createMessageToolCardSchema(), + }, + } + : null, + }; + } + const actions = new Set([ + "send", + "read", + "edit", + "thread-reply", + "pin", + "list-pins", + "unpin", + "member-info", + "channel-info", + "channel-list", + ]); + if (areAnyFeishuReactionActionsEnabled(cfg)) { + actions.add("react"); + actions.add("reactions"); + } + return { + actions: Array.from(actions), + capabilities: enabled ? ["cards"] : [], + schema: enabled + ? { + properties: { + card: createMessageToolCardSchema(), + }, + } + : null, + }; } function setFeishuNamedAccountEnabled( @@ -392,34 +452,8 @@ export const feishuPlugin: ChannelPlugin = { formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }), }, actions: { - listActions: ({ cfg }) => { - if (listEnabledFeishuAccounts(cfg).length === 0) { - return []; - } - const actions = new Set([ - "send", - "read", - "edit", - "thread-reply", - "pin", - "list-pins", - "unpin", - "member-info", - "channel-info", - "channel-list", - ]); - if (areAnyFeishuReactionActionsEnabled(cfg)) { - actions.add("react"); - actions.add("reactions"); - } - return Array.from(actions); - }, - getCapabilities: ({ cfg }) => { - return cfg.channels?.feishu?.enabled !== false && - Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)) - ? (["cards"] as const) - : []; - }, + describeMessageTool: describeFeishuMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeFeishuMessageTool), handleAction: async (ctx) => { const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined }); if ( @@ -820,11 +854,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 ccaf6ea6d0d..fe4e04dc310 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -1,6 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js"; +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 }; @@ -11,7 +21,6 @@ const httpsProxyAgentCtorMock = vi.hoisted(() => return { proxyUrl }; }), ); - const mockBaseHttpInstance = vi.hoisted(() => ({ request: vi.fn().mockResolvedValue({}), get: vi.fn().mockResolvedValue({}), @@ -22,34 +31,17 @@ const mockBaseHttpInstance = vi.hoisted(() => ({ head: vi.fn().mockResolvedValue({}), options: vi.fn().mockResolvedValue({}), })); - -vi.mock("@larksuiteoapi/node-sdk", () => ({ - AppType: { SelfBuild: "self" }, - Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" }, - LoggerLevel: { info: "info" }, - Client: vi.fn(), - WSClient: wsClientCtorMock, - EventDispatcher: vi.fn(), - defaultHttpInstance: mockBaseHttpInstance, -})); - -vi.mock("https-proxy-agent", () => ({ - HttpsProxyAgent: httpsProxyAgentCtorMock, -})); - -import { Client as LarkClient } from "@larksuiteoapi/node-sdk"; -import { - createFeishuClient, - createFeishuWSClient, - clearClientCache, - FEISHU_HTTP_TIMEOUT_MS, - FEISHU_HTTP_TIMEOUT_MAX_MS, - FEISHU_HTTP_TIMEOUT_ENV_VAR, -} 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; @@ -69,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]; @@ -78,6 +94,21 @@ beforeEach(() => { delete process.env[key]; } vi.clearAllMocks(); + setFeishuClientRuntimeForTest({ + sdk: { + AppType: { SelfBuild: "self" } as never, + Domain: { + Feishu: "https://open.feishu.cn", + Lark: "https://open.larksuite.com", + } as never, + LoggerLevel: { info: "info" } as never, + Client: clientCtorMock as never, + WSClient: wsClientCtorMock as never, + EventDispatcher: vi.fn() as never, + defaultHttpInstance: mockBaseHttpInstance as never, + }, + HttpsProxyAgent: httpsProxyAgentCtorMock as never, + }); }); afterEach(() => { @@ -94,6 +125,7 @@ afterEach(() => { } else { process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = priorFeishuTimeoutEnv; } + setFeishuClientRuntimeForTest(); }); describe("createFeishuClient HTTP timeout", () => { @@ -102,7 +134,7 @@ describe("createFeishuClient HTTP timeout", () => { }); const getLastClientHttpInstance = () => { - const calls = (LarkClient as unknown as ReturnType).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; @@ -122,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 = (LarkClient as unknown as ReturnType).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 = (LarkClient as unknown as ReturnType).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" } }, @@ -152,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 = (LarkClient as unknown as ReturnType).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", @@ -241,13 +275,14 @@ describe("createFeishuClient HTTP timeout", () => { config: { httpTimeoutMs: 45_000 }, }); - const calls = (LarkClient as unknown as ReturnType).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", @@ -262,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/client.ts b/extensions/feishu/src/client.ts index d9fdde7f059..c4498dcffc3 100644 --- a/extensions/feishu/src/client.ts +++ b/extensions/feishu/src/client.ts @@ -2,6 +2,30 @@ import * as Lark from "@larksuiteoapi/node-sdk"; import { HttpsProxyAgent } from "https-proxy-agent"; import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js"; +type FeishuClientSdk = Pick< + typeof Lark, + | "AppType" + | "Client" + | "defaultHttpInstance" + | "Domain" + | "EventDispatcher" + | "LoggerLevel" + | "WSClient" +>; + +const defaultFeishuClientSdk: FeishuClientSdk = { + AppType: Lark.AppType, + Client: Lark.Client, + defaultHttpInstance: Lark.defaultHttpInstance, + Domain: Lark.Domain, + EventDispatcher: Lark.EventDispatcher, + LoggerLevel: Lark.LoggerLevel, + WSClient: Lark.WSClient, +}; + +let feishuClientSdk: FeishuClientSdk = defaultFeishuClientSdk; +let httpsProxyAgentCtor: typeof HttpsProxyAgent = HttpsProxyAgent; + /** Default HTTP timeout for Feishu API requests (30 seconds). */ export const FEISHU_HTTP_TIMEOUT_MS = 30_000; export const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000; @@ -14,7 +38,7 @@ function getWsProxyAgent(): HttpsProxyAgent | undefined { process.env.http_proxy || process.env.HTTP_PROXY; if (!proxyUrl) return undefined; - return new HttpsProxyAgent(proxyUrl); + return new httpsProxyAgentCtor(proxyUrl); } // Multi-account client cache @@ -28,10 +52,10 @@ const clientCache = new Map< function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string { if (domain === "lark") { - return Lark.Domain.Lark; + return feishuClientSdk.Domain.Lark; } if (domain === "feishu" || !domain) { - return Lark.Domain.Feishu; + return feishuClientSdk.Domain.Feishu; } return domain.replace(/\/+$/, ""); // Custom URL for private deployment } @@ -42,7 +66,8 @@ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string { * (e.g. when the Feishu API is slow, causing per-chat queue deadlocks). */ function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance { - const base: Lark.HttpInstance = Lark.defaultHttpInstance as unknown as Lark.HttpInstance; + const base: Lark.HttpInstance = + feishuClientSdk.defaultHttpInstance as unknown as Lark.HttpInstance; function injectTimeout(opts?: Lark.HttpRequestOptions): Lark.HttpRequestOptions { return { timeout: defaultTimeoutMs, ...opts } as Lark.HttpRequestOptions; @@ -129,10 +154,10 @@ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client } // Create new client with timeout-aware HTTP instance - const client = new Lark.Client({ + const client = new feishuClientSdk.Client({ appId, appSecret, - appType: Lark.AppType.SelfBuild, + appType: feishuClientSdk.AppType.SelfBuild, domain: resolveDomain(domain), httpInstance: createTimeoutHttpInstance(defaultHttpTimeoutMs), }); @@ -158,11 +183,11 @@ export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSCli } const agent = getWsProxyAgent(); - return new Lark.WSClient({ + return new feishuClientSdk.WSClient({ appId, appSecret, domain: resolveDomain(domain), - loggerLevel: Lark.LoggerLevel.info, + loggerLevel: feishuClientSdk.LoggerLevel.info, ...(agent ? { agent } : {}), }); } @@ -171,7 +196,7 @@ export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSCli * Create an event dispatcher for an account. */ export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher { - return new Lark.EventDispatcher({ + return new feishuClientSdk.EventDispatcher({ encryptKey: account.encryptKey, verificationToken: account.verificationToken, }); @@ -194,3 +219,13 @@ export function clearClientCache(accountId?: string): void { clientCache.clear(); } } + +export function setFeishuClientRuntimeForTest(overrides?: { + sdk?: Partial; + HttpsProxyAgent?: typeof HttpsProxyAgent; +}): void { + feishuClientSdk = overrides?.sdk + ? { ...defaultFeishuClientSdk, ...overrides.sdk } + : defaultFeishuClientSdk; + httpsProxyAgentCtor = overrides?.HttpsProxyAgent ?? HttpsProxyAgent; +} diff --git a/extensions/feishu/src/monitor.bot-menu.test.ts b/extensions/feishu/src/monitor.bot-menu.test.ts index cecb0b0512c..988e04d80ca 100644 --- a/extensions/feishu/src/monitor.bot-menu.test.ts +++ b/extensions/feishu/src/monitor.bot-menu.test.ts @@ -5,7 +5,7 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../../src/auto-reply/inbound-debounce.js"; -import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import { monitorSingleAccount } from "./monitor.account.js"; import { setFeishuRuntime } from "./runtime.js"; import type { ResolvedFeishuAccount } from "./types.js"; diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts index 001b8140f80..048aed2247e 100644 --- a/extensions/feishu/src/monitor.reaction.test.ts +++ b/extensions/feishu/src/monitor.reaction.test.ts @@ -5,7 +5,7 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../../src/auto-reply/inbound-debounce.js"; -import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js"; import * as dedup from "./dedup.js"; import { monitorSingleAccount } from "./monitor.account.js"; diff --git a/extensions/feishu/src/probe.test.ts b/extensions/feishu/src/probe.test.ts index bfc270a4459..f394aec8b3e 100644 --- a/extensions/feishu/src/probe.test.ts +++ b/extensions/feishu/src/probe.test.ts @@ -6,7 +6,15 @@ vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock, })); -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 = { @@ -40,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); @@ -93,7 +106,10 @@ async function readSequentialDefaultProbePair() { } describe("probeFeishu", () => { - beforeEach(() => { + beforeEach(async () => { + ({ FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } = await importProbeModule( + `probe-${Date.now()}-${Math.random()}`, + )); clearProbeCache(); vi.restoreAllMocks(); }); diff --git a/extensions/feishu/src/subagent-hooks.test.ts b/extensions/feishu/src/subagent-hooks.test.ts index a86e8996f35..87450b10265 100644 --- a/extensions/feishu/src/subagent-hooks.test.ts +++ b/extensions/feishu/src/subagent-hooks.test.ts @@ -1,5 +1,9 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getRequiredHookHandler, + registerHookHandlersForTest, +} from "../../../test/helpers/extensions/subagent-hooks.js"; import { registerFeishuSubagentHooks } from "./subagent-hooks.js"; import { __testing as threadBindingTesting, @@ -12,26 +16,10 @@ const baseConfig = { }; function registerHandlersForTest(config: Record = baseConfig) { - const handlers = new Map unknown>(); - const api = { + return registerHookHandlersForTest({ config, - on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => { - handlers.set(hookName, handler); - }, - } as unknown as OpenClawPluginApi; - registerFeishuSubagentHooks(api); - return handlers; -} - -function getRequiredHandler( - handlers: Map unknown>, - hookName: string, -): (event: unknown, ctx: unknown) => unknown { - const handler = handlers.get(hookName); - if (!handler) { - throw new Error(`expected ${hookName} hook handler`); - } - return handler; + register: registerFeishuSubagentHooks, + }); } describe("feishu subagent hook handlers", () => { @@ -49,7 +37,7 @@ describe("feishu subagent hook handlers", () => { it("binds a Feishu DM conversation on subagent_spawning", async () => { const handlers = registerHandlersForTest(); - const handler = getRequiredHandler(handlers, "subagent_spawning"); + const handler = getRequiredHookHandler(handlers, "subagent_spawning"); createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); const result = await handler( @@ -70,7 +58,7 @@ describe("feishu subagent hook handlers", () => { expect(result).toEqual({ status: "ok", threadBindingReady: true }); - const deliveryTargetHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + const deliveryTargetHandler = getRequiredHookHandler(handlers, "subagent_delivery_target"); expect( deliveryTargetHandler( { @@ -96,7 +84,7 @@ describe("feishu subagent hook handlers", () => { it("preserves the original Feishu DM delivery target", async () => { const handlers = registerHandlersForTest(); - const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + const deliveryHandler = getRequiredHookHandler(handlers, "subagent_delivery_target"); const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); manager.bindConversation({ @@ -134,8 +122,8 @@ describe("feishu subagent hook handlers", () => { it("binds a Feishu topic conversation and preserves parent context", async () => { const handlers = registerHandlersForTest(); - const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); - const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + const spawnHandler = getRequiredHookHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHookHandler(handlers, "subagent_delivery_target"); createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); const result = await spawnHandler( @@ -183,8 +171,8 @@ describe("feishu subagent hook handlers", () => { it("uses the requester session binding to preserve sender-scoped topic conversations", async () => { const handlers = registerHandlersForTest(); - const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); - const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + const spawnHandler = getRequiredHookHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHookHandler(handlers, "subagent_delivery_target"); const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); manager.bindConversation({ @@ -252,8 +240,8 @@ describe("feishu subagent hook handlers", () => { it("prefers requester-matching bindings when multiple child bindings exist", async () => { const handlers = registerHandlersForTest(); - const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); - const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + const spawnHandler = getRequiredHookHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHookHandler(handlers, "subagent_delivery_target"); createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); await spawnHandler( @@ -312,8 +300,8 @@ describe("feishu subagent hook handlers", () => { it("fails closed when requester-session bindings remain ambiguous for the same topic", async () => { const handlers = registerHandlersForTest(); - const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); - const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + const spawnHandler = getRequiredHookHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHookHandler(handlers, "subagent_delivery_target"); const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); manager.bindConversation({ @@ -375,8 +363,8 @@ describe("feishu subagent hook handlers", () => { it("fails closed when both topic-level and sender-scoped requester bindings exist", async () => { const handlers = registerHandlersForTest(); - const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); - const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + const spawnHandler = getRequiredHookHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHookHandler(handlers, "subagent_delivery_target"); const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); manager.bindConversation({ @@ -438,9 +426,9 @@ describe("feishu subagent hook handlers", () => { it("no-ops for non-Feishu channels and non-threaded spawns", async () => { const handlers = registerHandlersForTest(); - const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); - const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); - const endedHandler = getRequiredHandler(handlers, "subagent_ended"); + const spawnHandler = getRequiredHookHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHookHandler(handlers, "subagent_delivery_target"); + const endedHandler = getRequiredHookHandler(handlers, "subagent_ended"); await expect( spawnHandler( @@ -506,7 +494,7 @@ describe("feishu subagent hook handlers", () => { }); it("returns an error for unsupported non-topic Feishu group conversations", async () => { - const handler = getRequiredHandler(registerHandlersForTest(), "subagent_spawning"); + const handler = getRequiredHookHandler(registerHandlersForTest(), "subagent_spawning"); createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); await expect( @@ -532,9 +520,9 @@ describe("feishu subagent hook handlers", () => { it("unbinds Feishu bindings on subagent_ended", async () => { const handlers = registerHandlersForTest(); - const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); - const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); - const endedHandler = getRequiredHandler(handlers, "subagent_ended"); + const spawnHandler = getRequiredHookHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHookHandler(handlers, "subagent_delivery_target"); + const endedHandler = getRequiredHookHandler(handlers, "subagent_ended"); createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); await spawnHandler( @@ -581,8 +569,8 @@ describe("feishu subagent hook handlers", () => { it("fails closed when the Feishu monitor-owned binding manager is unavailable", async () => { const handlers = registerHandlersForTest(); - const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); - const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + const spawnHandler = getRequiredHookHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHookHandler(handlers, "subagent_delivery_target"); await expect( spawnHandler( diff --git a/extensions/firecrawl/index.ts b/extensions/firecrawl/index.ts index 6b38ac6dc75..aa6e41070be 100644 --- a/extensions/firecrawl/index.ts +++ b/extensions/firecrawl/index.ts @@ -1,22 +1,15 @@ -import { - emptyPluginConfigSchema, - type AnyAgentTool, - type OpenClawPluginApi, -} from "openclaw/plugin-sdk/core"; +import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/core"; import { createFirecrawlScrapeTool } from "./src/firecrawl-scrape-tool.js"; import { createFirecrawlWebSearchProvider } from "./src/firecrawl-search-provider.js"; import { createFirecrawlSearchTool } from "./src/firecrawl-search-tool.js"; -const firecrawlPlugin = { +export default definePluginEntry({ id: "firecrawl", name: "Firecrawl Plugin", description: "Bundled Firecrawl search and scrape plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerWebSearchProvider(createFirecrawlWebSearchProvider()); api.registerTool(createFirecrawlSearchTool(api) as AnyAgentTool); api.registerTool(createFirecrawlScrapeTool(api) as AnyAgentTool); }, -}; - -export default firecrawlPlugin; +}); diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 45f964c60f0..ee85f76fd61 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -1,6 +1,5 @@ import { - emptyPluginConfigSchema, - type OpenClawPluginApi, + definePluginEntry, type ProviderAuthContext, type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, @@ -116,12 +115,11 @@ async function runGitHubCopilotAuth(ctx: ProviderAuthContext) { }; } -const githubCopilotPlugin = { +export default definePluginEntry({ id: "github-copilot", name: "GitHub Copilot Provider", description: "Bundled GitHub Copilot provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "GitHub Copilot", @@ -196,6 +194,4 @@ const githubCopilotPlugin = { await fetchCopilotUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), }); }, -}; - -export default githubCopilotPlugin; +}); diff --git a/extensions/github-copilot/usage.test.ts b/extensions/github-copilot/usage.test.ts index 0bc97974d70..f0687c33b0a 100644 --- a/extensions/github-copilot/usage.test.ts +++ b/extensions/github-copilot/usage.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; +import { + createProviderUsageFetch, + makeResponse, +} from "../../test/helpers/extensions/provider-usage-fetch.js"; import { fetchCopilotUsage } from "./usage.js"; describe("fetchCopilotUsage", () => { diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 87872051cbd..f9268cc0aae 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { buildGoogleImageGenerationProvider } from "openclaw/plugin-sdk/image-generation"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { @@ -14,12 +14,11 @@ import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; import { googleMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; -const googlePlugin = { +export default definePluginEntry({ id: "google", name: "Google Plugin", description: "Bundled Google plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: "google", label: "Google AI Studio", @@ -70,6 +69,4 @@ const googlePlugin = { }), ); }, -}; - -export default googlePlugin; +}); diff --git a/extensions/google/oauth.credentials.ts b/extensions/google/oauth.credentials.ts index 1c1e88db042..670ae4de943 100644 --- a/extensions/google/oauth.credentials.ts +++ b/extensions/google/oauth.credentials.ts @@ -1,7 +1,27 @@ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import type { Dirent } from "node:fs"; import { delimiter, dirname, join } from "node:path"; import { CLIENT_ID_KEYS, CLIENT_SECRET_KEYS } from "./oauth.shared.js"; +type CredentialFs = { + existsSync: (path: Parameters[0]) => ReturnType; + readFileSync: (path: Parameters[0], encoding: "utf8") => string; + realpathSync: (path: Parameters[0]) => string; + readdirSync: ( + path: Parameters[0], + options: { withFileTypes: true }, + ) => Dirent[]; +}; + +const defaultFs: CredentialFs = { + existsSync, + readFileSync, + realpathSync, + readdirSync, +}; + +let credentialFs: CredentialFs = defaultFs; + function resolveEnv(keys: string[]): string | undefined { for (const key of keys) { const value = process.env[key]?.trim(); @@ -18,6 +38,10 @@ export function clearCredentialsCache(): void { cachedGeminiCliCredentials = null; } +export function setOAuthCredentialsFsForTest(overrides?: Partial): void { + credentialFs = overrides ? { ...defaultFs, ...overrides } : defaultFs; +} + export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { if (cachedGeminiCliCredentials) { return cachedGeminiCliCredentials; @@ -29,7 +53,7 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret: return null; } - const resolvedPath = realpathSync(geminiPath); + const resolvedPath = credentialFs.realpathSync(geminiPath); const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath); let content: string | null = null; @@ -55,10 +79,9 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret: "oauth2.js", ), ]; - for (const path of searchPaths) { - if (existsSync(path)) { - content = readFileSync(path, "utf8"); + if (credentialFs.existsSync(path)) { + content = credentialFs.readFileSync(path, "utf8"); break; } } @@ -67,7 +90,7 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret: } const found = findFile(geminiCliDir, "oauth2.js", 10); if (found) { - content = readFileSync(found, "utf8"); + content = credentialFs.readFileSync(found, "utf8"); break; } } @@ -116,7 +139,7 @@ function findInPath(name: string): string | null { for (const dir of (process.env.PATH ?? "").split(delimiter)) { for (const ext of exts) { const path = join(dir, name + ext); - if (existsSync(path)) { + if (credentialFs.existsSync(path)) { return path; } } @@ -129,7 +152,7 @@ function findFile(dir: string, name: string, depth: number): string | null { return null; } try { - for (const entry of readdirSync(dir, { withFileTypes: true })) { + for (const entry of credentialFs.readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name); if (entry.isFile() && entry.name === name) { return path; diff --git a/extensions/google/oauth.test.ts b/extensions/google/oauth.test.ts index 8aec64d528d..d37f0751dbe 100644 --- a/extensions/google/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -21,23 +21,11 @@ vi.mock("../../src/infra/net/fetch-guard.js", () => ({ }, })); -// Mock fs module before importing the module under test const mockExistsSync = vi.fn(); const mockReadFileSync = vi.fn(); const mockRealpathSync = vi.fn(); const mockReaddirSync = vi.fn(); -vi.mock("node:fs", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - existsSync: (...args: Parameters) => mockExistsSync(...args), - readFileSync: (...args: Parameters) => mockReadFileSync(...args), - realpathSync: (...args: Parameters) => mockRealpathSync(...args), - readdirSync: (...args: Parameters) => mockReaddirSync(...args), - }; -}); - describe("extractGeminiCliCredentials", () => { const normalizePath = (value: string) => value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); @@ -51,6 +39,20 @@ describe("extractGeminiCliCredentials", () => { let originalPath: string | undefined; + async function loadCredentialsModule() { + return await import("./oauth.credentials.js"); + } + + async function installMockFs() { + const { setOAuthCredentialsFsForTest } = await loadCredentialsModule(); + setOAuthCredentialsFsForTest({ + existsSync: (...args) => mockExistsSync(...args), + readFileSync: (...args) => mockReadFileSync(...args), + realpathSync: (...args) => mockRealpathSync(...args), + readdirSync: (...args) => mockReaddirSync(...args), + }); + } + function makeFakeLayout() { const binDir = join(rootDir, "fake", "bin"); const geminiPath = join(binDir, "gemini"); @@ -157,17 +159,20 @@ describe("extractGeminiCliCredentials", () => { beforeEach(async () => { vi.clearAllMocks(); originalPath = process.env.PATH; + await installMockFs(); }); - afterEach(() => { + afterEach(async () => { process.env.PATH = originalPath; + const { setOAuthCredentialsFsForTest } = await loadCredentialsModule(); + setOAuthCredentialsFsForTest(); }); it("returns null when gemini binary is not in PATH", async () => { process.env.PATH = "/nonexistent"; mockExistsSync.mockReturnValue(false); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); expect(extractGeminiCliCredentials()).toBeNull(); }); @@ -175,7 +180,7 @@ describe("extractGeminiCliCredentials", () => { it("extracts credentials from oauth2.js in known path", async () => { installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); const result = extractGeminiCliCredentials(); @@ -185,7 +190,7 @@ describe("extractGeminiCliCredentials", () => { it("extracts credentials when PATH entry is an npm global shim", async () => { installNpmShimLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); const result = extractGeminiCliCredentials(); @@ -195,7 +200,7 @@ describe("extractGeminiCliCredentials", () => { it("returns null when oauth2.js cannot be found", async () => { installGeminiLayout({ oauth2Exists: false, readdir: [] }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); expect(extractGeminiCliCredentials()).toBeNull(); }); @@ -203,7 +208,7 @@ describe("extractGeminiCliCredentials", () => { it("returns null when oauth2.js lacks credentials", async () => { installGeminiLayout({ oauth2Exists: true, oauth2Content: "// no credentials here" }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); expect(extractGeminiCliCredentials()).toBeNull(); }); @@ -211,7 +216,7 @@ describe("extractGeminiCliCredentials", () => { it("caches credentials after first extraction", async () => { installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); // First call 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.outbound.test.ts b/extensions/googlechat/src/channel.outbound.test.ts index c9180dd8158..b936a5e3139 100644 --- a/extensions/googlechat/src/channel.outbound.test.ts +++ b/extensions/googlechat/src/channel.outbound.test.ts @@ -4,10 +4,14 @@ import { describe, expect, it, vi } from "vitest"; const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn()); const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn()); -vi.mock("./api.js", () => ({ - sendGoogleChatMessage: sendGoogleChatMessageMock, - uploadGoogleChatAttachment: uploadGoogleChatAttachmentMock, -})); +vi.mock("./api.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendGoogleChatMessage: sendGoogleChatMessageMock, + uploadGoogleChatAttachment: uploadGoogleChatAttachmentMock, + }; +}); import { googlechatPlugin } from "./channel.js"; import { setGoogleChatRuntime } from "./runtime.js"; diff --git a/extensions/googlechat/src/channel.runtime.ts b/extensions/googlechat/src/channel.runtime.ts index 1e41376c8f5..81f000f95e7 100644 --- a/extensions/googlechat/src/channel.runtime.ts +++ b/extensions/googlechat/src/channel.runtime.ts @@ -8,36 +8,10 @@ import { startGoogleChatMonitor as startGoogleChatMonitorImpl, } from "./monitor.js"; -type ProbeGoogleChat = typeof import("./api.js").probeGoogleChat; -type SendGoogleChatMessage = typeof import("./api.js").sendGoogleChatMessage; -type UploadGoogleChatAttachment = typeof import("./api.js").uploadGoogleChatAttachment; -type ResolveGoogleChatWebhookPath = typeof import("./monitor.js").resolveGoogleChatWebhookPath; -type StartGoogleChatMonitor = typeof import("./monitor.js").startGoogleChatMonitor; - -export function probeGoogleChat(...args: Parameters): ReturnType { - return probeGoogleChatImpl(...args); -} - -export function sendGoogleChatMessage( - ...args: Parameters -): ReturnType { - return sendGoogleChatMessageImpl(...args); -} - -export function uploadGoogleChatAttachment( - ...args: Parameters -): ReturnType { - return uploadGoogleChatAttachmentImpl(...args); -} - -export function resolveGoogleChatWebhookPath( - ...args: Parameters -): ReturnType { - return resolveGoogleChatWebhookPathImpl(...args); -} - -export function startGoogleChatMonitor( - ...args: Parameters -): ReturnType { - return startGoogleChatMonitorImpl(...args); -} +export const googleChatChannelRuntime = { + probeGoogleChat: probeGoogleChatImpl, + sendGoogleChatMessage: sendGoogleChatMessageImpl, + uploadGoogleChatAttachment: uploadGoogleChatAttachmentImpl, + resolveGoogleChatWebhookPath: resolveGoogleChatWebhookPathImpl, + startGoogleChatMonitor: startGoogleChatMonitorImpl, +}; diff --git a/extensions/googlechat/src/channel.startup.test.ts b/extensions/googlechat/src/channel.startup.test.ts index 11c46aa663a..e65aa444314 100644 --- a/extensions/googlechat/src/channel.startup.test.ts +++ b/extensions/googlechat/src/channel.startup.test.ts @@ -4,7 +4,7 @@ import { abortStartedAccount, expectPendingUntilAbort, startAccountAndTrackLifecycle, -} from "../../test-utils/start-account-lifecycle.js"; +} from "../../../test/helpers/extensions/start-account-lifecycle.js"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index faa1b4e4930..95aeccfbac2 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -27,6 +27,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/googlechat"; import { GoogleChatConfigSchema } from "openclaw/plugin-sdk/googlechat"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listGoogleChatAccountIds, @@ -47,9 +48,10 @@ import { const meta = getChatChannelMeta("googlechat"); -async function loadGoogleChatChannelRuntime() { - return await import("./channel.runtime.js"); -} +const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport( + () => import("./channel.runtime.js"), + "googleChatChannelRuntime", +); const formatAllowFromEntry = (entry: string) => entry diff --git a/extensions/googlechat/src/monitor.webhook-routing.test.ts b/extensions/googlechat/src/monitor.webhook-routing.test.ts index 2258d154449..f5e7c69ef8a 100644 --- a/extensions/googlechat/src/monitor.webhook-routing.test.ts +++ b/extensions/googlechat/src/monitor.webhook-routing.test.ts @@ -4,7 +4,7 @@ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/googlech import { afterEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; -import { createMockServerResponse } from "../../test-utils/mock-http-response.js"; +import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { verifyGoogleChatRequest } from "./auth.js"; import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget } from "./monitor.js"; diff --git a/extensions/googlechat/src/setup-surface.test.ts b/extensions/googlechat/src/setup-surface.test.ts index e8855648c99..15d77a46605 100644 --- a/extensions/googlechat/src/setup-surface.test.ts +++ b/extensions/googlechat/src/setup-surface.test.ts @@ -1,31 +1,13 @@ -import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/googlechat"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/googlechat"; import { describe, expect, it, vi } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { + createTestWizardPrompter, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { googlechatPlugin } from "./channel.js"; -const selectFirstOption = async (params: { options: Array<{ value: T }> }): Promise => { - const first = params.options[0]; - if (!first) { - throw new Error("no options"); - } - return first.value; -}; - -function createPrompter(overrides: Partial): WizardPrompter { - return { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), - select: selectFirstOption as WizardPrompter["select"], - multiselect: vi.fn(async () => []), - text: vi.fn(async () => "") as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - ...overrides, - }; -} - const googlechatConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ plugin: googlechatPlugin, wizard: googlechatPlugin.setupWizard!, @@ -33,7 +15,7 @@ const googlechatConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard describe("googlechat setup wizard", () => { it("configures service-account auth and webhook audience", async () => { - const prompter = createPrompter({ + const prompter = createTestWizardPrompter({ text: vi.fn(async ({ message }: { message: string }) => { if (message === "Service account JSON path") { return "/tmp/googlechat-service-account.json"; diff --git a/extensions/huggingface/index.ts b/extensions/huggingface/index.ts index c0c65f0051b..6f50743f43c 100644 --- a/extensions/huggingface/index.ts +++ b/extensions/huggingface/index.ts @@ -1,16 +1,15 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { applyHuggingfaceConfig, HUGGINGFACE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildHuggingfaceProvider } from "./provider-catalog.js"; const PROVIDER_ID = "huggingface"; -const huggingfacePlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Hugging Face Provider", description: "Bundled Hugging Face provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Hugging Face", @@ -56,6 +55,4 @@ const huggingfacePlugin = { }, }); }, -}; - -export default huggingfacePlugin; +}); 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.ts b/extensions/imessage/src/channel.ts index 973456af7bb..3c34cea1be7 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -15,6 +15,7 @@ import { 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 +24,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"]; diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index 17f1b7487d3..6ea7382106a 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -13,7 +13,7 @@ import type { ChannelSetupWizard, ChannelSetupWizardTextInput, } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +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 27e8b256ada..ae6cdb2fcc1 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -1,5 +1,5 @@ import { setSetupChannelEnabled, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { detectBinary } from "../../../src/plugins/setup-binary.js"; +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 1ede2ad412d..301b1848f99 100644 --- a/extensions/imessage/src/shared.ts +++ b/extensions/imessage/src/shared.ts @@ -3,19 +3,17 @@ import { collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../../../src/channels/plugins/config-helpers.js"; -import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; -import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; -import { IMessageConfigSchema } from "../../../src/config/zod-schema.providers-core.js"; -import { formatTrimmedAllowFromEntries, + getChatChannelMeta, + IMessageConfigSchema, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo, -} from "../../../src/plugin-sdk/channel-config-helpers.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; + setAccountEnabledInConfigSection, + type ChannelPlugin, +} 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/irc/src/channel.startup.test.ts b/extensions/irc/src/channel.startup.test.ts index 7b4416d1892..de3526a32d2 100644 --- a/extensions/irc/src/channel.startup.test.ts +++ b/extensions/irc/src/channel.startup.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { expectStopPendingUntilAbort, startAccountAndTrackLifecycle, -} from "../../test-utils/start-account-lifecycle.js"; +} from "../../../test/helpers/extensions/start-account-lifecycle.js"; import type { ResolvedIrcAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ diff --git a/extensions/irc/src/send.test.ts b/extensions/irc/src/send.test.ts index 8fbe58e7f22..7dc064930be 100644 --- a/extensions/irc/src/send.test.ts +++ b/extensions/irc/src/send.test.ts @@ -3,7 +3,7 @@ import { createSendCfgThreadingRuntime, expectProvidedCfgSkipsRuntimeLoad, expectRuntimeCfgFallback, -} from "../../test-utils/send-config.js"; +} from "../../../test/helpers/extensions/send-config.js"; import type { IrcClient } from "./client.js"; import type { CoreConfig } from "./types.js"; diff --git a/extensions/irc/src/setup-surface.test.ts b/extensions/irc/src/setup-surface.test.ts index 147432b6131..5741a90ad96 100644 --- a/extensions/irc/src/setup-surface.test.ts +++ b/extensions/irc/src/setup-surface.test.ts @@ -1,32 +1,14 @@ -import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/irc"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/irc"; import { describe, expect, it, vi } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { + createTestWizardPrompter, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { ircPlugin } from "./channel.js"; import type { CoreConfig } from "./types.js"; -const selectFirstOption = async (params: { options: Array<{ value: T }> }): Promise => { - const first = params.options[0]; - if (!first) { - throw new Error("no options"); - } - return first.value; -}; - -function createPrompter(overrides: Partial): WizardPrompter { - return { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), - select: selectFirstOption as WizardPrompter["select"], - multiselect: vi.fn(async () => []), - text: vi.fn(async () => "") as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - ...overrides, - }; -} - const ircConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ plugin: ircPlugin, wizard: ircPlugin.setupWizard!, @@ -34,7 +16,7 @@ const ircConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ describe("irc setup wizard", () => { it("configures host and nick via setup prompts", async () => { - const prompter = createPrompter({ + const prompter = createTestWizardPrompter({ text: vi.fn(async ({ message }: { message: string }) => { if (message === "IRC server host") { return "irc.libera.chat"; @@ -93,7 +75,7 @@ describe("irc setup wizard", () => { }); it("writes DM allowFrom to top-level config for non-default account prompts", async () => { - const prompter = createPrompter({ + const prompter = createTestWizardPrompter({ text: vi.fn(async ({ message }: { message: string }) => { if (message === "IRC allowFrom (nick or nick!user@host)") { return "Alice, Bob!ident@example.org"; diff --git a/extensions/kilocode/index.ts b/extensions/kilocode/index.ts index d875bfdb3c2..edbe5db7cfb 100644 --- a/extensions/kilocode/index.ts +++ b/extensions/kilocode/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { @@ -10,12 +10,11 @@ import { buildKilocodeProviderWithDiscovery } from "./provider-catalog.js"; const PROVIDER_ID = "kilocode"; -const kilocodePlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Kilo Gateway Provider", description: "Bundled Kilo Gateway provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Kilo Gateway", @@ -66,6 +65,4 @@ const kilocodePlugin = { isCacheTtlEligible: (ctx) => ctx.modelId.startsWith("anthropic/"), }); }, -}; - -export default kilocodePlugin; +}); diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts index 03f680a5c38..579f469d595 100644 --- a/extensions/kimi-coding/index.ts +++ b/extensions/kimi-coding/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { isRecord } from "openclaw/plugin-sdk/text-runtime"; import { applyKimiCodeConfig, KIMI_CODING_MODEL_REF } from "./onboard.js"; @@ -7,12 +7,11 @@ import { buildKimiCodingProvider } from "./provider-catalog.js"; const PLUGIN_ID = "kimi"; const PROVIDER_ID = "kimi"; -const kimiCodingPlugin = { +export default definePluginEntry({ id: PLUGIN_ID, name: "Kimi Provider", description: "Bundled Kimi provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Kimi", @@ -82,6 +81,4 @@ const kimiCodingPlugin = { }, }); }, -}; - -export default kimiCodingPlugin; +}); diff --git a/extensions/line/api.ts b/extensions/line/api.ts new file mode 100644 index 00000000000..c4150b2a242 --- /dev/null +++ b/extensions/line/api.ts @@ -0,0 +1,3 @@ +export * from "openclaw/plugin-sdk/line"; +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/line/src/card-command.ts b/extensions/line/src/card-command.ts index cc5ec78eeab..f63e42576c9 100644 --- a/extensions/line/src/card-command.ts +++ b/extensions/line/src/card-command.ts @@ -1,4 +1,4 @@ -import type { LineChannelData, OpenClawPluginApi, ReplyPayload } from "openclaw/plugin-sdk/line"; +import type { LineChannelData, OpenClawPluginApi, ReplyPayload } from "../api.js"; import { createActionCard, createImageCard, @@ -7,7 +7,7 @@ import { createReceiptCard, type CardAction, type ListItem, -} from "openclaw/plugin-sdk/line"; +} from "../api.js"; const CARD_USAGE = `Usage: /card "title" "body" [options] diff --git a/extensions/line/src/channel.logout.test.ts b/extensions/line/src/channel.logout.test.ts index b10d484fbb1..4f474032dc9 100644 --- a/extensions/line/src/channel.logout.test.ts +++ b/extensions/line/src/channel.logout.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "openclaw/plugin-sdk/line"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import { linePlugin } from "./channel.js"; import { setLineRuntime } from "./runtime.js"; diff --git a/extensions/line/src/channel.setup.ts b/extensions/line/src/channel.setup.ts index 771107dff58..5df541d6286 100644 --- a/extensions/line/src/channel.setup.ts +++ b/extensions/line/src/channel.setup.ts @@ -4,12 +4,8 @@ import { type ChannelPlugin, type OpenClawConfig, type ResolvedLineAccount, -} from "openclaw/plugin-sdk/line"; -import { - listLineAccountIds, - resolveDefaultLineAccountId, - resolveLineAccount, -} from "openclaw/plugin-sdk/line"; +} from "../api.js"; +import { listLineAccountIds, resolveDefaultLineAccountId, resolveLineAccount } from "../api.js"; import { lineSetupAdapter } from "./setup-core.js"; import { lineSetupWizard } from "./setup-surface.js"; diff --git a/extensions/line/src/channel.startup.test.ts b/extensions/line/src/channel.startup.test.ts index e4de0f38e3b..9f1e10cd6fc 100644 --- a/extensions/line/src/channel.startup.test.ts +++ b/extensions/line/src/channel.startup.test.ts @@ -6,7 +6,7 @@ import type { ResolvedLineAccount, } from "openclaw/plugin-sdk/line"; import { describe, expect, it, vi } from "vitest"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import { linePlugin } from "./channel.js"; import { setLineRuntime } from "./runtime.js"; diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index ee3c9597eab..f2d4b84f8bc 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -18,7 +18,7 @@ import { type LineConfig, type LineChannelData, type ResolvedLineAccount, -} from "openclaw/plugin-sdk/line"; +} from "../api.js"; import { getLineRuntime } from "./runtime.js"; import { lineSetupAdapter } from "./setup-core.js"; import { lineSetupWizard } from "./setup-surface.js"; diff --git a/extensions/line/src/runtime.ts b/extensions/line/src/runtime.ts index 65dd4d5394b..3541eed4ed7 100644 --- a/extensions/line/src/runtime.ts +++ b/extensions/line/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/line"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import type { PluginRuntime } from "../api.js"; const { setRuntime: setLineRuntime, getRuntime: getLineRuntime } = createPluginRuntimeStore("LINE runtime not initialized - plugin not registered"); diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 737ba1cc856..95554e0a835 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -1,11 +1,7 @@ -import { - listLineAccountIds, - normalizeAccountId, - resolveLineAccount, - type LineConfig, -} from "openclaw/plugin-sdk/line"; import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; +import { normalizeAccountId, resolveLineAccount } from "../../../src/line/accounts.js"; +import type { LineConfig } from "../../../src/line/types.js"; const channel = "line" as const; @@ -158,4 +154,4 @@ export const lineSetupAdapter: ChannelSetupAdapter = { }, }; -export { listLineAccountIds }; +export { listLineAccountIds } from "../../../src/line/accounts.js"; diff --git a/extensions/line/src/setup-surface.test.ts b/extensions/line/src/setup-surface.test.ts index 3fd98df4b2e..3c2e6bc05e4 100644 --- a/extensions/line/src/setup-surface.test.ts +++ b/extensions/line/src/setup-surface.test.ts @@ -6,30 +6,13 @@ import { resolveDefaultLineAccountId, resolveLineAccount, } from "../../../src/line/accounts.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { + createTestWizardPrompter, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { lineSetupAdapter, lineSetupWizard } from "./setup-surface.js"; -function createPrompter(overrides: Partial = {}): WizardPrompter { - return { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), - select: vi.fn(async ({ options }: { options: Array<{ value: string }> }) => { - const first = options[0]; - if (!first) { - throw new Error("no options"); - } - return first.value; - }) as WizardPrompter["select"], - multiselect: vi.fn(async () => []), - text: vi.fn(async () => "") as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - ...overrides, - }; -} - const lineConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ plugin: { id: "line", @@ -47,7 +30,7 @@ const lineConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ describe("line setup wizard", () => { it("configures token and secret for the default account", async () => { - const prompter = createPrompter({ + const prompter = createTestWizardPrompter({ text: vi.fn(async ({ message }: { message: string }) => { if (message === "Enter LINE channel access token") { return "line-token"; diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index d548b096434..1d994ebb128 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -1,4 +1,3 @@ -import { resolveLineAccount } from "openclaw/plugin-sdk/line"; import { DEFAULT_ACCOUNT_ID, formatDocsLink, @@ -8,6 +7,7 @@ import { type ChannelSetupDmPolicy, type ChannelSetupWizard, } from "openclaw/plugin-sdk/setup"; +import { resolveLineAccount } from "../../../src/line/accounts.js"; import { isLineConfigured, listLineAccountIds, 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 7d258ab6a39..68dd70503c2 100644 --- a/extensions/llm-task/index.ts +++ b/extensions/llm-task/index.ts @@ -1,6 +1,11 @@ -import type { AnyAgentTool, 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 function register(api: OpenClawPluginApi) { - api.registerTool(createLlmTaskTool(api) as unknown as AnyAgentTool, { optional: true }); -} +export default definePluginEntry({ + id: "llm-task", + name: "LLM Task", + description: "Optional tool for structured subtask execution", + register(api: OpenClawPluginApi) { + api.registerTool(createLlmTaskTool(api) as unknown as AnyAgentTool, { optional: true }); + }, +}); 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/index.ts b/extensions/lobster/index.ts index 1d5775c4d74..c70ccc49da0 100644 --- a/extensions/lobster/index.ts +++ b/extensions/lobster/index.ts @@ -1,18 +1,24 @@ -import type { - AnyAgentTool, - OpenClawPluginApi, - OpenClawPluginToolFactory, +import { + definePluginEntry, + type AnyAgentTool, + type OpenClawPluginApi, + type OpenClawPluginToolFactory, } from "openclaw/plugin-sdk/lobster"; import { createLobsterTool } from "./src/lobster-tool.js"; -export default function register(api: OpenClawPluginApi) { - api.registerTool( - ((ctx) => { - if (ctx.sandboxed) { - return null; - } - return createLobsterTool(api) as AnyAgentTool; - }) as OpenClawPluginToolFactory, - { optional: true }, - ); -} +export default definePluginEntry({ + id: "lobster", + name: "Lobster", + description: "Optional local shell helper tools", + register(api: OpenClawPluginApi) { + api.registerTool( + ((ctx) => { + if (ctx.sandboxed) { + return null; + } + return createLobsterTool(api) as AnyAgentTool; + }) as OpenClawPluginToolFactory, + { optional: true }, + ); + }, +}); 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.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts index 2c5bc9533f3..ced16d90638 100644 --- a/extensions/matrix/src/channel.directory.test.ts +++ b/extensions/matrix/src/channel.directory.test.ts @@ -1,6 +1,6 @@ import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import { matrixPlugin } from "./channel.js"; import { setMatrixRuntime } from "./runtime.js"; import { createMatrixBotSdkMock } from "./test-mocks.js"; diff --git a/extensions/matrix/src/channel.runtime.ts b/extensions/matrix/src/channel.runtime.ts index df56d07ff2c..475d53629e1 100644 --- a/extensions/matrix/src/channel.runtime.ts +++ b/extensions/matrix/src/channel.runtime.ts @@ -7,49 +7,12 @@ import { probeMatrix as probeMatrixImpl } from "./matrix/probe.js"; import { sendMessageMatrix as sendMessageMatrixImpl } from "./matrix/send.js"; import { matrixOutbound as matrixOutboundImpl } from "./outbound.js"; import { resolveMatrixTargets as resolveMatrixTargetsImpl } from "./resolve-targets.js"; - -type ListMatrixDirectoryGroupsLive = - typeof import("./directory-live.js").listMatrixDirectoryGroupsLive; -type ListMatrixDirectoryPeersLive = - typeof import("./directory-live.js").listMatrixDirectoryPeersLive; -type ResolveMatrixAuth = typeof import("./matrix/client.js").resolveMatrixAuth; -type ProbeMatrix = typeof import("./matrix/probe.js").probeMatrix; -type SendMessageMatrix = typeof import("./matrix/send.js").sendMessageMatrix; -type ResolveMatrixTargets = typeof import("./resolve-targets.js").resolveMatrixTargets; -type MatrixOutbound = typeof import("./outbound.js").matrixOutbound; - -export function listMatrixDirectoryGroupsLive( - ...args: Parameters -): ReturnType { - return listMatrixDirectoryGroupsLiveImpl(...args); -} - -export function listMatrixDirectoryPeersLive( - ...args: Parameters -): ReturnType { - return listMatrixDirectoryPeersLiveImpl(...args); -} - -export function resolveMatrixAuth( - ...args: Parameters -): ReturnType { - return resolveMatrixAuthImpl(...args); -} - -export function probeMatrix(...args: Parameters): ReturnType { - return probeMatrixImpl(...args); -} - -export function sendMessageMatrix( - ...args: Parameters -): ReturnType { - return sendMessageMatrixImpl(...args); -} - -export function resolveMatrixTargets( - ...args: Parameters -): ReturnType { - return resolveMatrixTargetsImpl(...args); -} - -export const matrixOutbound: MatrixOutbound = { ...matrixOutboundImpl }; +export const matrixChannelRuntime = { + listMatrixDirectoryGroupsLive: listMatrixDirectoryGroupsLiveImpl, + listMatrixDirectoryPeersLive: listMatrixDirectoryPeersLiveImpl, + resolveMatrixAuth: resolveMatrixAuthImpl, + probeMatrix: probeMatrixImpl, + sendMessageMatrix: sendMessageMatrixImpl, + resolveMatrixTargets: resolveMatrixTargetsImpl, + matrixOutbound: { ...matrixOutboundImpl }, +}; diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index d82d3eb2bdb..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, @@ -38,9 +39,10 @@ import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) let matrixStartupLock: Promise = Promise.resolve(); -async function loadMatrixChannelRuntime() { - return await import("./channel.runtime.js"); -} +const loadMatrixChannelRuntime = createLazyRuntimeNamedExport( + () => import("./channel.runtime.js"), + "matrixChannelRuntime", +); const meta = { id: "matrix", 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.test.ts b/extensions/mattermost/index.test.ts index b2ef565c4d2..d21403111cb 100644 --- a/extensions/mattermost/index.test.ts +++ b/extensions/mattermost/index.test.ts @@ -1,6 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/mattermost"; import { describe, expect, it, vi } from "vitest"; -import { createTestPluginApi } from "../test-utils/plugin-api.js"; +import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; import plugin from "./index.js"; function createApi( 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/runtime-api.ts b/extensions/mattermost/runtime-api.ts new file mode 100644 index 00000000000..e13fee5ad71 --- /dev/null +++ b/extensions/mattermost/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/mattermost"; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 887a878c5e8..5688e13d8ae 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -4,6 +4,11 @@ import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; +import { + createLegacyMessageToolDiscoveryMethods, + createMessageToolButtonsSchema, +} from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelMessageToolDiscovery } from "openclaw/plugin-sdk/channel-runtime"; import { buildComputedAccountStatusSnapshot, buildChannelConfigSchema, @@ -16,7 +21,7 @@ import { type ChannelMessageActionAdapter, type ChannelMessageActionName, type ChannelPlugin, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { MattermostConfigSchema } from "./config-schema.js"; import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; @@ -41,42 +46,52 @@ import { getMattermostRuntime } from "./runtime.js"; import { mattermostSetupAdapter } from "./setup-core.js"; import { mattermostSetupWizard } from "./setup-surface.js"; +function describeMattermostMessageTool({ + cfg, +}: Parameters< + NonNullable +>[0]): ChannelMessageToolDiscovery { + const enabledAccounts = listMattermostAccountIds(cfg) + .map((accountId) => resolveMattermostAccount({ cfg, accountId })) + .filter((account) => account.enabled) + .filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim())); + + const actions: ChannelMessageActionName[] = []; + + if (enabledAccounts.length > 0) { + actions.push("send"); + } + + const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined; + const baseReactions = actionsConfig?.reactions; + const hasReactionCapableAccount = enabledAccounts.some((account) => { + const accountActions = account.config.actions as { reactions?: boolean } | undefined; + return (accountActions?.reactions ?? baseReactions ?? true) !== false; + }); + if (hasReactionCapableAccount) { + actions.push("react"); + } + + return { + actions, + capabilities: enabledAccounts.length > 0 ? ["buttons"] : [], + schema: + enabledAccounts.length > 0 + ? { + properties: { + buttons: createMessageToolButtonsSchema(), + }, + } + : null, + }; +} + const mattermostMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { - const enabledAccounts = listMattermostAccountIds(cfg) - .map((accountId) => resolveMattermostAccount({ cfg, accountId })) - .filter((account) => account.enabled) - .filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim())); - - const actions: ChannelMessageActionName[] = []; - - // Send (buttons) is available whenever there's at least one enabled account - if (enabledAccounts.length > 0) { - actions.push("send"); - } - - // React requires per-account reactions config check - const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined; - const baseReactions = actionsConfig?.reactions; - const hasReactionCapableAccount = enabledAccounts.some((account) => { - const accountActions = account.config.actions as { reactions?: boolean } | undefined; - return (accountActions?.reactions ?? baseReactions ?? true) !== false; - }); - if (hasReactionCapableAccount) { - actions.push("react"); - } - - return actions; - }, + describeMessageTool: describeMattermostMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeMattermostMessageTool), supportsAction: ({ action }) => { return action === "send" || action === "react"; }, - getCapabilities: ({ cfg }) => { - const accounts = listMattermostAccountIds(cfg) - .map((id) => resolveMattermostAccount({ cfg, accountId: id })) - .filter((a) => a.enabled && a.botToken?.trim() && a.baseUrl?.trim()); - return accounts.length > 0 ? (["buttons"] as const) : []; - }, handleAction: async ({ action, params, cfg, accountId }) => { if (action === "react") { // Check reactions gate: per-account config takes precedence over base config diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 16ee615454c..bd1f42dfd7f 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -4,11 +4,37 @@ import { GroupPolicySchema, MarkdownConfigSchema, requireOpenAllowFrom, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; 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/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts index 153edc2c84c..4996d115371 100644 --- a/extensions/mattermost/src/group-mentions.ts +++ b/extensions/mattermost/src/group-mentions.ts @@ -1,5 +1,5 @@ import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; -import type { ChannelGroupContext } from "openclaw/plugin-sdk/mattermost"; +import type { ChannelGroupContext } from "./runtime-api.js"; import { resolveMattermostAccount } from "./mattermost/accounts.js"; export function resolveMattermostGroupRequireMention( diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts index ae154ba8923..7f2b3ff4175 100644 --- a/extensions/mattermost/src/mattermost/accounts.ts +++ b/extensions/mattermost/src/mattermost/accounts.ts @@ -1,5 +1,5 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import { createAccountListHelpers, type OpenClawConfig } from "../runtime-api.js"; import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js"; import type { MattermostAccountConfig, 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/directory.ts b/extensions/mattermost/src/mattermost/directory.ts index 1b9d3e91e86..630ed7c7194 100644 --- a/extensions/mattermost/src/mattermost/directory.ts +++ b/extensions/mattermost/src/mattermost/directory.ts @@ -2,7 +2,7 @@ import type { ChannelDirectoryEntry, OpenClawConfig, RuntimeEnv, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index f4ef06cf1ed..a51002667f8 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -4,7 +4,7 @@ import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import { getMattermostRuntime } from "../runtime.js"; import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js"; diff --git a/extensions/mattermost/src/mattermost/model-picker.ts b/extensions/mattermost/src/mattermost/model-picker.ts index 1547041a74a..925308b04cc 100644 --- a/extensions/mattermost/src/mattermost/model-picker.ts +++ b/extensions/mattermost/src/mattermost/model-picker.ts @@ -6,7 +6,7 @@ import { resolveStoredModelOverride, type ModelsProviderData, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import type { MattermostInteractiveButtonInput } from "./interactions.js"; const MATTERMOST_MODEL_PICKER_CONTEXT_KEY = "oc_model_picker"; diff --git a/extensions/mattermost/src/mattermost/monitor-auth.ts b/extensions/mattermost/src/mattermost/monitor-auth.ts index 7f263cd09b5..e83f06b8ba6 100644 --- a/extensions/mattermost/src/mattermost/monitor-auth.ts +++ b/extensions/mattermost/src/mattermost/monitor-auth.ts @@ -1,11 +1,11 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawConfig } from "../runtime-api.js"; import { evaluateSenderGroupAccessForPolicy, isDangerousNameMatchingEnabled, resolveAllowlistMatchSimple, resolveControlCommandGate, resolveEffectiveAllowFromLists, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import type { ResolvedMattermostAccount } from "./accounts.js"; import type { MattermostChannel } from "./client.js"; diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts index 219c0562638..d6ce7ee4aa9 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -2,8 +2,8 @@ import { formatInboundFromLabel as formatInboundFromLabelShared, resolveThreadSessionKeys as resolveThreadSessionKeysShared, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; -export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; +export { createDedupeCache, rawDataToString } from "../runtime-api.js"; export type ResponsePrefixContext = { model?: string; diff --git a/extensions/mattermost/src/mattermost/monitor-websocket.ts b/extensions/mattermost/src/mattermost/monitor-websocket.ts index 7f04a18f09b..c04affbae1d 100644 --- a/extensions/mattermost/src/mattermost/monitor-websocket.ts +++ b/extensions/mattermost/src/mattermost/monitor-websocket.ts @@ -1,4 +1,4 @@ -import type { ChannelAccountSnapshot, RuntimeEnv } from "openclaw/plugin-sdk/mattermost"; +import type { ChannelAccountSnapshot, RuntimeEnv } from "../runtime-api.js"; import WebSocket from "ws"; import type { MattermostPost } from "./client.js"; import { rawDataToString } from "./monitor-helpers.js"; diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index e56e4a9b9af..a849cf52160 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -4,7 +4,7 @@ import type { OpenClawConfig, ReplyPayload, RuntimeEnv, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import { buildAgentMediaPayload, buildModelsProviderData, @@ -30,7 +30,7 @@ import { warnMissingProviderGroupPolicyFallbackOnce, listSkillCommandsForAgents, type HistoryEntry, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import { getMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount, resolveMattermostReplyToMode } from "./accounts.js"; import { diff --git a/extensions/mattermost/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts index 2966e20f209..d3ee56ab3a0 100644 --- a/extensions/mattermost/src/mattermost/probe.ts +++ b/extensions/mattermost/src/mattermost/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/mattermost"; +import type { BaseProbeResult } from "../runtime-api.js"; import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js"; export type MattermostProbe = BaseProbeResult & { diff --git a/extensions/mattermost/src/mattermost/reactions.ts b/extensions/mattermost/src/mattermost/reactions.ts index 3515153edd2..42de67b4e10 100644 --- a/extensions/mattermost/src/mattermost/reactions.ts +++ b/extensions/mattermost/src/mattermost/reactions.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawConfig } from "../runtime-api.js"; import { resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, fetchMattermostMe, type MattermostClient } from "./client.js"; diff --git a/extensions/mattermost/src/mattermost/reply-delivery.ts b/extensions/mattermost/src/mattermost/reply-delivery.ts index 5c94e51934b..6fc88c8ba83 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "openclaw/plugin-sdk/mattermost"; -import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js"; +import { getAgentScopedMediaLocalRoots } from "../runtime-api.js"; type MarkdownTableMode = Parameters[1]; diff --git a/extensions/mattermost/src/mattermost/runtime-api.ts b/extensions/mattermost/src/mattermost/runtime-api.ts new file mode 100644 index 00000000000..cb133391638 --- /dev/null +++ b/extensions/mattermost/src/mattermost/runtime-api.ts @@ -0,0 +1 @@ +export * from "../../runtime-api.js"; diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts index 774f40f99fa..784b27677e6 100644 --- a/extensions/mattermost/src/mattermost/send.test.ts +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { expectProvidedCfgSkipsRuntimeLoad, expectRuntimeCfgFallback, -} from "../../../test-utils/send-config.js"; +} from "../../../../test/helpers/extensions/send-config.js"; import { parseMattermostTarget, sendMessageMattermost } from "./send.js"; import { resetMattermostOpaqueTargetCacheForTests } from "./target-resolution.js"; @@ -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..e6bbdf2298a 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -1,9 +1,9 @@ -import { loadOutboundMediaFromUrl, type OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import { loadOutboundMediaFromUrl, type OpenClawConfig } from "../runtime-api.js"; 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/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index a094b3571ff..401cc56172a 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -16,7 +16,7 @@ import { type OpenClawConfig, type ReplyPayload, type RuntimeEnv, -} from "openclaw/plugin-sdk/mattermost"; +} from "../runtime-api.js"; import type { ResolvedMattermostAccount } from "../mattermost/accounts.js"; import { getMattermostRuntime } from "../runtime.js"; import { diff --git a/extensions/mattermost/src/mattermost/slash-state.ts b/extensions/mattermost/src/mattermost/slash-state.ts index f79f670df8d..8e5fe1f08b3 100644 --- a/extensions/mattermost/src/mattermost/slash-state.ts +++ b/extensions/mattermost/src/mattermost/slash-state.ts @@ -10,7 +10,7 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawPluginApi } from "../runtime-api.js"; import type { ResolvedMattermostAccount } from "./accounts.js"; import { resolveSlashCommandConfig, type MattermostRegisteredCommand } from "./slash-commands.js"; import { createSlashCommandHttpHandler } from "./slash-http.js"; @@ -86,8 +86,8 @@ export function activateSlashCommands(params: { registeredCommands: MattermostRegisteredCommand[]; triggerMap?: Map; api: { - cfg: import("openclaw/plugin-sdk/mattermost").OpenClawConfig; - runtime: import("openclaw/plugin-sdk/mattermost").RuntimeEnv; + cfg: import("../runtime-api.js").OpenClawConfig; + runtime: import("../runtime-api.js").RuntimeEnv; }; log?: (msg: string) => void; }) { diff --git a/extensions/mattermost/src/mattermost/target-resolution.ts b/extensions/mattermost/src/mattermost/target-resolution.ts index d3b59a3e696..9fa1a170ca3 100644 --- a/extensions/mattermost/src/mattermost/target-resolution.ts +++ b/extensions/mattermost/src/mattermost/target-resolution.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import type { OpenClawConfig } from "../runtime-api.js"; import { resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, diff --git a/extensions/mattermost/src/runtime-api.ts b/extensions/mattermost/src/runtime-api.ts new file mode 100644 index 00000000000..ece735819df --- /dev/null +++ b/extensions/mattermost/src/runtime-api.ts @@ -0,0 +1 @@ +export * from "../runtime-api.js"; diff --git a/extensions/mattermost/src/runtime.ts b/extensions/mattermost/src/runtime.ts index b5ec1942973..e238fa963e2 100644 --- a/extensions/mattermost/src/runtime.ts +++ b/extensions/mattermost/src/runtime.ts @@ -1,4 +1,4 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost"; +import type { PluginRuntime } from "./runtime-api.js"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } = diff --git a/extensions/mattermost/src/secret-input.ts b/extensions/mattermost/src/secret-input.ts index 576f5b9fc45..b32083456e7 100644 --- a/extensions/mattermost/src/secret-input.ts +++ b/extensions/mattermost/src/secret-input.ts @@ -3,7 +3,7 @@ import { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; export { buildSecretInputSchema, diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts index 781967c70a6..13a4991fcd0 100644 --- a/extensions/mattermost/src/setup-core.ts +++ b/extensions/mattermost/src/setup-core.ts @@ -7,7 +7,7 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts index d3b0a66b4c8..385c4dc75e3 100644 --- a/extensions/mattermost/src/setup-surface.ts +++ b/extensions/mattermost/src/setup-surface.ts @@ -3,7 +3,7 @@ import { DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "openclaw/plugin-sdk/setup"; import { listMattermostAccountIds } from "./mattermost/accounts.js"; diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index f4038ac6920..b77a542122b 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -3,7 +3,7 @@ import type { DmPolicy, GroupPolicy, SecretInput, -} from "openclaw/plugin-sdk/mattermost"; +} from "./runtime-api.js"; export type MattermostReplyToMode = "off" | "first" | "all"; export type MattermostChatTypeKey = "direct" | "channel" | "group"; @@ -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-core/index.ts b/extensions/memory-core/index.ts index 6559485e46a..54c8a5361a7 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -1,13 +1,11 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/memory-core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; -const memoryCorePlugin = { +export default definePluginEntry({ id: "memory-core", name: "Memory (Core)", description: "File-backed memory search tools and CLI", kind: "memory", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerTool( (ctx) => { const memorySearchTool = api.runtime.tools.createMemorySearchTool({ @@ -33,6 +31,4 @@ const memoryCorePlugin = { { commands: ["memory"] }, ); }, -}; - -export default memoryCorePlugin; +}); 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.test.ts b/extensions/memory-lancedb/index.test.ts index a733c3dffb8..5dabcc9dabf 100644 --- a/extensions/memory-lancedb/index.test.ts +++ b/extensions/memory-lancedb/index.test.ts @@ -18,6 +18,18 @@ const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY); const liveEnabled = HAS_OPENAI_KEY && process.env.OPENCLAW_LIVE_TEST === "1"; const describeLive = liveEnabled ? describe : describe.skip; +type MemoryPluginTestConfig = { + embedding?: { + apiKey?: string; + model?: string; + dimensions?: number; + }; + dbPath?: string; + captureMaxChars?: number; + autoCapture?: boolean; + autoRecall?: boolean; +}; + function installTmpDirHarness(params: { prefix: string }) { let tmpDir = ""; let dbPath = ""; @@ -51,7 +63,7 @@ describe("memory plugin e2e", () => { }, dbPath: getDbPath(), ...overrides, - }); + }) as MemoryPluginTestConfig | undefined; } test("memory plugin registers and initializes correctly", async () => { @@ -89,7 +101,7 @@ describe("memory plugin e2e", () => { apiKey: "${TEST_MEMORY_API_KEY}", }, dbPath: getDbPath(), - }); + }) as MemoryPluginTestConfig | undefined; expect(config?.embedding?.apiKey).toBe("test-key-123"); diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index 6ae7574aaa8..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 type { OpenClawPluginApi } from "openclaw/plugin-sdk/memory-lancedb"; +import { definePluginEntry, type OpenClawPluginApi } from "./api.js"; import { DEFAULT_CAPTURE_MAX_CHARS, MEMORY_CATEGORIES, @@ -289,7 +289,7 @@ export function detectCategory(text: string): MemoryCategory { // Plugin Definition // ============================================================================ -const memoryPlugin = { +export default definePluginEntry({ id: "memory-lancedb", name: "Memory (LanceDB)", description: "LanceDB-backed long-term memory with auto-recall/capture", @@ -673,6 +673,4 @@ const memoryPlugin = { }, }); }, -}; - -export default memoryPlugin; +}); diff --git a/extensions/microsoft/index.ts b/extensions/microsoft/index.ts index db0bebbcc0b..e0e39e3a18f 100644 --- a/extensions/microsoft/index.ts +++ b/extensions/microsoft/index.ts @@ -1,14 +1,11 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { buildMicrosoftSpeechProvider } from "openclaw/plugin-sdk/speech"; -const microsoftPlugin = { +export default definePluginEntry({ id: "microsoft", name: "Microsoft Speech", description: "Bundled Microsoft speech provider", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerSpeechProvider(buildMicrosoftSpeechProvider()); }, -}; - -export default microsoftPlugin; +}); diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 1ebf7382d52..d1a97cb43dc 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -1,7 +1,6 @@ import { buildOauthProviderAuthResult, - emptyPluginConfigSchema, - type OpenClawPluginApi, + definePluginEntry, type ProviderAuthContext, type ProviderAuthResult, type ProviderCatalogContext, @@ -159,12 +158,11 @@ function createOAuthHandler(region: MiniMaxRegion) { }; } -const minimaxPlugin = { +export default definePluginEntry({ id: API_PROVIDER_ID, name: "MiniMax", description: "Bundled MiniMax API-key and OAuth provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: API_PROVIDER_ID, label: PROVIDER_LABEL, @@ -280,6 +278,4 @@ const minimaxPlugin = { api.registerMediaUnderstandingProvider(minimaxMediaUnderstandingProvider); api.registerMediaUnderstandingProvider(minimaxPortalMediaUnderstandingProvider); }, -}; - -export default minimaxPlugin; +}); diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index 5a15c50a857..cfb77d3a012 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -1,16 +1,15 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { mistralMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "mistral"; -const mistralPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Mistral Provider", description: "Bundled Mistral provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Mistral", @@ -53,6 +52,4 @@ const mistralPlugin = { }); api.registerMediaUnderstandingProvider(mistralMediaUnderstandingProvider); }, -}; - -export default mistralPlugin; +}); diff --git a/extensions/modelstudio/index.ts b/extensions/modelstudio/index.ts index 20318b2a022..fc5dab4c4f8 100644 --- a/extensions/modelstudio/index.ts +++ b/extensions/modelstudio/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { @@ -10,12 +10,11 @@ import { buildModelStudioProvider } from "./provider-catalog.js"; const PROVIDER_ID = "modelstudio"; -const modelStudioPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Model Studio Provider", description: "Bundled Model Studio provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Model Studio", @@ -89,6 +88,4 @@ const modelStudioPlugin = { }, }); }, -}; - -export default modelStudioPlugin; +}); diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index c47c4a92d41..704b841818c 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { @@ -20,12 +20,11 @@ import { buildMoonshotProvider } from "./provider-catalog.js"; const PROVIDER_ID = "moonshot"; -const moonshotPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Moonshot Provider", description: "Bundled Moonshot provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Moonshot", @@ -108,6 +107,4 @@ const moonshotPlugin = { }), ); }, -}; - -export default moonshotPlugin; +}); 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/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 790dc8bd33f..fa119a2b44a 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -1,6 +1,6 @@ import type { PluginRuntime, SsrFPolicy } from "openclaw/plugin-sdk/msteams"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import { buildMSTeamsAttachmentPlaceholder, buildMSTeamsGraphMessageUrls, diff --git a/extensions/msteams/src/channel.directory.test.ts b/extensions/msteams/src/channel.directory.test.ts index be95e6103ea..df3547d012a 100644 --- a/extensions/msteams/src/channel.directory.test.ts +++ b/extensions/msteams/src/channel.directory.test.ts @@ -1,6 +1,9 @@ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/msteams"; import { describe, expect, it } from "vitest"; -import { createDirectoryTestRuntime, expectDirectorySurface } from "../../test-utils/directory.js"; +import { + createDirectoryTestRuntime, + expectDirectorySurface, +} from "../../../test/helpers/extensions/directory.js"; import { msteamsPlugin } from "./channel.js"; describe("msteams directory", () => { diff --git a/extensions/msteams/src/channel.runtime.ts b/extensions/msteams/src/channel.runtime.ts index c55d0fc626a..bc6c36a101b 100644 --- a/extensions/msteams/src/channel.runtime.ts +++ b/extensions/msteams/src/channel.runtime.ts @@ -8,42 +8,11 @@ import { sendAdaptiveCardMSTeams as sendAdaptiveCardMSTeamsImpl, sendMessageMSTeams as sendMessageMSTeamsImpl, } from "./send.js"; - -type ListMSTeamsDirectoryGroupsLive = - typeof import("./directory-live.js").listMSTeamsDirectoryGroupsLive; -type ListMSTeamsDirectoryPeersLive = - typeof import("./directory-live.js").listMSTeamsDirectoryPeersLive; -type MSTeamsOutbound = typeof import("./outbound.js").msteamsOutbound; -type ProbeMSTeams = typeof import("./probe.js").probeMSTeams; -type SendAdaptiveCardMSTeams = typeof import("./send.js").sendAdaptiveCardMSTeams; -type SendMessageMSTeams = typeof import("./send.js").sendMessageMSTeams; - -export function listMSTeamsDirectoryGroupsLive( - ...args: Parameters -): ReturnType { - return listMSTeamsDirectoryGroupsLiveImpl(...args); -} - -export function listMSTeamsDirectoryPeersLive( - ...args: Parameters -): ReturnType { - return listMSTeamsDirectoryPeersLiveImpl(...args); -} - -export const msteamsOutbound: MSTeamsOutbound = { ...msteamsOutboundImpl }; - -export function probeMSTeams(...args: Parameters): ReturnType { - return probeMSTeamsImpl(...args); -} - -export function sendAdaptiveCardMSTeams( - ...args: Parameters -): ReturnType { - return sendAdaptiveCardMSTeamsImpl(...args); -} - -export function sendMessageMSTeams( - ...args: Parameters -): ReturnType { - return sendMessageMSTeamsImpl(...args); -} +export const msTeamsChannelRuntime = { + listMSTeamsDirectoryGroupsLive: listMSTeamsDirectoryGroupsLiveImpl, + listMSTeamsDirectoryPeersLive: listMSTeamsDirectoryPeersLiveImpl, + msteamsOutbound: { ...msteamsOutboundImpl }, + probeMSTeams: probeMSTeamsImpl, + sendAdaptiveCardMSTeams: sendAdaptiveCardMSTeamsImpl, + sendMessageMSTeams: sendMessageMSTeamsImpl, +}; diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index d61a377dd4d..7458389efb1 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,5 +1,14 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; +import { + createLegacyMessageToolDiscoveryMethods, + createMessageToolCardSchema, +} from "openclaw/plugin-sdk/channel-runtime"; +import type { + ChannelMessageActionAdapter, + ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-runtime"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionName, ChannelPlugin, @@ -56,8 +65,30 @@ const TEAMS_GRAPH_PERMISSION_HINTS: Record = { "Files.Read.All": "files (OneDrive)", }; -async function loadMSTeamsChannelRuntime() { - return await import("./channel.runtime.js"); +const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport( + () => import("./channel.runtime.js"), + "msTeamsChannelRuntime", +); + +function describeMSTeamsMessageTool({ + cfg, +}: Parameters< + NonNullable +>[0]): ChannelMessageToolDiscovery { + const enabled = + cfg.channels?.msteams?.enabled !== false && + Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)); + return { + actions: enabled ? (["poll"] satisfies ChannelMessageActionName[]) : [], + capabilities: enabled ? ["cards"] : [], + schema: enabled + ? { + properties: { + card: createMessageToolCardSchema(), + }, + } + : null, + }; } export const msteamsPlugin: ChannelPlugin = { @@ -366,21 +397,8 @@ export const msteamsPlugin: ChannelPlugin = { }, }, actions: { - listActions: ({ cfg }) => { - const enabled = - cfg.channels?.msteams?.enabled !== false && - Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)); - if (!enabled) { - return []; - } - return ["poll"] satisfies ChannelMessageActionName[]; - }, - getCapabilities: ({ cfg }) => { - return cfg.channels?.msteams?.enabled !== false && - Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) - ? (["cards"] as const) - : []; - }, + describeMessageTool: describeMSTeamsMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeMSTeamsMessageTool), handleAction: async (ctx) => { // Handle send action with card parameter if (ctx.action === "send" && ctx.params.card) { diff --git a/extensions/msteams/src/graph-upload.test.ts b/extensions/msteams/src/graph-upload.test.ts index 90a9da1d352..a41147840ec 100644 --- a/extensions/msteams/src/graph-upload.test.ts +++ b/extensions/msteams/src/graph-upload.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js"; import { uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js"; describe("graph upload helpers", () => { diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index cc4cf2fb6f0..e67017ed8fc 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { SILENT_REPLY_TOKEN, type PluginRuntime } from "openclaw/plugin-sdk/msteams"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import type { StoredConversationReference } from "./conversation-store.js"; const graphUploadMockState = vi.hoisted(() => ({ uploadAndShareOneDrive: vi.fn(), 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/nextcloud-talk/runtime-api.ts b/extensions/nextcloud-talk/runtime-api.ts new file mode 100644 index 00000000000..fc9283930bd --- /dev/null +++ b/extensions/nextcloud-talk/runtime-api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/nextcloud-talk"; diff --git a/extensions/nextcloud-talk/src/accounts.ts b/extensions/nextcloud-talk/src/accounts.ts index 1b9d2c16f93..d6a2a4edcaa 100644 --- a/extensions/nextcloud-talk/src/accounts.ts +++ b/extensions/nextcloud-talk/src/accounts.ts @@ -4,7 +4,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveAccountWithDefaultFallback, -} from "openclaw/plugin-sdk/nextcloud-talk"; +} from "../runtime-api.js"; import { normalizeResolvedSecretInputString } from "./secret-input.js"; import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js"; diff --git a/extensions/nextcloud-talk/src/channel.startup.test.ts b/extensions/nextcloud-talk/src/channel.startup.test.ts index 5fd0607e753..e0117936f51 100644 --- a/extensions/nextcloud-talk/src/channel.startup.test.ts +++ b/extensions/nextcloud-talk/src/channel.startup.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createStartAccountContext } from "../../test-utils/start-account-context.js"; +import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js"; import { expectStopPendingUntilAbort, startAccountAndTrackLifecycle, -} from "../../test-utils/start-account-lifecycle.js"; +} from "../../../test/helpers/extensions/start-account-lifecycle.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 6101136a5e3..16910b7371e 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -6,6 +6,7 @@ import { collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, } from "openclaw/plugin-sdk/channel-policy"; +import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; import { buildBaseChannelStatusSummary, buildChannelConfigSchema, @@ -16,8 +17,7 @@ import { setAccountEnabledInConfigSection, type ChannelPlugin, type OpenClawConfig, -} from "openclaw/plugin-sdk/nextcloud-talk"; -import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; +} from "../runtime-api.js"; import { listNextcloudTalkAccountIds, resolveDefaultNextcloudTalkAccountId, diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index 85cb14ff213..020a69d7992 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; +import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; import { BlockStreamingCoalesceSchema, DmConfigSchema, @@ -7,9 +9,7 @@ import { ReplyRuntimeConfigSchemaShape, ToolPolicySchema, requireOpenAllowFrom, -} from "openclaw/plugin-sdk/nextcloud-talk"; -import { z } from "zod"; -import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js"; +} from "../runtime-api.js"; import { buildSecretInputSchema } from "./secret-input.js"; export const NextcloudTalkRoomSchema = z diff --git a/extensions/nextcloud-talk/src/inbound.authz.test.ts b/extensions/nextcloud-talk/src/inbound.authz.test.ts index bde32abdb3c..873b74bc93a 100644 --- a/extensions/nextcloud-talk/src/inbound.authz.test.ts +++ b/extensions/nextcloud-talk/src/inbound.authz.test.ts @@ -5,28 +5,42 @@ import { handleNextcloudTalkInbound } from "./inbound.js"; import { setNextcloudTalkRuntime } from "./runtime.js"; import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js"; +function installInboundAuthzRuntime(params: { + readAllowFromStore: () => Promise; + buildMentionRegexes: () => RegExp[]; +}) { + setNextcloudTalkRuntime({ + channel: { + pairing: { + readAllowFromStore: params.readAllowFromStore, + }, + commands: { + shouldHandleTextCommands: () => false, + }, + text: { + hasControlCommand: () => false, + }, + mentions: { + buildMentionRegexes: params.buildMentionRegexes, + matchesMentionPatterns: () => false, + }, + }, + } as unknown as PluginRuntime); +} + +function createTestRuntimeEnv(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + } as unknown as RuntimeEnv; +} + describe("nextcloud-talk inbound authz", () => { it("does not treat DM pairing-store entries as group allowlist entries", async () => { const readAllowFromStore = vi.fn(async () => ["attacker"]); const buildMentionRegexes = vi.fn(() => [/@openclaw/i]); - setNextcloudTalkRuntime({ - channel: { - pairing: { - readAllowFromStore, - }, - commands: { - shouldHandleTextCommands: () => false, - }, - text: { - hasControlCommand: () => false, - }, - mentions: { - buildMentionRegexes, - matchesMentionPatterns: () => false, - }, - }, - } as unknown as PluginRuntime); + installInboundAuthzRuntime({ readAllowFromStore, buildMentionRegexes }); const message: NextcloudTalkInboundMessage = { messageId: "m-1", @@ -69,10 +83,7 @@ describe("nextcloud-talk inbound authz", () => { message, account, config, - runtime: { - log: vi.fn(), - error: vi.fn(), - } as unknown as RuntimeEnv, + runtime: createTestRuntimeEnv(), }); expect(readAllowFromStore).toHaveBeenCalledWith({ @@ -86,23 +97,7 @@ describe("nextcloud-talk inbound authz", () => { const readAllowFromStore = vi.fn(async () => []); const buildMentionRegexes = vi.fn(() => [/@openclaw/i]); - setNextcloudTalkRuntime({ - channel: { - pairing: { - readAllowFromStore, - }, - commands: { - shouldHandleTextCommands: () => false, - }, - text: { - hasControlCommand: () => false, - }, - mentions: { - buildMentionRegexes, - matchesMentionPatterns: () => false, - }, - }, - } as unknown as PluginRuntime); + installInboundAuthzRuntime({ readAllowFromStore, buildMentionRegexes }); const message: NextcloudTalkInboundMessage = { messageId: "m-2", @@ -146,10 +141,7 @@ describe("nextcloud-talk inbound authz", () => { }, }, }, - runtime: { - log: vi.fn(), - error: vi.fn(), - } as unknown as RuntimeEnv, + runtime: createTestRuntimeEnv(), }); expect(buildMentionRegexes).not.toHaveBeenCalled(); diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 10ecd924fd7..9eefe831835 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -14,7 +14,7 @@ import { type OutboundReplyPayload, type OpenClawConfig, type RuntimeEnv, -} from "openclaw/plugin-sdk/nextcloud-talk"; +} from "../runtime-api.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import { normalizeNextcloudTalkAllowlist, diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index d66a40d7429..8721ff5fe6b 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -1,12 +1,12 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import os from "node:os"; +import { resolveLoggerBackedRuntime } from "../../shared/runtime.js"; import { type RuntimeEnv, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, -} from "openclaw/plugin-sdk/nextcloud-talk"; -import { resolveLoggerBackedRuntime } from "../../shared/runtime.js"; +} from "../runtime-api.js"; import { resolveNextcloudTalkAccount } from "./accounts.js"; import { handleNextcloudTalkInbound } from "./inbound.js"; import { createNextcloudTalkReplayGuard } from "./replay-guard.js"; diff --git a/extensions/nextcloud-talk/src/policy.ts b/extensions/nextcloud-talk/src/policy.ts index 15e19da84de..849efac51e6 100644 --- a/extensions/nextcloud-talk/src/policy.ts +++ b/extensions/nextcloud-talk/src/policy.ts @@ -3,7 +3,7 @@ import type { ChannelGroupContext, GroupPolicy, GroupToolPolicyConfig, -} from "openclaw/plugin-sdk/nextcloud-talk"; +} from "../runtime-api.js"; import { buildChannelKeyCandidates, evaluateMatchedGroupAccessForPolicy, @@ -11,7 +11,7 @@ import { resolveChannelEntryMatchWithFallback, resolveMentionGatingWithBypass, resolveNestedAllowlistDecision, -} from "openclaw/plugin-sdk/nextcloud-talk"; +} from "../runtime-api.js"; import type { NextcloudTalkRoomConfig } from "./types.js"; function normalizeAllowEntry(raw: string): string { diff --git a/extensions/nextcloud-talk/src/replay-guard.ts b/extensions/nextcloud-talk/src/replay-guard.ts index 8dc8477e13f..ed4d1c7b79b 100644 --- a/extensions/nextcloud-talk/src/replay-guard.ts +++ b/extensions/nextcloud-talk/src/replay-guard.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { createPersistentDedupe } from "openclaw/plugin-sdk/nextcloud-talk"; +import { createPersistentDedupe } from "../runtime-api.js"; const DEFAULT_REPLAY_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_MEMORY_MAX_SIZE = 1_000; diff --git a/extensions/nextcloud-talk/src/room-info.ts b/extensions/nextcloud-talk/src/room-info.ts index eae5a1eeb51..eb1072e8baa 100644 --- a/extensions/nextcloud-talk/src/room-info.ts +++ b/extensions/nextcloud-talk/src/room-info.ts @@ -1,6 +1,5 @@ import { readFileSync } from "node:fs"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/nextcloud-talk"; -import type { RuntimeEnv } from "openclaw/plugin-sdk/nextcloud-talk"; +import { fetchWithSsrFGuard, type RuntimeEnv } from "../runtime-api.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import { normalizeResolvedSecretInputString } from "./secret-input.js"; diff --git a/extensions/nextcloud-talk/src/runtime.ts b/extensions/nextcloud-talk/src/runtime.ts index facf3a0cc05..c8251669314 100644 --- a/extensions/nextcloud-talk/src/runtime.ts +++ b/extensions/nextcloud-talk/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import type { PluginRuntime } from "../runtime-api.js"; const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } = createPluginRuntimeStore("Nextcloud Talk runtime not initialized"); diff --git a/extensions/nextcloud-talk/src/secret-input.ts b/extensions/nextcloud-talk/src/secret-input.ts index d26cb8e4e23..ad5746ffc31 100644 --- a/extensions/nextcloud-talk/src/secret-input.ts +++ b/extensions/nextcloud-talk/src/secret-input.ts @@ -3,7 +3,7 @@ import { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, -} from "openclaw/plugin-sdk/nextcloud-talk"; +} from "../runtime-api.js"; export { buildSecretInputSchema, diff --git a/extensions/nextcloud-talk/src/send.test.ts b/extensions/nextcloud-talk/src/send.test.ts index 3ee178b815d..b82ac1c4309 100644 --- a/extensions/nextcloud-talk/src/send.test.ts +++ b/extensions/nextcloud-talk/src/send.test.ts @@ -3,7 +3,7 @@ import { createSendCfgThreadingRuntime, expectProvidedCfgSkipsRuntimeLoad, expectRuntimeCfgFallback, -} from "../../test-utils/send-config.js"; +} from "../../../test/helpers/extensions/send-config.js"; const hoisted = vi.hoisted(() => ({ loadConfig: vi.fn(), diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index a9cfbef7d06..a7f2dc38ab0 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -4,7 +4,7 @@ import type { DmPolicy, GroupPolicy, SecretInput, -} from "openclaw/plugin-sdk/nextcloud-talk"; +} from "../runtime-api.js"; export type { DmPolicy, GroupPolicy }; diff --git a/extensions/nostr/api.ts b/extensions/nostr/api.ts new file mode 100644 index 00000000000..f2914e34190 --- /dev/null +++ b/extensions/nostr/api.ts @@ -0,0 +1,2 @@ +export * from "openclaw/plugin-sdk/nostr"; +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/nostr/src/channel.outbound.test.ts b/extensions/nostr/src/channel.outbound.test.ts index 0aa63485951..0bbe7f880bf 100644 --- a/extensions/nostr/src/channel.outbound.test.ts +++ b/extensions/nostr/src/channel.outbound.test.ts @@ -1,6 +1,6 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/nostr"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createStartAccountContext } from "../../test-utils/start-account-context.js"; +import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js"; import { nostrPlugin } from "./channel.js"; import { setNostrRuntime } from "./runtime.js"; diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 21dfce3a9da..4296f71b9ac 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -1,3 +1,7 @@ +import { + buildPassiveChannelStatusSummary, + buildTrafficStatusSummary, +} from "../../shared/channel-status-summary.js"; import { buildChannelConfigSchema, collectStatusIssuesFromLastError, @@ -6,11 +10,7 @@ import { formatPairingApproveHint, mapAllowFromEntries, type ChannelPlugin, -} from "openclaw/plugin-sdk/nostr"; -import { - buildPassiveChannelStatusSummary, - buildTrafficStatusSummary, -} from "../../shared/channel-status-summary.js"; +} from "../api.js"; import type { NostrProfile } from "./config-schema.js"; import { NostrConfigSchema } from "./config-schema.js"; import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index 53346b0789d..2746d518fe6 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -1,6 +1,6 @@ import { AllowFromListSchema, DmPolicySchema } from "openclaw/plugin-sdk/channel-config-schema"; -import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk/nostr"; import { z } from "zod"; +import { MarkdownConfigSchema, buildChannelConfigSchema } from "../api.js"; /** * Validates https:// URLs only (no javascript:, data:, file:, etc.) diff --git a/extensions/nostr/src/nostr-profile-http.ts b/extensions/nostr/src/nostr-profile-http.ts index 3dedf745125..5af5feb9d84 100644 --- a/extensions/nostr/src/nostr-profile-http.ts +++ b/extensions/nostr/src/nostr-profile-http.ts @@ -8,13 +8,13 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; +import { z } from "zod"; import { createFixedWindowRateLimiter, isBlockedHostnameOrIp, readJsonBodyWithLimit, requestBodyErrorToText, -} from "openclaw/plugin-sdk/nostr"; -import { z } from "zod"; +} from "../api.js"; import { publishNostrProfile, getNostrProfileState } from "./channel.js"; import { NostrProfileSchema, type NostrProfile } from "./config-schema.js"; import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js"; diff --git a/extensions/nostr/src/runtime.ts b/extensions/nostr/src/runtime.ts index 7c70d903712..6d99a5799a2 100644 --- a/extensions/nostr/src/runtime.ts +++ b/extensions/nostr/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/nostr"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import type { PluginRuntime } from "../api.js"; const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } = createPluginRuntimeStore("Nostr runtime not initialized"); diff --git a/extensions/nostr/src/setup-surface.test.ts b/extensions/nostr/src/setup-surface.test.ts index 0a46946f8f9..98e479842c5 100644 --- a/extensions/nostr/src/setup-surface.test.ts +++ b/extensions/nostr/src/setup-surface.test.ts @@ -1,30 +1,13 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/nostr"; import { describe, expect, it, vi } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { + createTestWizardPrompter, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { nostrPlugin } from "./channel.js"; -function createPrompter(overrides: Partial): WizardPrompter { - return { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), - select: vi.fn(async ({ options }: { options: Array<{ value: string }> }) => { - const first = options[0]; - if (!first) { - throw new Error("no options"); - } - return first.value; - }) as WizardPrompter["select"], - multiselect: vi.fn(async () => []), - text: vi.fn(async () => "") as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - ...overrides, - }; -} - const nostrConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ plugin: nostrPlugin, wizard: nostrPlugin.setupWizard!, @@ -32,7 +15,7 @@ const nostrConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ describe("nostr setup wizard", () => { it("configures a private key and relay URLs", async () => { - const prompter = createPrompter({ + const prompter = createTestWizardPrompter({ text: vi.fn(async ({ message }: { message: string }) => { if (message === "Nostr private key (nsec... or hex)") { return "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; diff --git a/extensions/nostr/src/types.ts b/extensions/nostr/src/types.ts index e2419c44ac3..78793b5e8d5 100644 --- a/extensions/nostr/src/types.ts +++ b/extensions/nostr/src/types.ts @@ -3,7 +3,7 @@ import { normalizeAccountId, normalizeOptionalAccountId, } from "openclaw/plugin-sdk/account-id"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/nostr"; +import type { OpenClawConfig } from "../api.js"; import type { NostrProfile } from "./config-schema.js"; import { DEFAULT_RELAYS } from "./default-relays.js"; import { getPublicKeyFromPrivate } from "./nostr-bus.js"; diff --git a/extensions/nvidia/index.ts b/extensions/nvidia/index.ts index 583932bc600..a5018e63579 100644 --- a/extensions/nvidia/index.ts +++ b/extensions/nvidia/index.ts @@ -1,15 +1,14 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { buildNvidiaProvider } from "./provider-catalog.js"; const PROVIDER_ID = "nvidia"; -const nvidiaPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "NVIDIA Provider", description: "Bundled NVIDIA provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "NVIDIA", @@ -27,6 +26,4 @@ const nvidiaPlugin = { }, }); }, -}; - -export default nvidiaPlugin; +}); diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 6f75f9b08a5..6f7ec7f2088 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -1,5 +1,5 @@ import { - emptyPluginConfigSchema, + definePluginEntry, type OpenClawPluginApi, type ProviderAuthContext, type ProviderAuthMethodNonInteractiveContext, @@ -15,11 +15,10 @@ async function loadProviderSetup() { return await import("openclaw/plugin-sdk/ollama-setup"); } -const ollamaPlugin = { +export default definePluginEntry({ id: "ollama", name: "Ollama Provider", description: "Bundled Ollama provider plugin", - configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerProvider({ id: PROVIDER_ID, @@ -123,6 +122,4 @@ const ollamaPlugin = { }, }); }, -}; - -export default ollamaPlugin; +}); diff --git a/extensions/open-prose/index.ts b/extensions/open-prose/index.ts index 76fa2b18f9e..540148f498c 100644 --- a/extensions/open-prose/index.ts +++ b/extensions/open-prose/index.ts @@ -1,5 +1,10 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/open-prose"; +import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/open-prose"; -export default function register(_api: OpenClawPluginApi) { - // OpenProse is delivered via plugin-shipped skills. -} +export default definePluginEntry({ + id: "open-prose", + name: "OpenProse", + description: "Plugin-shipped prose skills bundle", + register(_api: OpenClawPluginApi) { + // OpenProse is delivered via plugin-shipped skills. + }, +}); diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts index dd8bbdd615d..5664d19b82c 100644 --- a/extensions/openai/index.ts +++ b/extensions/openai/index.ts @@ -1,22 +1,19 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { buildOpenAIImageGenerationProvider } from "openclaw/plugin-sdk/image-generation"; import { buildOpenAISpeechProvider } from "openclaw/plugin-sdk/speech"; import { openaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; import { buildOpenAIProvider } from "./openai-provider.js"; -const openAIPlugin = { +export default definePluginEntry({ id: "openai", name: "OpenAI Provider", description: "Bundled OpenAI provider plugins", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider(buildOpenAIProvider()); api.registerProvider(buildOpenAICodexProviderPlugin()); api.registerSpeechProvider(buildOpenAISpeechProvider()); api.registerMediaUnderstandingProvider(openaiMediaUnderstandingProvider); api.registerImageGenerationProvider(buildOpenAIImageGenerationProvider()); }, -}; - -export default openAIPlugin; +}); diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts new file mode 100644 index 00000000000..04ef3700fb3 --- /dev/null +++ b/extensions/openai/openai-provider.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { buildOpenAIProvider } from "./openai-provider.js"; + +describe("buildOpenAIProvider", () => { + it("resolves gpt-5.4 mini and nano from GPT-5 small-model templates", () => { + const provider = buildOpenAIProvider(); + const registry = { + find(providerId: string, id: string) { + if (providerId !== "openai") { + return null; + } + if (id === "gpt-5-mini") { + return { + id, + name: "GPT-5 mini", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }; + } + if (id === "gpt-5-nano") { + return { + id, + name: "GPT-5 nano", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0.5, output: 1, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 64_000, + }; + } + return null; + }, + }; + + const mini = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-5.4-mini", + modelRegistry: registry as never, + }); + const nano = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-5.4-nano", + modelRegistry: registry as never, + }); + + expect(mini).toMatchObject({ + provider: "openai", + id: "gpt-5.4-mini", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + contextWindow: 400_000, + maxTokens: 128_000, + }); + expect(nano).toMatchObject({ + provider: "openai", + id: "gpt-5.4-nano", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + contextWindow: 200_000, + maxTokens: 64_000, + }); + }); + + it("surfaces gpt-5.4 mini and nano in xhigh and augmented catalog metadata", () => { + const provider = buildOpenAIProvider(); + + expect( + provider.supportsXHighThinking?.({ + provider: "openai", + modelId: "gpt-5.4-mini", + } as never), + ).toBe(true); + expect( + provider.supportsXHighThinking?.({ + provider: "openai", + modelId: "gpt-5.4-nano", + } as never), + ).toBe(true); + + const entries = provider.augmentModelCatalog?.({ + env: process.env, + entries: [ + { provider: "openai", id: "gpt-5-mini", name: "GPT-5 mini" }, + { provider: "openai", id: "gpt-5-nano", name: "GPT-5 nano" }, + ], + } as never); + + expect(entries).toContainEqual({ + provider: "openai", + id: "gpt-5.4-mini", + name: "gpt-5.4-mini", + }); + expect(entries).toContainEqual({ + provider: "openai", + id: "gpt-5.4-nano", + name: "gpt-5.4-nano", + }); + }); +}); diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 8e97b56573f..17053e29e69 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -5,6 +5,7 @@ import { import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { applyOpenAIConfig, + DEFAULT_CONTEXT_TOKENS, normalizeModelCompat, normalizeProviderId, OPENAI_DEFAULT_MODEL, @@ -20,12 +21,29 @@ import { const PROVIDER_ID = "openai"; const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; +const OPENAI_GPT_54_MINI_MODEL_ID = "gpt-5.4-mini"; +const OPENAI_GPT_54_NANO_MODEL_ID = "gpt-5.4-nano"; const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000; const OPENAI_GPT_54_MAX_TOKENS = 128_000; const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; -const OPENAI_XHIGH_MODEL_IDS = ["gpt-5.4", "gpt-5.4-pro", "gpt-5.2"] as const; -const OPENAI_MODERN_MODEL_IDS = ["gpt-5.4", "gpt-5.4-pro", "gpt-5.2", "gpt-5.0"] as const; +const OPENAI_GPT_54_MINI_TEMPLATE_MODEL_IDS = ["gpt-5-mini"] as const; +const OPENAI_GPT_54_NANO_TEMPLATE_MODEL_IDS = ["gpt-5-nano", "gpt-5-mini"] as const; +const OPENAI_XHIGH_MODEL_IDS = [ + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.2", +] as const; +const OPENAI_MODERN_MODEL_IDS = [ + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.2", + "gpt-5.0", +] as const; const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]); @@ -49,10 +67,47 @@ function resolveOpenAIGpt54ForwardCompatModel( const trimmedModelId = ctx.modelId.trim(); const lower = trimmedModelId.toLowerCase(); let templateIds: readonly string[]; + let patch: Partial; if (lower === OPENAI_GPT_54_MODEL_ID) { templateIds = OPENAI_GPT_54_TEMPLATE_MODEL_IDS; + patch = { + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, + maxTokens: OPENAI_GPT_54_MAX_TOKENS, + }; } else if (lower === OPENAI_GPT_54_PRO_MODEL_ID) { templateIds = OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS; + patch = { + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, + maxTokens: OPENAI_GPT_54_MAX_TOKENS, + }; + } else if (lower === OPENAI_GPT_54_MINI_MODEL_ID) { + templateIds = OPENAI_GPT_54_MINI_TEMPLATE_MODEL_IDS; + patch = { + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + }; + } else if (lower === OPENAI_GPT_54_NANO_MODEL_ID) { + templateIds = OPENAI_GPT_54_NANO_TEMPLATE_MODEL_IDS; + patch = { + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + }; } else { return undefined; } @@ -63,27 +118,15 @@ function resolveOpenAIGpt54ForwardCompatModel( modelId: trimmedModelId, templateIds, ctx, - patch: { - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - }, + patch, }) ?? normalizeModelCompat({ id: trimmedModelId, name: trimmedModelId, - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], + ...patch, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, + contextWindow: patch.contextWindow ?? DEFAULT_CONTEXT_TOKENS, + maxTokens: patch.maxTokens ?? DEFAULT_CONTEXT_TOKENS, } as ProviderRuntimeModel) ); } @@ -157,6 +200,16 @@ export function buildOpenAIProvider(): ProviderPlugin { providerId: PROVIDER_ID, templateIds: OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS, }); + const openAiGpt54MiniTemplate = findCatalogTemplate({ + entries: ctx.entries, + providerId: PROVIDER_ID, + templateIds: OPENAI_GPT_54_MINI_TEMPLATE_MODEL_IDS, + }); + const openAiGpt54NanoTemplate = findCatalogTemplate({ + entries: ctx.entries, + providerId: PROVIDER_ID, + templateIds: OPENAI_GPT_54_NANO_TEMPLATE_MODEL_IDS, + }); return [ openAiGpt54Template ? { @@ -172,6 +225,20 @@ export function buildOpenAIProvider(): ProviderPlugin { name: OPENAI_GPT_54_PRO_MODEL_ID, } : undefined, + openAiGpt54MiniTemplate + ? { + ...openAiGpt54MiniTemplate, + id: OPENAI_GPT_54_MINI_MODEL_ID, + name: OPENAI_GPT_54_MINI_MODEL_ID, + } + : undefined, + openAiGpt54NanoTemplate + ? { + ...openAiGpt54NanoTemplate, + id: OPENAI_GPT_54_NANO_MODEL_ID, + name: OPENAI_GPT_54_NANO_MODEL_ID, + } + : undefined, ].filter((entry): entry is NonNullable => entry !== undefined); }, }; diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index 09319628684..8ef9b6ea0b4 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -1,16 +1,15 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { OPENCODE_GO_DEFAULT_MODEL_REF } from "openclaw/plugin-sdk/provider-models"; import { applyOpencodeGoConfig } from "./onboard.js"; const PROVIDER_ID = "opencode-go"; -const opencodeGoPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "OpenCode Go Provider", description: "Bundled OpenCode Go provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "OpenCode Go", @@ -53,6 +52,4 @@ const opencodeGoPlugin = { isModernModelRef: () => true, }); }, -}; - -export default opencodeGoPlugin; +}); diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index 4f9bbb1384a..9649ff6e83b 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "openclaw/plugin-sdk/provider-models"; import { applyOpencodeZenConfig } from "./onboard.js"; @@ -14,12 +14,11 @@ function isModernOpencodeModel(modelId: string): boolean { return !lower.startsWith(MINIMAX_PREFIX); } -const opencodePlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "OpenCode Zen Provider", description: "Bundled OpenCode Zen provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "OpenCode Zen", @@ -63,6 +62,4 @@ const opencodePlugin = { isModernModelRef: ({ modelId }) => isModernOpencodeModel(modelId), }); }, -}; - -export default opencodePlugin; +}); diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index b4c1d908c4f..3d20250e760 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -1,7 +1,6 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import { - emptyPluginConfigSchema, - type OpenClawPluginApi, + definePluginEntry, type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; @@ -74,12 +73,11 @@ function isOpenRouterCacheTtlModel(modelId: string): boolean { return OPENROUTER_CACHE_TTL_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix)); } -const openRouterPlugin = { +export default definePluginEntry({ id: "openrouter", name: "OpenRouter Provider", description: "Bundled OpenRouter provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "OpenRouter", @@ -151,6 +149,4 @@ const openRouterPlugin = { isCacheTtlEligible: (ctx) => isOpenRouterCacheTtlModel(ctx.modelId), }); }, -}; - -export default openRouterPlugin; +}); diff --git a/extensions/perplexity/index.ts b/extensions/perplexity/index.ts index 0fe3034a000..95ae612ed35 100644 --- a/extensions/perplexity/index.ts +++ b/extensions/perplexity/index.ts @@ -1,16 +1,15 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createPluginBackedWebSearchProvider, getScopedCredentialValue, setScopedCredentialValue, } from "openclaw/plugin-sdk/provider-web-search"; -const perplexityPlugin = { +export default definePluginEntry({ id: "perplexity", name: "Perplexity Plugin", description: "Bundled Perplexity plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ id: "perplexity", @@ -27,6 +26,4 @@ const perplexityPlugin = { }), ); }, -}; - -export default perplexityPlugin; +}); diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts index 1eee0ff9d64..e5fe260463b 100644 --- a/extensions/phone-control/index.test.ts +++ b/extensions/phone-control/index.test.ts @@ -7,7 +7,7 @@ import type { PluginCommandContext, } from "openclaw/plugin-sdk/phone-control"; import { describe, expect, it, vi } from "vitest"; -import { createTestPluginApi } from "../test-utils/plugin-api.js"; +import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; import registerPhoneControl from "./index.js"; function createApi(params: { @@ -68,7 +68,7 @@ describe("phone-control plugin", () => { }); let command: OpenClawPluginCommandDefinition | undefined; - registerPhoneControl( + registerPhoneControl.register( createApi({ stateDir, getConfig: () => config, diff --git a/extensions/phone-control/index.ts b/extensions/phone-control/index.ts index 7b63b67b10c..88446e4fde7 100644 --- a/extensions/phone-control/index.ts +++ b/extensions/phone-control/index.ts @@ -1,6 +1,10 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { OpenClawPluginApi, OpenClawPluginService } from "openclaw/plugin-sdk/phone-control"; +import { + definePluginEntry, + type OpenClawPluginApi, + type OpenClawPluginService, +} from "openclaw/plugin-sdk/phone-control"; type ArmGroup = "camera" | "screen" | "writes" | "all"; @@ -283,139 +287,144 @@ function formatStatus(state: ArmStateFile | null): string { return `Phone control: armed (${until}).\nTemporarily allowed: ${cmdLabel}`; } -export default function register(api: OpenClawPluginApi) { - let expiryInterval: ReturnType | null = null; +export default definePluginEntry({ + id: "phone-control", + name: "Phone Control", + description: "Temporary allowlist control for phone automation commands", + register(api: OpenClawPluginApi) { + let expiryInterval: ReturnType | null = null; - const timerService: OpenClawPluginService = { - id: "phone-control-expiry", - start: async (ctx) => { - const statePath = resolveStatePath(ctx.stateDir); - const tick = async () => { - const state = await readArmState(statePath); - if (!state || state.expiresAtMs == null) { - return; - } - if (Date.now() < state.expiresAtMs) { - return; - } - await disarmNow({ - api, - stateDir: ctx.stateDir, - statePath, - reason: "expired", - }); - }; - - // Best effort; don't crash the gateway if state is corrupt. - await tick().catch(() => {}); - - expiryInterval = setInterval(() => { - tick().catch(() => {}); - }, 15_000); - expiryInterval.unref?.(); - - return; - }, - stop: async () => { - if (expiryInterval) { - clearInterval(expiryInterval); - expiryInterval = null; - } - return; - }, - }; - - api.registerService(timerService); - - api.registerCommand({ - name: "phone", - description: "Arm/disarm high-risk phone node commands (camera/screen/writes).", - acceptsArgs: true, - handler: async (ctx) => { - const args = ctx.args?.trim() ?? ""; - const tokens = args.split(/\s+/).filter(Boolean); - const action = tokens[0]?.toLowerCase() ?? ""; - - const stateDir = api.runtime.state.resolveStateDir(); - const statePath = resolveStatePath(stateDir); - - if (!action || action === "help") { - const state = await readArmState(statePath); - return { text: `${formatStatus(state)}\n\n${formatHelp()}` }; - } - - if (action === "status") { - const state = await readArmState(statePath); - return { text: formatStatus(state) }; - } - - if (action === "disarm") { - const res = await disarmNow({ - api, - stateDir, - statePath, - reason: "manual", - }); - if (!res.changed) { - return { text: "Phone control: disarmed." }; - } - const restoredLabel = res.restored.length > 0 ? res.restored.join(", ") : "none"; - const removedLabel = res.removed.length > 0 ? res.removed.join(", ") : "none"; - return { - text: `Phone control: disarmed.\nRemoved allowlist: ${removedLabel}\nRestored denylist: ${restoredLabel}`, - }; - } - - if (action === "arm") { - const group = parseGroup(tokens[1]); - if (!group) { - return { text: `Usage: /phone arm [duration]\nGroups: ${formatGroupList()}` }; - } - const durationMs = parseDurationMs(tokens[2]) ?? 10 * 60_000; - const expiresAtMs = Date.now() + durationMs; - - const commands = resolveCommandsForGroup(group); - const cfg = api.runtime.config.loadConfig(); - const allowSet = new Set(normalizeAllowList(cfg)); - const denySet = new Set(normalizeDenyList(cfg)); - - const addedToAllow: string[] = []; - const removedFromDeny: string[] = []; - for (const cmd of commands) { - if (!allowSet.has(cmd)) { - allowSet.add(cmd); - addedToAllow.push(cmd); + const timerService: OpenClawPluginService = { + id: "phone-control-expiry", + start: async (ctx) => { + const statePath = resolveStatePath(ctx.stateDir); + const tick = async () => { + const state = await readArmState(statePath); + if (!state || state.expiresAtMs == null) { + return; } - if (denySet.delete(cmd)) { - removedFromDeny.push(cmd); + if (Date.now() < state.expiresAtMs) { + return; } - } - const next = patchConfigNodeLists(cfg, { - allowCommands: uniqSorted([...allowSet]), - denyCommands: uniqSorted([...denySet]), - }); - await api.runtime.config.writeConfigFile(next); - - await writeArmState(statePath, { - version: STATE_VERSION, - armedAtMs: Date.now(), - expiresAtMs, - group, - armedCommands: uniqSorted(commands), - addedToAllow: uniqSorted(addedToAllow), - removedFromDeny: uniqSorted(removedFromDeny), - }); - - const allowedLabel = uniqSorted(commands).join(", "); - return { - text: - `Phone control: armed for ${formatDuration(durationMs)}.\n` + - `Temporarily allowed: ${allowedLabel}\n` + - `To disarm early: /phone disarm`, + await disarmNow({ + api, + stateDir: ctx.stateDir, + statePath, + reason: "expired", + }); }; - } - return { text: formatHelp() }; - }, - }); -} + // Best effort; don't crash the gateway if state is corrupt. + await tick().catch(() => {}); + + expiryInterval = setInterval(() => { + tick().catch(() => {}); + }, 15_000); + expiryInterval.unref?.(); + + return; + }, + stop: async () => { + if (expiryInterval) { + clearInterval(expiryInterval); + expiryInterval = null; + } + return; + }, + }; + + api.registerService(timerService); + + api.registerCommand({ + name: "phone", + description: "Arm/disarm high-risk phone node commands (camera/screen/writes).", + acceptsArgs: true, + handler: async (ctx) => { + const args = ctx.args?.trim() ?? ""; + const tokens = args.split(/\s+/).filter(Boolean); + const action = tokens[0]?.toLowerCase() ?? ""; + + const stateDir = api.runtime.state.resolveStateDir(); + const statePath = resolveStatePath(stateDir); + + if (!action || action === "help") { + const state = await readArmState(statePath); + return { text: `${formatStatus(state)}\n\n${formatHelp()}` }; + } + + if (action === "status") { + const state = await readArmState(statePath); + return { text: formatStatus(state) }; + } + + if (action === "disarm") { + const res = await disarmNow({ + api, + stateDir, + statePath, + reason: "manual", + }); + if (!res.changed) { + return { text: "Phone control: disarmed." }; + } + const restoredLabel = res.restored.length > 0 ? res.restored.join(", ") : "none"; + const removedLabel = res.removed.length > 0 ? res.removed.join(", ") : "none"; + return { + text: `Phone control: disarmed.\nRemoved allowlist: ${removedLabel}\nRestored denylist: ${restoredLabel}`, + }; + } + + if (action === "arm") { + const group = parseGroup(tokens[1]); + if (!group) { + return { text: `Usage: /phone arm [duration]\nGroups: ${formatGroupList()}` }; + } + const durationMs = parseDurationMs(tokens[2]) ?? 10 * 60_000; + const expiresAtMs = Date.now() + durationMs; + + const commands = resolveCommandsForGroup(group); + const cfg = api.runtime.config.loadConfig(); + const allowSet = new Set(normalizeAllowList(cfg)); + const denySet = new Set(normalizeDenyList(cfg)); + + const addedToAllow: string[] = []; + const removedFromDeny: string[] = []; + for (const cmd of commands) { + if (!allowSet.has(cmd)) { + allowSet.add(cmd); + addedToAllow.push(cmd); + } + if (denySet.delete(cmd)) { + removedFromDeny.push(cmd); + } + } + const next = patchConfigNodeLists(cfg, { + allowCommands: uniqSorted([...allowSet]), + denyCommands: uniqSorted([...denySet]), + }); + await api.runtime.config.writeConfigFile(next); + + await writeArmState(statePath, { + version: STATE_VERSION, + armedAtMs: Date.now(), + expiresAtMs, + group, + armedCommands: uniqSorted(commands), + addedToAllow: uniqSorted(addedToAllow), + removedFromDeny: uniqSorted(removedFromDeny), + }); + + const allowedLabel = uniqSorted(commands).join(", "); + return { + text: + `Phone control: armed for ${formatDuration(durationMs)}.\n` + + `Temporarily allowed: ${allowedLabel}\n` + + `To disarm early: /phone disarm`, + }; + } + + return { text: formatHelp() }; + }, + }); + }, +}); diff --git a/extensions/qianfan/index.ts b/extensions/qianfan/index.ts index 04094e1c2ca..0bb9c7760f6 100644 --- a/extensions/qianfan/index.ts +++ b/extensions/qianfan/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyQianfanConfig, QIANFAN_DEFAULT_MODEL_REF } from "./onboard.js"; @@ -6,12 +6,11 @@ import { buildQianfanProvider } from "./provider-catalog.js"; const PROVIDER_ID = "qianfan"; -const qianfanPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Qianfan Provider", description: "Bundled Qianfan provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Qianfan", @@ -50,6 +49,4 @@ const qianfanPlugin = { }, }); }, -}; - -export default qianfanPlugin; +}); diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index 2a9538a33ab..377a4a598af 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -2,8 +2,7 @@ import { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin import { QWEN_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime"; import { buildOauthProviderAuthResult, - emptyPluginConfigSchema, - type OpenClawPluginApi, + definePluginEntry, type ProviderAuthContext, type ProviderCatalogContext, } from "openclaw/plugin-sdk/qwen-portal-auth"; @@ -55,12 +54,11 @@ function resolveCatalog(ctx: ProviderCatalogContext) { }; } -const qwenPortalPlugin = { +export default definePluginEntry({ id: "qwen-portal-auth", name: "Qwen OAuth", description: "OAuth flow for Qwen (free-tier) models", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: PROVIDER_LABEL, @@ -146,6 +144,4 @@ const qwenPortalPlugin = { }), }); }, -}; - -export default qwenPortalPlugin; +}); diff --git a/extensions/sglang/index.ts b/extensions/sglang/index.ts index 9918c7ee98b..eb6b302ee01 100644 --- a/extensions/sglang/index.ts +++ b/extensions/sglang/index.ts @@ -5,7 +5,7 @@ import { SGLANG_PROVIDER_LABEL, } from "openclaw/plugin-sdk/agent-runtime"; import { - emptyPluginConfigSchema, + definePluginEntry, type OpenClawPluginApi, type ProviderAuthMethodNonInteractiveContext, } from "openclaw/plugin-sdk/core"; @@ -16,11 +16,10 @@ async function loadProviderSetup() { return await import("openclaw/plugin-sdk/self-hosted-provider-setup"); } -const sglangPlugin = { +export default definePluginEntry({ id: "sglang", name: "SGLang Provider", description: "Bundled SGLang provider plugin", - configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerProvider({ id: PROVIDER_ID, @@ -87,6 +86,4 @@ const sglangPlugin = { }, }); }, -}; - -export default sglangPlugin; +}); 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/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts index 10cf32b383a..bcca049f4d7 100644 --- a/extensions/signal/src/monitor.tool-result.test-harness.ts +++ b/extensions/signal/src/monitor.tool-result.test-harness.ts @@ -1,6 +1,6 @@ import { resetSystemEventsForTest } from "openclaw/plugin-sdk/infra-runtime"; import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; -import type { MockFn } from "openclaw/plugin-sdk/test-utils"; +import type { MockFn } from "openclaw/plugin-sdk/testing"; import { beforeEach, vi } from "vitest"; import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js"; diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index a1433a34f13..a89f25dc268 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -14,8 +14,7 @@ import type { ChannelSetupWizard, ChannelSetupWizardTextInput, } from "openclaw/plugin-sdk/setup"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +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 edcea39d6b1..88d4d07a212 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,6 +1,5 @@ import { setSetupChannelEnabled, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { detectBinary } from "../../../src/plugins/setup-binary.js"; -import { installSignalCli } from "../../../src/plugins/signal-cli-install.js"; +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 60dfd0ed010..f03ecd847e2 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -4,15 +4,15 @@ import { collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, + getChatChannelMeta, + normalizeE164, setAccountEnabledInConfigSection, -} from "../../../src/channels/plugins/config-helpers.js"; -import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; -import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; -import { SignalConfigSchema } from "../../../src/config/zod-schema.providers-core.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { normalizeE164 } from "../../../src/utils.js"; + SignalConfigSchema, + type ChannelPlugin, +} 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..68281fd83d3 --- /dev/null +++ b/extensions/slack/runtime-api.ts @@ -0,0 +1,5 @@ +export * from "./src/action-runtime.js"; +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/src/agents/tools/slack-actions.test.ts b/extensions/slack/src/action-runtime.test.ts similarity index 64% rename from src/agents/tools/slack-actions.test.ts rename to extensions/slack/src/action-runtime.test.ts index bf28c2bed01..803118b877a 100644 --- a/src/agents/tools/slack-actions.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -1,7 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { handleSlackAction } from "./slack-actions.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { handleSlackAction, slackActionRuntime } from "./action-runtime.js"; +import { parseSlackBlocksInput } from "./blocks-input.js"; +const originalSlackActionRuntime = { ...slackActionRuntime }; const deleteSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const downloadSlackFile = vi.fn(async (..._args: unknown[]) => null); const editSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); @@ -14,31 +16,10 @@ const reactSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const readSlackMessages = vi.fn(async (..._args: unknown[]) => ({})); const removeOwnSlackReactions = vi.fn(async (..._args: unknown[]) => ["thumbsup"]); const removeSlackReaction = vi.fn(async (..._args: unknown[]) => ({})); -const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); +const recordSlackThreadParticipation = vi.fn(); +const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({ channelId: "C123" })); const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); -vi.mock("../../../extensions/slack/src/actions.js", () => ({ - deleteSlackMessage: (...args: Parameters) => - deleteSlackMessage(...args), - downloadSlackFile: (...args: Parameters) => downloadSlackFile(...args), - editSlackMessage: (...args: Parameters) => editSlackMessage(...args), - getSlackMemberInfo: (...args: Parameters) => - getSlackMemberInfo(...args), - listSlackEmojis: (...args: Parameters) => listSlackEmojis(...args), - listSlackPins: (...args: Parameters) => listSlackPins(...args), - listSlackReactions: (...args: Parameters) => - listSlackReactions(...args), - pinSlackMessage: (...args: Parameters) => pinSlackMessage(...args), - reactSlackMessage: (...args: Parameters) => reactSlackMessage(...args), - readSlackMessages: (...args: Parameters) => readSlackMessages(...args), - removeOwnSlackReactions: (...args: Parameters) => - removeOwnSlackReactions(...args), - removeSlackReaction: (...args: Parameters) => - removeSlackReaction(...args), - sendSlackMessage: (...args: Parameters) => sendSlackMessage(...args), - unpinSlackMessage: (...args: Parameters) => unpinSlackMessage(...args), -})); - describe("handleSlackAction", () => { function slackConfig(overrides?: Record): OpenClawConfig { return { @@ -105,6 +86,24 @@ describe("handleSlackAction", () => { beforeEach(() => { vi.clearAllMocks(); + Object.assign(slackActionRuntime, originalSlackActionRuntime, { + deleteSlackMessage, + downloadSlackFile, + editSlackMessage, + getSlackMemberInfo, + listSlackEmojis, + listSlackPins, + listSlackReactions, + parseSlackBlocksInput, + pinSlackMessage, + reactSlackMessage, + readSlackMessages, + recordSlackThreadParticipation, + removeOwnSlackReactions, + removeSlackReaction, + sendSlackMessage, + unpinSlackMessage, + }); }); it.each([ @@ -261,6 +260,7 @@ describe("handleSlackAction", () => { { action: "sendMessage", to: "channel:C123", + content: "", blocks, }, slackConfig(), @@ -275,7 +275,7 @@ describe("handleSlackAction", () => { it.each([ { name: "invalid blocks JSON", - blocks: "{bad-json", + blocks: "{not json", expectedError: /blocks must be valid JSON/i, }, { name: "empty blocks arrays", blocks: "[]", expectedError: /at least one block/i }, @@ -285,6 +285,7 @@ describe("handleSlackAction", () => { { action: "sendMessage", to: "channel:C123", + content: "", blocks, }, slackConfig(), @@ -311,8 +312,9 @@ describe("handleSlackAction", () => { { action: "sendMessage", to: "channel:C123", - blocks: [{ type: "divider" }], - mediaUrl: "https://example.com/image.png", + content: "hello", + mediaUrl: "https://example.com/file.png", + blocks: JSON.stringify([{ type: "divider" }]), }, slackConfig(), ), @@ -322,13 +324,13 @@ describe("handleSlackAction", () => { it.each([ { name: "JSON blocks", - blocks: JSON.stringify([{ type: "section", text: { type: "mrkdwn", text: "Updated" } }]), - expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "Updated" } }], + blocks: JSON.stringify([{ type: "divider" }]), + expectedBlocks: [{ type: "divider" }], }, { name: "array blocks", - blocks: [{ type: "divider" }], - expectedBlocks: [{ type: "divider" }], + blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], + expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], }, ])("passes $name to editSlackMessage", async ({ blocks, expectedBlocks }) => { await handleSlackAction( @@ -336,6 +338,7 @@ describe("handleSlackAction", () => { action: "editMessage", channelId: "C123", messageId: "123.456", + content: "", blocks, }, slackConfig(), @@ -360,40 +363,32 @@ describe("handleSlackAction", () => { }); it("auto-injects threadTs from context when replyToMode=all", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( { action: "sendMessage", to: "channel:C123", - content: "Auto-threaded", + content: "Threaded reply", }, - cfg, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Auto-threaded", { - mediaUrl: undefined, - threadTs: "1111111111.111111", - blocks: undefined, - }); + expectLastSlackSend("Threaded reply", "1111111111.111111"); }); it("replyToMode=first threads first message then stops", async () => { - const { cfg, context, hasRepliedRef } = createReplyToFirstScenario(); + const { cfg, context } = createReplyToFirstScenario(); - // First message should be threaded await handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "First" }, cfg, context, ); - expectLastSlackSend("First", "1111111111.111111"); - expect(hasRepliedRef.value).toBe(true); + expectLastSlackSend("First", "1111111111.111111"); await sendSecondMessageAndExpectNoThread({ cfg, context }); }); @@ -405,73 +400,54 @@ describe("handleSlackAction", () => { action: "sendMessage", to: "channel:C123", content: "Explicit", - threadTs: "2222222222.222222", + threadTs: "9999999999.999999", }, cfg, context, ); - expectLastSlackSend("Explicit", "2222222222.222222"); - expect(hasRepliedRef.value).toBe(true); + expectLastSlackSend("Explicit", "9999999999.999999"); + expect(hasRepliedRef.value).toBe(true); await sendSecondMessageAndExpectNoThread({ cfg, context }); }); it("replyToMode=first without hasRepliedRef does not thread", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); - await handleSlackAction({ action: "sendMessage", to: "channel:C123", content: "No ref" }, cfg, { - currentChannelId: "C123", - currentThreadTs: "1111111111.111111", - replyToMode: "first", - // no hasRepliedRef - }); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "No ref", { - mediaUrl: undefined, - threadTs: undefined, - blocks: undefined, - }); + await handleSlackAction( + { action: "sendMessage", to: "channel:C123", content: "No ref" }, + slackConfig(), + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "first", + }, + ); + expectLastSlackSend("No ref"); }); it("does not auto-inject threadTs when replyToMode=off", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( - { - action: "sendMessage", - to: "channel:C123", - content: "Off mode", - }, - cfg, + { action: "sendMessage", to: "channel:C123", content: "No thread" }, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "off", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Off mode", { - mediaUrl: undefined, - threadTs: undefined, - blocks: undefined, - }); + expectLastSlackSend("No thread"); }); it("does not auto-inject threadTs when sending to different channel", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( - { - action: "sendMessage", - to: "channel:C999", - content: "Different channel", - }, - cfg, + { action: "sendMessage", to: "channel:C999", content: "Other channel" }, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Different channel", { + expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Other channel", { mediaUrl: undefined, threadTs: undefined, blocks: undefined, @@ -479,46 +455,34 @@ describe("handleSlackAction", () => { }); it("explicit threadTs overrides context threadTs", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( { action: "sendMessage", to: "channel:C123", - content: "Explicit thread", - threadTs: "2222222222.222222", + content: "Explicit wins", + threadTs: "9999999999.999999", }, - cfg, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Explicit thread", { - mediaUrl: undefined, - threadTs: "2222222222.222222", - blocks: undefined, - }); + expectLastSlackSend("Explicit wins", "9999999999.999999"); }); it("handles channel target without prefix when replyToMode=all", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - sendSlackMessage.mockClear(); await handleSlackAction( - { - action: "sendMessage", - to: "C123", - content: "No prefix", - }, - cfg, + { action: "sendMessage", to: "C123", content: "Bare target" }, + slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("C123", "No prefix", { + expect(sendSlackMessage).toHaveBeenCalledWith("C123", "Bare target", { mediaUrl: undefined, threadTs: "1111111111.111111", blocks: undefined, @@ -526,104 +490,164 @@ describe("handleSlackAction", () => { }); it("adds normalized timestamps to readMessages payloads", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; readSlackMessages.mockResolvedValueOnce({ - messages: [{ ts: "1735689600.456", text: "hi" }], + messages: [{ ts: "1712345678.123456", text: "hi" }], hasMore: false, }); - const result = await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg); - const payload = result.details as { - messages: Array<{ timestampMs?: number; timestampUtc?: string }>; - }; + const result = await handleSlackAction( + { action: "readMessages", channelId: "C1" }, + slackConfig(), + ); - const expectedMs = Math.round(1735689600.456 * 1000); - expect(payload.messages[0].timestampMs).toBe(expectedMs); - expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString()); + expect(result).toMatchObject({ + details: { + ok: true, + hasMore: false, + messages: [ + expect.objectContaining({ + ts: "1712345678.123456", + timestampMs: 1712345678123, + }), + ], + }, + }); }); it("passes threadId through to readSlackMessages", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - readSlackMessages.mockClear(); readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false }); await handleSlackAction( - { action: "readMessages", channelId: "C1", threadId: "12345.6789" }, - cfg, + { action: "readMessages", channelId: "C1", threadId: "1712345678.123456" }, + slackConfig(), ); - const opts = readSlackMessages.mock.calls[0]?.[1] as { threadId?: string } | undefined; - expect(opts?.threadId).toBe("12345.6789"); + expect(readSlackMessages).toHaveBeenCalledWith("C1", { + threadId: "1712345678.123456", + limit: undefined, + before: undefined, + after: undefined, + }); }); it("adds normalized timestamps to pin payloads", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - listSlackPins.mockResolvedValueOnce([ - { - type: "message", - message: { ts: "1735689600.789", text: "pinned" }, + listSlackPins.mockResolvedValueOnce([{ message: { ts: "1712345678.123456", text: "pin" } }]); + + const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, slackConfig()); + + expect(result).toMatchObject({ + details: { + ok: true, + pins: [ + { + message: expect.objectContaining({ + ts: "1712345678.123456", + timestampMs: 1712345678123, + }), + }, + ], }, - ]); - - const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg); - const payload = result.details as { - pins: Array<{ message?: { timestampMs?: number; timestampUtc?: string } }>; - }; - - const expectedMs = Math.round(1735689600.789 * 1000); - expect(payload.pins[0].message?.timestampMs).toBe(expectedMs); - expect(payload.pins[0].message?.timestampUtc).toBe(new Date(expectedMs).toISOString()); + }); }); it("uses user token for reads when available", async () => { - const cfg = { - channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } }, - } as OpenClawConfig; - expect(await resolveReadToken(cfg)).toBe("xoxp-1"); + const token = await resolveReadToken( + slackConfig({ + accounts: { + default: { + botToken: "xoxb-bot", + userToken: "xoxp-user", + }, + }, + }), + ); + expect(token).toBe("xoxp-user"); }); it("falls back to bot token for reads when user token missing", async () => { - const cfg = { - channels: { slack: { botToken: "xoxb-1" } }, - } as OpenClawConfig; - expect(await resolveReadToken(cfg)).toBeUndefined(); + const token = await resolveReadToken( + slackConfig({ + accounts: { + default: { + botToken: "xoxb-bot", + }, + }, + }), + ); + expect(token).toBeUndefined(); }); it("uses bot token for writes when userTokenReadOnly is true", async () => { - const cfg = { - channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } }, - } as OpenClawConfig; - expect(await resolveSendToken(cfg)).toBeUndefined(); + const token = await resolveSendToken( + slackConfig({ + accounts: { + default: { + botToken: "xoxb-bot", + userToken: "xoxp-user", + userTokenReadOnly: true, + }, + }, + }), + ); + expect(token).toBeUndefined(); }); it("allows user token writes when bot token is missing", async () => { - const cfg = { + const token = await resolveSendToken({ channels: { - slack: { userToken: "xoxp-1", userTokenReadOnly: false }, + slack: { + accounts: { + default: { + userToken: "xoxp-user", + userTokenReadOnly: false, + }, + }, + }, }, - } as OpenClawConfig; - expect(await resolveSendToken(cfg)).toBe("xoxp-1"); + } as OpenClawConfig); + expect(token).toBe("xoxp-user"); }); it("returns all emojis when no limit is provided", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const emojiMap = { wave: "url1", smile: "url2", heart: "url3" }; - listSlackEmojis.mockResolvedValueOnce({ ok: true, emoji: emojiMap }); - const result = await handleSlackAction({ action: "emojiList" }, cfg); - const payload = result.details as { ok: boolean; emojis: { emoji: Record } }; - expect(payload.ok).toBe(true); - expect(Object.keys(payload.emojis.emoji)).toHaveLength(3); + listSlackEmojis.mockResolvedValueOnce({ + ok: true, + emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" }, + }); + + const result = await handleSlackAction({ action: "emojiList" }, slackConfig()); + + expect(result).toMatchObject({ + details: { + ok: true, + emojis: { + emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" }, + }, + }, + }); }); it("applies limit to emoji-list results", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const emojiMap = { wave: "url1", smile: "url2", heart: "url3", fire: "url4", star: "url5" }; - listSlackEmojis.mockResolvedValueOnce({ ok: true, emoji: emojiMap }); - const result = await handleSlackAction({ action: "emojiList", limit: 2 }, cfg); - const payload = result.details as { ok: boolean; emojis: { emoji: Record } }; - expect(payload.ok).toBe(true); - const emojiKeys = Object.keys(payload.emojis.emoji); - expect(emojiKeys).toHaveLength(2); - expect(emojiKeys.every((k) => k in emojiMap)).toBe(true); + listSlackEmojis.mockResolvedValueOnce({ + ok: true, + emoji: { + wave: "https://example.com/wave.png", + party: "https://example.com/party.png", + tada: "https://example.com/tada.png", + }, + }); + + const result = await handleSlackAction({ action: "emojiList", limit: 2 }, slackConfig()); + + expect(result).toMatchObject({ + details: { + ok: true, + emojis: { + emoji: { + party: "https://example.com/party.png", + tada: "https://example.com/tada.png", + }, + }, + }, + }); }); }); diff --git a/src/agents/tools/slack-actions.ts b/extensions/slack/src/action-runtime.ts similarity index 79% rename from src/agents/tools/slack-actions.ts rename to extensions/slack/src/action-runtime.ts index 11283394ec8..deb5eb0218e 100644 --- a/src/agents/tools/slack-actions.ts +++ b/extensions/slack/src/action-runtime.ts @@ -1,6 +1,15 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { resolveSlackAccount } from "../../plugin-sdk/account-resolution.js"; +import { withNormalizedTimestamp } from "../../../src/agents/date-time.js"; +import { + createActionGate, + imageResultFromFile, + jsonResult, + readNumberParam, + readReactionParams, + readStringParam, +} from "../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveSlackAccount } from "./accounts.js"; import { deleteSlackMessage, downloadSlackFile, @@ -16,22 +25,10 @@ import { removeSlackReaction, sendSlackMessage, unpinSlackMessage, -} from "../../plugin-sdk/slack.js"; -import { - parseSlackBlocksInput, - parseSlackTarget, - recordSlackThreadParticipation, - resolveSlackChannelId, -} from "../../plugin-sdk/slack.js"; -import { withNormalizedTimestamp } from "../date-time.js"; -import { - createActionGate, - imageResultFromFile, - jsonResult, - readNumberParam, - readReactionParams, - readStringParam, -} from "./common.js"; +} from "./actions.js"; +import { parseSlackBlocksInput } from "./blocks-input.js"; +import { recordSlackThreadParticipation } from "./sent-thread-cache.js"; +import { parseSlackTarget, resolveSlackChannelId } from "./targets.js"; const messagingActions = new Set([ "sendMessage", @@ -44,6 +41,25 @@ const messagingActions = new Set([ const reactionsActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); +export const slackActionRuntime = { + deleteSlackMessage, + downloadSlackFile, + editSlackMessage, + getSlackMemberInfo, + listSlackEmojis, + listSlackPins, + listSlackReactions, + parseSlackBlocksInput, + pinSlackMessage, + reactSlackMessage, + readSlackMessages, + recordSlackThreadParticipation, + removeOwnSlackReactions, + removeSlackReaction, + sendSlackMessage, + unpinSlackMessage, +}; + export type SlackActionContext = { /** Current channel ID for auto-threading. */ currentChannelId?: string; @@ -102,7 +118,7 @@ function resolveThreadTsFromContext( } function readSlackBlocksParam(params: Record) { - return parseSlackBlocksInput(params.blocks); + return slackActionRuntime.parseSlackBlocksInput(params.blocks); } export async function handleSlackAction( @@ -163,28 +179,28 @@ export async function handleSlackAction( }); if (remove) { if (writeOpts) { - await removeSlackReaction(channelId, messageId, emoji, writeOpts); + await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji, writeOpts); } else { - await removeSlackReaction(channelId, messageId, emoji); + await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji); } return jsonResult({ ok: true, removed: emoji }); } if (isEmpty) { const removed = writeOpts - ? await removeOwnSlackReactions(channelId, messageId, writeOpts) - : await removeOwnSlackReactions(channelId, messageId); + ? await slackActionRuntime.removeOwnSlackReactions(channelId, messageId, writeOpts) + : await slackActionRuntime.removeOwnSlackReactions(channelId, messageId); return jsonResult({ ok: true, removed }); } if (writeOpts) { - await reactSlackMessage(channelId, messageId, emoji, writeOpts); + await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji, writeOpts); } else { - await reactSlackMessage(channelId, messageId, emoji); + await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji); } return jsonResult({ ok: true, added: emoji }); } const reactions = readOpts - ? await listSlackReactions(channelId, messageId, readOpts) - : await listSlackReactions(channelId, messageId); + ? await slackActionRuntime.listSlackReactions(channelId, messageId, readOpts) + : await slackActionRuntime.listSlackReactions(channelId, messageId); return jsonResult({ ok: true, reactions }); } @@ -211,7 +227,7 @@ export async function handleSlackAction( to, context, ); - const result = await sendSlackMessage(to, content ?? "", { + const result = await slackActionRuntime.sendSlackMessage(to, content ?? "", { ...writeOpts, mediaUrl: mediaUrl ?? undefined, mediaLocalRoots: context?.mediaLocalRoots, @@ -220,7 +236,11 @@ export async function handleSlackAction( }); if (threadTs && result.channelId && account.accountId) { - recordSlackThreadParticipation(account.accountId, result.channelId, threadTs); + slackActionRuntime.recordSlackThreadParticipation( + account.accountId, + result.channelId, + threadTs, + ); } // Keep "first" mode consistent even when the agent explicitly provided @@ -248,12 +268,12 @@ export async function handleSlackAction( throw new Error("Slack editMessage requires content or blocks."); } if (writeOpts) { - await editSlackMessage(channelId, messageId, content ?? "", { + await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", { ...writeOpts, blocks, }); } else { - await editSlackMessage(channelId, messageId, content ?? "", { + await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", { blocks, }); } @@ -265,9 +285,9 @@ export async function handleSlackAction( required: true, }); if (writeOpts) { - await deleteSlackMessage(channelId, messageId, writeOpts); + await slackActionRuntime.deleteSlackMessage(channelId, messageId, writeOpts); } else { - await deleteSlackMessage(channelId, messageId); + await slackActionRuntime.deleteSlackMessage(channelId, messageId); } return jsonResult({ ok: true }); } @@ -279,7 +299,7 @@ export async function handleSlackAction( const before = readStringParam(params, "before"); const after = readStringParam(params, "after"); const threadId = readStringParam(params, "threadId"); - const result = await readSlackMessages(channelId, { + const result = await slackActionRuntime.readSlackMessages(channelId, { ...readOpts, limit, before: before ?? undefined, @@ -302,7 +322,7 @@ export async function handleSlackAction( const maxBytes = account.config?.mediaMaxMb ? account.config.mediaMaxMb * 1024 * 1024 : 20 * 1024 * 1024; - const downloaded = await downloadSlackFile(fileId, { + const downloaded = await slackActionRuntime.downloadSlackFile(fileId, { ...readOpts, maxBytes, channelId, @@ -336,9 +356,9 @@ export async function handleSlackAction( required: true, }); if (writeOpts) { - await pinSlackMessage(channelId, messageId, writeOpts); + await slackActionRuntime.pinSlackMessage(channelId, messageId, writeOpts); } else { - await pinSlackMessage(channelId, messageId); + await slackActionRuntime.pinSlackMessage(channelId, messageId); } return jsonResult({ ok: true }); } @@ -347,15 +367,15 @@ export async function handleSlackAction( required: true, }); if (writeOpts) { - await unpinSlackMessage(channelId, messageId, writeOpts); + await slackActionRuntime.unpinSlackMessage(channelId, messageId, writeOpts); } else { - await unpinSlackMessage(channelId, messageId); + await slackActionRuntime.unpinSlackMessage(channelId, messageId); } return jsonResult({ ok: true }); } const pins = writeOpts - ? await listSlackPins(channelId, readOpts) - : await listSlackPins(channelId); + ? await slackActionRuntime.listSlackPins(channelId, readOpts) + : await slackActionRuntime.listSlackPins(channelId); const normalizedPins = pins.map((pin) => { const message = pin.message ? withNormalizedTimestamp( @@ -374,8 +394,8 @@ export async function handleSlackAction( } const userId = readStringParam(params, "userId", { required: true }); const info = writeOpts - ? await getSlackMemberInfo(userId, readOpts) - : await getSlackMemberInfo(userId); + ? await slackActionRuntime.getSlackMemberInfo(userId, readOpts) + : await slackActionRuntime.getSlackMemberInfo(userId); return jsonResult({ ok: true, info }); } @@ -383,7 +403,9 @@ export async function handleSlackAction( if (!isActionEnabled("emojiList")) { throw new Error("Slack emoji list is disabled."); } - const result = readOpts ? await listSlackEmojis(readOpts) : await listSlackEmojis(); + const result = readOpts + ? await slackActionRuntime.listSlackEmojis(readOpts) + : await slackActionRuntime.listSlackEmojis(); const limit = readNumberParam(params, "limit", { integer: true }); if (limit != null && limit > 0 && result.emoji != null) { const entries = Object.entries(result.emoji).toSorted(([a], [b]) => a.localeCompare(b)); diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index bafc5fc8c91..5e25f0187b1 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -22,11 +22,11 @@ import { resolveConfiguredFromRequiredCredentialStatuses, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, + createSlackActions, type ChannelPlugin, type OpenClawConfig, + type SlackActionContext, } from "openclaw/plugin-sdk/slack"; -import type { SlackActionContext } from "../../../src/agents/tools/slack-actions.js"; -import { createSlackActions } from "../../../src/channels/plugins/slack.actions.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listEnabledSlackAccounts, diff --git a/extensions/slack/src/monitor/media.test.ts b/extensions/slack/src/monitor/media.test.ts index 9d5114e2961..9ac0bc0eeb1 100644 --- a/extensions/slack/src/monitor/media.test.ts +++ b/extensions/slack/src/monitor/media.test.ts @@ -4,7 +4,10 @@ import * as mediaFetch from "../../../../src/media/fetch.js"; import type { SavedMedia } from "../../../../src/media/store.js"; import * as mediaStore from "../../../../src/media/store.js"; import { mockPinnedHostnameResolution } from "../../../../src/test-helpers/ssrf.js"; -import { type FetchMock, withFetchPreconnect } from "../../../test-utils/fetch-mock.js"; +import { + type FetchMock, + withFetchPreconnect, +} from "../../../../test/helpers/extensions/fetch-mock.js"; import { fetchWithSlackAuth, resolveSlackAttachmentContent, 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 af71e5edc52..5a8fe1feab4 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -18,7 +18,7 @@ import { type ChannelSetupWizard, type ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +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 f7a52a72888..6731ddff84b 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -11,7 +11,7 @@ import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +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 4471e851097..0d4fd0a3481 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -3,14 +3,18 @@ import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; -import { patchChannelConfigForAccount } from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { SlackConfigSchema } from "../../../src/config/zod-schema.providers-core.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +import { + formatDocsLink, + hasConfiguredSecretInput, + patchChannelConfigForAccount, +} from "openclaw/plugin-sdk/setup"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + SlackConfigSchema, + type ChannelPlugin, + type OpenClawConfig, +} 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..1707865e258 --- /dev/null +++ b/extensions/synology-chat/api.ts @@ -0,0 +1,2 @@ +export * from "openclaw/plugin-sdk/synology-chat"; +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/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 0bc771a7d26..67aadff1c12 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -4,13 +4,13 @@ * Implements the ChannelPlugin interface following the LINE pattern. */ +import { z } from "zod"; import { DEFAULT_ACCOUNT_ID, setAccountEnabledInConfigSection, registerPluginHttpRoute, buildChannelConfigSchema, -} from "openclaw/plugin-sdk/synology-chat"; -import { z } from "zod"; +} from "../api.js"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { sendMessage, sendFileUrl } from "./client.js"; import { getSynologyRuntime } from "./runtime.js"; diff --git a/extensions/synology-chat/src/runtime.ts b/extensions/synology-chat/src/runtime.ts index 68df66decc7..e1288f74468 100644 --- a/extensions/synology-chat/src/runtime.ts +++ b/extensions/synology-chat/src/runtime.ts @@ -1,5 +1,5 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import type { PluginRuntime } from "openclaw/plugin-sdk/synology-chat"; +import type { PluginRuntime } from "../api.js"; const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } = createPluginRuntimeStore( diff --git a/extensions/synology-chat/src/security.ts b/extensions/synology-chat/src/security.ts index 5b661eb6b84..8ac50016a12 100644 --- a/extensions/synology-chat/src/security.ts +++ b/extensions/synology-chat/src/security.ts @@ -3,10 +3,7 @@ */ import * as crypto from "node:crypto"; -import { - createFixedWindowRateLimiter, - type FixedWindowRateLimiter, -} from "openclaw/plugin-sdk/synology-chat"; +import { createFixedWindowRateLimiter, type FixedWindowRateLimiter } from "../api.js"; export type DmAuthorizationResult = | { allowed: true } diff --git a/extensions/synology-chat/src/setup-surface.test.ts b/extensions/synology-chat/src/setup-surface.test.ts index 6c1289a8a84..5b30c747813 100644 --- a/extensions/synology-chat/src/setup-surface.test.ts +++ b/extensions/synology-chat/src/setup-surface.test.ts @@ -1,31 +1,14 @@ import { describe, expect, it, vi } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { + createTestWizardPrompter, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { synologyChatPlugin } from "./channel.js"; import { synologyChatSetupWizard } from "./setup-surface.js"; -function createPrompter(overrides: Partial = {}): WizardPrompter { - return { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), - select: vi.fn(async ({ options }: { options: Array<{ value: string }> }) => { - const first = options[0]; - if (!first) { - throw new Error("no options"); - } - return first.value; - }) as WizardPrompter["select"], - multiselect: vi.fn(async () => []), - text: vi.fn(async () => "") as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - ...overrides, - }; -} - const synologyChatConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ plugin: synologyChatPlugin, wizard: synologyChatSetupWizard, @@ -33,7 +16,7 @@ const synologyChatConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWiza describe("synology-chat setup wizard", () => { it("configures token and incoming webhook for the default account", async () => { - const prompter = createPrompter({ + const prompter = createTestWizardPrompter({ text: vi.fn(async ({ message }: { message: string }) => { if (message === "Enter Synology Chat outgoing webhook token") { return "synology-token"; @@ -67,7 +50,7 @@ describe("synology-chat setup wizard", () => { }); it("records allowed user ids when setup forces allowFrom", async () => { - const prompter = createPrompter({ + const prompter = createTestWizardPrompter({ text: vi.fn(async ({ message }: { message: string }) => { if (message === "Enter Synology Chat outgoing webhook token") { return "synology-token"; diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index 05cd425b06f..4f38136e9a5 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -9,7 +9,7 @@ import { isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, -} from "openclaw/plugin-sdk/synology-chat"; +} from "../api.js"; import { sendMessage, resolveChatUserId } from "./client.js"; import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js"; import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js"; diff --git a/extensions/synthetic/index.ts b/extensions/synthetic/index.ts index 9bdeea0b8a5..360e4124cdd 100644 --- a/extensions/synthetic/index.ts +++ b/extensions/synthetic/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applySyntheticConfig, SYNTHETIC_DEFAULT_MODEL_REF } from "./onboard.js"; @@ -6,12 +6,11 @@ import { buildSyntheticProvider } from "./provider-catalog.js"; const PROVIDER_ID = "synthetic"; -const syntheticPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Synthetic Provider", description: "Bundled Synthetic provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Synthetic", @@ -50,6 +49,4 @@ const syntheticPlugin = { }, }); }, -}; - -export default syntheticPlugin; +}); 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.test.ts b/extensions/talk-voice/index.test.ts index 15876987554..487df4a2d7a 100644 --- a/extensions/talk-voice/index.test.ts +++ b/extensions/talk-voice/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawPluginCommandDefinition } from "../test-utils/plugin-command.js"; -import { createPluginRuntimeMock } from "../test-utils/plugin-runtime-mock.js"; +import type { OpenClawPluginCommandDefinition } from "../../test/helpers/extensions/plugin-command.js"; +import { createPluginRuntimeMock } from "../../test/helpers/extensions/plugin-runtime-mock.js"; import register from "./index.js"; function createHarness(config: Record) { @@ -20,7 +20,7 @@ function createHarness(config: Record) { command = definition; }), }; - register(api as never); + register.register(api as never); if (!command) { throw new Error("talk-voice command not registered"); } diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index fb9e7bdb39d..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 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(); @@ -99,119 +99,124 @@ function asProviderBaseUrl(value: unknown): string | undefined { return trimmed || undefined; } -export default function register(api: OpenClawPluginApi) { - api.registerCommand({ - name: "voice", - nativeNames: { - discord: "talkvoice", - }, - description: "List/set Talk provider voices (affects iOS Talk playback).", - acceptsArgs: true, - handler: async (ctx) => { - const commandLabel = resolveCommandLabel(ctx.channel); - const args = ctx.args?.trim() ?? ""; - const tokens = args.split(/\s+/).filter(Boolean); - const action = (tokens[0] ?? "status").toLowerCase(); +export default definePluginEntry({ + id: "talk-voice", + name: "Talk Voice", + description: "Command helpers for managing Talk voice configuration", + register(api: OpenClawPluginApi) { + api.registerCommand({ + name: "voice", + nativeNames: { + discord: "talkvoice", + }, + description: "List/set Talk provider voices (affects iOS Talk playback).", + acceptsArgs: true, + handler: async (ctx) => { + const commandLabel = resolveCommandLabel(ctx.channel); + const args = ctx.args?.trim() ?? ""; + const tokens = args.split(/\s+/).filter(Boolean); + const action = (tokens[0] ?? "status").toLowerCase(); - const cfg = api.runtime.config.loadConfig(); - const active = resolveActiveTalkProviderConfig(cfg.talk); - if (!active) { - return { - text: - "Talk voice is not configured.\n\n" + - "Missing: talk.provider and talk.providers..\n" + - "Set it on the gateway, then retry.", - }; - } - const providerId = active.provider; - const providerLabel = resolveProviderLabel(providerId); - const apiKey = asTrimmedString(active.config.apiKey); - const baseUrl = asProviderBaseUrl(active.config.baseUrl); - - const currentVoiceId = - asTrimmedString(active.config.voiceId) || asTrimmedString(cfg.talk?.voiceId); - - if (action === "status") { - return { - text: - "Talk voice status:\n" + - `- provider: ${providerId}\n` + - `- talk.voiceId: ${currentVoiceId ? currentVoiceId : "(unset)"}\n` + - `- ${providerId}.apiKey: ${apiKey ? mask(apiKey) : "(unset)"}`, - }; - } - - if (action === "list") { - const limit = Number.parseInt(tokens[1] ?? "12", 10); - try { - const voices = await api.runtime.tts.listVoices({ - provider: providerId, - cfg, - apiKey: apiKey || undefined, - baseUrl, - }); + const cfg = api.runtime.config.loadConfig(); + const active = resolveActiveTalkProviderConfig(cfg.talk); + if (!active) { return { - text: formatVoiceList(voices, Number.isFinite(limit) ? limit : 12, providerId), + text: + "Talk voice is not configured.\n\n" + + "Missing: talk.provider and talk.providers..\n" + + "Set it on the gateway, then retry.", }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { text: `${providerLabel} voice list failed: ${message}` }; } - } + const providerId = active.provider; + const providerLabel = resolveProviderLabel(providerId); + const apiKey = asTrimmedString(active.config.apiKey); + const baseUrl = asProviderBaseUrl(active.config.baseUrl); - if (action === "set") { - const query = tokens.slice(1).join(" ").trim(); - if (!query) { - return { text: `Usage: ${commandLabel} set ` }; - } - let voices: SpeechVoiceOption[]; - try { - voices = await api.runtime.tts.listVoices({ - provider: providerId, - cfg, - apiKey: apiKey || undefined, - baseUrl, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { text: `${providerLabel} voice lookup failed: ${message}` }; - } - const chosen = findVoice(voices, query); - if (!chosen) { - const hint = isLikelyVoiceId(query) ? query : `"${query}"`; - return { text: `No voice found for ${hint}. Try: ${commandLabel} list` }; + const currentVoiceId = + asTrimmedString(active.config.voiceId) || asTrimmedString(cfg.talk?.voiceId); + + if (action === "status") { + return { + text: + "Talk voice status:\n" + + `- provider: ${providerId}\n` + + `- talk.voiceId: ${currentVoiceId ? currentVoiceId : "(unset)"}\n` + + `- ${providerId}.apiKey: ${apiKey ? mask(apiKey) : "(unset)"}`, + }; } - const nextConfig = { - ...cfg, - talk: { - ...cfg.talk, - provider: providerId, - providers: { - ...(cfg.talk?.providers ?? {}), - [providerId]: { - ...(cfg.talk?.providers?.[providerId] ?? {}), - voiceId: chosen.id, + if (action === "list") { + const limit = Number.parseInt(tokens[1] ?? "12", 10); + try { + const voices = await api.runtime.tts.listVoices({ + provider: providerId, + cfg, + apiKey: apiKey || undefined, + baseUrl, + }); + return { + text: formatVoiceList(voices, Number.isFinite(limit) ? limit : 12, providerId), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { text: `${providerLabel} voice list failed: ${message}` }; + } + } + + if (action === "set") { + const query = tokens.slice(1).join(" ").trim(); + if (!query) { + return { text: `Usage: ${commandLabel} set ` }; + } + let voices: SpeechVoiceOption[]; + try { + voices = await api.runtime.tts.listVoices({ + provider: providerId, + cfg, + apiKey: apiKey || undefined, + baseUrl, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { text: `${providerLabel} voice lookup failed: ${message}` }; + } + const chosen = findVoice(voices, query); + if (!chosen) { + const hint = isLikelyVoiceId(query) ? query : `"${query}"`; + return { text: `No voice found for ${hint}. Try: ${commandLabel} list` }; + } + + const nextConfig = { + ...cfg, + talk: { + ...cfg.talk, + provider: providerId, + providers: { + ...(cfg.talk?.providers ?? {}), + [providerId]: { + ...(cfg.talk?.providers?.[providerId] ?? {}), + voiceId: chosen.id, + }, }, + ...(providerId === "elevenlabs" ? { voiceId: chosen.id } : {}), }, - ...(providerId === "elevenlabs" ? { voiceId: chosen.id } : {}), - }, + }; + await api.runtime.config.writeConfigFile(nextConfig); + + const name = (chosen.name ?? "").trim() || "(unnamed)"; + return { text: `āœ… ${providerLabel} Talk voice set to ${name}\n${chosen.id}` }; + } + + return { + text: [ + "Voice commands:", + "", + `${commandLabel} status`, + `${commandLabel} list [limit]`, + `${commandLabel} set `, + ].join("\n"), }; - await api.runtime.config.writeConfigFile(nextConfig); - - const name = (chosen.name ?? "").trim() || "(unnamed)"; - return { text: `āœ… ${providerLabel} Talk voice set to ${name}\n${chosen.id}` }; - } - - return { - text: [ - "Voice commands:", - "", - `${commandLabel} status`, - `${commandLabel} list [limit]`, - `${commandLabel} set `, - ].join("\n"), - }; - }, - }); -} + }, + }); + }, +}); 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..76f87396469 --- /dev/null +++ b/extensions/telegram/runtime-api.ts @@ -0,0 +1,8 @@ +export * from "./src/audit.js"; +export * from "./src/action-runtime.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/account-inspect.test.ts b/extensions/telegram/src/account-inspect.test.ts index 54915edb61c..735cf4e53bb 100644 --- a/extensions/telegram/src/account-inspect.test.ts +++ b/extensions/telegram/src/account-inspect.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { withEnv } from "../../test-utils/env.js"; +import { withEnv } from "../../../test/helpers/extensions/env.js"; import { inspectTelegramAccount } from "./account-inspect.js"; describe("inspectTelegramAccount SecretRef resolution", () => { diff --git a/extensions/telegram/src/accounts.test.ts b/extensions/telegram/src/accounts.test.ts index 6155b89d0af..ae8a56c66cf 100644 --- a/extensions/telegram/src/accounts.test.ts +++ b/extensions/telegram/src/accounts.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; import * as subsystemModule from "../../../src/logging/subsystem.js"; -import { withEnv } from "../../test-utils/env.js"; +import { withEnv } from "../../../test/helpers/extensions/env.js"; import { listTelegramAccountIds, resetMissingDefaultWarnFlag, diff --git a/src/agents/tools/telegram-actions.test.ts b/extensions/telegram/src/action-runtime.test.ts similarity index 95% rename from src/agents/tools/telegram-actions.test.ts rename to extensions/telegram/src/action-runtime.test.ts index 997de707765..ad59933415f 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/extensions/telegram/src/action-runtime.test.ts @@ -1,8 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { captureEnv } from "../../test-utils/env.js"; -import { handleTelegramAction, readTelegramButtons } from "./telegram-actions.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { captureEnv } from "../../../test/helpers/extensions/env.js"; +import { + handleTelegramAction, + readTelegramButtons, + telegramActionRuntime, +} from "./action-runtime.js"; +const originalTelegramActionRuntime = { ...telegramActionRuntime }; const reactMessageTelegram = vi.fn(async () => ({ ok: true })); const sendMessageTelegram = vi.fn(async () => ({ messageId: "789", @@ -36,24 +41,6 @@ const createForumTopicTelegram = vi.fn(async () => ({ })); let envSnapshot: ReturnType; -vi.mock("../../../extensions/telegram/src/send.js", () => ({ - reactMessageTelegram: (...args: Parameters) => - reactMessageTelegram(...args), - sendMessageTelegram: (...args: Parameters) => - sendMessageTelegram(...args), - sendPollTelegram: (...args: Parameters) => sendPollTelegram(...args), - sendStickerTelegram: (...args: Parameters) => - sendStickerTelegram(...args), - deleteMessageTelegram: (...args: Parameters) => - deleteMessageTelegram(...args), - editMessageTelegram: (...args: Parameters) => - editMessageTelegram(...args), - editForumTopicTelegram: (...args: Parameters) => - editForumTopicTelegram(...args), - createForumTopicTelegram: (...args: Parameters) => - createForumTopicTelegram(...args), -})); - describe("handleTelegramAction", () => { const defaultReactionAction = { action: "react", @@ -107,6 +94,16 @@ describe("handleTelegramAction", () => { beforeEach(() => { envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN"]); + Object.assign(telegramActionRuntime, originalTelegramActionRuntime, { + reactMessageTelegram, + sendMessageTelegram, + sendPollTelegram, + sendStickerTelegram, + deleteMessageTelegram, + editMessageTelegram, + editForumTopicTelegram, + createForumTopicTelegram, + }); reactMessageTelegram.mockClear(); sendMessageTelegram.mockClear(); sendPollTelegram.mockClear(); diff --git a/src/agents/tools/telegram-actions.ts b/extensions/telegram/src/action-runtime.ts similarity index 87% rename from src/agents/tools/telegram-actions.ts rename to extensions/telegram/src/action-runtime.ts index d648b1e5f41..e6e56e9eb3a 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -1,15 +1,23 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; +import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { - createTelegramActionGate, - resolveTelegramPollActionGateState, -} from "../../plugin-sdk/telegram.js"; -import type { TelegramButtonStyle, TelegramInlineButtons } from "../../plugin-sdk/telegram.js"; + jsonResult, + readNumberParam, + readReactionParams, + readStringArrayParam, + readStringOrNumberParam, + readStringParam, +} from "../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramActionConfig } from "../../../src/config/types.telegram.js"; +import { resolvePollMaxSelections } from "../../../src/polls.js"; +import { createTelegramActionGate, resolveTelegramPollActionGateState } from "./accounts.js"; +import type { TelegramButtonStyle, TelegramInlineButtons } from "./button-types.js"; import { resolveTelegramInlineButtonsScope, resolveTelegramTargetChatType, -} from "../../plugin-sdk/telegram.js"; +} from "./inline-buttons.js"; +import { resolveTelegramReactionLevel } from "./reaction-level.js"; import { createForumTopicTelegram, deleteMessageTelegram, @@ -19,22 +27,22 @@ import { sendMessageTelegram, sendPollTelegram, sendStickerTelegram, -} from "../../plugin-sdk/telegram.js"; -import { +} from "./send.js"; +import { getCacheStats, searchStickers } from "./sticker-cache.js"; +import { resolveTelegramToken } from "./token.js"; + +export const telegramActionRuntime = { + createForumTopicTelegram, + deleteMessageTelegram, + editForumTopicTelegram, + editMessageTelegram, getCacheStats, - resolveTelegramReactionLevel, - resolveTelegramToken, + reactMessageTelegram, searchStickers, -} from "../../plugin-sdk/telegram.js"; -import { resolvePollMaxSelections } from "../../polls.js"; -import { - jsonResult, - readNumberParam, - readReactionParams, - readStringArrayParam, - readStringOrNumberParam, - readStringParam, -} from "./common.js"; + sendMessageTelegram, + sendPollTelegram, + sendStickerTelegram, +}; const TELEGRAM_BUTTON_STYLES: readonly TelegramButtonStyle[] = ["danger", "success", "primary"]; @@ -155,14 +163,19 @@ export async function handleTelegramAction( hint: "Telegram bot token missing. Do not retry.", }); } - let reactionResult: Awaited>; + let reactionResult: Awaited>; try { - reactionResult = await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", { - cfg, - token, - remove, - accountId: accountId ?? undefined, - }); + reactionResult = await telegramActionRuntime.reactMessageTelegram( + chatId ?? "", + messageId ?? 0, + emoji ?? "", + { + cfg, + token, + remove, + accountId: accountId ?? undefined, + }, + ); } catch (err) { const isInvalid = String(err).includes("REACTION_INVALID"); return jsonResult({ @@ -241,7 +254,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await sendMessageTelegram(to, content, { + const result = await telegramActionRuntime.sendMessageTelegram(to, content, { cfg, token, accountId: accountId ?? undefined, @@ -290,7 +303,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await sendPollTelegram( + const result = await telegramActionRuntime.sendPollTelegram( to, { question, @@ -334,7 +347,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - await deleteMessageTelegram(chatId ?? "", messageId ?? 0, { + await telegramActionRuntime.deleteMessageTelegram(chatId ?? "", messageId ?? 0, { cfg, token, accountId: accountId ?? undefined, @@ -375,12 +388,17 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, { - cfg, - token, - accountId: accountId ?? undefined, - buttons, - }); + const result = await telegramActionRuntime.editMessageTelegram( + chatId ?? "", + messageId ?? 0, + content, + { + cfg, + token, + accountId: accountId ?? undefined, + buttons, + }, + ); return jsonResult({ ok: true, messageId: result.messageId, @@ -408,7 +426,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await sendStickerTelegram(to, fileId, { + const result = await telegramActionRuntime.sendStickerTelegram(to, fileId, { cfg, token, accountId: accountId ?? undefined, @@ -430,7 +448,7 @@ export async function handleTelegramAction( } const query = readStringParam(params, "query", { required: true }); const limit = readNumberParam(params, "limit", { integer: true }) ?? 5; - const results = searchStickers(query, limit); + const results = telegramActionRuntime.searchStickers(query, limit); return jsonResult({ ok: true, count: results.length, @@ -444,7 +462,7 @@ export async function handleTelegramAction( } if (action === "stickerCacheStats") { - const stats = getCacheStats(); + const stats = telegramActionRuntime.getCacheStats(); return jsonResult({ ok: true, ...stats }); } @@ -464,7 +482,7 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await createForumTopicTelegram(chatId ?? "", name, { + const result = await telegramActionRuntime.createForumTopicTelegram(chatId ?? "", name, { cfg, token, accountId: accountId ?? undefined, @@ -500,13 +518,17 @@ export async function handleTelegramAction( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", ); } - const result = await editForumTopicTelegram(chatId ?? "", messageThreadId, { - cfg, - token, - accountId: accountId ?? undefined, - name: name ?? undefined, - iconCustomEmojiId: iconCustomEmojiId ?? undefined, - }); + const result = await telegramActionRuntime.editForumTopicTelegram( + chatId ?? "", + messageThreadId, + { + cfg, + token, + accountId: accountId ?? undefined, + name: name ?? undefined, + iconCustomEmojiId: iconCustomEmojiId ?? undefined, + }, + ); return jsonResult(result); } diff --git a/extensions/telegram/src/bot-handlers.ts b/extensions/telegram/src/bot-handlers.ts index 18db7c3405f..92d584b8ea9 100644 --- a/extensions/telegram/src/bot-handlers.ts +++ b/extensions/telegram/src/bot-handlers.ts @@ -64,7 +64,10 @@ import { resolveTelegramGroupAllowFromContext, } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; -import { resolveTelegramConversationRoute } from "./conversation-route.js"; +import { + resolveTelegramConversationBaseSessionKey, + resolveTelegramConversationRoute, +} from "./conversation-route.js"; import { enforceTelegramDmAccess } from "./dm-access.js"; import { isTelegramExecApprovalApprover, @@ -331,7 +334,13 @@ export const registerTelegramHandlers = ({ senderId: params.senderId, topicAgentId: topicConfig?.agentId, }); - const baseSessionKey = route.sessionKey; + const baseSessionKey = resolveTelegramConversationBaseSessionKey({ + cfg, + route, + chatId: params.chatId, + isGroup: params.isGroup, + senderId: params.senderId, + }); const threadKeys = dmThreadId != null ? resolveThreadSessionKeys({ baseSessionKey, threadId: `${params.chatId}:${dmThreadId}` }) 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.named-account-dm.test.ts b/extensions/telegram/src/bot-message-context.named-account-dm.test.ts index a60904514ba..e51c7920ae7 100644 --- a/extensions/telegram/src/bot-message-context.named-account-dm.test.ts +++ b/extensions/telegram/src/bot-message-context.named-account-dm.test.ts @@ -6,9 +6,13 @@ import { import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined); -vi.mock("../../../src/channels/session.js", () => ({ - recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), -})); +vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), + }; +}); describe("buildTelegramMessageContext named-account DM fallback", () => { const baseCfg = { diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index d77fd52f2fc..78ba9f02492 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -7,9 +7,9 @@ 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 { buildAgentSessionKey, deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; +import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { withTelegramApiErrorLogging } from "./api-logging.js"; @@ -17,12 +17,11 @@ import { firstDefined, normalizeAllowFrom, normalizeDmAllowFromWithStore } from import { resolveTelegramInboundBody } from "./bot-message-context.body.js"; import { buildTelegramInboundContextPayload } from "./bot-message-context.session.js"; import type { BuildTelegramMessageContextParams } from "./bot-message-context.types.js"; +import { buildTypingThreadParams, resolveTelegramThreadSpec } from "./bot/helpers.js"; import { - buildTypingThreadParams, - resolveTelegramDirectPeerId, - resolveTelegramThreadSpec, -} from "./bot/helpers.js"; -import { resolveTelegramConversationRoute } from "./conversation-route.js"; + resolveTelegramConversationBaseSessionKey, + resolveTelegramConversationRoute, +} from "./conversation-route.js"; import { enforceTelegramDmAccess } from "./dm-access.js"; import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; import { @@ -202,44 +201,35 @@ 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; }; - const baseSessionKey = isNamedAccountFallback - ? buildAgentSessionKey({ - agentId: route.agentId, - channel: "telegram", - accountId: route.accountId, - peer: { - kind: "direct", - id: resolveTelegramDirectPeerId({ - chatId, - senderId, - }), - }, - dmScope: "per-account-channel-peer", - identityLinks: freshCfg.session?.identityLinks, - }).toLowerCase() - : route.sessionKey; + const baseSessionKey = resolveTelegramConversationBaseSessionKey({ + cfg: freshCfg, + route, + chatId, + isGroup, + senderId, + }); // DMs: use thread suffix for session isolation (works regardless of dmScope) const threadKeys = dmThreadId != null diff --git a/extensions/telegram/src/bot-native-commands.registry.test.ts b/extensions/telegram/src/bot-native-commands.registry.test.ts index 55379e6a5fa..d671be06609 100644 --- a/extensions/telegram/src/bot-native-commands.registry.test.ts +++ b/extensions/telegram/src/bot-native-commands.registry.test.ts @@ -17,6 +17,38 @@ import { waitForRegisteredCommands, } from "./bot-native-commands.menu-test-support.js"; +function registerPairPluginCommand(params?: { + nativeNames?: { telegram?: string; discord?: string }; +}) { + expect( + registerPluginCommand("demo-plugin", { + name: "pair", + ...(params?.nativeNames ? { nativeNames: params.nativeNames } : {}), + description: "Pair device", + acceptsArgs: true, + requireAuth: false, + handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), + }), + ).toEqual({ ok: true }); +} + +async function registerPairMenu(params: { + bot: ReturnType["bot"]; + setMyCommands: ReturnType["setMyCommands"]; + nativeNames?: { telegram?: string; discord?: string }; +}) { + registerPairPluginCommand({ + ...(params.nativeNames ? { nativeNames: params.nativeNames } : {}), + }); + + registerTelegramNativeCommands({ + ...createNativeCommandTestParams({}), + bot: params.bot, + }); + + return await waitForRegisteredCommands(params.setMyCommands); +} + describe("registerTelegramNativeCommands real plugin registry", () => { beforeEach(() => { clearPluginCommands(); @@ -31,22 +63,7 @@ describe("registerTelegramNativeCommands real plugin registry", () => { it("registers and executes plugin commands through the real plugin registry", async () => { const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot(); - expect( - registerPluginCommand("demo-plugin", { - name: "pair", - description: "Pair device", - acceptsArgs: true, - requireAuth: false, - handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), - }), - ).toEqual({ ok: true }); - - registerTelegramNativeCommands({ - ...createNativeCommandTestParams({}), - bot, - }); - - const registeredCommands = await waitForRegisteredCommands(setMyCommands); + const registeredCommands = await registerPairMenu({ bot, setMyCommands }); expect(registeredCommands).toEqual( expect.arrayContaining([{ command: "pair", description: "Pair device" }]), ); @@ -67,26 +84,14 @@ describe("registerTelegramNativeCommands real plugin registry", () => { it("round-trips Telegram native aliases through the real plugin registry", async () => { const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot(); - expect( - registerPluginCommand("demo-plugin", { - name: "pair", - nativeNames: { - telegram: "pair_device", - discord: "pairdiscord", - }, - description: "Pair device", - acceptsArgs: true, - requireAuth: false, - handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), - }), - ).toEqual({ ok: true }); - - registerTelegramNativeCommands({ - ...createNativeCommandTestParams({}), + const registeredCommands = await registerPairMenu({ bot, + setMyCommands, + nativeNames: { + telegram: "pair_device", + discord: "pairdiscord", + }, }); - - const registeredCommands = await waitForRegisteredCommands(setMyCommands); expect(registeredCommands).toEqual( expect.arrayContaining([{ command: "pair_device", description: "Pair device" }]), ); @@ -107,15 +112,7 @@ describe("registerTelegramNativeCommands real plugin registry", () => { it("keeps real plugin command handlers available when native menu registration is disabled", () => { const { bot, commandHandlers, setMyCommands } = createCommandBot(); - expect( - registerPluginCommand("demo-plugin", { - name: "pair", - description: "Pair device", - acceptsArgs: true, - requireAuth: false, - handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), - }), - ).toEqual({ ok: true }); + registerPairPluginCommand(); registerTelegramNativeCommands({ ...createNativeCommandTestParams({}, { accountId: "default" }), @@ -130,15 +127,7 @@ describe("registerTelegramNativeCommands real plugin registry", () => { it("allows requireAuth:false plugin commands for unauthorized senders through the real registry", async () => { const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot(); - expect( - registerPluginCommand("demo-plugin", { - name: "pair", - description: "Pair device", - acceptsArgs: true, - requireAuth: false, - handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), - }), - ).toEqual({ ok: true }); + registerPairPluginCommand(); registerTelegramNativeCommands({ ...createNativeCommandTestParams({ 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 d15db967767..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,6 +4,10 @@ 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, @@ -11,18 +15,6 @@ import { waitForRegisteredCommands, } from "./bot-native-commands.menu-test-support.js"; -const pluginCommandMocks = vi.hoisted(() => ({ - getPluginCommandSpecs: vi.fn(() => []), - matchPluginCommand: vi.fn(() => null), - executePluginCommand: vi.fn(async () => ({ text: "ok" })), -})); - -vi.mock("../../../src/plugins/commands.js", () => ({ - getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, - matchPluginCommand: pluginCommandMocks.matchPluginCommand, - executePluginCommand: pluginCommandMocks.executePluginCommand, -})); - const tempDirs: string[] = []; async function makeWorkspace(prefix: string) { @@ -34,9 +26,7 @@ async function makeWorkspace(prefix: string) { describe("registerTelegramNativeCommands skill allowlist integration", () => { afterEach(async () => { resetNativeCommandMenuMocks(); - pluginCommandMocks.getPluginCommandSpecs.mockClear().mockReturnValue([]); - pluginCommandMocks.matchPluginCommand.mockClear().mockReturnValue(null); - pluginCommandMocks.executePluginCommand.mockClear().mockResolvedValue({ text: "ok" }); + resetPluginCommandMocks(); await Promise.all( tempDirs .splice(0, tempDirs.length) diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index 43059cd9b61..3afeb63fbb2 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -2,7 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import type { MockFn } from "openclaw/plugin-sdk/test-utils"; +import type { MockFn } from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; import { createNativeCommandTestParams, diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index 683842fa2df..e20806b11e4 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -5,6 +5,10 @@ import { STATE_DIR } from "../../../src/config/paths.js"; import { TELEGRAM_COMMAND_NAME_PATTERN } from "../../../src/config/telegram-custom-commands.js"; import type { TelegramAccountConfig } from "../../../src/config/types.js"; import type { RuntimeEnv } from "../../../src/runtime.js"; +import { + pluginCommandMocks, + resetPluginCommandMocks, +} from "../../../test/helpers/extensions/telegram-plugin-command.js"; const skillCommandMocks = vi.hoisted(() => ({ listSkillCommandsForAgents: vi.fn(() => []), })); @@ -32,29 +36,13 @@ import { waitForRegisteredCommands, } from "./bot-native-commands.menu-test-support.js"; -const pluginCommandMocks = vi.hoisted(() => ({ - getPluginCommandSpecs: vi.fn(() => []), - matchPluginCommand: vi.fn(() => null), - executePluginCommand: vi.fn(async () => ({ text: "ok" })), -})); -vi.mock("../../../src/plugins/commands.js", () => ({ - getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, - matchPluginCommand: pluginCommandMocks.matchPluginCommand, - executePluginCommand: pluginCommandMocks.executePluginCommand, -})); - describe("registerTelegramNativeCommands", () => { beforeEach(() => { skillCommandMocks.listSkillCommandsForAgents.mockClear(); skillCommandMocks.listSkillCommandsForAgents.mockReturnValue([]); deliveryMocks.deliverReplies.mockClear(); deliveryMocks.deliverReplies.mockResolvedValue({ delivered: true }); - pluginCommandMocks.getPluginCommandSpecs.mockClear(); - pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([]); - pluginCommandMocks.matchPluginCommand.mockClear(); - pluginCommandMocks.matchPluginCommand.mockReturnValue(null); - pluginCommandMocks.executePluginCommand.mockClear(); - pluginCommandMocks.executePluginCommand.mockResolvedValue({ text: "ok" }); + resetPluginCommandMocks(); }); it("scopes skill commands when account binding exists", () => { diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 740dc1d8c08..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, @@ -63,7 +63,10 @@ import { resolveTelegramThreadSpec, } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; -import { resolveTelegramConversationRoute } from "./conversation-route.js"; +import { + resolveTelegramConversationBaseSessionKey, + resolveTelegramConversationRoute, +} from "./conversation-route.js"; import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; import type { TelegramTransport } from "./fetch.js"; import { @@ -487,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", @@ -650,7 +653,13 @@ export const registerTelegramNativeCommands = ({ }); return; } - const baseSessionKey = route.sessionKey; + const baseSessionKey = resolveTelegramConversationBaseSessionKey({ + cfg, + route, + chatId, + isGroup, + senderId, + }); // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined; const threadKeys = 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 69c0557ee3a..f8573fecadd 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -2,7 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import type { GetReplyOptions, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; -import type { MockFn } from "openclaw/plugin-sdk/test-utils"; +import type { MockFn } from "openclaw/plugin-sdk/testing"; import { beforeEach, vi } from "vitest"; type AnyMock = MockFn<(...args: unknown[]) => unknown>; @@ -20,7 +20,7 @@ export function getLoadWebMediaMock(): AnyMock { return loadWebMedia; } -vi.mock("../../whatsapp/src/media.js", () => ({ +vi.mock("openclaw/plugin-sdk/web-media", () => ({ loadWebMedia, })); @@ -68,28 +68,59 @@ export function getUpsertChannelPairingRequestMock(): AnyAsyncMock { return upsertChannelPairingRequest; } -vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ - readChannelAllowFromStore, - upsertChannelPairingRequest, -})); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readChannelAllowFromStore, + upsertChannelPairingRequest, + }; +}); const skillCommandsHoisted = vi.hoisted(() => ({ listSkillCommandsForAgents: vi.fn(() => []), + replySpy: vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => { + await opts?.onReplyStart?.(); + return undefined; + }) as MockFn< + ( + ctx: MsgContext, + opts?: GetReplyOptions, + configOverride?: OpenClawConfig, + ) => Promise + >, })); export const listSkillCommandsForAgents = skillCommandsHoisted.listSkillCommandsForAgents; +export const replySpy = skillCommandsHoisted.replySpy; -vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ - listSkillCommandsForAgents, -})); +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listSkillCommandsForAgents: skillCommandsHoisted.listSkillCommandsForAgents, + getReplyFromConfig: skillCommandsHoisted.replySpy, + __replySpy: skillCommandsHoisted.replySpy, + dispatchReplyWithBufferedBlockDispatcher: vi.fn( + async ({ ctx, replyOptions }: { ctx: MsgContext; replyOptions?: GetReplyOptions }) => { + await skillCommandsHoisted.replySpy(ctx, replyOptions); + return { queuedFinal: false }; + }, + ), + }; +}); const systemEventsHoisted = vi.hoisted(() => ({ enqueueSystemEventSpy: vi.fn(), })); export const enqueueSystemEventSpy: AnyMock = systemEventsHoisted.enqueueSystemEventSpy; -vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({ - enqueueSystemEvent: enqueueSystemEventSpy, -})); +vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + enqueueSystemEvent: systemEventsHoisted.enqueueSystemEventSpy, + }; +}); const sentMessageCacheHoisted = vi.hoisted(() => ({ wasSentByBot: vi.fn(() => false), @@ -97,7 +128,7 @@ const sentMessageCacheHoisted = vi.hoisted(() => ({ export const wasSentByBot = sentMessageCacheHoisted.wasSentByBot; vi.mock("./sent-message-cache.js", () => ({ - wasSentByBot, + wasSentByBot: sentMessageCacheHoisted.wasSentByBot, recordSentMessage: vi.fn(), clearSentMessageCache: vi.fn(), })); @@ -182,36 +213,28 @@ vi.mock("grammy", () => ({ InputFile: class {}, })); -const sequentializeMiddleware = vi.fn(); -export const sequentializeSpy: AnyMock = vi.fn(() => sequentializeMiddleware); +const runnerHoisted = vi.hoisted(() => ({ + sequentializeMiddleware: vi.fn(async (_ctx: unknown, next?: () => Promise) => { + if (typeof next === "function") { + await next(); + } + }), + sequentializeSpy: vi.fn(() => runnerHoisted.sequentializeMiddleware), + throttlerSpy: vi.fn(() => "throttler"), +})); +export const sequentializeSpy: AnyMock = runnerHoisted.sequentializeSpy; export let sequentializeKey: ((ctx: unknown) => string) | undefined; vi.mock("@grammyjs/runner", () => ({ sequentialize: (keyFn: (ctx: unknown) => string) => { sequentializeKey = keyFn; - return sequentializeSpy(); + return runnerHoisted.sequentializeSpy(); }, })); -export const throttlerSpy: AnyMock = vi.fn(() => "throttler"); +export const throttlerSpy: AnyMock = runnerHoisted.throttlerSpy; vi.mock("@grammyjs/transformer-throttler", () => ({ - apiThrottler: () => throttlerSpy(), -})); - -export const replySpy: MockFn< - ( - ctx: MsgContext, - opts?: GetReplyOptions, - configOverride?: OpenClawConfig, - ) => Promise -> = vi.fn(async (_ctx, opts) => { - await opts?.onReplyStart?.(); - return undefined; -}); - -vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ - getReplyFromConfig: replySpy, - __replySpy: replySpy, + apiThrottler: () => runnerHoisted.throttlerSpy(), })); export const getOnHandler = (event: string) => { @@ -336,7 +359,14 @@ beforeEach(() => { listSkillCommandsForAgents.mockReset(); listSkillCommandsForAgents.mockReturnValue([]); middlewareUseSpy.mockReset(); + runnerHoisted.sequentializeMiddleware.mockReset(); + runnerHoisted.sequentializeMiddleware.mockImplementation(async (_ctx, next) => { + if (typeof next === "function") { + await next(); + } + }); sequentializeSpy.mockReset(); + sequentializeSpy.mockImplementation(() => runnerHoisted.sequentializeMiddleware); botCtorSpy.mockReset(); sequentializeKey = undefined; }); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 3390aa3ff24..1cb0fd98512 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -3,8 +3,8 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; -import { withEnvAsync } from "../../test-utils/env.js"; -import { useFrozenTime, useRealTime } from "../../test-utils/frozen-time.js"; +import { withEnvAsync } from "../../../test/helpers/extensions/env.js"; +import { useFrozenTime, useRealTime } from "../../../test/helpers/extensions/frozen-time.js"; import { answerCallbackQuerySpy, botCtorSpy, 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-actions.test.ts b/extensions/telegram/src/channel-actions.test.ts index 0e5170431b1..0addd92af78 100644 --- a/extensions/telegram/src/channel-actions.test.ts +++ b/extensions/telegram/src/channel-actions.test.ts @@ -1,12 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { telegramMessageActions, telegramMessageActionRuntime } from "./channel-actions.js"; const handleTelegramActionMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../../src/agents/tools/telegram-actions.js", () => ({ - handleTelegramAction: (...args: unknown[]) => handleTelegramActionMock(...args), -})); - -import { telegramMessageActions } from "./channel-actions.js"; +const originalHandleTelegramAction = telegramMessageActionRuntime.handleTelegramAction; describe("telegramMessageActions", () => { beforeEach(() => { @@ -15,6 +11,12 @@ describe("telegramMessageActions", () => { content: [], details: {}, }); + telegramMessageActionRuntime.handleTelegramAction = (...args) => + handleTelegramActionMock(...args); + }); + + afterEach(() => { + telegramMessageActionRuntime.handleTelegramAction = originalHandleTelegramAction; }); it("allows interactive-only sends", async () => { diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 50c472ea600..cd757688835 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -4,16 +4,20 @@ import { readStringOrNumberParam, readStringParam, } from "openclaw/plugin-sdk/agent-runtime"; -import { handleTelegramAction } from "openclaw/plugin-sdk/agent-runtime"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; import { + createLegacyMessageToolDiscoveryMethods, + createMessageToolButtonsSchema, + createTelegramPollExtraToolSchemas, createUnionActionGate, listTokenSourcedAccounts, } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelMessageToolSchemaContribution, } from "openclaw/plugin-sdk/channel-runtime"; import type { TelegramActionConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveTelegramPollVisibility } from "openclaw/plugin-sdk/telegram"; @@ -23,11 +27,102 @@ import { listEnabledTelegramAccounts, resolveTelegramPollActionGateState, } from "./accounts.js"; +import { handleTelegramAction } from "./action-runtime.js"; import { resolveTelegramInlineButtons } from "./button-types.js"; import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js"; const providerId = "telegram"; +export const telegramMessageActionRuntime = { + handleTelegramAction, +}; + +function resolveTelegramActionDiscovery(cfg: Parameters[0]) { + const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); + if (accounts.length === 0) { + return null; + } + const unionGate = createUnionActionGate(accounts, (account) => + createTelegramActionGate({ + cfg, + accountId: account.accountId, + }), + ); + const pollEnabled = accounts.some((account) => { + const accountGate = createTelegramActionGate({ + cfg, + accountId: account.accountId, + }); + return resolveTelegramPollActionGateState(accountGate).enabled; + }); + const buttonsEnabled = accounts.some((account) => + isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }), + ); + return { + isEnabled: (key: keyof TelegramActionConfig, defaultValue = true) => + unionGate(key, defaultValue), + pollEnabled, + buttonsEnabled, + }; +} + +function describeTelegramMessageTool({ + cfg, +}: Parameters< + NonNullable +>[0]): ChannelMessageToolDiscovery { + const discovery = resolveTelegramActionDiscovery(cfg); + if (!discovery) { + return { + actions: [], + capabilities: [], + schema: null, + }; + } + const actions = new Set(["send"]); + if (discovery.pollEnabled) { + actions.add("poll"); + } + if (discovery.isEnabled("reactions")) { + actions.add("react"); + } + if (discovery.isEnabled("deleteMessage")) { + actions.add("delete"); + } + if (discovery.isEnabled("editMessage")) { + actions.add("edit"); + } + if (discovery.isEnabled("sticker", false)) { + actions.add("sticker"); + actions.add("sticker-search"); + } + if (discovery.isEnabled("createForumTopic")) { + actions.add("topic-create"); + } + if (discovery.isEnabled("editForumTopic")) { + actions.add("topic-edit"); + } + const schema: ChannelMessageToolSchemaContribution[] = []; + if (discovery.buttonsEnabled) { + schema.push({ + properties: { + buttons: createMessageToolButtonsSchema(), + }, + }); + } + if (discovery.pollEnabled) { + schema.push({ + properties: createTelegramPollExtraToolSchemas(), + visibility: "all-configured", + }); + } + return { + actions: Array.from(actions), + capabilities: discovery.buttonsEnabled ? ["interactive", "buttons"] : [], + schema, + }; +} + function readTelegramSendParams(params: Record) { const to = readStringParam(params, "to", { required: true }); const mediaUrl = readStringParam(params, "media", { trim: false }); @@ -82,69 +177,15 @@ function readTelegramMessageIdParam(params: Record): number { } export const telegramMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { - const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); - if (accounts.length === 0) { - return []; - } - // Union of all accounts' action gates (any account enabling an action makes it available) - const gate = createUnionActionGate(accounts, (account) => - createTelegramActionGate({ - cfg, - accountId: account.accountId, - }), - ); - const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) => - gate(key, defaultValue); - const actions = new Set(["send"]); - const pollEnabledForAnyAccount = accounts.some((account) => { - const accountGate = createTelegramActionGate({ - cfg, - accountId: account.accountId, - }); - return resolveTelegramPollActionGateState(accountGate).enabled; - }); - if (pollEnabledForAnyAccount) { - actions.add("poll"); - } - if (isEnabled("reactions")) { - actions.add("react"); - } - if (isEnabled("deleteMessage")) { - actions.add("delete"); - } - if (isEnabled("editMessage")) { - actions.add("edit"); - } - if (isEnabled("sticker", false)) { - actions.add("sticker"); - actions.add("sticker-search"); - } - if (isEnabled("createForumTopic")) { - actions.add("topic-create"); - } - if (isEnabled("editForumTopic")) { - actions.add("topic-edit"); - } - return Array.from(actions); - }, - getCapabilities: ({ cfg }) => { - const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); - if (accounts.length === 0) { - return []; - } - const buttonsEnabled = accounts.some((account) => - isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }), - ); - return buttonsEnabled ? (["interactive", "buttons"] as const) : []; - }, + describeMessageTool: describeTelegramMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeTelegramMessageTool), extractToolSend: ({ args }) => { return extractToolSend(args, "sendMessage"); }, handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, toolContext }) => { if (action === "send") { const sendParams = readTelegramSendParams(params); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "sendMessage", ...sendParams, @@ -159,7 +200,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { const messageId = resolveReactionMessageId({ args: params, toolContext }); const emoji = readStringParam(params, "emoji", { allowEmpty: true }); const remove = readBooleanParam(params, "remove"); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "react", chatId: readTelegramChatIdParam(params), @@ -192,7 +233,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { const pollPublic = readBooleanParam(params, "pollPublic"); const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic }); const silent = readBooleanParam(params, "silent"); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "poll", to, @@ -215,7 +256,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { if (action === "delete") { const chatId = readTelegramChatIdParam(params); const messageId = readTelegramMessageIdParam(params); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "deleteMessage", chatId, @@ -232,7 +273,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { const messageId = readTelegramMessageIdParam(params); const message = readStringParam(params, "message", { required: true, allowEmpty: false }); const buttons = params.buttons; - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "editMessage", chatId, @@ -254,7 +295,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { const fileId = stickerIds?.[0] ?? readStringParam(params, "fileId", { required: true }); const replyToMessageId = readNumberParam(params, "replyTo", { integer: true }); const messageThreadId = readNumberParam(params, "threadId", { integer: true }); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "sendSticker", to, @@ -271,7 +312,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { if (action === "sticker-search") { const query = readStringParam(params, "query", { required: true }); const limit = readNumberParam(params, "limit", { integer: true }); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "searchSticker", query, @@ -288,7 +329,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { const name = readStringParam(params, "name", { required: true }); const iconColor = readNumberParam(params, "iconColor", { integer: true }); const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "createForumTopic", chatId, @@ -312,7 +353,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { } const name = readStringParam(params, "name"); const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); - return await handleTelegramAction( + return await telegramMessageActionRuntime.handleTelegramAction( { action: "editForumTopic", chatId, diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 48d16361b1a..6c1f4da5e73 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -5,7 +5,7 @@ import type { PluginRuntime, } from "openclaw/plugin-sdk/telegram"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { ResolvedTelegramAccount } from "./accounts.js"; import * as auditModule from "./audit.js"; import { telegramPlugin } from "./channel.js"; @@ -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..6d536fb8513 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -248,10 +248,14 @@ function hasTelegramExecApprovalDmRoute(cfg: OpenClawConfig): boolean { } const telegramMessageActions: ChannelMessageActionAdapter = { + describeMessageTool: (ctx) => + getTelegramRuntime().channel.telegram.messageActions?.describeMessageTool?.(ctx) ?? null, listActions: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.listActions?.(ctx) ?? [], getCapabilities: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.getCapabilities?.(ctx) ?? [], + getToolSchema: (ctx) => + getTelegramRuntime().channel.telegram.messageActions?.getToolSchema?.(ctx) ?? null, extractToolSend: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.extractToolSend?.(ctx) ?? null, handleAction: async (ctx) => { @@ -330,11 +334,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.base-session-key.test.ts b/extensions/telegram/src/conversation-route.base-session-key.test.ts new file mode 100644 index 00000000000..baebab3470c --- /dev/null +++ b/extensions/telegram/src/conversation-route.base-session-key.test.ts @@ -0,0 +1,64 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +import { describe, expect, it } from "vitest"; +import { resolveTelegramConversationBaseSessionKey } from "./conversation-route.js"; + +describe("resolveTelegramConversationBaseSessionKey", () => { + const cfg: OpenClawConfig = {}; + + it("keeps the routed session key for the default account", () => { + expect( + resolveTelegramConversationBaseSessionKey({ + cfg, + route: { + agentId: "main", + accountId: "default", + matchedBy: "default", + sessionKey: "agent:main:main", + }, + chatId: 12345, + isGroup: false, + senderId: 12345, + }), + ).toBe("agent:main:main"); + }); + + it("uses the per-account fallback key for named-account DMs without an explicit binding", () => { + expect( + resolveTelegramConversationBaseSessionKey({ + cfg, + route: { + agentId: "main", + accountId: "personal", + matchedBy: "default", + sessionKey: "agent:main:main", + }, + chatId: 12345, + isGroup: false, + senderId: 12345, + }), + ).toBe("agent:main:telegram:personal:direct:12345"); + }); + + it("keeps DM topic isolation on the named-account fallback key", () => { + const baseSessionKey = resolveTelegramConversationBaseSessionKey({ + cfg, + route: { + agentId: "main", + accountId: "personal", + matchedBy: "default", + sessionKey: "agent:main:main", + }, + chatId: 12345, + isGroup: false, + senderId: 12345, + }); + + expect( + resolveThreadSessionKeys({ + baseSessionKey, + threadId: "12345:99", + }).sessionKey, + ).toBe("agent:main:telegram:personal:direct:12345:thread:12345:99"); + }); +}); diff --git a/extensions/telegram/src/conversation-route.ts b/extensions/telegram/src/conversation-route.ts index fc06221936f..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 { @@ -9,6 +12,7 @@ import { } from "openclaw/plugin-sdk/routing"; import { buildAgentMainSessionKey, + DEFAULT_ACCOUNT_ID, resolveAgentIdFromSessionKey, sanitizeAgentId, } from "openclaw/plugin-sdk/routing"; @@ -30,7 +34,7 @@ export function resolveTelegramConversationRoute(params: { topicAgentId?: string | null; }): { route: ReturnType; - configuredBinding: ReturnType["configuredBinding"]; + configuredBinding: ConfiguredBindingRouteResult["bindingResolution"]; configuredBindingSessionKey: string; } { const peerId = params.isGroup @@ -93,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; @@ -148,3 +154,34 @@ export function resolveTelegramConversationRoute(params: { configuredBindingSessionKey, }; } + +export function resolveTelegramConversationBaseSessionKey(params: { + cfg: OpenClawConfig; + route: Pick< + ReturnType["route"], + "agentId" | "accountId" | "matchedBy" | "sessionKey" + >; + chatId: number | string; + isGroup: boolean; + senderId?: string | number | null; +}): string { + const isNamedAccountFallback = + params.route.accountId !== DEFAULT_ACCOUNT_ID && params.route.matchedBy === "default"; + if (!isNamedAccountFallback || params.isGroup) { + return params.route.sessionKey; + } + return buildAgentSessionKey({ + agentId: params.route.agentId, + channel: "telegram", + accountId: params.route.accountId, + peer: { + kind: "direct", + id: resolveTelegramDirectPeerId({ + chatId: params.chatId, + senderId: params.senderId, + }), + }, + dmScope: "per-account-channel-peer", + identityLinks: params.cfg.session?.identityLinks, + }).toLowerCase(); +} 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/probe.test.ts b/extensions/telegram/src/probe.test.ts index 970e2559540..da6f86f9b80 100644 --- a/extensions/telegram/src/probe.test.ts +++ b/extensions/telegram/src/probe.test.ts @@ -1,5 +1,5 @@ import { afterEach, type Mock, describe, expect, it, vi } from "vitest"; -import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js"; import { probeTelegram, resetTelegramProbeFetcherCacheForTests } from "./probe.js"; const resolveTelegramFetch = vi.hoisted(() => vi.fn()); diff --git a/extensions/telegram/src/send.test-harness.ts b/extensions/telegram/src/send.test-harness.ts index c12a571c642..28ad1e6bb0a 100644 --- a/extensions/telegram/src/send.test-harness.ts +++ b/extensions/telegram/src/send.test-harness.ts @@ -1,4 +1,4 @@ -import type { MockFn } from "openclaw/plugin-sdk/test-utils"; +import type { MockFn } from "openclaw/plugin-sdk/testing"; import { beforeEach, vi } from "vitest"; const { botApi, botCtorSpy } = vi.hoisted(() => ({ @@ -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 542fffc0500..afc302500bf 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -8,8 +8,7 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +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 335213adead..2a6fbf41d0b 100644 --- a/extensions/telegram/src/shared.ts +++ b/extensions/telegram/src/shared.ts @@ -3,12 +3,14 @@ import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; -import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { TelegramConfigSchema } from "../../../src/config/zod-schema.providers-core.js"; -import { normalizeAccountId } from "../../../src/routing/session-key.js"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + normalizeAccountId, + TelegramConfigSchema, + type ChannelPlugin, + type OpenClawConfig, +} 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/telegram/src/webhook.test.ts b/extensions/telegram/src/webhook.test.ts index 0f2736a30b9..549e73f9ba3 100644 --- a/extensions/telegram/src/webhook.test.ts +++ b/extensions/telegram/src/webhook.test.ts @@ -40,6 +40,35 @@ function collectResponseBody( }); } +function createSingleSettlement(params: { + resolve: (value: T) => void; + reject: (error: unknown) => void; + clear: () => void; +}) { + let settled = false; + return { + isSettled() { + return settled; + }, + resolve(value: T) { + if (settled) { + return; + } + settled = true; + params.clear(); + params.resolve(value); + }, + reject(error: unknown) { + if (settled) { + return; + } + settled = true; + params.clear(); + params.reject(error); + }, + }; +} + vi.mock("grammy", async (importOriginal) => { const actual = await importOriginal(); return { @@ -96,23 +125,11 @@ async function postWebhookHeadersOnly(params: { timeoutMs?: number; }): Promise<{ statusCode: number; body: string }> { return await new Promise((resolve, reject) => { - let settled = false; - const finishResolve = (value: { statusCode: number; body: string }) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timeout); - resolve(value); - }; - const finishReject = (error: unknown) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timeout); - reject(error); - }; + const settle = createSingleSettlement({ + resolve, + reject, + clear: () => clearTimeout(timeout), + }); const req = request( { @@ -128,7 +145,7 @@ async function postWebhookHeadersOnly(params: { }, (res) => { collectResponseBody(res, (payload) => { - finishResolve(payload); + settle.resolve(payload); req.destroy(); }); }, @@ -138,14 +155,14 @@ async function postWebhookHeadersOnly(params: { req.destroy( new Error(`webhook header-only post timed out after ${params.timeoutMs ?? 5_000}ms`), ); - finishReject(new Error("timed out waiting for webhook response")); + settle.reject(new Error("timed out waiting for webhook response")); }, params.timeoutMs ?? 5_000); req.on("error", (error) => { - if (settled && (error as NodeJS.ErrnoException).code === "ECONNRESET") { + if (settle.isSettled() && (error as NodeJS.ErrnoException).code === "ECONNRESET") { return; } - finishReject(error); + settle.reject(error); }); req.flushHeaders(); @@ -173,23 +190,11 @@ async function postWebhookPayloadWithChunkPlan(params: { let bytesQueued = 0; let chunksQueued = 0; let phase: "writing" | "awaiting-response" = "writing"; - let settled = false; - const finishResolve = (value: { statusCode: number; body: string }) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timeout); - resolve(value); - }; - const finishReject = (error: unknown) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timeout); - reject(error); - }; + const settle = createSingleSettlement({ + resolve, + reject, + clear: () => clearTimeout(timeout), + }); const req = request( { @@ -204,12 +209,12 @@ async function postWebhookPayloadWithChunkPlan(params: { }, }, (res) => { - collectResponseBody(res, finishResolve); + collectResponseBody(res, settle.resolve); }, ); const timeout = setTimeout(() => { - finishReject( + settle.reject( new Error( `webhook post timed out after ${params.timeoutMs ?? 15_000}ms (phase=${phase}, bytesQueued=${bytesQueued}, chunksQueued=${chunksQueued}, totalBytes=${payloadBuffer.length})`, ), @@ -218,7 +223,7 @@ async function postWebhookPayloadWithChunkPlan(params: { }, params.timeoutMs ?? 15_000); req.on("error", (error) => { - finishReject(error); + settle.reject(error); }); const writeAll = async () => { @@ -251,7 +256,7 @@ async function postWebhookPayloadWithChunkPlan(params: { }; void writeAll().catch((error) => { - finishReject(error); + settle.reject(error); }); }); } diff --git a/extensions/test-utils/chunk-test-helpers.ts b/extensions/test-utils/chunk-test-helpers.ts deleted file mode 100644 index 643e28e5c24..00000000000 --- a/extensions/test-utils/chunk-test-helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export { countLines, hasBalancedFences } from "../../src/test-utils/chunk-test-helpers.js"; diff --git a/extensions/test-utils/env.ts b/extensions/test-utils/env.ts deleted file mode 100644 index b171aa55a6c..00000000000 --- a/extensions/test-utils/env.ts +++ /dev/null @@ -1 +0,0 @@ -export { captureEnv, withEnv, withEnvAsync } from "../../src/test-utils/env.js"; diff --git a/extensions/test-utils/fetch-mock.ts b/extensions/test-utils/fetch-mock.ts deleted file mode 100644 index 2cd6b65e680..00000000000 --- a/extensions/test-utils/fetch-mock.ts +++ /dev/null @@ -1 +0,0 @@ -export { withFetchPreconnect, type FetchMock } from "../../src/test-utils/fetch-mock.js"; diff --git a/extensions/test-utils/frozen-time.ts b/extensions/test-utils/frozen-time.ts deleted file mode 100644 index ec31962fb76..00000000000 --- a/extensions/test-utils/frozen-time.ts +++ /dev/null @@ -1 +0,0 @@ -export { useFrozenTime, useRealTime } from "../../src/test-utils/frozen-time.js"; diff --git a/extensions/test-utils/mock-http-response.ts b/extensions/test-utils/mock-http-response.ts deleted file mode 100644 index bf0d8bef20c..00000000000 --- a/extensions/test-utils/mock-http-response.ts +++ /dev/null @@ -1 +0,0 @@ -export { createMockServerResponse } from "../../src/test-utils/mock-http-response.js"; diff --git a/extensions/test-utils/plugin-registration.ts b/extensions/test-utils/plugin-registration.ts deleted file mode 100644 index 7a7da8ecdad..00000000000 --- a/extensions/test-utils/plugin-registration.ts +++ /dev/null @@ -1 +0,0 @@ -export { registerSingleProviderPlugin } from "../../src/test-utils/plugin-registration.js"; diff --git a/extensions/test-utils/provider-usage-fetch.ts b/extensions/test-utils/provider-usage-fetch.ts deleted file mode 100644 index d70a6e1657a..00000000000 --- a/extensions/test-utils/provider-usage-fetch.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - createProviderUsageFetch, - makeResponse, -} from "../../src/test-utils/provider-usage-fetch.js"; diff --git a/extensions/test-utils/temp-dir.ts b/extensions/test-utils/temp-dir.ts deleted file mode 100644 index 3bd69bcc7b9..00000000000 --- a/extensions/test-utils/temp-dir.ts +++ /dev/null @@ -1 +0,0 @@ -export { withTempDir } from "../../src/test-utils/temp-dir.js"; diff --git a/extensions/test-utils/typed-cases.ts b/extensions/test-utils/typed-cases.ts deleted file mode 100644 index 4b6bd35b1ec..00000000000 --- a/extensions/test-utils/typed-cases.ts +++ /dev/null @@ -1 +0,0 @@ -export { typedCases } from "../../src/test-utils/typed-cases.js"; 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.test.ts b/extensions/thread-ownership/index.test.ts index 3d98d8f9735..44bdf51b312 100644 --- a/extensions/thread-ownership/index.test.ts +++ b/extensions/thread-ownership/index.test.ts @@ -39,7 +39,7 @@ describe("thread-ownership plugin", () => { }); it("registers message_received and message_sending hooks", () => { - register(api as any); + register.register(api as any); expect(api.on).toHaveBeenCalledTimes(2); expect(api.on).toHaveBeenCalledWith("message_received", expect.any(Function)); @@ -48,7 +48,7 @@ describe("thread-ownership plugin", () => { describe("message_sending", () => { beforeEach(() => { - register(api as any); + register.register(api as any); }); async function sendSlackThreadMessage() { @@ -120,7 +120,7 @@ describe("thread-ownership plugin", () => { describe("message_received @-mention tracking", () => { beforeEach(() => { - register(api as any); + register.register(api as any); }); it("tracks @-mentions and skips ownership check for mentioned threads", async () => { diff --git a/extensions/thread-ownership/index.ts b/extensions/thread-ownership/index.ts index f0d2cb6291b..603b064bc68 100644 --- a/extensions/thread-ownership/index.ts +++ b/extensions/thread-ownership/index.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/thread-ownership"; +import { definePluginEntry, type OpenClawConfig, type OpenClawPluginApi } from "./api.js"; type ThreadOwnershipConfig = { forwarderUrl?: string; @@ -39,95 +39,79 @@ function resolveOwnershipAgent(config: OpenClawConfig): { id: string; name: stri return { id, name }; } -export default function register(api: OpenClawPluginApi) { - const pluginCfg = (api.pluginConfig ?? {}) as ThreadOwnershipConfig; - const forwarderUrl = ( - pluginCfg.forwarderUrl ?? - process.env.SLACK_FORWARDER_URL ?? - "http://slack-forwarder:8750" - ).replace(/\/$/, ""); +export default definePluginEntry({ + id: "thread-ownership", + name: "Thread Ownership", + description: "Slack thread claim coordination for multi-agent setups", + register(api: OpenClawPluginApi) { + const pluginCfg = (api.pluginConfig ?? {}) as ThreadOwnershipConfig; + const forwarderUrl = ( + pluginCfg.forwarderUrl ?? + process.env.SLACK_FORWARDER_URL ?? + "http://slack-forwarder:8750" + ).replace(/\/$/, ""); - const abTestChannels = new Set( - pluginCfg.abTestChannels ?? - process.env.THREAD_OWNERSHIP_CHANNELS?.split(",").filter(Boolean) ?? - [], - ); + const abTestChannels = new Set( + pluginCfg.abTestChannels ?? + process.env.THREAD_OWNERSHIP_CHANNELS?.split(",").filter(Boolean) ?? + [], + ); - const { id: agentId, name: agentName } = resolveOwnershipAgent(api.config); - const botUserId = process.env.SLACK_BOT_USER_ID ?? ""; + const { id: agentId, name: agentName } = resolveOwnershipAgent(api.config); + const botUserId = process.env.SLACK_BOT_USER_ID ?? ""; - // --------------------------------------------------------------------------- - // message_received: track @-mentions so the agent can reply even if it - // doesn't own the thread. - // --------------------------------------------------------------------------- - api.on("message_received", async (event, ctx) => { - if (ctx.channelId !== "slack") return; + api.on("message_received", async (event, ctx) => { + if (ctx.channelId !== "slack") return; - const text = event.content ?? ""; - const threadTs = (event.metadata?.threadTs as string) ?? ""; - const channelId = (event.metadata?.channelId as string) ?? ctx.conversationId ?? ""; + const text = event.content ?? ""; + const threadTs = (event.metadata?.threadTs as string) ?? ""; + const channelId = (event.metadata?.channelId as string) ?? ctx.conversationId ?? ""; + if (!threadTs || !channelId) return; - if (!threadTs || !channelId) return; + const mentioned = + (agentName && text.includes(`@${agentName}`)) || + (botUserId && text.includes(`<@${botUserId}>`)); + if (mentioned) { + cleanExpiredMentions(); + mentionedThreads.set(`${channelId}:${threadTs}`, Date.now()); + } + }); - // Check if this agent was @-mentioned. - const mentioned = - (agentName && text.includes(`@${agentName}`)) || - (botUserId && text.includes(`<@${botUserId}>`)); + api.on("message_sending", async (event, ctx) => { + if (ctx.channelId !== "slack") return; + + const threadTs = (event.metadata?.threadTs as string) ?? ""; + const channelId = (event.metadata?.channelId as string) ?? event.to; + if (!threadTs) return; + if (abTestChannels.size > 0 && !abTestChannels.has(channelId)) return; - if (mentioned) { cleanExpiredMentions(); - mentionedThreads.set(`${channelId}:${threadTs}`, Date.now()); - } - }); + if (mentionedThreads.has(`${channelId}:${threadTs}`)) return; - // --------------------------------------------------------------------------- - // message_sending: check thread ownership before sending to Slack. - // Returns { cancel: true } if another agent owns the thread. - // --------------------------------------------------------------------------- - api.on("message_sending", async (event, ctx) => { - if (ctx.channelId !== "slack") return; + try { + const resp = await fetch(`${forwarderUrl}/api/v1/ownership/${channelId}/${threadTs}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent_id: agentId }), + signal: AbortSignal.timeout(3000), + }); - const threadTs = (event.metadata?.threadTs as string) ?? ""; - const channelId = (event.metadata?.channelId as string) ?? event.to; - - // Top-level messages (no thread) are always allowed. - if (!threadTs) return; - - // Only enforce in A/B test channels (if set is empty, skip entirely). - if (abTestChannels.size > 0 && !abTestChannels.has(channelId)) return; - - // If this agent was @-mentioned in this thread recently, skip ownership check. - cleanExpiredMentions(); - if (mentionedThreads.has(`${channelId}:${threadTs}`)) return; - - // Try to claim ownership via the forwarder HTTP API. - try { - const resp = await fetch(`${forwarderUrl}/api/v1/ownership/${channelId}/${threadTs}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agent_id: agentId }), - signal: AbortSignal.timeout(3000), - }); - - if (resp.ok) { - // We own it (or just claimed it), proceed. - return; - } - - if (resp.status === 409) { - // Another agent owns this thread — cancel the send. - const body = (await resp.json()) as { owner?: string }; - api.logger.info?.( - `thread-ownership: cancelled send to ${channelId}:${threadTs} — owned by ${body.owner}`, + if (resp.ok) { + return; + } + if (resp.status === 409) { + const body = (await resp.json()) as { owner?: string }; + api.logger.info?.( + `thread-ownership: cancelled send to ${channelId}:${threadTs} — owned by ${body.owner}`, + ); + return { cancel: true }; + } + api.logger.warn?.(`thread-ownership: unexpected status ${resp.status}, allowing send`); + } catch (err) { + api.logger.warn?.( + `thread-ownership: ownership check failed (${String(err)}), allowing send`, ); - return { cancel: true }; } - - // Unexpected status — fail open. - api.logger.warn?.(`thread-ownership: unexpected status ${resp.status}, allowing send`); - } catch (err) { - // Network error — fail open. - api.logger.warn?.(`thread-ownership: ownership check failed (${String(err)}), allowing send`); - } - }); -} + }); + }, +}); diff --git a/extensions/tlon/api.ts b/extensions/tlon/api.ts new file mode 100644 index 00000000000..ca61d62ee69 --- /dev/null +++ b/extensions/tlon/api.ts @@ -0,0 +1,3 @@ +export * from "openclaw/plugin-sdk/tlon"; +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.runtime.ts b/extensions/tlon/src/channel.runtime.ts index 525359a2a4e..c6523f61739 100644 --- a/extensions/tlon/src/channel.runtime.ts +++ b/extensions/tlon/src/channel.runtime.ts @@ -1,10 +1,7 @@ import crypto from "node:crypto"; import { configureClient } from "@tloncorp/api"; -import type { - ChannelOutboundAdapter, - ChannelPlugin, - OpenClawConfig, -} from "openclaw/plugin-sdk/tlon"; +import type { ChannelOutboundAdapter, ChannelPlugin, OpenClawConfig } from "../api.js"; +import { createLoggerBackedRuntime, createReplyPrefixOptions } from "../api.js"; import { monitorTlonProvider } from "./monitor/index.js"; import { tlonSetupWizard } from "./setup-surface.js"; import { @@ -230,7 +227,7 @@ export async function startTlonGatewayAccount( accountId: account.accountId, ship: account.ship, url: account.url, - } as import("openclaw/plugin-sdk/tlon").ChannelAccountSnapshot); + } as ChannelAccountSnapshot); ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`); return monitorTlonProvider({ runtime: ctx.runtime, diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 5f754201ac1..0e22d237589 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,4 +1,4 @@ -import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/tlon"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { applyTlonSetupConfig, @@ -13,16 +13,12 @@ import { resolveTlonOutboundTarget, } from "./targets.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; +import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "../api.js"; 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 }) => @@ -218,7 +214,7 @@ export const tlonPlugin: ChannelPlugin = { lastError: runtime?.lastError ?? null, probe, }; - return snapshot as import("openclaw/plugin-sdk/tlon").ChannelAccountSnapshot; + return snapshot as ChannelAccountSnapshot; }, }, gateway: { diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index 666f65e35da..7f12949f30d 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -1,4 +1,4 @@ -import { buildChannelConfigSchema } from "openclaw/plugin-sdk/tlon"; +import { buildChannelConfigSchema } from "../api.js"; import { z } from "zod"; const ShipSchema = z.string().min(1); diff --git a/extensions/tlon/src/monitor/discovery.ts b/extensions/tlon/src/monitor/discovery.ts index a7224608bf0..66ec43a2680 100644 --- a/extensions/tlon/src/monitor/discovery.ts +++ b/extensions/tlon/src/monitor/discovery.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk/tlon"; +import type { RuntimeEnv } from "../../api.js"; import type { Foreigns } from "../urbit/foreigns.js"; import { formatChangesDate } from "./utils.js"; diff --git a/extensions/tlon/src/monitor/history.ts b/extensions/tlon/src/monitor/history.ts index a67fae7ada4..0ebfc6e231c 100644 --- a/extensions/tlon/src/monitor/history.ts +++ b/extensions/tlon/src/monitor/history.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk/tlon"; +import type { RuntimeEnv } from "../../api.js"; import { extractMessageText } from "./utils.js"; /** diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 1ea42902aaf..e7749010462 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -1,5 +1,5 @@ -import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk/tlon"; -import { createLoggerBackedRuntime, createReplyPrefixOptions } from "openclaw/plugin-sdk/tlon"; +import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "../../api.js"; +import { createLoggerBackedRuntime, createReplyPrefixOptions } from "../../api.js"; import { getTlonRuntime } from "../runtime.js"; import { createSettingsManager, type TlonSettingsStore } from "../settings.js"; import { normalizeShip, parseChannelNest } from "../targets.js"; @@ -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 boolean; diff --git a/extensions/tlon/src/monitor/utils.ts b/extensions/tlon/src/monitor/utils.ts index 3eccbf6cbc9..ed2610a620d 100644 --- a/extensions/tlon/src/monitor/utils.ts +++ b/extensions/tlon/src/monitor/utils.ts @@ -161,6 +161,24 @@ export function isGroupInviteAllowed( return allowlist.map((ship) => 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/runtime.ts b/extensions/tlon/src/runtime.ts index a07eb5cf648..bf284e214a8 100644 --- a/extensions/tlon/src/runtime.ts +++ b/extensions/tlon/src/runtime.ts @@ -1,5 +1,5 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import type { PluginRuntime } from "openclaw/plugin-sdk/tlon"; +import type { PluginRuntime } from "../api.js"; const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } = createPluginRuntimeStore("Tlon runtime not initialized"); 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/tlon/src/setup-surface.test.ts b/extensions/tlon/src/setup-surface.test.ts index d54db2c75a1..e88fd15a89e 100644 --- a/extensions/tlon/src/setup-surface.test.ts +++ b/extensions/tlon/src/setup-surface.test.ts @@ -1,31 +1,13 @@ -import type { OpenClawConfig, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/tlon"; +import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/tlon"; import { describe, expect, it, vi } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { + createTestWizardPrompter, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { tlonPlugin } from "./channel.js"; -const selectFirstOption = async (params: { options: Array<{ value: T }> }): Promise => { - const first = params.options[0]; - if (!first) { - throw new Error("no options"); - } - return first.value; -}; - -function createPrompter(overrides: Partial): WizardPrompter { - return { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), - select: selectFirstOption as WizardPrompter["select"], - multiselect: vi.fn(async () => []), - text: vi.fn(async () => "") as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - ...overrides, - }; -} - const tlonConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ plugin: tlonPlugin, wizard: tlonPlugin.setupWizard!, @@ -33,7 +15,7 @@ const tlonConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ describe("tlon setup wizard", () => { it("configures ship, auth, and discovery settings", async () => { - const prompter = createPrompter({ + const prompter = createTestWizardPrompter({ text: vi.fn(async ({ message }: { message: string }) => { if (message === "Ship name") { return "sampel-palnet"; diff --git a/extensions/tlon/src/types.ts b/extensions/tlon/src/types.ts index e9bc27ac169..7aa0690c14f 100644 --- a/extensions/tlon/src/types.ts +++ b/extensions/tlon/src/types.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/tlon"; +import type { OpenClawConfig } from "../api.js"; export type TlonResolvedAccount = { accountId: string; diff --git a/extensions/tlon/src/urbit/auth.ts b/extensions/tlon/src/urbit/auth.ts index 3b7ccd16593..687fb0e4121 100644 --- a/extensions/tlon/src/urbit/auth.ts +++ b/extensions/tlon/src/urbit/auth.ts @@ -1,4 +1,4 @@ -import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon"; +import type { LookupFn, SsrFPolicy } from "../../api.js"; import { UrbitAuthError } from "./errors.js"; import { urbitFetch } from "./fetch.js"; diff --git a/extensions/tlon/src/urbit/base-url.ts b/extensions/tlon/src/urbit/base-url.ts index e90168b47a9..15321d3e391 100644 --- a/extensions/tlon/src/urbit/base-url.ts +++ b/extensions/tlon/src/urbit/base-url.ts @@ -1,4 +1,4 @@ -import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/tlon"; +import { isBlockedHostnameOrIp } from "../../api.js"; export type UrbitBaseUrlValidation = | { ok: true; baseUrl: string; hostname: string } diff --git a/extensions/tlon/src/urbit/channel-ops.ts b/extensions/tlon/src/urbit/channel-ops.ts index ef65e4ca9fe..98b3981942e 100644 --- a/extensions/tlon/src/urbit/channel-ops.ts +++ b/extensions/tlon/src/urbit/channel-ops.ts @@ -1,4 +1,4 @@ -import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon"; +import type { LookupFn, SsrFPolicy } from "../../api.js"; import { UrbitHttpError } from "./errors.js"; import { urbitFetch } from "./fetch.js"; diff --git a/extensions/tlon/src/urbit/context.ts b/extensions/tlon/src/urbit/context.ts index 6fbae002f5d..01b49d94041 100644 --- a/extensions/tlon/src/urbit/context.ts +++ b/extensions/tlon/src/urbit/context.ts @@ -1,4 +1,4 @@ -import type { SsrFPolicy } from "openclaw/plugin-sdk/tlon"; +import type { SsrFPolicy } from "../../api.js"; import { validateUrbitBaseUrl } from "./base-url.js"; import { UrbitUrlError } from "./errors.js"; diff --git a/extensions/tlon/src/urbit/fetch.ts b/extensions/tlon/src/urbit/fetch.ts index a1551df547d..638c70f0840 100644 --- a/extensions/tlon/src/urbit/fetch.ts +++ b/extensions/tlon/src/urbit/fetch.ts @@ -1,5 +1,5 @@ -import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/tlon"; +import type { LookupFn, SsrFPolicy } from "../../api.js"; +import { fetchWithSsrFGuard } from "../../api.js"; import { validateUrbitBaseUrl } from "./base-url.js"; import { UrbitUrlError } from "./errors.js"; diff --git a/extensions/tlon/src/urbit/sse-client.ts b/extensions/tlon/src/urbit/sse-client.ts index afa87502320..2fae6b82041 100644 --- a/extensions/tlon/src/urbit/sse-client.ts +++ b/extensions/tlon/src/urbit/sse-client.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { Readable } from "node:stream"; -import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon"; +import type { LookupFn, SsrFPolicy } from "../../api.js"; import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js"; import { getUrbitContext, normalizeUrbitCookie } from "./context.js"; import { urbitFetch } from "./fetch.js"; diff --git a/extensions/tlon/src/urbit/upload.ts b/extensions/tlon/src/urbit/upload.ts index 81aaef84a06..6176c132207 100644 --- a/extensions/tlon/src/urbit/upload.ts +++ b/extensions/tlon/src/urbit/upload.ts @@ -2,7 +2,7 @@ * Upload an image from a URL to Tlon storage. */ import { uploadFile } from "@tloncorp/api"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/tlon"; +import { fetchWithSsrFGuard } from "../../api.js"; import { getDefaultSsrFPolicy } from "./context.js"; /** diff --git a/extensions/together/index.ts b/extensions/together/index.ts index 01bf59338f1..d4ae42bba82 100644 --- a/extensions/together/index.ts +++ b/extensions/together/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyTogetherConfig, TOGETHER_DEFAULT_MODEL_REF } from "./onboard.js"; @@ -6,12 +6,11 @@ import { buildTogetherProvider } from "./provider-catalog.js"; const PROVIDER_ID = "together"; -const togetherPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Together Provider", description: "Bundled Together provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Together", @@ -50,6 +49,4 @@ const togetherPlugin = { }, }); }, -}; - -export default togetherPlugin; +}); 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/venice/index.ts b/extensions/venice/index.ts index 37d4e767db3..2565049647e 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "./onboard.js"; @@ -6,12 +6,11 @@ import { buildVeniceProvider } from "./provider-catalog.js"; const PROVIDER_ID = "venice"; -const venicePlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Venice Provider", description: "Bundled Venice provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Venice", @@ -56,6 +55,4 @@ const venicePlugin = { }, }); }, -}; - -export default venicePlugin; +}); diff --git a/extensions/vercel-ai-gateway/index.ts b/extensions/vercel-ai-gateway/index.ts index fc4dbae156a..ecaa6d96d33 100644 --- a/extensions/vercel-ai-gateway/index.ts +++ b/extensions/vercel-ai-gateway/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyVercelAiGatewayConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from "./onboard.js"; @@ -6,12 +6,11 @@ import { buildVercelAiGatewayProvider } from "./provider-catalog.js"; const PROVIDER_ID = "vercel-ai-gateway"; -const vercelAiGatewayPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Vercel AI Gateway Provider", description: "Bundled Vercel AI Gateway provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Vercel AI Gateway", @@ -50,6 +49,4 @@ const vercelAiGatewayPlugin = { }, }); }, -}; - -export default vercelAiGatewayPlugin; +}); diff --git a/extensions/vllm/index.ts b/extensions/vllm/index.ts index 24805e700a6..7017977861c 100644 --- a/extensions/vllm/index.ts +++ b/extensions/vllm/index.ts @@ -5,7 +5,7 @@ import { VLLM_PROVIDER_LABEL, } from "openclaw/plugin-sdk/agent-runtime"; import { - emptyPluginConfigSchema, + definePluginEntry, type OpenClawPluginApi, type ProviderAuthMethodNonInteractiveContext, } from "openclaw/plugin-sdk/core"; @@ -16,11 +16,10 @@ async function loadProviderSetup() { return await import("openclaw/plugin-sdk/self-hosted-provider-setup"); } -const vllmPlugin = { +export default definePluginEntry({ id: "vllm", name: "vLLM Provider", description: "Bundled vLLM provider plugin", - configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerProvider({ id: PROVIDER_ID, @@ -87,6 +86,4 @@ const vllmPlugin = { }, }); }, -}; - -export default vllmPlugin; +}); 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 f20e2da6674..ad63cf1f52a 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -1,8 +1,9 @@ import { Type } from "@sinclair/typebox"; -import type { - GatewayRequestHandlerOptions, - OpenClawPluginApi, -} from "openclaw/plugin-sdk/voice-call"; +import { + definePluginEntry, + type GatewayRequestHandlerOptions, + type OpenClawPluginApi, +} from "./api.js"; import { registerVoiceCallCli } from "./src/cli.js"; import { VoiceCallConfigSchema, @@ -143,7 +144,7 @@ const VoiceCallToolSchema = Type.Union([ }), ]); -const voiceCallPlugin = { +export default definePluginEntry({ id: "voice-call", name: "Voice Call", description: "Voice-call plugin with Telnyx/Twilio/Plivo providers", @@ -560,6 +561,4 @@ const voiceCallPlugin = { }, }); }, -}; - -export default voiceCallPlugin; +}); 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/volcengine/index.ts b/extensions/volcengine/index.ts index 975bcce610d..f6b4b020746 100644 --- a/extensions/volcengine/index.ts +++ b/extensions/volcengine/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard"; import { buildDoubaoCodingProvider, buildDoubaoProvider } from "./provider-catalog.js"; @@ -6,12 +6,11 @@ import { buildDoubaoCodingProvider, buildDoubaoProvider } from "./provider-catal const PROVIDER_ID = "volcengine"; const VOLCENGINE_DEFAULT_MODEL_REF = "volcengine-plan/ark-code-latest"; -const volcenginePlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Volcengine Provider", description: "Bundled Volcengine provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Volcengine", @@ -60,6 +59,4 @@ const volcenginePlugin = { }, }); }, -}; - -export default volcenginePlugin; +}); 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..531cee4b524 --- /dev/null +++ b/extensions/whatsapp/runtime-api.ts @@ -0,0 +1,10 @@ +export * from "./src/active-listener.js"; +export * from "./src/action-runtime.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/accounts.whatsapp-auth.test.ts b/extensions/whatsapp/src/accounts.whatsapp-auth.test.ts index 43d1739e13f..9926b3c5324 100644 --- a/extensions/whatsapp/src/accounts.whatsapp-auth.test.ts +++ b/extensions/whatsapp/src/accounts.whatsapp-auth.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { captureEnv } from "../../test-utils/env.js"; +import { captureEnv } from "../../../test/helpers/extensions/env.js"; import { hasAnyWhatsAppAuth, listWhatsAppAuthDirs } from "./accounts.js"; describe("hasAnyWhatsAppAuth", () => { diff --git a/src/agents/tools/whatsapp-target-auth.ts b/extensions/whatsapp/src/action-runtime-target-auth.ts similarity index 70% rename from src/agents/tools/whatsapp-target-auth.ts rename to extensions/whatsapp/src/action-runtime-target-auth.ts index edc0052fbab..8686ac24261 100644 --- a/src/agents/tools/whatsapp-target-auth.ts +++ b/extensions/whatsapp/src/action-runtime-target-auth.ts @@ -1,7 +1,7 @@ -import type { OpenClawConfig } from "../../config/config.js"; -import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js"; -import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; -import { ToolAuthorizationError } from "./common.js"; +import { ToolAuthorizationError } from "../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveWhatsAppOutboundTarget } from "../../../src/whatsapp/resolve-outbound-target.js"; +import { resolveWhatsAppAccount } from "./accounts.js"; export function resolveAuthorizedWhatsAppOutboundTarget(params: { cfg: OpenClawConfig; diff --git a/src/agents/tools/whatsapp-actions.test.ts b/extensions/whatsapp/src/action-runtime.test.ts similarity index 88% rename from src/agents/tools/whatsapp-actions.test.ts rename to extensions/whatsapp/src/action-runtime.test.ts index 1fc195ffd1e..b8fb950281a 100644 --- a/src/agents/tools/whatsapp-actions.test.ts +++ b/extensions/whatsapp/src/action-runtime.test.ts @@ -1,17 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; -import { handleWhatsAppAction } from "./whatsapp-actions.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { handleWhatsAppAction, whatsAppActionRuntime } from "./action-runtime.js"; -const { sendReactionWhatsApp, sendPollWhatsApp } = vi.hoisted(() => ({ - sendReactionWhatsApp: vi.fn(async () => undefined), - sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "jid-1" })), -})); - -vi.mock("../../../extensions/whatsapp/src/send.js", () => ({ - sendReactionWhatsApp, - sendPollWhatsApp, -})); +const originalWhatsAppActionRuntime = { ...whatsAppActionRuntime }; +const sendReactionWhatsApp = vi.fn(async () => undefined); const enabledConfig = { channels: { whatsapp: { actions: { reactions: true } } }, @@ -20,6 +13,9 @@ const enabledConfig = { describe("handleWhatsAppAction", () => { beforeEach(() => { vi.clearAllMocks(); + Object.assign(whatsAppActionRuntime, originalWhatsAppActionRuntime, { + sendReactionWhatsApp, + }); }); it("adds reactions", async () => { diff --git a/src/agents/tools/whatsapp-actions.ts b/extensions/whatsapp/src/action-runtime.ts similarity index 72% rename from src/agents/tools/whatsapp-actions.ts rename to extensions/whatsapp/src/action-runtime.ts index a84dc0a3d5b..6a805440633 100644 --- a/src/agents/tools/whatsapp-actions.ts +++ b/extensions/whatsapp/src/action-runtime.ts @@ -1,8 +1,18 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { sendReactionWhatsApp } from "../../plugin-sdk/whatsapp.js"; -import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js"; -import { resolveAuthorizedWhatsAppOutboundTarget } from "./whatsapp-target-auth.js"; +import { + createActionGate, + jsonResult, + readReactionParams, + readStringParam, +} from "../../../src/agents/tools/common.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveAuthorizedWhatsAppOutboundTarget } from "./action-runtime-target-auth.js"; +import { sendReactionWhatsApp } from "./send.js"; + +export const whatsAppActionRuntime = { + resolveAuthorizedWhatsAppOutboundTarget, + sendReactionWhatsApp, +}; export async function handleWhatsAppAction( params: Record, @@ -26,7 +36,7 @@ export async function handleWhatsAppAction( const fromMe = typeof fromMeRaw === "boolean" ? fromMeRaw : undefined; // Resolve account + allowFrom via shared account logic so auth and routing stay aligned. - const resolved = resolveAuthorizedWhatsAppOutboundTarget({ + const resolved = whatsAppActionRuntime.resolveAuthorizedWhatsAppOutboundTarget({ cfg, chatJid, accountId, @@ -34,7 +44,7 @@ export async function handleWhatsAppAction( }); const resolvedEmoji = remove ? "" : emoji; - await sendReactionWhatsApp(resolved.to, messageId, resolvedEmoji, { + await whatsAppActionRuntime.sendReactionWhatsApp(resolved.to, messageId, resolvedEmoji, { verbose: false, fromMe, participant: participant ?? undefined, diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 6a5184fc059..235942663a8 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -5,7 +5,7 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { setLoggerOverride } from "../../../src/logging.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; -import { withEnvAsync } from "../../test-utils/env.js"; +import { withEnvAsync } from "../../../test/helpers/extensions/env.js"; import { createMockWebListener, createWebListenerFactoryCapture, diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts index d1011f5c7f8..eb733d14e0e 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { saveSessionStore } from "../../../../src/config/sessions.js"; -import { withTempDir } from "../../../test-utils/temp-dir.js"; +import { withTempDir } from "../../../../test/helpers/extensions/temp-dir.js"; import { debugMention, isBotMentionedFromTargets, 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/media.test.ts b/extensions/whatsapp/src/media.test.ts index 45f3fbae309..ce3e98c549c 100644 --- a/extensions/whatsapp/src/media.test.ts +++ b/extensions/whatsapp/src/media.test.ts @@ -7,8 +7,8 @@ import { resolveStateDir } from "../../../src/config/paths.js"; import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js"; import { optimizeImageToPng } from "../../../src/media/image-ops.js"; import { mockPinnedHostnameResolution } from "../../../src/test-helpers/ssrf.js"; +import { captureEnv } from "../../../test/helpers/extensions/env.js"; import { sendVoiceMessageDiscord } from "../../discord/src/send.js"; -import { captureEnv } from "../../test-utils/env.js"; import { LocalMediaAccessError, loadWebMedia, diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 7bee33c2ef4..e836362bca5 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -11,8 +11,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +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 575954a516c..1777de07736 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -3,22 +3,20 @@ import { collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, } from "openclaw/plugin-sdk/channel-policy"; -import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; -import { - resolveWhatsAppGroupRequireMention, - resolveWhatsAppGroupToolPolicy, -} from "../../../src/channels/plugins/group-mentions.js"; -import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js"; -import { resolveWhatsAppGroupIntroHint } from "../../../src/channels/plugins/whatsapp-shared.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; -import { WhatsAppConfigSchema } from "../../../src/config/zod-schema.providers-whatsapp.js"; import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, formatWhatsAppConfigAllowFromEntries, + getChatChannelMeta, + normalizeE164, resolveWhatsAppConfigAllowFrom, resolveWhatsAppConfigDefaultTo, -} from "../../../src/plugin-sdk/channel-config-helpers.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { normalizeE164 } from "../../../src/utils.js"; + resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, + WhatsAppConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/whatsapp-core"; import { listWhatsAppAccountIds, resolveDefaultWhatsAppAccountId, diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index 7771575795a..485b7ec6461 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-models"; import { @@ -16,12 +16,11 @@ function matchesModernXaiModel(modelId: string): boolean { return XAI_MODERN_MODEL_PREFIXES.some((prefix) => normalized.startsWith(prefix)); } -const xaiPlugin = { +export default definePluginEntry({ id: "xai", name: "xAI Plugin", description: "Bundled xAI plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "xAI", @@ -68,6 +67,4 @@ const xaiPlugin = { }), ); }, -}; - -export default xaiPlugin; +}); diff --git a/extensions/xiaomi/index.ts b/extensions/xiaomi/index.ts index dd18127edfa..def263b1cda 100644 --- a/extensions/xiaomi/index.ts +++ b/extensions/xiaomi/index.ts @@ -1,4 +1,4 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { PROVIDER_LABELS } from "openclaw/plugin-sdk/provider-usage"; @@ -7,12 +7,11 @@ import { buildXiaomiProvider } from "./provider-catalog.js"; const PROVIDER_ID = "xiaomi"; -const xiaomiPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Xiaomi Provider", description: "Bundled Xiaomi provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Xiaomi", @@ -62,6 +61,4 @@ const xiaomiPlugin = { }), }); }, -}; - -export default xiaomiPlugin; +}); diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index 33929645968..79ae3a9d8aa 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -1,6 +1,5 @@ import { - emptyPluginConfigSchema, - type OpenClawPluginApi, + definePluginEntry, type ProviderAuthContext, type ProviderAuthMethod, type ProviderAuthMethodNonInteractiveContext, @@ -226,12 +225,11 @@ function buildZaiApiKeyMethod(params: { }; } -const zaiPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "Z.AI Provider", description: "Bundled Z.AI provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { + register(api) { api.registerProvider({ id: PROVIDER_ID, label: "Z.AI", @@ -311,6 +309,4 @@ const zaiPlugin = { }); api.registerMediaUnderstandingProvider(zaiMediaUnderstandingProvider); }, -}; - -export default zaiPlugin; +}); 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.runtime.ts b/extensions/zalo/src/actions.runtime.ts index d463edc5b24..0fd0a2c6f58 100644 --- a/extensions/zalo/src/actions.runtime.ts +++ b/extensions/zalo/src/actions.runtime.ts @@ -1,7 +1,5 @@ import { sendMessageZalo as sendMessageZaloImpl } from "./send.js"; -type SendMessageZalo = typeof import("./send.js").sendMessageZalo; - -export function sendMessageZalo(...args: Parameters): ReturnType { - return sendMessageZaloImpl(...args); -} +export const zaloActionsRuntime = { + sendMessageZalo: sendMessageZaloImpl, +}; diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index 6f8572b01cd..201838f0b04 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -1,3 +1,4 @@ +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageActionName, @@ -6,12 +7,10 @@ import type { import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk/zalo"; import { listEnabledZaloAccounts } from "./accounts.js"; -let zaloActionsRuntimePromise: Promise | null = null; - -async function loadZaloActionsRuntime() { - zaloActionsRuntimePromise ??= import("./actions.runtime.js"); - return zaloActionsRuntimePromise; -} +const loadZaloActionsRuntime = createLazyRuntimeNamedExport( + () => import("./actions.runtime.js"), + "zaloActionsRuntime", +); const providerId = "zalo"; diff --git a/extensions/zalo/src/channel.directory.test.ts b/extensions/zalo/src/channel.directory.test.ts index 8a303e72a97..ac079109736 100644 --- a/extensions/zalo/src/channel.directory.test.ts +++ b/extensions/zalo/src/channel.directory.test.ts @@ -1,6 +1,9 @@ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/zalo"; import { describe, expect, it } from "vitest"; -import { createDirectoryTestRuntime, expectDirectorySurface } from "../../test-utils/directory.js"; +import { + createDirectoryTestRuntime, + expectDirectorySurface, +} from "../../../test/helpers/extensions/directory.js"; import { zaloPlugin } from "./channel.js"; describe("zalo directory", () => { diff --git a/extensions/zalo/src/channel.startup.test.ts b/extensions/zalo/src/channel.startup.test.ts index ea0718d29a2..d99f2397438 100644 --- a/extensions/zalo/src/channel.startup.test.ts +++ b/extensions/zalo/src/channel.startup.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { expectPendingUntilAbort, startAccountAndTrackLifecycle, -} from "../../test-utils/start-account-lifecycle.js"; +} from "../../../test/helpers/extensions/start-account-lifecycle.js"; import type { ResolvedZaloAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ 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/zalo/src/setup-surface.test.ts b/extensions/zalo/src/setup-surface.test.ts index f00060b50c6..8470a3bce66 100644 --- a/extensions/zalo/src/setup-surface.test.ts +++ b/extensions/zalo/src/setup-surface.test.ts @@ -1,23 +1,13 @@ -import type { OpenClawConfig, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/zalo"; +import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/zalo"; import { describe, expect, it, vi } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { + createTestWizardPrompter, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { zaloPlugin } from "./channel.js"; -function createPrompter(overrides: Partial): WizardPrompter { - return { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), - select: vi.fn(async () => "plaintext") as WizardPrompter["select"], - multiselect: vi.fn(async () => []), - text: vi.fn(async () => "") as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - ...overrides, - }; -} - const zaloConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ plugin: zaloPlugin, wizard: zaloPlugin.setupWizard!, @@ -25,7 +15,8 @@ const zaloConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ describe("zalo setup wizard", () => { it("configures a polling token flow", async () => { - const prompter = createPrompter({ + const prompter = createTestWizardPrompter({ + select: vi.fn(async () => "plaintext") as WizardPrompter["select"], text: vi.fn(async ({ message }: { message: string }) => { if (message === "Enter Zalo bot token") { return "12345689:abc-xyz"; diff --git a/extensions/zalo/src/status-issues.test.ts b/extensions/zalo/src/status-issues.test.ts index 581a0dfe916..1187d45a298 100644 --- a/extensions/zalo/src/status-issues.test.ts +++ b/extensions/zalo/src/status-issues.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { expectOpenDmPolicyConfigIssue } from "../../test-utils/status-issues.js"; +import { expectOpenDmPolicyConfigIssue } from "../../../test/helpers/extensions/status-issues.js"; import { collectZaloStatusIssues } from "./status-issues.js"; describe("collectZaloStatusIssues", () => { 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/extensions/zalouser/package.json b/extensions/zalouser/package.json index 5e3a1070237..322053904fd 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -5,7 +5,7 @@ "type": "module", "dependencies": { "@sinclair/typebox": "0.34.48", - "zca-js": "2.1.1", + "zca-js": "2.1.2", "zod": "^4.3.6" }, "openclaw": { @@ -24,7 +24,7 @@ "zlu" ], "order": 85, - "quickstartAllowFrom": true + "quickstartAllowFrom": false }, "install": { "npmSpec": "@openclaw/zalouser", diff --git a/extensions/zalouser/setup-entry.ts b/extensions/zalouser/setup-entry.ts index 0320d3cf945..df1681dd12d 100644 --- a/extensions/zalouser/setup-entry.ts +++ b/extensions/zalouser/setup-entry.ts @@ -1,4 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; -import { zalouserPlugin } from "./src/channel.js"; +import { zalouserSetupPlugin } from "./src/channel.setup.js"; -export default defineSetupPluginEntry(zalouserPlugin); +export { zalouserSetupPlugin } from "./src/channel.setup.js"; + +export default defineSetupPluginEntry(zalouserSetupPlugin); diff --git a/extensions/zalouser/src/accounts.test.ts b/extensions/zalouser/src/accounts.test.ts index 7b6a63d66a7..11f9704f759 100644 --- a/extensions/zalouser/src/accounts.test.ts +++ b/extensions/zalouser/src/accounts.test.ts @@ -124,6 +124,19 @@ describe("zalouser account resolution", () => { expect(resolved.config.allowFrom).toEqual(["123"]); }); + it("defaults group policy to allowlist when unset", () => { + const cfg = asConfig({ + channels: { + zalouser: { + enabled: true, + }, + }, + }); + + const resolved = resolveZalouserAccountSync({ cfg, accountId: "default" }); + expect(resolved.config.groupPolicy).toBe("allowlist"); + }); + it("resolves profile precedence correctly", () => { const cfg = asConfig({ channels: { diff --git a/extensions/zalouser/src/accounts.ts b/extensions/zalouser/src/accounts.ts index 26a02ed47a0..71385db0e17 100644 --- a/extensions/zalouser/src/accounts.ts +++ b/extensions/zalouser/src/accounts.ts @@ -24,7 +24,12 @@ function mergeZalouserAccountConfig(cfg: OpenClawConfig, accountId: string): Zal const raw = (cfg.channels?.zalouser ?? {}) as ZalouserConfig; const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw; const account = resolveAccountConfig(cfg, accountId) ?? {}; - return { ...base, ...account }; + const merged = { ...base, ...account }; + return { + ...merged, + // Match Telegram's safe default: groups stay allowlisted unless explicitly opened. + groupPolicy: merged.groupPolicy ?? "allowlist", + }; } function resolveProfile(config: ZalouserAccountConfig, accountId: string): string { diff --git a/extensions/zalouser/src/channel.setup.test.ts b/extensions/zalouser/src/channel.setup.test.ts new file mode 100644 index 00000000000..552a45c882e --- /dev/null +++ b/extensions/zalouser/src/channel.setup.test.ts @@ -0,0 +1,35 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { withEnvAsync } from "../../../test/helpers/extensions/env.js"; +import { zalouserSetupPlugin } from "./channel.setup.js"; + +const zalouserSetupAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ + plugin: zalouserSetupPlugin, + wizard: zalouserSetupPlugin.setupWizard!, +}); + +describe("zalouser setup plugin", () => { + it("builds setup status without an initialized runtime", async () => { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-setup-")); + + try { + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + await expect( + zalouserSetupAdapter.getStatus({ + cfg: {}, + accountOverrides: {}, + }), + ).resolves.toMatchObject({ + channel: "zalouser", + configured: false, + statusLines: ["Zalo Personal: needs QR login"], + }); + }); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); +}); diff --git a/extensions/zalouser/src/channel.setup.ts b/extensions/zalouser/src/channel.setup.ts new file mode 100644 index 00000000000..1280bbb0e51 --- /dev/null +++ b/extensions/zalouser/src/channel.setup.ts @@ -0,0 +1,12 @@ +import type { ChannelPlugin } from "openclaw/plugin-sdk/zalouser"; +import type { ResolvedZalouserAccount } from "./accounts.js"; +import { zalouserSetupAdapter } from "./setup-core.js"; +import { zalouserSetupWizard } from "./setup-surface.js"; +import { createZalouserPluginBase } from "./shared.js"; + +export const zalouserSetupPlugin: ChannelPlugin = { + ...createZalouserPluginBase({ + setupWizard: zalouserSetupWizard, + setup: zalouserSetupAdapter, + }), +}; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 1fee83709ef..4822ecb3f3e 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,4 +1,3 @@ -import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { buildAccountScopedDmSecurityPolicy } from "openclaw/plugin-sdk/channel-policy"; import type { @@ -13,15 +12,11 @@ import type { import { buildChannelSendResult, buildBaseAccountStatusSnapshot, - buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - formatAllowFromLowercase, isDangerousNameMatchingEnabled, isNumericTargetId, normalizeAccountId, sendPayloadWithChunkedTextAndMedia, - setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/zalouser"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { @@ -32,7 +27,6 @@ import { checkZcaAuthenticated, type ResolvedZalouserAccount, } from "./accounts.js"; -import { ZalouserConfigSchema } from "./config-schema.js"; import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js"; import { resolveZalouserReactionMessageIds } from "./message-sid.js"; import { probeZalouser } from "./probe.js"; @@ -41,6 +35,7 @@ import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; import { zalouserSetupAdapter } from "./setup-core.js"; import { zalouserSetupWizard } from "./setup-surface.js"; +import { createZalouserPluginBase } from "./shared.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; import { listZaloFriendsMatching, @@ -52,18 +47,6 @@ import { getZaloUserInfo, } from "./zalo-js.js"; -const meta = { - id: "zalouser", - label: "Zalo Personal", - selectionLabel: "Zalo (Personal Account)", - docsPath: "/channels/zalouser", - docsLabel: "zalouser", - blurb: "Zalo personal account via QR code login.", - aliases: ["zlu"], - order: 85, - quickstartAllowFrom: true, -}; - const ZALOUSER_TEXT_CHUNK_LIMIT = 2000; function stripZalouserTargetPrefix(raw: string): string { @@ -304,62 +287,10 @@ const zalouserMessageActions: ChannelMessageActionAdapter = { }; export const zalouserPlugin: ChannelPlugin = { - id: "zalouser", - meta, - setup: zalouserSetupAdapter, - setupWizard: zalouserSetupWizard, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - reactions: true, - threads: false, - polls: false, - nativeCommands: false, - blockStreaming: true, - }, - reload: { configPrefixes: ["channels.zalouser"] }, - configSchema: buildChannelConfigSchema(ZalouserConfigSchema), - config: { - listAccountIds: (cfg) => listZalouserAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveZalouserAccountSync({ cfg: cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg: cfg, - sectionKey: "zalouser", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg: cfg, - sectionKey: "zalouser", - accountId, - clearBaseFields: [ - "profile", - "name", - "dmPolicy", - "allowFrom", - "historyLimit", - "groupAllowFrom", - "groupPolicy", - "groups", - "messagePrefix", - ], - }), - isConfigured: async (account) => await checkZcaAuthenticated(account.profile), - describeAccount: (account): ChannelAccountSnapshot => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: undefined, - }), - resolveAllowFrom: ({ cfg, accountId }) => - mapAllowFromEntries(resolveZalouserAccountSync({ cfg: cfg, accountId }).config.allowFrom), - formatAllowFrom: ({ allowFrom }) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }), - }, + ...createZalouserPluginBase({ + setupWizard: zalouserSetupWizard, + setup: zalouserSetupAdapter, + }), security: { resolveDmPolicy: ({ cfg, accountId, account }) => { return buildAccountScopedDmSecurityPolicy({ diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index 475ba16bca2..e3c4c4ae7ea 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -24,7 +24,7 @@ const zalouserAccountSchema = z.object({ allowFrom: AllowFromListSchema, historyLimit: z.number().int().min(0).optional(), groupAllowFrom: AllowFromListSchema, - groupPolicy: GroupPolicySchema.optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), groups: z.object({}).catchall(groupConfigSchema).optional(), messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts index 9ac3b29841b..ebf28342f26 100644 --- a/extensions/zalouser/src/monitor.group-gating.test.ts +++ b/extensions/zalouser/src/monitor.group-gating.test.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/zalouser"; import { beforeEach, describe, expect, it, vi } from "vitest"; import "./monitor.send-mocks.js"; +import { resolveZalouserAccountSync } from "./accounts.js"; import { __testing } from "./monitor.js"; import { sendDeliveredZalouserMock, @@ -376,6 +377,34 @@ describe("zalouser monitor group mention gating", () => { await expectSkippedGroupMessage(); }); + it("blocks mentioned group messages by default when groupPolicy is omitted", async () => { + const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ + commandAuthorized: false, + }); + const cfg: OpenClawConfig = { + channels: { + zalouser: { + enabled: true, + }, + }, + }; + const account = resolveZalouserAccountSync({ cfg, accountId: "default" }); + + await __testing.processMessage({ + message: createGroupMessage({ + content: "ping @bot", + hasAnyMention: true, + wasExplicitlyMentioned: true, + }), + account, + config: cfg, + runtime: createRuntimeEnv(), + }); + + expect(account.config.groupPolicy).toBe("allowlist"); + expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + it("fails closed when requireMention=true but mention detection is unavailable", async () => { await expectSkippedGroupMessage({ canResolveExplicitMention: false, diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts index fc95b90ab8d..b36b5801a54 100644 --- a/extensions/zalouser/src/setup-surface.test.ts +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -1,7 +1,8 @@ -import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/zalouser"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/zalouser"; import { describe, expect, it, vi } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { createTestWizardPrompter } from "../../../test/helpers/extensions/setup-wizard.js"; vi.mock("./zalo-js.js", async (importOriginal) => { const actual = await importOriginal(); @@ -28,28 +29,6 @@ vi.mock("./zalo-js.js", async (importOriginal) => { import { zalouserPlugin } from "./channel.js"; -const selectFirstOption = async (params: { options: Array<{ value: T }> }): Promise => { - const first = params.options[0]; - if (!first) { - throw new Error("no options"); - } - return first.value; -}; - -function createPrompter(overrides: Partial): WizardPrompter { - return { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), - select: selectFirstOption as WizardPrompter["select"], - multiselect: vi.fn(async () => []), - text: vi.fn(async () => "") as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - ...overrides, - }; -} - const zalouserConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ plugin: zalouserPlugin, wizard: zalouserPlugin.setupWizard!, @@ -58,7 +37,7 @@ const zalouserConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ describe("zalouser setup wizard", () => { it("enables the account without forcing QR login", async () => { const runtime = createRuntimeEnv(); - const prompter = createPrompter({ + const prompter = createTestWizardPrompter({ confirm: vi.fn(async ({ message }: { message: string }) => { if (message === "Login via QR code now?") { return false; @@ -82,5 +61,243 @@ describe("zalouser setup wizard", () => { expect(result.accountId).toBe("default"); expect(result.cfg.channels?.zalouser?.enabled).toBe(true); + expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true); + }); + + it("prompts DM policy before group access in quickstart", async () => { + const runtime = createRuntimeEnv(); + const seen: string[] = []; + const prompter = createTestWizardPrompter({ + confirm: vi.fn(async ({ message }: { message: string }) => { + seen.push(message); + if (message === "Login via QR code now?") { + return false; + } + if (message === "Configure Zalo groups access?") { + return false; + } + return false; + }), + select: vi.fn( + async ({ message, options }: { message: string; options: Array<{ value: string }> }) => { + const first = options[0]; + if (!first) { + throw new Error("no options"); + } + seen.push(message); + if (message === "Zalo Personal DM policy") { + return "pairing"; + } + return first.value; + }, + ) as ReturnType["select"], + }); + + const result = await zalouserConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: { quickstartDefaults: true }, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.zalouser?.enabled).toBe(true); + expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true); + expect(result.cfg.channels?.zalouser?.dmPolicy).toBe("pairing"); + expect(seen.indexOf("Zalo Personal DM policy")).toBeGreaterThanOrEqual(0); + expect(seen.indexOf("Configure Zalo groups access?")).toBeGreaterThanOrEqual(0); + expect(seen.indexOf("Zalo Personal DM policy")).toBeLessThan( + seen.indexOf("Configure Zalo groups access?"), + ); + }); + + it("allows an empty quickstart DM allowlist with a warning", async () => { + const runtime = createRuntimeEnv(); + const note = vi.fn(async (_message: string, _title?: string) => {}); + const prompter = createTestWizardPrompter({ + note, + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Login via QR code now?") { + return false; + } + if (message === "Configure Zalo groups access?") { + return false; + } + return false; + }), + select: vi.fn( + async ({ message, options }: { message: string; options: Array<{ value: string }> }) => { + const first = options[0]; + if (!first) { + throw new Error("no options"); + } + if (message === "Zalo Personal DM policy") { + return "allowlist"; + } + return first.value; + }, + ) as ReturnType["select"], + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Zalouser allowFrom (name or user id)") { + return ""; + } + return ""; + }) as ReturnType["text"], + }); + + const result = await zalouserConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: { quickstartDefaults: true }, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.zalouser?.enabled).toBe(true); + expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true); + expect(result.cfg.channels?.zalouser?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.zalouser?.allowFrom).toEqual([]); + expect( + note.mock.calls.some(([message]) => + String(message).includes("No DM allowlist entries added yet."), + ), + ).toBe(true); + }); + + it("allows an empty group allowlist with a warning", async () => { + const runtime = createRuntimeEnv(); + const note = vi.fn(async (_message: string, _title?: string) => {}); + const prompter = createTestWizardPrompter({ + note, + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Login via QR code now?") { + return false; + } + if (message === "Configure Zalo groups access?") { + return true; + } + return false; + }), + select: vi.fn( + async ({ message, options }: { message: string; options: Array<{ value: string }> }) => { + const first = options[0]; + if (!first) { + throw new Error("no options"); + } + if (message === "Zalo groups access") { + return "allowlist"; + } + return first.value; + }, + ) as ReturnType["select"], + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Zalo groups allowlist (comma-separated)") { + return ""; + } + return ""; + }) as ReturnType["text"], + }); + + const result = await zalouserConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.cfg.channels?.zalouser?.groupPolicy).toBe("allowlist"); + expect(result.cfg.channels?.zalouser?.groups).toEqual({}); + expect( + note.mock.calls.some(([message]) => + String(message).includes("No group allowlist entries added yet."), + ), + ).toBe(true); + }); + + it("preserves non-quickstart forceAllowFrom behavior", async () => { + const runtime = createRuntimeEnv(); + const note = vi.fn(async (_message: string, _title?: string) => {}); + const seen: string[] = []; + const prompter = createTestWizardPrompter({ + note, + confirm: vi.fn(async ({ message }: { message: string }) => { + seen.push(message); + if (message === "Login via QR code now?") { + return false; + } + if (message === "Configure Zalo groups access?") { + return false; + } + return false; + }), + text: vi.fn(async ({ message }: { message: string }) => { + seen.push(message); + if (message === "Zalouser allowFrom (name or user id)") { + return ""; + } + return ""; + }) as ReturnType["text"], + }); + + const result = await zalouserConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: true, + }); + + expect(result.cfg.channels?.zalouser?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.zalouser?.allowFrom).toEqual([]); + expect(seen).not.toContain("Zalo Personal DM policy"); + expect(seen).toContain("Zalouser allowFrom (name or user id)"); + expect( + note.mock.calls.some(([message]) => + String(message).includes("No DM allowlist entries added yet."), + ), + ).toBe(true); + }); + + it("allowlists the plugin when a plugin allowlist already exists", async () => { + const runtime = createRuntimeEnv(); + const prompter = createTestWizardPrompter({ + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Login via QR code now?") { + return false; + } + if (message === "Configure Zalo groups access?") { + return false; + } + return false; + }), + }); + + const result = await zalouserConfigureAdapter.configure({ + cfg: { + plugins: { + allow: ["telegram"], + }, + } as OpenClawConfig, + runtime, + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true); + expect(result.cfg.plugins?.allow).toEqual(["telegram", "zalouser"]); }); }); diff --git a/extensions/zalouser/src/setup-surface.ts b/extensions/zalouser/src/setup-surface.ts index f51b55ff068..1249bf9b5de 100644 --- a/extensions/zalouser/src/setup-surface.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -1,5 +1,6 @@ import { DEFAULT_ACCOUNT_ID, + formatCliCommand, formatDocsLink, formatResolvedUnresolvedNote, mergeAllowFromEntries, @@ -8,6 +9,7 @@ import { setTopLevelChannelDmPolicyWithAllowFrom, type ChannelSetupDmPolicy, type ChannelSetupWizard, + type DmPolicy, type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; import { @@ -27,6 +29,18 @@ import { } from "./zalo-js.js"; const channel = "zalouser" as const; +const ZALOUSER_ALLOW_FROM_PLACEHOLDER = "Alice, 123456789, or leave empty to configure later"; +const ZALOUSER_GROUPS_PLACEHOLDER = "Family, Work, 123456789, or leave empty for now"; +const ZALOUSER_DM_ACCESS_TITLE = "Zalo Personal DM access"; +const ZALOUSER_ALLOWLIST_TITLE = "Zalo Personal allowlist"; +const ZALOUSER_GROUPS_TITLE = "Zalo groups"; + +function parseZalouserEntries(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} function setZalouserAccountScopedConfig( cfg: OpenClawConfig, @@ -43,10 +57,7 @@ function setZalouserAccountScopedConfig( }) as OpenClawConfig; } -function setZalouserDmPolicy( - cfg: OpenClawConfig, - dmPolicy: "pairing" | "allowlist" | "open" | "disabled", -): OpenClawConfig { +function setZalouserDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, channel, @@ -69,12 +80,41 @@ function setZalouserGroupAllowlist( accountId: string, groupKeys: string[], ): OpenClawConfig { - const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }])); + const groups = Object.fromEntries( + groupKeys.map((key) => [key, { allow: true, requireMention: true }]), + ); return setZalouserAccountScopedConfig(cfg, accountId, { groups, }); } +function ensureZalouserPluginEnabled(cfg: OpenClawConfig): OpenClawConfig { + const next: OpenClawConfig = { + ...cfg, + plugins: { + ...cfg.plugins, + entries: { + ...cfg.plugins?.entries, + zalouser: { + ...cfg.plugins?.entries?.zalouser, + enabled: true, + }, + }, + }, + }; + const allow = next.plugins?.allow; + if (!Array.isArray(allow) || allow.includes(channel)) { + return next; + } + return { + ...next, + plugins: { + ...next.plugins, + allow: [...allow, channel], + }, + }; +} + async function noteZalouserHelp( prompter: Parameters>[0]["prompter"], ): Promise { @@ -98,20 +138,28 @@ async function promptZalouserAllowFrom(params: { const { cfg, prompter, accountId } = params; const resolved = resolveZalouserAccountSync({ cfg, accountId }); const existingAllowFrom = resolved.config.allowFrom ?? []; - const parseInput = (raw: string) => - raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); while (true) { const entry = await prompter.text({ message: "Zalouser allowFrom (name or user id)", - placeholder: "Alice, 123456789", - initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + placeholder: ZALOUSER_ALLOW_FROM_PLACEHOLDER, + initialValue: existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : undefined, }); - const parts = parseInput(String(entry)); + const parts = parseZalouserEntries(String(entry)); + if (parts.length === 0) { + await prompter.note( + [ + "No DM allowlist entries added yet.", + "Direct chats will stay blocked until you add people later.", + `Tip: use \`${formatCliCommand("openclaw directory peers list --channel zalouser")}\` to look up people after onboarding.`, + ].join("\n"), + ZALOUSER_ALLOWLIST_TITLE, + ); + return setZalouserAccountScopedConfig(cfg, accountId, { + dmPolicy: "allowlist", + allowFrom: [], + }); + } const resolvedEntries = await resolveZaloAllowFromEntries({ profile: resolved.profile, entries: parts, @@ -121,7 +169,7 @@ async function promptZalouserAllowFrom(params: { if (unresolved.length > 0) { await prompter.note( `Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or exact friend names.`, - "Zalo Personal allowlist", + ZALOUSER_ALLOWLIST_TITLE, ); continue; } @@ -135,7 +183,7 @@ async function promptZalouserAllowFrom(params: { .filter((item) => item.note) .map((item) => `${item.input} -> ${item.id} (${item.note})`); if (notes.length > 0) { - await prompter.note(notes.join("\n"), "Zalo Personal allowlist"); + await prompter.note(notes.join("\n"), ZALOUSER_ALLOWLIST_TITLE); } return setZalouserAccountScopedConfig(cfg, accountId, { @@ -150,7 +198,7 @@ const zalouserDmPolicy: ChannelSetupDmPolicy = { channel, policyKey: "channels.zalouser.dmPolicy", allowFromKey: "channels.zalouser.allowFrom", - getCurrent: (cfg) => (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as "pairing", + getCurrent: (cfg) => (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as DmPolicy, setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: async ({ cfg, prompter, accountId }) => { const id = @@ -165,6 +213,52 @@ const zalouserDmPolicy: ChannelSetupDmPolicy = { }, }; +async function promptZalouserQuickstartDmPolicy(params: { + cfg: OpenClawConfig; + prompter: Parameters>[0]["prompter"]; + accountId: string; +}): Promise { + const { cfg, prompter, accountId } = params; + const resolved = resolveZalouserAccountSync({ cfg, accountId }); + const existingPolicy = (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as DmPolicy; + const existingAllowFrom = resolved.config.allowFrom ?? []; + const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; + + await prompter.note( + [ + "Direct chats are configured separately from group chats.", + "- pairing (default): unknown people get a pairing code", + "- allowlist: only listed people can DM", + "- open: anyone can DM", + "- disabled: ignore DMs", + "", + `Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`, + "If you choose allowlist now, you can leave it empty and add people later.", + ].join("\n"), + ZALOUSER_DM_ACCESS_TITLE, + ); + + const policy = (await prompter.select({ + message: "Zalo Personal DM policy", + options: [ + { value: "pairing", label: "Pairing (recommended)" }, + { value: "allowlist", label: "Allowlist (specific users only)" }, + { value: "open", label: "Open (public inbound DMs)" }, + { value: "disabled", label: "Disabled (ignore DMs)" }, + ], + initialValue: existingPolicy, + })) as DmPolicy; + + if (policy === "allowlist") { + return await promptZalouserAllowFrom({ + cfg, + prompter, + accountId, + }); + } + return setZalouserDmPolicy(cfg, policy); +} + export { zalouserSetupAdapter } from "./setup-core.js"; export const zalouserSetupWizard: ChannelSetupWizard = { @@ -191,7 +285,7 @@ export const zalouserSetupWizard: ChannelSetupWizard = { return [`Zalo Personal: ${configured ? "logged in" : "needs QR login"}`]; }, }, - prepare: async ({ cfg, accountId, prompter }) => { + prepare: async ({ cfg, accountId, prompter, options }) => { let next = cfg; const account = resolveZalouserAccountSync({ cfg: next, accountId }); const alreadyAuthenticated = await checkZcaAuthenticated(account.profile); @@ -265,12 +359,20 @@ export const zalouserSetupWizard: ChannelSetupWizard = { { profile: account.profile, enabled: true }, ); + if (options?.quickstartDefaults) { + next = await promptZalouserQuickstartDmPolicy({ + cfg: next, + prompter, + accountId, + }); + } + return { cfg: next }; }, credentials: [], groupAccess: { label: "Zalo groups", - placeholder: "Family, Work, 123456789", + placeholder: ZALOUSER_GROUPS_PLACEHOLDER, currentPolicy: ({ cfg, accountId }) => resolveZalouserAccountSync({ cfg, accountId }).config.groupPolicy ?? "allowlist", currentEntries: ({ cfg, accountId }) => @@ -281,6 +383,15 @@ export const zalouserSetupWizard: ChannelSetupWizard = { setZalouserGroupPolicy(cfg as OpenClawConfig, accountId, policy), resolveAllowlist: async ({ cfg, accountId, entries, prompter }) => { if (entries.length === 0) { + await prompter.note( + [ + "No group allowlist entries added yet.", + "Group chats will stay blocked until you add groups later.", + `Tip: use \`${formatCliCommand("openclaw directory groups list --channel zalouser")}\` after onboarding to find group IDs.`, + "Mention requirement stays on by default for groups you allow later.", + ].join("\n"), + ZALOUSER_GROUPS_TITLE, + ); return []; } const updatedAccount = resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId }); @@ -299,13 +410,13 @@ export const zalouserSetupWizard: ChannelSetupWizard = { unresolved, }); if (resolution) { - await prompter.note(resolution, "Zalo groups"); + await prompter.note(resolution, ZALOUSER_GROUPS_TITLE); } return keys; } catch (err) { await prompter.note( `Group lookup failed; keeping entries as typed. ${String(err)}`, - "Zalo groups", + ZALOUSER_GROUPS_TITLE, ); return entries.map((entry) => entry.trim()).filter(Boolean); } @@ -313,16 +424,16 @@ export const zalouserSetupWizard: ChannelSetupWizard = { applyAllowlist: ({ cfg, accountId, resolved }) => setZalouserGroupAllowlist(cfg as OpenClawConfig, accountId, resolved as string[]), }, - finalize: async ({ cfg, accountId, forceAllowFrom, prompter }) => { + finalize: async ({ cfg, accountId, forceAllowFrom, options, prompter }) => { let next = cfg; - if (forceAllowFrom) { + if (forceAllowFrom && !options?.quickstartDefaults) { next = await promptZalouserAllowFrom({ cfg: next, prompter, accountId, }); } - return { cfg: next }; + return { cfg: ensureZalouserPluginEnabled(next) }; }, dmPolicy: zalouserDmPolicy, }; diff --git a/extensions/zalouser/src/shared.ts b/extensions/zalouser/src/shared.ts new file mode 100644 index 00000000000..bac69441806 --- /dev/null +++ b/extensions/zalouser/src/shared.ts @@ -0,0 +1,95 @@ +import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/zalouser"; +import { + buildChannelConfigSchema, + deleteAccountFromConfigSection, + formatAllowFromLowercase, + setAccountEnabledInConfigSection, +} from "openclaw/plugin-sdk/zalouser"; +import { + listZalouserAccountIds, + resolveDefaultZalouserAccountId, + resolveZalouserAccountSync, + checkZcaAuthenticated, + type ResolvedZalouserAccount, +} from "./accounts.js"; +import { ZalouserConfigSchema } from "./config-schema.js"; + +export const zalouserMeta = { + id: "zalouser", + label: "Zalo Personal", + selectionLabel: "Zalo (Personal Account)", + docsPath: "/channels/zalouser", + docsLabel: "zalouser", + blurb: "Zalo personal account via QR code login.", + aliases: ["zlu"], + order: 85, + quickstartAllowFrom: false, +} satisfies ChannelPlugin["meta"]; + +export function createZalouserPluginBase(params: { + setupWizard: NonNullable["setupWizard"]>; + setup: NonNullable["setup"]>; +}): Pick< + ChannelPlugin, + "id" | "meta" | "setupWizard" | "capabilities" | "reload" | "configSchema" | "config" | "setup" +> { + return { + id: "zalouser", + meta: zalouserMeta, + setupWizard: params.setupWizard, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: true, + threads: false, + polls: false, + nativeCommands: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.zalouser"] }, + configSchema: buildChannelConfigSchema(ZalouserConfigSchema), + config: { + listAccountIds: (cfg) => listZalouserAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveZalouserAccountSync({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "zalouser", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "zalouser", + accountId, + clearBaseFields: [ + "profile", + "name", + "dmPolicy", + "allowFrom", + "historyLimit", + "groupAllowFrom", + "groupPolicy", + "groups", + "messagePrefix", + ], + }), + isConfigured: async (account) => await checkZcaAuthenticated(account.profile), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: undefined, + }), + resolveAllowFrom: ({ cfg, accountId }) => + mapAllowFromEntries(resolveZalouserAccountSync({ cfg, accountId }).config.allowFrom), + formatAllowFrom: ({ allowFrom }) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }), + }, + setup: params.setup, + }; +} diff --git a/extensions/zalouser/src/status-issues.test.ts b/extensions/zalouser/src/status-issues.test.ts index c1e142c88e8..bd1ae4d4cd4 100644 --- a/extensions/zalouser/src/status-issues.test.ts +++ b/extensions/zalouser/src/status-issues.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { expectOpenDmPolicyConfigIssue } from "../../test-utils/status-issues.js"; +import { expectOpenDmPolicyConfigIssue } from "../../../test/helpers/extensions/status-issues.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; describe("collectZalouserStatusIssues", () => { diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts index 0e2d744232f..8cc20e59158 100644 --- a/extensions/zalouser/src/zalo-js.ts +++ b/extensions/zalouser/src/zalo-js.ts @@ -3,9 +3,9 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { resolveStateDir as resolvePluginStateDir } from "openclaw/plugin-sdk/state-paths"; import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/zalouser"; import { normalizeZaloReactionIcon } from "./reaction.js"; -import { getZalouserRuntime } from "./runtime.js"; import type { ZaloAuthStatus, ZaloEventMessage, @@ -85,7 +85,7 @@ type StoredZaloCredentials = { }; function resolveStateDir(env: NodeJS.ProcessEnv = process.env): string { - return getZalouserRuntime().state.resolveStateDir(env, os.homedir); + return resolvePluginStateDir(env, os.homedir); } function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string { diff --git a/package.json b/package.json index 08acac5db40..32f107da7cc 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" @@ -278,6 +310,10 @@ "types": "./dist/plugin-sdk/talk-voice.d.ts", "default": "./dist/plugin-sdk/talk-voice.js" }, + "./plugin-sdk/testing": { + "types": "./dist/plugin-sdk/testing.d.ts", + "default": "./dist/plugin-sdk/testing.js" + }, "./plugin-sdk/test-utils": { "types": "./dist/plugin-sdk/test-utils.d.ts", "default": "./dist/plugin-sdk/test-utils.js" @@ -338,6 +374,10 @@ "types": "./dist/plugin-sdk/channel-config-schema.d.ts", "default": "./dist/plugin-sdk/channel-config-schema.js" }, + "./plugin-sdk/channel-lifecycle": { + "types": "./dist/plugin-sdk/channel-lifecycle.d.ts", + "default": "./dist/plugin-sdk/channel-lifecycle.js" + }, "./plugin-sdk/channel-policy": { "types": "./dist/plugin-sdk/channel-policy.d.ts", "default": "./dist/plugin-sdk/channel-policy.js" @@ -390,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" @@ -414,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" @@ -524,6 +564,7 @@ "test": "node scripts/test-parallel.mjs", "test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", "test:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts", + "test:build:singleton": "node scripts/test-built-plugin-singleton.mjs", "test:channels": "vitest run --config vitest.channels.config.ts", "test:contracts": "pnpm test:contracts:channels && pnpm test:contracts:plugins", "test:contracts:channels": "OPENCLAW_TEST_PROFILE=low pnpm test -- src/channels/plugins/contracts", @@ -542,9 +583,11 @@ "test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 vitest run --config vitest.e2e.config.ts test/openshell-sandbox.e2e.test.ts", "test:extension": "node scripts/test-extension.mjs", "test:extensions": "vitest run --config vitest.extensions.config.ts", + "test:extensions:memory": "node scripts/profile-extension-memory.mjs", "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/pnpm-lock.yaml b/pnpm-lock.yaml index 2a0562f6b33..723eeca7a45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,6 +288,8 @@ importers: extensions/byteplus: {} + extensions/chutes: {} + extensions/cloudflare-ai-gateway: {} extensions/copilot-proxy: {} @@ -603,8 +605,8 @@ importers: specifier: 0.34.48 version: 0.34.48 zca-js: - specifier: 2.1.1 - version: 2.1.1 + specifier: 2.1.2 + version: 2.1.2 zod: specifier: ^4.3.6 version: 4.3.6 @@ -6881,8 +6883,8 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - zca-js@2.1.1: - resolution: {integrity: sha512-6zCmaIIWg/1eYlvCvO4rVsFt6SQ8MRodro3dCzMkk+LNgB3MyaEMBywBJfsw44WhODmOh8iMlPv4xDTNTMWDWA==} + zca-js@2.1.2: + resolution: {integrity: sha512-82+zCqoIXnXEF6C9YuN3Kf7WKlyyujY/6Ejl2n8PkwazYkBK0k7kiPd8S7nHvC5Wl7vjwGRhDYeAM8zTHyoRxQ==} engines: {node: '>=18.0.0'} zod-to-json-schema@3.25.1: @@ -14351,7 +14353,7 @@ snapshots: yoctocolors@2.1.2: {} - zca-js@2.1.1: + zca-js@2.1.2: dependencies: crypto-js: 4.2.0 form-data: 2.5.4 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/check-no-extension-test-core-imports.ts b/scripts/check-no-extension-test-core-imports.ts index b8e3b1bc764..01d6639df1e 100644 --- a/scripts/check-no-extension-test-core-imports.ts +++ b/scripts/check-no-extension-test-core-imports.ts @@ -6,17 +6,25 @@ const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; hint: string }> = [ pattern: /["']openclaw\/plugin-sdk["']/, hint: "Use openclaw/plugin-sdk/ instead of the monolithic root entry.", }, + { + pattern: /["']openclaw\/plugin-sdk\/test-utils["']/, + hint: "Use openclaw/plugin-sdk/testing for the public extension test seam.", + }, { pattern: /["']openclaw\/plugin-sdk\/compat["']/, hint: "Use a focused public plugin-sdk subpath instead of compat.", }, + { + pattern: /["'](?:\.\.\/)+(?:test-utils\/)[^"']+["']/, + hint: "Use test/helpers/extensions/* for repo-only bundled extension test helpers.", + }, { pattern: /["'](?:\.\.\/)+(?:src\/test-utils\/)[^"']+["']/, - hint: "Use extensions/test-utils/* bridges for shared extension test helpers.", + hint: "Use test/helpers/extensions/* for repo-only helpers, or openclaw/plugin-sdk/testing for public seams.", }, { pattern: /["'](?:\.\.\/)+(?:src\/plugins\/types\.js)["']/, - hint: "Use public plugin-sdk/core types or extensions/test-utils bridges instead.", + hint: "Use public plugin-sdk/core types or test/helpers/extensions/* instead.", }, ]; diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 205982588fd..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", @@ -59,6 +67,7 @@ "qwen-portal-auth", "synology-chat", "talk-voice", + "testing", "test-utils", "thread-ownership", "tlon", @@ -74,6 +83,7 @@ "boolean-param", "channel-config-helpers", "channel-config-schema", + "channel-lifecycle", "channel-policy", "group-access", "directory-runtime", @@ -87,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/scripts/profile-extension-memory.mjs b/scripts/profile-extension-memory.mjs new file mode 100644 index 00000000000..0145ed832a4 --- /dev/null +++ b/scripts/profile-extension-memory.mjs @@ -0,0 +1,359 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const DEFAULT_CONCURRENCY = 6; +const DEFAULT_TIMEOUT_MS = 90_000; +const DEFAULT_COMBINED_TIMEOUT_MS = 180_000; +const DEFAULT_TOP = 10; +const RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__="; + +function printHelp() { + console.log(`Usage: node scripts/profile-extension-memory.mjs [options] + +Profiles peak RSS for built extension entrypoints in dist/extensions/*/index.js. +Run pnpm build first if you want stats for the latest source changes. + +Options: + --extension, -e Limit profiling to one or more extension ids (repeatable) + --concurrency Number of per-extension workers (default: ${DEFAULT_CONCURRENCY}) + --timeout-ms Per-extension timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS}) + --combined-timeout-ms + Combined-import timeout in milliseconds (default: ${DEFAULT_COMBINED_TIMEOUT_MS}) + --top Show top N entries by delta from baseline (default: ${DEFAULT_TOP}) + --json Write full JSON report to this path + --skip-combined Skip the combined all-imports measurement + --help Show this help + +Examples: + pnpm test:extensions:memory + pnpm test:extensions:memory -- --extension discord + pnpm test:extensions:memory -- --extension discord --extension telegram --skip-combined +`); +} + +function parsePositiveInt(raw, flagName) { + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${flagName} must be a positive integer`); + } + return parsed; +} + +function parseArgs(argv) { + const options = { + extensions: [], + concurrency: DEFAULT_CONCURRENCY, + timeoutMs: DEFAULT_TIMEOUT_MS, + combinedTimeoutMs: DEFAULT_COMBINED_TIMEOUT_MS, + top: DEFAULT_TOP, + jsonPath: null, + skipCombined: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case "--": + break; + case "--extension": + case "-e": { + const next = argv[index + 1]; + if (!next) { + throw new Error(`${arg} requires a value`); + } + options.extensions.push(next); + index += 1; + break; + } + case "--concurrency": + options.concurrency = parsePositiveInt(argv[index + 1], arg); + index += 1; + break; + case "--timeout-ms": + options.timeoutMs = parsePositiveInt(argv[index + 1], arg); + index += 1; + break; + case "--combined-timeout-ms": + options.combinedTimeoutMs = parsePositiveInt(argv[index + 1], arg); + index += 1; + break; + case "--top": + options.top = parsePositiveInt(argv[index + 1], arg); + index += 1; + break; + case "--json": { + const next = argv[index + 1]; + if (!next) { + throw new Error(`${arg} requires a value`); + } + options.jsonPath = path.resolve(next); + index += 1; + break; + } + case "--skip-combined": + options.skipCombined = true; + break; + case "--help": + case "-h": + printHelp(); + process.exit(0); + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + return options; +} + +function parseMaxRssMb(stderr) { + const matches = [...stderr.matchAll(new RegExp(`^${RSS_MARKER}(\\d+)\\s*$`, "gm"))]; + const last = matches.at(-1); + return last ? Number(last[1]) / 1024 : null; +} + +function summarizeStderr(stderr, lines = 8) { + return stderr.trim().split("\n").filter(Boolean).slice(0, lines).join("\n"); +} + +async function runCase({ repoRoot, env, hookPath, name, body, timeoutMs }) { + return await new Promise((resolve) => { + const child = spawn( + process.execPath, + ["--import", hookPath, "--input-type=module", "--eval", body], + { + cwd: repoRoot, + env, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, timeoutMs); + + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("close", (code, signal) => { + clearTimeout(timer); + resolve({ + name, + code, + signal, + timedOut, + stdout, + stderr, + maxRssMb: parseMaxRssMb(stderr), + }); + }); + }); +} + +function buildImportBody(entryFiles, label) { + const imports = entryFiles + .map((filePath) => `await import(${JSON.stringify(filePath)});`) + .join("\n"); + return `${imports}\nconsole.log(${JSON.stringify(label)});\nprocess.exit(0);\n`; +} + +function findExtensionEntries(repoRoot) { + const extensionsDir = path.join(repoRoot, "dist", "extensions"); + if (!existsSync(extensionsDir)) { + throw new Error("dist/extensions not found. Run pnpm build first."); + } + + const entries = readdirSync(extensionsDir) + .map((dir) => ({ dir, file: path.join(extensionsDir, dir, "index.js") })) + .filter((entry) => existsSync(entry.file)) + .toSorted((a, b) => a.dir.localeCompare(b.dir)); + + if (entries.length === 0) { + throw new Error("No built extension entrypoints found under dist/extensions/*/index.js"); + } + return entries; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const repoRoot = process.cwd(); + const allEntries = findExtensionEntries(repoRoot); + const selectedEntries = + options.extensions.length === 0 + ? allEntries + : allEntries.filter((entry) => options.extensions.includes(entry.dir)); + + const missing = options.extensions.filter((id) => !allEntries.some((entry) => entry.dir === id)); + if (missing.length > 0) { + throw new Error(`Unknown built extension ids: ${missing.join(", ")}`); + } + if (selectedEntries.length === 0) { + throw new Error("No extensions selected for profiling"); + } + + const tmpHome = mkdtempSync(path.join(os.tmpdir(), "openclaw-extension-memory-")); + const hookPath = path.join(tmpHome, "measure-rss.mjs"); + const jsonPath = options.jsonPath ?? path.join(os.tmpdir(), "openclaw-extension-memory.json"); + + writeFileSync( + hookPath, + [ + "process.on('exit', () => {", + " const usage = typeof process.resourceUsage === 'function' ? process.resourceUsage() : null;", + ` if (usage && typeof usage.maxRSS === 'number') console.error('${RSS_MARKER}' + String(usage.maxRSS));`, + "});", + "", + ].join("\n"), + "utf8", + ); + + const env = { + ...process.env, + HOME: tmpHome, + USERPROFILE: tmpHome, + XDG_CONFIG_HOME: path.join(tmpHome, ".config"), + XDG_DATA_HOME: path.join(tmpHome, ".local", "share"), + XDG_CACHE_HOME: path.join(tmpHome, ".cache"), + NODE_DISABLE_COMPILE_CACHE: "1", + OPENCLAW_NO_RESPAWN: "1", + TERM: process.env.TERM ?? "dumb", + LANG: process.env.LANG ?? "C.UTF-8", + }; + + try { + const baseline = await runCase({ + repoRoot, + env, + hookPath, + name: "baseline", + body: "process.exit(0)", + timeoutMs: options.timeoutMs, + }); + + const combined = options.skipCombined + ? null + : await runCase({ + repoRoot, + env, + hookPath, + name: "combined", + body: buildImportBody( + selectedEntries.map((entry) => entry.file), + "IMPORTED_ALL", + ), + timeoutMs: options.combinedTimeoutMs, + }); + + const pending = [...selectedEntries]; + const results = []; + + async function worker() { + while (pending.length > 0) { + const next = pending.shift(); + if (next === undefined) { + return; + } + const result = await runCase({ + repoRoot, + env, + hookPath, + name: next.dir, + body: buildImportBody([next.file], "IMPORTED"), + timeoutMs: options.timeoutMs, + }); + results.push({ + dir: next.dir, + file: next.file, + status: result.timedOut ? "timeout" : result.code === 0 ? "ok" : "fail", + maxRssMb: result.maxRssMb, + deltaFromBaselineMb: + result.maxRssMb !== null && baseline.maxRssMb !== null + ? result.maxRssMb - baseline.maxRssMb + : null, + stderrPreview: summarizeStderr(result.stderr), + }); + + const status = result.timedOut ? "timeout" : result.code === 0 ? "ok" : "fail"; + const rss = result.maxRssMb === null ? "n/a" : `${result.maxRssMb.toFixed(1)} MB`; + console.log(`[extension-memory] ${next.dir}: ${status} ${rss}`); + } + } + + await Promise.all( + Array.from({ length: Math.min(options.concurrency, selectedEntries.length) }, () => worker()), + ); + + results.sort((a, b) => a.dir.localeCompare(b.dir)); + const top = results + .filter((entry) => entry.status === "ok" && typeof entry.deltaFromBaselineMb === "number") + .toSorted((a, b) => (b.deltaFromBaselineMb ?? 0) - (a.deltaFromBaselineMb ?? 0)) + .slice(0, options.top); + + const report = { + generatedAt: new Date().toISOString(), + repoRoot, + selectedExtensions: selectedEntries.map((entry) => entry.dir), + baseline: { + status: baseline.timedOut ? "timeout" : baseline.code === 0 ? "ok" : "fail", + maxRssMb: baseline.maxRssMb, + }, + combined: + combined === null + ? null + : { + status: combined.timedOut ? "timeout" : combined.code === 0 ? "ok" : "fail", + maxRssMb: combined.maxRssMb, + stderrPreview: summarizeStderr(combined.stderr, 12), + }, + counts: { + totalEntries: selectedEntries.length, + ok: results.filter((entry) => entry.status === "ok").length, + fail: results.filter((entry) => entry.status === "fail").length, + timeout: results.filter((entry) => entry.status === "timeout").length, + }, + options: { + concurrency: options.concurrency, + timeoutMs: options.timeoutMs, + combinedTimeoutMs: options.combinedTimeoutMs, + skipCombined: options.skipCombined, + }, + topByDeltaMb: top, + results, + }; + + writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + + console.log(`[extension-memory] report: ${jsonPath}`); + console.log( + JSON.stringify( + { + baselineMb: report.baseline.maxRssMb, + combinedMb: report.combined?.maxRssMb ?? null, + counts: report.counts, + topByDeltaMb: report.topByDeltaMb, + }, + null, + 2, + ), + ); + } finally { + rmSync(tmpHome, { recursive: true, force: true }); + } +} + +try { + await main(); +} catch (error) { + console.error(`[extension-memory] ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 9b67303b4a6..fba6d197357 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -341,31 +341,47 @@ const requiredPluginSdkExports = [ "DEFAULT_GROUP_HISTORY_LIMIT", ]; -function checkPluginSdkExports() { - const distPath = resolve("dist", "plugin-sdk", "index.js"); - let content: string; +async function collectDistPluginSdkExports(): Promise> { + const pluginSdkDir = resolve("dist", "plugin-sdk"); + let entries: string[]; try { - content = readFileSync(distPath, "utf8"); + entries = readdirSync(pluginSdkDir) + .filter((entry) => entry.endsWith(".js")) + .toSorted(); } catch { - console.error("release-check: dist/plugin-sdk/index.js not found (build missing?)."); + console.error("release-check: dist/plugin-sdk directory not found (build missing?)."); process.exit(1); - return; + return new Set(); } - const exportMatch = content.match(/export\s*\{([^}]+)\}\s*;?\s*$/); - if (!exportMatch) { - console.error("release-check: could not find export statement in dist/plugin-sdk/index.js."); - process.exit(1); - return; + const exportedNames = new Set(); + for (const entry of entries) { + const content = readFileSync(join(pluginSdkDir, entry), "utf8"); + for (const match of content.matchAll(/export\s*\{([^}]+)\}(?:\s*from\s*["'][^"']+["'])?/g)) { + const names = match[1]?.split(",") ?? []; + for (const name of names) { + const parts = name.trim().split(/\s+as\s+/); + const exportName = (parts[parts.length - 1] || "").trim(); + if (exportName) { + exportedNames.add(exportName); + } + } + } + for (const match of content.matchAll( + /export\s+(?:const|function|class|let|var)\s+([A-Za-z0-9_$]+)/g, + )) { + const exportName = match[1]?.trim(); + if (exportName) { + exportedNames.add(exportName); + } + } } - const exportedNames = new Set( - exportMatch[1].split(",").map((s) => { - const parts = s.trim().split(/\s+as\s+/); - return (parts[parts.length - 1] || "").trim(); - }), - ); + return exportedNames; +} +async function checkPluginSdkExports() { + const exportedNames = await collectDistPluginSdkExports(); const missingExports = requiredPluginSdkExports.filter((name) => !exportedNames.has(name)); if (missingExports.length > 0) { console.error("release-check: missing critical plugin-sdk exports (#27569):"); @@ -376,10 +392,10 @@ function checkPluginSdkExports() { } } -function main() { +async function main() { checkPluginVersions(); checkAppcastSparkleVersions(); - checkPluginSdkExports(); + await checkPluginSdkExports(); checkBundledExtensionRootDependencyMirrors(); const results = runPackDry(); @@ -423,5 +439,8 @@ function main() { } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - main(); + void main().catch((error: unknown) => { + console.error(error); + process.exit(1); + }); } diff --git a/scripts/test-built-plugin-singleton.mjs b/scripts/test-built-plugin-singleton.mjs new file mode 100644 index 00000000000..04e11c5f900 --- /dev/null +++ b/scripts/test-built-plugin-singleton.mjs @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { stageBundledPluginRuntime } from "./stage-bundled-plugin-runtime.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const smokeEntryPath = path.join(repoRoot, "dist", "plugins", "build-smoke-entry.js"); +assert.ok(fs.existsSync(smokeEntryPath), `missing build output: ${smokeEntryPath}`); + +const { clearPluginCommands, getPluginCommandSpecs, loadOpenClawPlugins, matchPluginCommand } = + await import(pathToFileURL(smokeEntryPath).href); + +assert.equal(typeof loadOpenClawPlugins, "function", "built loader export missing"); +assert.equal(typeof clearPluginCommands, "function", "clearPluginCommands missing"); +assert.equal(typeof getPluginCommandSpecs, "function", "getPluginCommandSpecs missing"); +assert.equal(typeof matchPluginCommand, "function", "matchPluginCommand missing"); + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-build-smoke-")); + +function cleanup() { + clearPluginCommands(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +} + +process.on("exit", cleanup); +process.on("SIGINT", () => { + cleanup(); + process.exit(130); +}); +process.on("SIGTERM", () => { + cleanup(); + process.exit(143); +}); + +const pluginId = "build-smoke-plugin"; +const distPluginDir = path.join(tempRoot, "dist", "extensions", pluginId); +fs.mkdirSync(distPluginDir, { recursive: true }); +fs.writeFileSync(path.join(tempRoot, "package.json"), '{ "type": "module" }\n', "utf8"); +fs.writeFileSync( + path.join(distPluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/build-smoke-plugin", + type: "module", + openclaw: { + extensions: ["./index.js"], + }, + }, + null, + 2, + ), + "utf8", +); +fs.writeFileSync( + path.join(distPluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: pluginId, + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, + null, + 2, + ), + "utf8", +); +fs.writeFileSync( + path.join(distPluginDir, "index.js"), + [ + "import sdk from 'openclaw/plugin-sdk';", + "const { emptyPluginConfigSchema } = sdk;", + "", + "export default {", + ` id: ${JSON.stringify(pluginId)},`, + " configSchema: emptyPluginConfigSchema(),", + " register(api) {", + " api.registerCommand({", + " name: 'pair',", + " description: 'Pair a device',", + " acceptsArgs: true,", + " nativeNames: { telegram: 'pair', discord: 'pair' },", + " async handler({ args }) {", + " return { text: `paired:${args ?? ''}` };", + " },", + " });", + " },", + "};", + "", + ].join("\n"), + "utf8", +); + +stageBundledPluginRuntime({ repoRoot: tempRoot }); + +const runtimeEntryPath = path.join(tempRoot, "dist-runtime", "extensions", pluginId, "index.js"); +assert.ok(fs.existsSync(runtimeEntryPath), "runtime overlay entry missing"); +assert.equal( + fs.existsSync(path.join(tempRoot, "dist-runtime", "plugins", "commands.js")), + false, + "dist-runtime must not stage a duplicate commands module", +); + +clearPluginCommands(); + +const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: tempRoot, + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(tempRoot, "dist-runtime", "extensions"), + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + }, + config: { + plugins: { + enabled: true, + allow: [pluginId], + entries: { + [pluginId]: { enabled: true }, + }, + }, + }, +}); + +const record = registry.plugins.find((entry) => entry.id === pluginId); +assert.ok(record, "smoke plugin missing from registry"); +assert.equal(record.status, "loaded", record.error ?? "smoke plugin failed to load"); + +assert.deepEqual(getPluginCommandSpecs("telegram"), [ + { name: "pair", description: "Pair a device", acceptsArgs: true }, +]); + +const match = matchPluginCommand("/pair now"); +assert.ok(match, "canonical built command registry did not receive the command"); +assert.equal(match.args, "now"); +const result = await match.command.handler({ args: match.args }); +assert.deepEqual(result, { text: "paired:now" }); + +process.stdout.write("[build-smoke] built plugin singleton smoke passed\n"); diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 76a0be3b466..dd933b4e4ae 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -93,6 +93,19 @@ const unitIsolatedFilesRaw = [ "src/infra/git-commit.test.ts", ]; const unitIsolatedFiles = unitIsolatedFilesRaw.filter((file) => fs.existsSync(file)); +const unitSingletonIsolatedFilesRaw = []; +const unitSingletonIsolatedFiles = unitSingletonIsolatedFilesRaw.filter((file) => + fs.existsSync(file), +); +const unitVmForkSingletonFilesRaw = [ + "src/channels/plugins/contracts/inbound.telegram.contract.test.ts", +]; +const unitVmForkSingletonFiles = unitVmForkSingletonFilesRaw.filter((file) => fs.existsSync(file)); +const groupedUnitIsolatedFiles = unitIsolatedFiles.filter( + (file) => !unitSingletonIsolatedFiles.includes(file), +); +const channelSingletonFilesRaw = []; +const channelSingletonFiles = channelSingletonFilesRaw.filter((file) => fs.existsSync(file)); const children = new Set(); const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; @@ -139,20 +152,55 @@ const runs = [ "vitest.unit.config.ts", `--pool=${useVmForks ? "vmForks" : "forks"}`, ...(disableIsolation ? ["--isolate=false"] : []), - ...unitIsolatedFiles.flatMap((file) => ["--exclude", file]), + ...[ + ...unitIsolatedFiles, + ...unitSingletonIsolatedFiles, + ...unitVmForkSingletonFiles, + ].flatMap((file) => ["--exclude", file]), ], }, - { - name: "unit-isolated", + ...(groupedUnitIsolatedFiles.length > 0 + ? [ + { + name: "unit-isolated", + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + "--pool=forks", + ...groupedUnitIsolatedFiles, + ], + }, + ] + : []), + ...unitSingletonIsolatedFiles.map((file) => ({ + name: `${path.basename(file, ".test.ts")}-isolated`, args: [ "vitest", "run", "--config", "vitest.unit.config.ts", - "--pool=forks", - ...unitIsolatedFiles, + `--pool=${useVmForks ? "vmForks" : "forks"}`, + file, ], - }, + })), + ...unitVmForkSingletonFiles.map((file) => ({ + name: `${path.basename(file, ".test.ts")}-vmforks`, + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + `--pool=${useVmForks ? "vmForks" : "forks"}`, + ...(disableIsolation ? ["--isolate=false"] : []), + file, + ], + })), + ...channelSingletonFiles.map((file) => ({ + name: `${path.basename(file, ".test.ts")}-channels-isolated`, + args: ["vitest", "run", "--config", "vitest.channels.config.ts", "--pool=forks", file], + })), ] : [ { @@ -380,9 +428,24 @@ const resolveFilterMatches = (fileFilter) => { } return allKnownTestFiles.filter((file) => file.includes(normalizedFilter)); }; +const isVmForkSingletonUnitFile = (fileFilter) => unitVmForkSingletonFiles.includes(fileFilter); const createTargetedEntry = (owner, isolated, filters) => { const name = isolated ? `${owner}-isolated` : owner; const forceForks = isolated; + if (owner === "unit-vmforks") { + return { + name, + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + `--pool=${useVmForks ? "vmForks" : "forks"}`, + ...(disableIsolation ? ["--isolate=false"] : []), + ...filters, + ], + }; + } if (owner === "unit") { return { name, @@ -460,16 +523,19 @@ const targetedEntries = (() => { const groups = passthroughFileFilters.reduce((acc, fileFilter) => { const matchedFiles = resolveFilterMatches(fileFilter); if (matchedFiles.length === 0) { - const target = inferTarget(normalizeRepoPath(fileFilter)); - const key = `${target.owner}:${target.isolated ? "isolated" : "default"}`; + const normalizedFile = normalizeRepoPath(fileFilter); + const target = inferTarget(normalizedFile); + const owner = isVmForkSingletonUnitFile(normalizedFile) ? "unit-vmforks" : target.owner; + const key = `${owner}:${target.isolated ? "isolated" : "default"}`; const files = acc.get(key) ?? []; - files.push(normalizeRepoPath(fileFilter)); + files.push(normalizedFile); acc.set(key, files); return acc; } for (const matchedFile of matchedFiles) { const target = inferTarget(matchedFile); - const key = `${target.owner}:${target.isolated ? "isolated" : "default"}`; + const owner = isVmForkSingletonUnitFile(matchedFile) ? "unit-vmforks" : target.owner; + const key = `${owner}:${target.isolated ? "isolated" : "default"}`; const files = acc.get(key) ?? []; files.push(matchedFile); acc.set(key, files); 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 8152944834c..7229e34914d 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { AcpSessionRuntimeOptions, SessionAcpMeta } from "../../config/sessions/types.js"; -import { AcpRuntimeError } from "../runtime/errors.js"; import type { AcpRuntime, AcpRuntimeCapabilities } from "../runtime/types.js"; const hoisted = vi.hoisted(() => { @@ -32,7 +31,8 @@ vi.mock("../runtime/registry.js", async (importOriginal) => { }; }); -const { AcpSessionManager } = await import("./manager.js"); +let AcpSessionManager: typeof import("./manager.js").AcpSessionManager; +let AcpRuntimeError: typeof import("../runtime/errors.js").AcpRuntimeError; const baseCfg = { acp: { @@ -146,7 +146,10 @@ function extractRuntimeOptionsFromUpserts(): Array { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ AcpSessionManager } = await import("./manager.js")); + ({ AcpRuntimeError } = await import("../runtime/errors.js")); hoisted.listAcpSessionEntriesMock.mockReset().mockResolvedValue([]); hoisted.readAcpSessionEntryMock.mockReset(); hoisted.upsertAcpSessionMetaMock.mockReset().mockResolvedValue(null); @@ -351,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({ @@ -528,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 { @@ -804,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 147c4a455c9..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, })); -import { - buildConfiguredAcpSessionKey, - ensureConfiguredAcpBindingSession, - resetAcpSessionInPlace, - resolveConfiguredAcpBindingRecord, - resolveConfiguredAcpBindingSpecBySessionKey, -} from "./persistent-bindings.js"; +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" }, @@ -252,7 +285,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "work", @@ -307,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", @@ -336,7 +369,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "telegram", accountId: "default", @@ -353,7 +386,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -373,7 +406,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -394,7 +427,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -416,7 +449,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -438,7 +471,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -457,7 +490,7 @@ describe("resolveConfiguredAcpBindingRecord", () => { }), ]); - const resolved = resolveConfiguredAcpBindingRecord({ + const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", @@ -503,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", () => { @@ -523,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", }); @@ -557,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 ?? "", }); @@ -603,7 +655,7 @@ describe("ensureConfiguredAcpBindingSession", () => { cwd: "/workspace/openclaw", }); - const ensured = await ensureConfiguredAcpBindingSession({ + const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({ cfg: baseCfg, spec, }); @@ -622,7 +674,7 @@ describe("ensureConfiguredAcpBindingSession", () => { cwd: "/workspace/other-repo", }); - const ensured = await ensureConfiguredAcpBindingSession({ + const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({ cfg: baseCfg, spec, }); @@ -638,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", @@ -645,7 +717,7 @@ describe("ensureConfiguredAcpBindingSession", () => { }); managerMocks.resolveSession.mockReturnValue({ kind: "none" }); - const ensured = await ensureConfiguredAcpBindingSession({ + const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({ cfg: baseCfg, spec, }); @@ -681,7 +753,7 @@ describe("resetAcpSessionInPlace", () => { }); managerMocks.resolveSession.mockReturnValue({ kind: "none" }); - const result = await resetAcpSessionInPlace({ + const result = await persistentBindings.resetAcpSessionInPlace({ cfg, sessionKey, reason: "new", @@ -710,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", @@ -741,7 +813,7 @@ describe("resetAcpSessionInPlace", () => { }, }); - const result = await resetAcpSessionInPlace({ + const result = await persistentBindings.resetAcpSessionInPlace({ cfg, sessionKey, reason: "reset", @@ -755,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.test.ts b/src/acp/runtime/session-meta.test.ts index f9a0f399f81..b5279d6f0ac 100644 --- a/src/acp/runtime/session-meta.test.ts +++ b/src/acp/runtime/session-meta.test.ts @@ -22,10 +22,14 @@ vi.mock("../../config/sessions.js", async () => { }; }); -const { listAcpSessionEntries } = await import("./session-meta.js"); +type SessionMetaModule = typeof import("./session-meta.js"); + +let listAcpSessionEntries: SessionMetaModule["listAcpSessionEntries"]; describe("listAcpSessionEntries", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ listAcpSessionEntries } = await import("./session-meta.js")); vi.clearAllMocks(); }); 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/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 55446550f9f..162afe6160c 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -308,6 +308,7 @@ describe("acp session UX bridge behavior", () => { "low", "medium", "high", + "xhigh", "adaptive", ]); expect(result.configOptions).toEqual( 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/auth-profiles.runtime.ts b/src/agents/auth-profiles.runtime.ts index 5c25bb97c84..7e2da31c058 100644 --- a/src/agents/auth-profiles.runtime.ts +++ b/src/agents/auth-profiles.runtime.ts @@ -1 +1,9 @@ -export { ensureAuthProfileStore } from "./auth-profiles.js"; +import { ensureAuthProfileStore as ensureAuthProfileStoreImpl } from "./auth-profiles.js"; + +type EnsureAuthProfileStore = typeof import("./auth-profiles.js").ensureAuthProfileStore; + +export function ensureAuthProfileStore( + ...args: Parameters +): ReturnType { + return ensureAuthProfileStoreImpl(...args); +} diff --git a/src/agents/bootstrap-budget.test.ts b/src/agents/bootstrap-budget.test.ts index a4d65cc964c..17d693f2128 100644 --- a/src/agents/bootstrap-budget.test.ts +++ b/src/agents/bootstrap-budget.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { + appendBootstrapPromptWarning, analyzeBootstrapBudget, buildBootstrapInjectionStats, buildBootstrapPromptWarning, buildBootstrapTruncationReportMeta, buildBootstrapTruncationSignature, formatBootstrapTruncationWarningLines, - prependBootstrapPromptWarning, resolveBootstrapWarningSignaturesSeen, } from "./bootstrap-budget.js"; import { buildAgentSystemPrompt } from "./system-prompt.js"; @@ -106,29 +106,31 @@ describe("analyzeBootstrapBudget", () => { }); describe("bootstrap prompt warnings", () => { - it("prepends warning details to the turn prompt instead of mutating the system prompt", () => { - const prompt = prependBootstrapPromptWarning("Please continue.", [ + it("appends warning details to the turn prompt instead of mutating the system prompt", () => { + const prompt = appendBootstrapPromptWarning("Please continue.", [ "AGENTS.md: 200 raw -> 0 injected", ]); + expect(prompt.startsWith("Please continue.")).toBe(true); expect(prompt).toContain("[Bootstrap truncation warning]"); expect(prompt).toContain("Treat Project Context as partial"); expect(prompt).toContain("- AGENTS.md: 200 raw -> 0 injected"); - expect(prompt).toContain("Please continue."); + expect(prompt.endsWith("- AGENTS.md: 200 raw -> 0 injected")).toBe(true); }); - it("preserves raw prompt whitespace when prepending warning details", () => { - const prompt = prependBootstrapPromptWarning(" indented\nkeep tail ", [ + it("preserves raw prompt whitespace when appending warning details", () => { + const prompt = appendBootstrapPromptWarning(" indented\nkeep tail ", [ "AGENTS.md: 200 raw -> 0 injected", ]); - expect(prompt.endsWith(" indented\nkeep tail ")).toBe(true); + expect(prompt).toContain(" indented\nkeep tail "); + expect(prompt.indexOf(" indented\nkeep tail ")).toBe(0); }); - it("preserves exact heartbeat prompts without warning prefixes", () => { + it("preserves exact heartbeat prompts without warning suffixes", () => { const heartbeatPrompt = "Read HEARTBEAT.md. Reply HEARTBEAT_OK."; expect( - prependBootstrapPromptWarning(heartbeatPrompt, ["AGENTS.md: 200 raw -> 0 injected"], { + appendBootstrapPromptWarning(heartbeatPrompt, ["AGENTS.md: 200 raw -> 0 injected"], { preserveExactPrompt: heartbeatPrompt, }), ).toBe(heartbeatPrompt); diff --git a/src/agents/bootstrap-budget.ts b/src/agents/bootstrap-budget.ts index 4d5c3ff6f58..060541925b3 100644 --- a/src/agents/bootstrap-budget.ts +++ b/src/agents/bootstrap-budget.ts @@ -330,7 +330,7 @@ export function buildBootstrapPromptWarning(params: { }; } -export function prependBootstrapPromptWarning( +export function appendBootstrapPromptWarning( prompt: string, warningLines?: string[], options?: { @@ -350,9 +350,12 @@ export function prependBootstrapPromptWarning( "Treat Project Context as partial and read the relevant files directly if details seem missing.", ...normalizedLines.map((line) => `- ${line}`), ].join("\n"); - return prompt ? `${warningBlock}\n\n${prompt}` : warningBlock; + return prompt ? `${prompt}\n\n${warningBlock}` : warningBlock; } +// Backward-compatible alias while older callers still import the prepend name. +export const prependBootstrapPromptWarning = appendBootstrapPromptWarning; + export function buildBootstrapTruncationReportMeta(params: { analysis: BootstrapBudgetAnalysis; warningMode: BootstrapPromptWarningMode; diff --git a/src/agents/channel-tools.test.ts b/src/agents/channel-tools.test.ts index 26552f81f9f..0dad6dc3a7c 100644 --- a/src/agents/channel-tools.test.ts +++ b/src/agents/channel-tools.test.ts @@ -84,4 +84,64 @@ describe("channel tools", () => { expect(listChannelSupportedActions({ cfg, channel: "polltest" })).toEqual([]); expect(listAllChannelSupportedActions({ cfg })).toEqual([]); }); + + it("normalizes channel aliases before listing supported actions", () => { + const plugin: ChannelPlugin = { + id: "telegram", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/channels/telegram", + blurb: "telegram plugin", + aliases: ["tg"], + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + actions: { + listActions: () => ["react"], + }, + }; + + setActivePluginRegistry(createTestRegistry([{ pluginId: "telegram", source: "test", plugin }])); + + const cfg = {} as OpenClawConfig; + expect(listChannelSupportedActions({ cfg, channel: "tg" })).toEqual(["react"]); + }); + + it("uses unified message tool discovery when available", () => { + const listActions = vi.fn(() => { + throw new Error("legacy listActions should not run"); + }); + const plugin: ChannelPlugin = { + id: "telegram", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/channels/telegram", + blurb: "telegram plugin", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + actions: { + describeMessageTool: () => ({ + actions: ["react"], + }), + listActions, + }, + }; + + setActivePluginRegistry(createTestRegistry([{ pluginId: "telegram", source: "test", plugin }])); + + const cfg = {} as OpenClawConfig; + expect(listChannelSupportedActions({ cfg, channel: "telegram" })).toEqual(["react"]); + expect(listActions).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index 242cce868c1..c94204e8802 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -1,12 +1,13 @@ import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; -import type { - ChannelAgentTool, - ChannelMessageActionName, - ChannelPlugin, -} from "../channels/plugins/types.js"; +import { + createMessageActionDiscoveryContext, + resolveMessageActionDiscoveryForPlugin, + resolveMessageActionDiscoveryChannelId, + __testing as messageActionTesting, +} from "../channels/plugins/message-action-discovery.js"; +import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js"; import { normalizeAnyChannelId } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/config.js"; -import { defaultRuntime } from "../runtime.js"; /** * Get the list of supported message actions for a specific channel. @@ -15,16 +16,29 @@ import { defaultRuntime } from "../runtime.js"; export function listChannelSupportedActions(params: { cfg?: OpenClawConfig; channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; }): ChannelMessageActionName[] { - if (!params.channel) { + const channelId = resolveMessageActionDiscoveryChannelId(params.channel); + if (!channelId) { return []; } - const plugin = getChannelPlugin(params.channel as Parameters[0]); - if (!plugin?.actions?.listActions) { + const plugin = getChannelPlugin(channelId as Parameters[0]); + if (!plugin?.actions) { return []; } - const cfg = params.cfg ?? ({} as OpenClawConfig); - return runPluginListActions(plugin, cfg); + return resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: createMessageActionDiscoveryContext(params), + includeActions: true, + }).actions; } /** @@ -32,14 +46,26 @@ export function listChannelSupportedActions(params: { */ export function listAllChannelSupportedActions(params: { cfg?: OpenClawConfig; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; }): ChannelMessageActionName[] { const actions = new Set(); for (const plugin of listChannelPlugins()) { - if (!plugin.actions?.listActions) { - continue; - } - const cfg = params.cfg ?? ({} as OpenClawConfig); - const channelActions = runPluginListActions(plugin, cfg); + const channelActions = resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: createMessageActionDiscoveryContext({ + ...params, + currentChannelProvider: plugin.id, + }), + includeActions: true, + }).actions; for (const action of channelActions) { actions.add(action); } @@ -82,38 +108,8 @@ export function resolveChannelMessageToolHints(params: { .filter(Boolean); } -const loggedListActionErrors = new Set(); - -function runPluginListActions( - plugin: ChannelPlugin, - cfg: OpenClawConfig, -): ChannelMessageActionName[] { - if (!plugin.actions?.listActions) { - return []; - } - try { - const listed = plugin.actions.listActions({ cfg }); - return Array.isArray(listed) ? listed : []; - } catch (err) { - logListActionsError(plugin.id, err); - return []; - } -} - -function logListActionsError(pluginId: string, err: unknown) { - const message = err instanceof Error ? err.message : String(err); - const key = `${pluginId}:${message}`; - if (loggedListActionErrors.has(key)) { - return; - } - loggedListActionErrors.add(key); - const stack = err instanceof Error && err.stack ? err.stack : null; - const details = stack ?? message; - defaultRuntime.error?.(`[channel-tools] ${pluginId}.actions.listActions failed: ${details}`); -} - export const __testing = { resetLoggedListActionErrors() { - loggedListActionErrors.clear(); + messageActionTesting.resetLoggedMessageActionErrors(); }, }; 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/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts index ec345f960a2..fae294ab951 100644 --- a/src/agents/cli-runner/bundle-mcp.test.ts +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -1,61 +1,28 @@ import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { clearPluginManifestRegistryCache } from "../../plugins/manifest-registry.js"; +import { + createBundleMcpTempHarness, + createBundleProbePlugin, +} from "../../plugins/bundle-mcp.test-support.js"; import { captureEnv } from "../../test-utils/env.js"; import { prepareCliBundleMcpConfig } from "./bundle-mcp.js"; -const tempDirs: string[] = []; - -async function createTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} +const tempHarness = createBundleMcpTempHarness(); afterEach(async () => { - clearPluginManifestRegistryCache(); - await Promise.all( - tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); + await tempHarness.cleanup(); }); describe("prepareCliBundleMcpConfig", () => { it("injects a merged --mcp-config overlay for claude-cli", async () => { const env = captureEnv(["HOME"]); try { - const homeDir = await createTempDir("openclaw-cli-bundle-mcp-home-"); - const workspaceDir = await createTempDir("openclaw-cli-bundle-mcp-workspace-"); + const homeDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-home-"); + const workspaceDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-workspace-"); process.env.HOME = homeDir; - const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "bundle-probe"); - const serverPath = path.join(pluginRoot, "servers", "probe.mjs"); - await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); - await fs.mkdir(path.dirname(serverPath), { recursive: true }); - await fs.writeFile(serverPath, "export {};\n", "utf-8"); - await fs.writeFile( - path.join(pluginRoot, ".claude-plugin", "plugin.json"), - `${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`, - "utf-8", - ); - await fs.writeFile( - path.join(pluginRoot, ".mcp.json"), - `${JSON.stringify( - { - mcpServers: { - bundleProbe: { - command: "node", - args: ["./servers/probe.mjs"], - }, - }, - }, - null, - 2, - )}\n`, - "utf-8", - ); + const { serverPath } = await createBundleProbePlugin(homeDir); const config: OpenClawConfig = { plugins: { diff --git a/src/agents/command-poll-backoff.runtime.ts b/src/agents/command-poll-backoff.runtime.ts index 1667abba083..87494f4013f 100644 --- a/src/agents/command-poll-backoff.runtime.ts +++ b/src/agents/command-poll-backoff.runtime.ts @@ -1 +1,9 @@ -export { pruneStaleCommandPolls } from "./command-poll-backoff.js"; +import { pruneStaleCommandPolls as pruneStaleCommandPollsImpl } from "./command-poll-backoff.js"; + +type PruneStaleCommandPolls = typeof import("./command-poll-backoff.js").pruneStaleCommandPolls; + +export function pruneStaleCommandPolls( + ...args: Parameters +): ReturnType { + return pruneStaleCommandPollsImpl(...args); +} 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/model-catalog.test.ts b/src/agents/model-catalog.test.ts index cf7d6e444f2..8d56da2389a 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -181,6 +181,22 @@ describe("loadModelCatalog", () => { contextWindow: 1_050_000, input: ["text", "image"], }, + { + id: "gpt-5-mini", + provider: "openai", + name: "GPT-5 mini", + reasoning: true, + contextWindow: 400_000, + input: ["text", "image"], + }, + { + id: "gpt-5-nano", + provider: "openai", + name: "GPT-5 nano", + reasoning: true, + contextWindow: 400_000, + input: ["text", "image"], + }, { id: "gpt-5.3-codex", provider: "openai-codex", @@ -207,6 +223,20 @@ describe("loadModelCatalog", () => { name: "gpt-5.4-pro", }), ); + expect(result).toContainEqual( + expect.objectContaining({ + provider: "openai", + id: "gpt-5.4-mini", + name: "gpt-5.4-mini", + }), + ); + expect(result).toContainEqual( + expect.objectContaining({ + provider: "openai", + id: "gpt-5.4-nano", + name: "gpt-5.4-nano", + }), + ); expect(result).toContainEqual( expect.objectContaining({ provider: "openai-codex", diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 4c35f87dd62..e576bc621b3 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -347,7 +347,8 @@ describe("isModernModelRef", () => { it("includes plugin-advertised modern models", () => { providerRuntimeMocks.resolveProviderModernModelRef.mockImplementation(({ provider, context }) => - provider === "openai" && ["gpt-5.4", "gpt-5.4-pro"].includes(context.modelId) + provider === "openai" && + ["gpt-5.4", "gpt-5.4-pro", "gpt-5.4-mini", "gpt-5.4-nano"].includes(context.modelId) ? true : provider === "openai-codex" && context.modelId === "gpt-5.4" ? true @@ -360,6 +361,8 @@ describe("isModernModelRef", () => { expect(isModernModelRef({ provider: "openai", id: "gpt-5.4" })).toBe(true); expect(isModernModelRef({ provider: "openai", id: "gpt-5.4-pro" })).toBe(true); + expect(isModernModelRef({ provider: "openai", id: "gpt-5.4-mini" })).toBe(true); + expect(isModernModelRef({ provider: "openai", id: "gpt-5.4-nano" })).toBe(true); expect(isModernModelRef({ provider: "openai-codex", id: "gpt-5.4" })).toBe(true); expect(isModernModelRef({ provider: "opencode", id: "claude-opus-4-6" })).toBe(true); expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true); diff --git a/src/agents/model-suppression.runtime.ts b/src/agents/model-suppression.runtime.ts index 472a662b810..7d39bf2b8a3 100644 --- a/src/agents/model-suppression.runtime.ts +++ b/src/agents/model-suppression.runtime.ts @@ -1 +1,10 @@ -export { shouldSuppressBuiltInModel } from "./model-suppression.js"; +import { shouldSuppressBuiltInModel as shouldSuppressBuiltInModelImpl } from "./model-suppression.js"; + +type ShouldSuppressBuiltInModel = + typeof import("./model-suppression.js").shouldSuppressBuiltInModel; + +export function shouldSuppressBuiltInModel( + ...args: Parameters +): ReturnType { + return shouldSuppressBuiltInModelImpl(...args); +} 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 new file mode 100644 index 00000000000..9ad49f66371 --- /dev/null +++ b/src/agents/openclaw-tools.image-generation.test.ts @@ -0,0 +1,80 @@ +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", () => ({ + resolvePluginTools: () => [], +})); + +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({ + agents: { + defaults: { + imageGenerationModel: { + primary: "openai/gpt-image-1", + }, + }, + }, + }), + agentDir: "/tmp/openclaw-agent-main", + }); + + expect(tools.map((tool) => tool.name)).toContain("image_generate"); + }); + + 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", + }); + + expect(tools.map((tool) => tool.name)).not.toContain("image_generate"); + }); +}); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 32bd92f4207..de5e91fdf0c 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -12,6 +12,7 @@ import { createCanvasTool } from "./tools/canvas-tool.js"; import type { AnyAgentTool } from "./tools/common.js"; import { createCronTool } from "./tools/cron-tool.js"; import { createGatewayTool } from "./tools/gateway-tool.js"; +import { createImageGenerateTool } from "./tools/image-generate-tool.js"; import { createImageTool } from "./tools/image-tool.js"; import { createMessageTool } from "./tools/message-tool.js"; import { createNodesTool } from "./tools/nodes-tool.js"; @@ -103,6 +104,13 @@ export function createOpenClawTools( modelHasVision: options?.modelHasVision, }) : null; + const imageGenerateTool = createImageGenerateTool({ + config: options?.config, + agentDir: options?.agentDir, + workspaceDir, + sandbox, + fsPolicy: options?.fsPolicy, + }); const pdfTool = options?.agentDir?.trim() ? createPdfTool({ config: options?.config, @@ -127,6 +135,7 @@ export function createOpenClawTools( : createMessageTool({ agentAccountId: options?.agentAccountId, agentSessionKey: options?.agentSessionKey, + sessionId: options?.sessionId, config: options?.config, currentChannelId: options?.currentChannelId, currentChannelProvider: options?.agentChannel, @@ -163,6 +172,7 @@ export function createOpenClawTools( agentChannel: options?.agentChannel, config: options?.config, }), + ...(imageGenerateTool ? [imageGenerateTool] : []), createGatewayTool({ agentSessionKey: options?.agentSessionKey, config: options?.config, diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts new file mode 100644 index 00000000000..e065b0105b3 --- /dev/null +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -0,0 +1,417 @@ +import { vi, type Mock } from "vitest"; + +type MockResolvedModel = { + model: { provider: string; api: string; id: string; input: unknown[] }; + error: null; + authStorage: { setRuntimeApiKey: Mock<(provider?: string, apiKey?: string) => void> }; + modelRegistry: Record; +}; +type MockMemorySearchManager = { + manager: { + sync: (params?: unknown) => Promise; + }; +}; + +export const contextEngineCompactMock = vi.fn(async () => ({ + ok: true as boolean, + compacted: true as boolean, + reason: undefined as string | undefined, + result: { summary: "engine-summary", tokensAfter: 50 } as + | { summary: string; tokensAfter: number } + | undefined, +})); + +export const hookRunner = { + hasHooks: vi.fn<(hookName?: string) => boolean>(), + runBeforeCompaction: vi.fn(async () => undefined), + runAfterCompaction: vi.fn(async () => undefined), +}; + +export const ensureRuntimePluginsLoaded: Mock<(params?: unknown) => void> = vi.fn(); +export const resolveContextEngineMock = vi.fn(async () => ({ + info: { ownsCompaction: true as boolean }, + compact: contextEngineCompactMock, +})); +export const resolveModelMock: Mock< + (provider?: string, modelId?: string, agentDir?: string, cfg?: unknown) => MockResolvedModel +> = vi.fn((_provider?: string, _modelId?: string, _agentDir?: string, _cfg?: unknown) => ({ + model: { provider: "openai", api: "responses", id: "fake", input: [] }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, +})); +export const sessionCompactImpl = vi.fn(async () => ({ + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + details: { ok: true }, +})); +export const triggerInternalHook: Mock<(event?: unknown) => void> = vi.fn(); +export const sanitizeSessionHistoryMock = vi.fn( + async (params: { messages: unknown[] }) => params.messages, +); +export const getMemorySearchManagerMock: Mock< + (params?: unknown) => Promise +> = vi.fn(async () => ({ + manager: { + sync: vi.fn(async (_params?: unknown) => {}), + }, +})); +export const resolveMemorySearchConfigMock = vi.fn(() => ({ + sources: ["sessions"], + sync: { + sessions: { + postCompactionForce: true, + }, + }, +})); +export const resolveSessionAgentIdMock = vi.fn(() => "main"); +export const estimateTokensMock = vi.fn((_message?: unknown) => 10); +export const sessionAbortCompactionMock: Mock<(reason?: unknown) => void> = vi.fn(); +export const createOpenClawCodingToolsMock = vi.fn(() => []); + +export function resetCompactHooksHarnessMocks(): void { + hookRunner.hasHooks.mockReset(); + hookRunner.hasHooks.mockReturnValue(false); + hookRunner.runBeforeCompaction.mockReset(); + hookRunner.runBeforeCompaction.mockResolvedValue(undefined); + hookRunner.runAfterCompaction.mockReset(); + hookRunner.runAfterCompaction.mockResolvedValue(undefined); + + ensureRuntimePluginsLoaded.mockReset(); + + resolveContextEngineMock.mockReset(); + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: true }, + compact: contextEngineCompactMock, + }); + contextEngineCompactMock.mockReset(); + contextEngineCompactMock.mockResolvedValue({ + ok: true, + compacted: true, + reason: undefined, + result: { summary: "engine-summary", tokensAfter: 50 }, + }); + + resolveModelMock.mockReset(); + resolveModelMock.mockReturnValue({ + model: { provider: "openai", api: "responses", id: "fake", input: [] }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + }); + + sessionCompactImpl.mockReset(); + sessionCompactImpl.mockResolvedValue({ + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + details: { ok: true }, + }); + + triggerInternalHook.mockReset(); + sanitizeSessionHistoryMock.mockReset(); + sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => { + return params.messages; + }); + + getMemorySearchManagerMock.mockReset(); + getMemorySearchManagerMock.mockResolvedValue({ + manager: { + sync: vi.fn(async () => {}), + }, + }); + resolveMemorySearchConfigMock.mockReset(); + resolveMemorySearchConfigMock.mockReturnValue({ + sources: ["sessions"], + sync: { + sessions: { + postCompactionForce: true, + }, + }, + }); + resolveSessionAgentIdMock.mockReset(); + resolveSessionAgentIdMock.mockReturnValue("main"); + estimateTokensMock.mockReset(); + estimateTokensMock.mockReturnValue(10); + sessionAbortCompactionMock.mockReset(); + createOpenClawCodingToolsMock.mockReset(); + createOpenClawCodingToolsMock.mockReturnValue([]); +} + +export async function loadCompactHooksHarness(): Promise<{ + compactEmbeddedPiSessionDirect: typeof import("./compact.js").compactEmbeddedPiSessionDirect; + compactEmbeddedPiSession: typeof import("./compact.js").compactEmbeddedPiSession; + onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate; +}> { + resetCompactHooksHarnessMocks(); + vi.resetModules(); + + vi.doMock("../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => hookRunner, + })); + + vi.doMock("../runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded, + })); + + vi.doMock("../../hooks/internal-hooks.js", async () => { + const actual = await vi.importActual( + "../../hooks/internal-hooks.js", + ); + return { + ...actual, + triggerInternalHook, + }; + }); + + vi.doMock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: vi.fn(), + getOAuthProviders: vi.fn(() => []), + })); + + vi.doMock("@mariozechner/pi-coding-agent", () => ({ + AuthStorage: class AuthStorage {}, + ModelRegistry: class ModelRegistry {}, + createAgentSession: vi.fn(async () => { + const session = { + sessionId: "session-1", + messages: [ + { role: "user", content: "hello", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 }, + { + role: "toolResult", + toolCallId: "t1", + toolName: "exec", + content: [{ type: "text", text: "output" }], + isError: false, + timestamp: 3, + }, + ], + agent: { + replaceMessages: vi.fn((messages: unknown[]) => { + session.messages = [...(messages as typeof session.messages)]; + }), + streamFn: vi.fn(), + }, + compact: vi.fn(async () => { + session.messages.splice(1); + return await sessionCompactImpl(); + }), + abortCompaction: sessionAbortCompactionMock, + dispose: vi.fn(), + }; + return { session }; + }), + DefaultResourceLoader: class DefaultResourceLoader {}, + SessionManager: { + open: vi.fn(() => ({})), + }, + SettingsManager: { + create: vi.fn(() => ({})), + }, + estimateTokens: estimateTokensMock, + })); + + vi.doMock("../session-tool-result-guard-wrapper.js", () => ({ + guardSessionManager: vi.fn(() => ({ + flushPendingToolResults: vi.fn(), + })), + })); + + vi.doMock("../pi-settings.js", () => ({ + ensurePiCompactionReserveTokens: vi.fn(), + resolveCompactionReserveTokensFloor: vi.fn(() => 0), + })); + + vi.doMock("../models-config.js", () => ({ + ensureOpenClawModelsJson: vi.fn(async () => {}), + })); + + vi.doMock("../model-auth.js", () => ({ + applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), + getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })), + resolveModelAuthMode: vi.fn(() => "env"), + })); + + vi.doMock("../sandbox.js", () => ({ + resolveSandboxContext: vi.fn(async () => null), + })); + + vi.doMock("../session-file-repair.js", () => ({ + repairSessionFileIfNeeded: vi.fn(async () => {}), + })); + + vi.doMock("../session-write-lock.js", () => ({ + acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })), + resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0), + })); + + vi.doMock("../../context-engine/index.js", () => ({ + ensureContextEnginesInitialized: vi.fn(), + resolveContextEngine: resolveContextEngineMock, + })); + + vi.doMock("../../process/command-queue.js", () => ({ + enqueueCommandInLane: vi.fn((_lane: unknown, task: () => unknown) => task()), + })); + + vi.doMock("./lanes.js", () => ({ + resolveSessionLane: vi.fn(() => "test-session-lane"), + resolveGlobalLane: vi.fn(() => "test-global-lane"), + })); + + vi.doMock("../context-window-guard.js", () => ({ + resolveContextWindowInfo: vi.fn(() => ({ tokens: 128_000 })), + })); + + vi.doMock("../bootstrap-files.js", () => ({ + makeBootstrapWarn: vi.fn(() => () => {}), + resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })), + })); + + vi.doMock("../docs-path.js", () => ({ + resolveOpenClawDocsPath: vi.fn(async () => undefined), + })); + + vi.doMock("../channel-tools.js", () => ({ + listChannelSupportedActions: vi.fn(() => undefined), + resolveChannelMessageToolHints: vi.fn(() => undefined), + })); + + vi.doMock("../pi-tools.js", () => ({ + createOpenClawCodingTools: createOpenClawCodingToolsMock, + })); + + vi.doMock("./google.js", () => ({ + logToolSchemasForGoogle: vi.fn(), + sanitizeSessionHistory: sanitizeSessionHistoryMock, + sanitizeToolsForGoogle: vi.fn(({ tools }: { tools: unknown[] }) => tools), + })); + + vi.doMock("./tool-split.js", () => ({ + splitSdkTools: vi.fn(() => ({ builtInTools: [], customTools: [] })), + })); + + vi.doMock("../transcript-policy.js", () => ({ + resolveTranscriptPolicy: vi.fn(() => ({ + allowSyntheticToolResults: false, + validateGeminiTurns: false, + validateAnthropicTurns: false, + })), + })); + + vi.doMock("./extensions.js", () => ({ + buildEmbeddedExtensionFactories: vi.fn(() => ({ factories: [] })), + })); + + vi.doMock("./history.js", () => ({ + getDmHistoryLimitFromSessionKey: vi.fn(() => undefined), + limitHistoryTurns: vi.fn((msgs: unknown[]) => msgs.slice(0, 2)), + })); + + vi.doMock("../skills.js", () => ({ + applySkillEnvOverrides: vi.fn(() => () => {}), + applySkillEnvOverridesFromSnapshot: vi.fn(() => () => {}), + loadWorkspaceSkillEntries: vi.fn(() => []), + resolveSkillsPromptForRun: vi.fn(() => undefined), + })); + + vi.doMock("../agent-paths.js", () => ({ + resolveOpenClawAgentDir: vi.fn(() => "/tmp"), + })); + + vi.doMock("../agent-scope.js", () => ({ + resolveSessionAgentId: resolveSessionAgentIdMock, + resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })), + })); + + vi.doMock("../memory-search.js", () => ({ + resolveMemorySearchConfig: resolveMemorySearchConfigMock, + })); + + vi.doMock("../../memory/index.js", () => ({ + getMemorySearchManager: getMemorySearchManagerMock, + })); + + vi.doMock("../date-time.js", () => ({ + formatUserTime: vi.fn(() => ""), + resolveUserTimeFormat: vi.fn(() => ""), + resolveUserTimezone: vi.fn(() => ""), + })); + + vi.doMock("../defaults.js", () => ({ + DEFAULT_MODEL: "fake-model", + DEFAULT_PROVIDER: "openai", + DEFAULT_CONTEXT_TOKENS: 128_000, + })); + + vi.doMock("../utils.js", () => ({ + resolveUserPath: vi.fn((p: string) => p), + })); + + vi.doMock("../../infra/machine-name.js", () => ({ + getMachineDisplayName: vi.fn(async () => "machine"), + })); + + vi.doMock("../../config/channel-capabilities.js", () => ({ + resolveChannelCapabilities: vi.fn(() => undefined), + })); + + vi.doMock("../../utils/message-channel.js", () => ({ + INTERNAL_MESSAGE_CHANNEL: "webchat", + normalizeMessageChannel: vi.fn(() => undefined), + })); + + vi.doMock("../pi-embedded-helpers.js", () => ({ + ensureSessionHeader: vi.fn(async () => {}), + validateAnthropicTurns: vi.fn((m: unknown[]) => m), + validateGeminiTurns: vi.fn((m: unknown[]) => m), + })); + + vi.doMock("../pi-project-settings.js", () => ({ + createPreparedEmbeddedPiSettingsManager: vi.fn(() => ({ + getGlobalSettings: vi.fn(() => ({})), + })), + })); + + vi.doMock("./sandbox-info.js", () => ({ + buildEmbeddedSandboxInfo: vi.fn(() => undefined), + })); + + vi.doMock("./model.js", () => ({ + buildModelAliasLines: vi.fn(() => []), + resolveModel: resolveModelMock, + resolveModelAsync: vi.fn( + async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => + resolveModelMock(provider, modelId, agentDir, cfg), + ), + })); + + vi.doMock("./session-manager-cache.js", () => ({ + prewarmSessionFile: vi.fn(async () => {}), + trackSessionManagerAccess: vi.fn(), + })); + + vi.doMock("./system-prompt.js", () => ({ + applySystemPromptOverrideToSession: vi.fn(), + buildEmbeddedSystemPrompt: vi.fn(() => ""), + createSystemPromptOverride: vi.fn(() => () => ""), + })); + + vi.doMock("./utils.js", () => ({ + describeUnknownError: vi.fn((err: unknown) => String(err)), + mapThinkingLevel: vi.fn(() => "off"), + resolveExecToolDefaults: vi.fn(() => undefined), + })); + + const [compactModule, transcriptEvents] = await Promise.all([ + import("./compact.js"), + import("../../sessions/transcript-events.js"), + ]); + + return { + ...compactModule, + onSessionTranscriptUpdate: transcriptEvents.onSessionTranscriptUpdate, + }; +} diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 54ad50539e3..1a97501959e 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -1,348 +1,57 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; - -const { - hookRunner, +import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { getCustomApiRegistrySourceId } from "../custom-api-registry.js"; +import { + contextEngineCompactMock, + createOpenClawCodingToolsMock, ensureRuntimePluginsLoaded, + estimateTokensMock, + getMemorySearchManagerMock, + hookRunner, + loadCompactHooksHarness, resolveContextEngineMock, + resolveMemorySearchConfigMock, resolveModelMock, + resolveSessionAgentIdMock, + resetCompactHooksHarnessMocks, + sanitizeSessionHistoryMock, + sessionAbortCompactionMock, sessionCompactImpl, triggerInternalHook, - sanitizeSessionHistoryMock, - contextEngineCompactMock, - getMemorySearchManagerMock, - resolveMemorySearchConfigMock, - resolveSessionAgentIdMock, - estimateTokensMock, - sessionAbortCompactionMock, - createOpenClawCodingToolsMock, -} = vi.hoisted(() => { - const contextEngineCompactMock = vi.fn(async () => ({ - ok: true as boolean, - compacted: true as boolean, - reason: undefined as string | undefined, - result: { summary: "engine-summary", tokensAfter: 50 } as - | { summary: string; tokensAfter: number } - | undefined, - })); +} from "./compact.hooks.harness.js"; - return { - hookRunner: { - hasHooks: vi.fn(), - runBeforeCompaction: vi.fn(), - runAfterCompaction: vi.fn(), - }, - ensureRuntimePluginsLoaded: vi.fn(), - resolveContextEngineMock: vi.fn(async () => ({ - info: { ownsCompaction: true }, - compact: contextEngineCompactMock, - })), - resolveModelMock: vi.fn( - (_provider?: string, _modelId?: string, _agentDir?: string, _cfg?: unknown) => ({ - model: { provider: "openai", api: "responses", id: "fake", input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - }), - ), - sessionCompactImpl: vi.fn(async () => ({ - summary: "summary", - firstKeptEntryId: "entry-1", - tokensBefore: 120, - details: { ok: true }, - })), - triggerInternalHook: vi.fn(), - sanitizeSessionHistoryMock: vi.fn(async (params: { messages: unknown[] }) => params.messages), - contextEngineCompactMock, - getMemorySearchManagerMock: vi.fn(async () => ({ - manager: { - sync: vi.fn(async () => {}), - }, - })), - resolveMemorySearchConfigMock: vi.fn(() => ({ - sources: ["sessions"], - sync: { - sessions: { - postCompactionForce: true, - }, - }, - })), - resolveSessionAgentIdMock: vi.fn(() => "main"), - estimateTokensMock: vi.fn((_message?: unknown) => 10), - sessionAbortCompactionMock: vi.fn(), - createOpenClawCodingToolsMock: vi.fn(() => []), - }; -}); - -vi.mock("../../plugins/hook-runner-global.js", () => ({ - getGlobalHookRunner: () => hookRunner, -})); - -vi.mock("../runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded, -})); - -vi.mock("../../hooks/internal-hooks.js", async () => { - const actual = await vi.importActual( - "../../hooks/internal-hooks.js", - ); - return { - ...actual, - triggerInternalHook, - }; -}); - -vi.mock("@mariozechner/pi-ai/oauth", () => ({ - getOAuthApiKey: vi.fn(), - getOAuthProviders: vi.fn(() => []), -})); - -vi.mock("@mariozechner/pi-coding-agent", () => { - return { - AuthStorage: class AuthStorage {}, - ModelRegistry: class ModelRegistry {}, - createAgentSession: vi.fn(async () => { - const session = { - sessionId: "session-1", - messages: [ - { role: "user", content: "hello", timestamp: 1 }, - { role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 }, - { - role: "toolResult", - toolCallId: "t1", - toolName: "exec", - content: [{ type: "text", text: "output" }], - isError: false, - timestamp: 3, - }, - ], - agent: { - replaceMessages: vi.fn((messages: unknown[]) => { - session.messages = [...(messages as typeof session.messages)]; - }), - streamFn: vi.fn(), - }, - compact: vi.fn(async () => { - // simulate compaction trimming to a single message - session.messages.splice(1); - return await sessionCompactImpl(); - }), - abortCompaction: sessionAbortCompactionMock, - dispose: vi.fn(), - }; - return { session }; - }), - SessionManager: { - open: vi.fn(() => ({})), - }, - SettingsManager: { - create: vi.fn(() => ({})), - }, - estimateTokens: estimateTokensMock, - }; -}); - -vi.mock("../session-tool-result-guard-wrapper.js", () => ({ - guardSessionManager: vi.fn(() => ({ - flushPendingToolResults: vi.fn(), - })), -})); - -vi.mock("../pi-settings.js", () => ({ - ensurePiCompactionReserveTokens: vi.fn(), - resolveCompactionReserveTokensFloor: vi.fn(() => 0), -})); - -vi.mock("../models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn(async () => {}), -})); - -vi.mock("../model-auth.js", () => ({ - applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), - getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })), - resolveModelAuthMode: vi.fn(() => "env"), -})); - -vi.mock("../sandbox.js", () => ({ - resolveSandboxContext: vi.fn(async () => null), -})); - -vi.mock("../session-file-repair.js", () => ({ - repairSessionFileIfNeeded: vi.fn(async () => {}), -})); - -vi.mock("../session-write-lock.js", () => ({ - acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })), - resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0), -})); - -vi.mock("../../context-engine/index.js", () => ({ - ensureContextEnginesInitialized: vi.fn(), - resolveContextEngine: resolveContextEngineMock, -})); - -vi.mock("../../process/command-queue.js", () => ({ - enqueueCommandInLane: vi.fn((_lane: unknown, task: () => unknown) => task()), -})); - -vi.mock("./lanes.js", () => ({ - resolveSessionLane: vi.fn(() => "test-session-lane"), - resolveGlobalLane: vi.fn(() => "test-global-lane"), -})); - -vi.mock("../context-window-guard.js", () => ({ - resolveContextWindowInfo: vi.fn(() => ({ tokens: 128_000 })), -})); - -vi.mock("../bootstrap-files.js", () => ({ - makeBootstrapWarn: vi.fn(() => () => {}), - resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })), -})); - -vi.mock("../docs-path.js", () => ({ - resolveOpenClawDocsPath: vi.fn(async () => undefined), -})); - -vi.mock("../channel-tools.js", () => ({ - listChannelSupportedActions: vi.fn(() => undefined), - resolveChannelMessageToolHints: vi.fn(() => undefined), -})); - -vi.mock("../pi-tools.js", () => ({ - createOpenClawCodingTools: createOpenClawCodingToolsMock, -})); - -vi.mock("./google.js", () => ({ - logToolSchemasForGoogle: vi.fn(), - sanitizeSessionHistory: sanitizeSessionHistoryMock, - sanitizeToolsForGoogle: vi.fn(({ tools }: { tools: unknown[] }) => tools), -})); - -vi.mock("./tool-split.js", () => ({ - splitSdkTools: vi.fn(() => ({ builtInTools: [], customTools: [] })), -})); - -vi.mock("../transcript-policy.js", () => ({ - resolveTranscriptPolicy: vi.fn(() => ({ - allowSyntheticToolResults: false, - validateGeminiTurns: false, - validateAnthropicTurns: false, - })), -})); - -vi.mock("./extensions.js", () => ({ - buildEmbeddedExtensionFactories: vi.fn(() => ({ factories: [] })), -})); - -vi.mock("./history.js", () => ({ - getDmHistoryLimitFromSessionKey: vi.fn(() => undefined), - limitHistoryTurns: vi.fn((msgs: unknown[]) => msgs.slice(0, 2)), -})); - -vi.mock("../skills.js", () => ({ - applySkillEnvOverrides: vi.fn(() => () => {}), - applySkillEnvOverridesFromSnapshot: vi.fn(() => () => {}), - loadWorkspaceSkillEntries: vi.fn(() => []), - resolveSkillsPromptForRun: vi.fn(() => undefined), -})); - -vi.mock("../agent-paths.js", () => ({ - resolveOpenClawAgentDir: vi.fn(() => "/tmp"), -})); - -vi.mock("../agent-scope.js", () => ({ - resolveSessionAgentId: resolveSessionAgentIdMock, - resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })), -})); - -vi.mock("../memory-search.js", () => ({ - resolveMemorySearchConfig: resolveMemorySearchConfigMock, -})); - -vi.mock("../../memory/index.js", () => ({ - getMemorySearchManager: getMemorySearchManagerMock, -})); - -vi.mock("../date-time.js", () => ({ - formatUserTime: vi.fn(() => ""), - resolveUserTimeFormat: vi.fn(() => ""), - resolveUserTimezone: vi.fn(() => ""), -})); - -vi.mock("../defaults.js", () => ({ - DEFAULT_MODEL: "fake-model", - DEFAULT_PROVIDER: "openai", - DEFAULT_CONTEXT_TOKENS: 128_000, -})); - -vi.mock("../utils.js", () => ({ - resolveUserPath: vi.fn((p: string) => p), -})); - -vi.mock("../../infra/machine-name.js", () => ({ - getMachineDisplayName: vi.fn(async () => "machine"), -})); - -vi.mock("../../config/channel-capabilities.js", () => ({ - resolveChannelCapabilities: vi.fn(() => undefined), -})); - -vi.mock("../../utils/message-channel.js", () => ({ - INTERNAL_MESSAGE_CHANNEL: "webchat", - normalizeMessageChannel: vi.fn(() => undefined), -})); - -vi.mock("../pi-embedded-helpers.js", () => ({ - ensureSessionHeader: vi.fn(async () => {}), - validateAnthropicTurns: vi.fn((m: unknown[]) => m), - validateGeminiTurns: vi.fn((m: unknown[]) => m), -})); - -vi.mock("../pi-project-settings.js", () => ({ - createPreparedEmbeddedPiSettingsManager: vi.fn(() => ({ - getGlobalSettings: vi.fn(() => ({})), - })), -})); - -vi.mock("./sandbox-info.js", () => ({ - buildEmbeddedSandboxInfo: vi.fn(() => undefined), -})); - -vi.mock("./model.js", () => ({ - buildModelAliasLines: vi.fn(() => []), - resolveModel: resolveModelMock, - resolveModelAsync: vi.fn( - async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => - resolveModelMock(provider, modelId, agentDir, cfg), - ), -})); - -vi.mock("./session-manager-cache.js", () => ({ - prewarmSessionFile: vi.fn(async () => {}), - trackSessionManagerAccess: vi.fn(), -})); - -vi.mock("./system-prompt.js", () => ({ - applySystemPromptOverrideToSession: vi.fn(), - buildEmbeddedSystemPrompt: vi.fn(() => ""), - createSystemPromptOverride: vi.fn(() => () => ""), -})); - -vi.mock("./utils.js", () => ({ - describeUnknownError: vi.fn((err: unknown) => String(err)), - mapThinkingLevel: vi.fn(() => "off"), - resolveExecToolDefaults: vi.fn(() => undefined), -})); - -import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai"; -import { getCustomApiRegistrySourceId } from "../custom-api-registry.js"; -import { compactEmbeddedPiSessionDirect, compactEmbeddedPiSession } from "./compact.js"; +let compactEmbeddedPiSessionDirect: typeof import("./compact.js").compactEmbeddedPiSessionDirect; +let compactEmbeddedPiSession: typeof import("./compact.js").compactEmbeddedPiSession; +let onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate; const TEST_SESSION_ID = "session-1"; const TEST_SESSION_KEY = "agent:main:session-1"; const TEST_SESSION_FILE = "/tmp/session.jsonl"; const TEST_WORKSPACE_DIR = "/tmp"; const TEST_CUSTOM_INSTRUCTIONS = "focus on decisions"; +type SessionHookEvent = { + type?: string; + action?: string; + sessionKey?: string; + context?: Record; +}; +type PostCompactionSyncParams = { + reason: string; + sessionFiles: string[]; +}; +type PostCompactionSync = (params?: unknown) => Promise; +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} function mockResolvedModel() { resolveModelMock.mockReset(); @@ -389,10 +98,22 @@ function wrappedCompactionArgs(overrides: Record = {}) { }; } -const sessionHook = (action: string) => - triggerInternalHook.mock.calls.find( - (call) => call[0]?.type === "session" && call[0]?.action === action, - )?.[0]; +const sessionHook = (action: string): SessionHookEvent | undefined => + triggerInternalHook.mock.calls.find((call) => { + const event = call[0] as SessionHookEvent | undefined; + return event?.type === "session" && event.action === action; + })?.[0] as SessionHookEvent | undefined; + +beforeAll(async () => { + const loaded = await loadCompactHooksHarness(); + compactEmbeddedPiSessionDirect = loaded.compactEmbeddedPiSessionDirect; + compactEmbeddedPiSession = loaded.compactEmbeddedPiSession; + onSessionTranscriptUpdate = loaded.onSessionTranscriptUpdate; +}); + +beforeEach(() => { + resetCompactHooksHarnessMocks(); +}); describe("compactEmbeddedPiSessionDirect hooks", () => { beforeEach(() => { @@ -529,6 +250,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { messageCount: 1, tokenCount: 10, compactedCount: 1, + sessionFile: "/tmp/session.jsonl", }, expect.objectContaining({ sessionKey: "agent:main:session-1", messageProvider: "telegram" }), ); @@ -688,11 +410,12 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); it("awaits post-compaction memory sync in await mode when postCompactionForce is true", async () => { - let releaseSync: (() => void) | undefined; - const syncGate = new Promise((resolve) => { - releaseSync = resolve; + const syncStarted = createDeferred(); + const syncRelease = createDeferred(); + const sync = vi.fn(async (params) => { + syncStarted.resolve(params as PostCompactionSyncParams); + await syncRelease.promise; }); - const sync = vi.fn(() => syncGate); getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); let settled = false; @@ -705,14 +428,12 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { void resultPromise.then(() => { settled = true; }); - await vi.waitFor(() => { - expect(sync).toHaveBeenCalledWith({ - reason: "post-compaction", - sessionFiles: [TEST_SESSION_FILE], - }); + await expect(syncStarted.promise).resolves.toEqual({ + reason: "post-compaction", + sessionFiles: [TEST_SESSION_FILE], }); expect(settled).toBe(false); - releaseSync?.(); + syncRelease.resolve(undefined); const result = await resultPromise; expect(result.ok).toBe(true); expect(settled).toBe(true); @@ -735,12 +456,17 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); it("fires post-compaction memory sync without awaiting it in async mode", async () => { - const sync = vi.fn(async () => {}); - let resolveManager: ((value: { manager: { sync: typeof sync } }) => void) | undefined; - const managerGate = new Promise<{ manager: { sync: typeof sync } }>((resolve) => { - resolveManager = resolve; + const sync = vi.fn(async () => {}); + const managerRequested = createDeferred(); + const managerGate = createDeferred<{ manager: { sync: PostCompactionSync } }>(); + const syncStarted = createDeferred(); + sync.mockImplementation(async (params) => { + syncStarted.resolve(params as PostCompactionSyncParams); + }); + getMemorySearchManagerMock.mockImplementation(async () => { + managerRequested.resolve(undefined); + return await managerGate.promise; }); - getMemorySearchManagerMock.mockImplementation(() => managerGate); let settled = false; const resultPromise = compactEmbeddedPiSessionDirect( @@ -749,26 +475,19 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }), ); - await vi.waitFor(() => { - expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1); - }); + await managerRequested.promise; void resultPromise.then(() => { settled = true; }); - await vi.waitFor(() => { - expect(settled).toBe(true); - }); + await resultPromise; + expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1); + expect(settled).toBe(true); expect(sync).not.toHaveBeenCalled(); - resolveManager?.({ manager: { sync } }); - await managerGate; - await vi.waitFor(() => { - expect(sync).toHaveBeenCalledWith({ - reason: "post-compaction", - sessionFiles: [TEST_SESSION_FILE], - }); + managerGate.resolve({ manager: { sync } }); + await expect(syncStarted.promise).resolves.toEqual({ + reason: "post-compaction", + sessionFiles: [TEST_SESSION_FILE], }); - const result = await resultPromise; - expect(result.ok).toBe(true); }); it("registers the Ollama api provider before compaction", async () => { diff --git a/src/agents/pi-embedded-runner/compact.runtime.ts b/src/agents/pi-embedded-runner/compact.runtime.ts index 33c4ed7066a..f6230265bac 100644 --- a/src/agents/pi-embedded-runner/compact.runtime.ts +++ b/src/agents/pi-embedded-runner/compact.runtime.ts @@ -1 +1,9 @@ -export { compactEmbeddedPiSessionDirect } from "./compact.js"; +import { compactEmbeddedPiSessionDirect as compactEmbeddedPiSessionDirectImpl } from "./compact.js"; + +type CompactEmbeddedPiSessionDirect = typeof import("./compact.js").compactEmbeddedPiSessionDirect; + +export function compactEmbeddedPiSessionDirect( + ...args: Parameters +): ReturnType { + return compactEmbeddedPiSessionDirectImpl(...args); +} diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 98a3b438d21..8c46de5c165 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -91,6 +91,7 @@ import { import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; +import { buildEmbeddedMessageActionDiscoveryInput } from "./message-action-discovery-input.js"; import { buildModelAliasLines, resolveModelAsync } from "./model.js"; import { buildEmbeddedSandboxInfo } from "./sandbox-info.js"; import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js"; @@ -113,6 +114,11 @@ export type CompactEmbeddedPiSessionParams = { messageChannel?: string; messageProvider?: string; agentAccountId?: string; + currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + /** Trusted sender id from inbound context for scoped message-tool discovery. */ + senderId?: string; authProfileId?: string; /** Group id for channel-level tool policy resolution. */ groupId?: string | null; @@ -649,12 +655,26 @@ export async function compactEmbeddedPiSessionDirect( return undefined; })() : undefined; + const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ + sessionKey: params.sessionKey, + config: params.config, + }); // Resolve channel-specific message actions for system prompt const channelActions = runtimeChannel - ? listChannelSupportedActions({ - cfg: params.config, - channel: runtimeChannel, - }) + ? listChannelSupportedActions( + buildEmbeddedMessageActionDiscoveryInput({ + cfg: params.config, + channel: runtimeChannel, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.agentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: sessionAgentId, + senderId: params.senderId, + }), + ) : undefined; const messageToolHints = runtimeChannel ? resolveChannelMessageToolHints({ @@ -680,10 +700,6 @@ export async function compactEmbeddedPiSessionDirect( const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); - const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ - sessionKey: params.sessionKey, - config: params.config, - }); const isDefaultAgent = sessionAgentId === defaultAgentId; const promptMode = isSubagentSessionKey(params.sessionKey) || isCronSessionKey(params.sessionKey) @@ -1039,6 +1055,7 @@ export async function compactEmbeddedPiSessionDirect( messageCount: messageCountAfter, tokenCount: tokensAfter, compactedCount, + sessionFile: params.sessionFile, }, { sessionId: params.sessionId, diff --git a/src/agents/pi-embedded-runner/compaction-runtime-context.test.ts b/src/agents/pi-embedded-runner/compaction-runtime-context.test.ts new file mode 100644 index 00000000000..9c87bfc6aaf --- /dev/null +++ b/src/agents/pi-embedded-runner/compaction-runtime-context.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js"; + +describe("buildEmbeddedCompactionRuntimeContext", () => { + it("preserves sender and current message routing for compaction", () => { + expect( + buildEmbeddedCompactionRuntimeContext({ + sessionKey: "agent:main:thread:1", + messageChannel: "slack", + messageProvider: "slack", + agentAccountId: "acct-1", + currentChannelId: "C123", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + authProfileId: "openai:p1", + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + config: {} as OpenClawConfig, + senderIsOwner: true, + senderId: "user-123", + provider: "openai-codex", + modelId: "gpt-5.3-codex", + thinkLevel: "off", + reasoningLevel: "on", + extraSystemPrompt: "extra", + ownerNumbers: ["+15555550123"], + }), + ).toMatchObject({ + sessionKey: "agent:main:thread:1", + messageChannel: "slack", + messageProvider: "slack", + agentAccountId: "acct-1", + currentChannelId: "C123", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + authProfileId: "openai:p1", + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + senderId: "user-123", + provider: "openai-codex", + model: "gpt-5.3-codex", + }); + }); + + it("normalizes nullable compaction routing fields to undefined", () => { + expect( + buildEmbeddedCompactionRuntimeContext({ + sessionKey: null, + messageChannel: null, + messageProvider: null, + agentAccountId: null, + currentChannelId: null, + currentThreadTs: null, + currentMessageId: null, + authProfileId: null, + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + senderId: null, + provider: null, + modelId: null, + }), + ).toMatchObject({ + sessionKey: undefined, + messageChannel: undefined, + messageProvider: undefined, + agentAccountId: undefined, + currentChannelId: undefined, + currentThreadTs: undefined, + currentMessageId: undefined, + authProfileId: undefined, + senderId: undefined, + provider: undefined, + model: undefined, + }); + }); +}); diff --git a/src/agents/pi-embedded-runner/compaction-runtime-context.ts b/src/agents/pi-embedded-runner/compaction-runtime-context.ts new file mode 100644 index 00000000000..5f64089f63b --- /dev/null +++ b/src/agents/pi-embedded-runner/compaction-runtime-context.ts @@ -0,0 +1,76 @@ +import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { ExecElevatedDefaults } from "../bash-tools.js"; +import type { SkillSnapshot } from "../skills.js"; + +export type EmbeddedCompactionRuntimeContext = { + sessionKey?: string; + messageChannel?: string; + messageProvider?: string; + agentAccountId?: string; + currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + authProfileId?: string; + workspaceDir: string; + agentDir: string; + config?: OpenClawConfig; + skillsSnapshot?: SkillSnapshot; + senderIsOwner?: boolean; + senderId?: string; + provider?: string; + model?: string; + thinkLevel?: ThinkLevel; + reasoningLevel?: ReasoningLevel; + bashElevated?: ExecElevatedDefaults; + extraSystemPrompt?: string; + ownerNumbers?: string[]; +}; + +export function buildEmbeddedCompactionRuntimeContext(params: { + sessionKey?: string | null; + messageChannel?: string | null; + messageProvider?: string | null; + agentAccountId?: string | null; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + authProfileId?: string | null; + workspaceDir: string; + agentDir: string; + config?: OpenClawConfig; + skillsSnapshot?: SkillSnapshot; + senderIsOwner?: boolean; + senderId?: string | null; + provider?: string | null; + modelId?: string | null; + thinkLevel?: ThinkLevel; + reasoningLevel?: ReasoningLevel; + bashElevated?: ExecElevatedDefaults; + extraSystemPrompt?: string; + ownerNumbers?: string[]; +}): EmbeddedCompactionRuntimeContext { + return { + sessionKey: params.sessionKey ?? undefined, + messageChannel: params.messageChannel ?? undefined, + messageProvider: params.messageProvider ?? undefined, + agentAccountId: params.agentAccountId ?? undefined, + currentChannelId: params.currentChannelId ?? undefined, + currentThreadTs: params.currentThreadTs ?? undefined, + currentMessageId: params.currentMessageId ?? undefined, + authProfileId: params.authProfileId ?? undefined, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + config: params.config, + skillsSnapshot: params.skillsSnapshot, + senderIsOwner: params.senderIsOwner, + senderId: params.senderId ?? undefined, + provider: params.provider ?? undefined, + model: params.modelId ?? undefined, + thinkLevel: params.thinkLevel, + reasoningLevel: params.reasoningLevel, + bashElevated: params.bashElevated, + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + }; +} diff --git a/src/agents/pi-embedded-runner/extensions.test.ts b/src/agents/pi-embedded-runner/extensions.test.ts index ff95a0b2dee..e3f412cafd0 100644 --- a/src/agents/pi-embedded-runner/extensions.test.ts +++ b/src/agents/pi-embedded-runner/extensions.test.ts @@ -6,13 +6,36 @@ import { getCompactionSafeguardRuntime } from "../pi-extensions/compaction-safeg import compactionSafeguardExtension from "../pi-extensions/compaction-safeguard.js"; import { buildEmbeddedExtensionFactories } from "./extensions.js"; +function buildSafeguardFactories(cfg: OpenClawConfig) { + const sessionManager = {} as SessionManager; + const model = { + id: "claude-sonnet-4-20250514", + contextWindow: 200_000, + } as Model; + + const factories = buildEmbeddedExtensionFactories({ + cfg, + sessionManager, + provider: "anthropic", + modelId: "claude-sonnet-4-20250514", + model, + }); + + return { factories, sessionManager }; +} + +function expectSafeguardRuntime( + cfg: OpenClawConfig, + expectedRuntime: { qualityGuardEnabled: boolean; qualityGuardMaxRetries?: number }, +) { + const { factories, sessionManager } = buildSafeguardFactories(cfg); + + expect(factories).toContain(compactionSafeguardExtension); + expect(getCompactionSafeguardRuntime(sessionManager)).toMatchObject(expectedRuntime); +} + describe("buildEmbeddedExtensionFactories", () => { it("does not opt safeguard mode into quality-guard retries", () => { - const sessionManager = {} as SessionManager; - const model = { - id: "claude-sonnet-4-20250514", - contextWindow: 200_000, - } as Model; const cfg = { agents: { defaults: { @@ -22,27 +45,12 @@ describe("buildEmbeddedExtensionFactories", () => { }, }, } as OpenClawConfig; - - const factories = buildEmbeddedExtensionFactories({ - cfg, - sessionManager, - provider: "anthropic", - modelId: "claude-sonnet-4-20250514", - model, - }); - - expect(factories).toContain(compactionSafeguardExtension); - expect(getCompactionSafeguardRuntime(sessionManager)).toMatchObject({ + expectSafeguardRuntime(cfg, { qualityGuardEnabled: false, }); }); it("wires explicit safeguard quality-guard runtime flags", () => { - const sessionManager = {} as SessionManager; - const model = { - id: "claude-sonnet-4-20250514", - contextWindow: 200_000, - } as Model; const cfg = { agents: { defaults: { @@ -56,17 +64,7 @@ describe("buildEmbeddedExtensionFactories", () => { }, }, } as OpenClawConfig; - - const factories = buildEmbeddedExtensionFactories({ - cfg, - sessionManager, - provider: "anthropic", - modelId: "claude-sonnet-4-20250514", - model, - }); - - expect(factories).toContain(compactionSafeguardExtension); - expect(getCompactionSafeguardRuntime(sessionManager)).toMatchObject({ + expectSafeguardRuntime(cfg, { qualityGuardEnabled: true, qualityGuardMaxRetries: 2, }); diff --git a/src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts b/src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts index cd093a86e7c..b988a8c3c59 100644 --- a/src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts @@ -2,6 +2,25 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import { describe, expect, it, vi } from "vitest"; import { applyExtraParamsToAgent } from "../pi-embedded-runner.js"; +function applyAndExpectWrapped(params: { + cfg?: Parameters[1]; + extraParamsOverride?: Parameters[4]; + modelId: string; + provider: string; +}) { + const agent: { streamFn?: StreamFn } = {}; + + applyExtraParamsToAgent( + agent, + params.cfg, + params.provider, + params.modelId, + params.extraParamsOverride, + ); + + expect(agent.streamFn).toBeDefined(); +} + // Mock the logger to avoid noise in tests vi.mock("./logger.js", () => ({ log: { @@ -12,15 +31,10 @@ vi.mock("./logger.js", () => ({ describe("cacheRetention default behavior", () => { it("returns 'short' for Anthropic when not configured", () => { - const agent: { streamFn?: StreamFn } = {}; - const cfg = undefined; - const provider = "anthropic"; - const modelId = "claude-3-sonnet"; - - applyExtraParamsToAgent(agent, cfg, provider, modelId); - - // Verify streamFn was set (indicating cache retention was applied) - expect(agent.streamFn).toBeDefined(); + applyAndExpectWrapped({ + modelId: "claude-3-sonnet", + provider: "anthropic", + }); // The fact that agent.streamFn was modified indicates that cacheRetention // default "short" was applied. We don't need to call the actual function @@ -28,75 +42,63 @@ describe("cacheRetention default behavior", () => { }); it("respects explicit 'none' config", () => { - const agent: { streamFn?: StreamFn } = {}; - const cfg = { - agents: { - defaults: { - models: { - "anthropic/claude-3-sonnet": { - params: { - cacheRetention: "none" as const, + applyAndExpectWrapped({ + cfg: { + agents: { + defaults: { + models: { + "anthropic/claude-3-sonnet": { + params: { + cacheRetention: "none" as const, + }, }, }, }, }, }, - }; - const provider = "anthropic"; - const modelId = "claude-3-sonnet"; - - applyExtraParamsToAgent(agent, cfg, provider, modelId); - - // Verify streamFn was set (config was applied) - expect(agent.streamFn).toBeDefined(); + modelId: "claude-3-sonnet", + provider: "anthropic", + }); }); it("respects explicit 'long' config", () => { - const agent: { streamFn?: StreamFn } = {}; - const cfg = { - agents: { - defaults: { - models: { - "anthropic/claude-3-opus": { - params: { - cacheRetention: "long" as const, + applyAndExpectWrapped({ + cfg: { + agents: { + defaults: { + models: { + "anthropic/claude-3-opus": { + params: { + cacheRetention: "long" as const, + }, }, }, }, }, }, - }; - const provider = "anthropic"; - const modelId = "claude-3-opus"; - - applyExtraParamsToAgent(agent, cfg, provider, modelId); - - // Verify streamFn was set (config was applied) - expect(agent.streamFn).toBeDefined(); + modelId: "claude-3-opus", + provider: "anthropic", + }); }); it("respects legacy cacheControlTtl config", () => { - const agent: { streamFn?: StreamFn } = {}; - const cfg = { - agents: { - defaults: { - models: { - "anthropic/claude-3-haiku": { - params: { - cacheControlTtl: "1h", + applyAndExpectWrapped({ + cfg: { + agents: { + defaults: { + models: { + "anthropic/claude-3-haiku": { + params: { + cacheControlTtl: "1h", + }, }, }, }, }, }, - }; - const provider = "anthropic"; - const modelId = "claude-3-haiku"; - - applyExtraParamsToAgent(agent, cfg, provider, modelId); - - // Verify streamFn was set (legacy config was applied) - expect(agent.streamFn).toBeDefined(); + modelId: "claude-3-haiku", + provider: "anthropic", + }); }); it("returns undefined for non-Anthropic providers", () => { @@ -113,42 +115,33 @@ describe("cacheRetention default behavior", () => { }); it("prefers explicit cacheRetention over default", () => { - const agent: { streamFn?: StreamFn } = {}; - const cfg = { - agents: { - defaults: { - models: { - "anthropic/claude-3-sonnet": { - params: { - cacheRetention: "long" as const, - temperature: 0.7, + applyAndExpectWrapped({ + cfg: { + agents: { + defaults: { + models: { + "anthropic/claude-3-sonnet": { + params: { + cacheRetention: "long" as const, + temperature: 0.7, + }, }, }, }, }, }, - }; - const provider = "anthropic"; - const modelId = "claude-3-sonnet"; - - applyExtraParamsToAgent(agent, cfg, provider, modelId); - - // Verify streamFn was set with explicit config - expect(agent.streamFn).toBeDefined(); + modelId: "claude-3-sonnet", + provider: "anthropic", + }); }); it("works with extraParamsOverride", () => { - const agent: { streamFn?: StreamFn } = {}; - const cfg = undefined; - const provider = "anthropic"; - const modelId = "claude-3-sonnet"; - const extraParamsOverride = { - cacheRetention: "none" as const, - }; - - applyExtraParamsToAgent(agent, cfg, provider, modelId, extraParamsOverride); - - // Verify streamFn was set (override was applied) - expect(agent.streamFn).toBeDefined(); + applyAndExpectWrapped({ + extraParamsOverride: { + cacheRetention: "none" as const, + }, + modelId: "claude-3-sonnet", + provider: "anthropic", + }); }); }); diff --git a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts b/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts index c4e81d2d804..4ebd56c5d05 100644 --- a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts @@ -1,15 +1,8 @@ -import type { StreamFn } from "@mariozechner/pi-agent-core"; -import type { Context, Model } from "@mariozechner/pi-ai"; -import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; +import type { Model } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { captureEnv } from "../../test-utils/env.js"; -import { applyExtraParamsToAgent } from "./extra-params.js"; - -type CapturedCall = { - headers?: Record; - payload?: Record; -}; +import { runExtraParamsCase } from "./extra-params.test-support.js"; const TEST_CFG = { plugins: { @@ -26,30 +19,39 @@ function applyAndCapture(params: { modelId: string; callerHeaders?: Record; cfg?: OpenClawConfig; -}): CapturedCall { - const captured: CapturedCall = {}; - - const baseStreamFn: StreamFn = (model, _context, options) => { - captured.headers = options?.headers; - options?.onPayload?.({}, model); - return createAssistantMessageEventStream(); - }; - const agent = { streamFn: baseStreamFn }; - - applyExtraParamsToAgent(agent, params.cfg ?? TEST_CFG, params.provider, params.modelId); - - const model = { - api: "openai-completions", - provider: params.provider, - id: params.modelId, - } as Model<"openai-completions">; - const context: Context = { messages: [] }; - - void agent.streamFn?.(model, context, { - headers: params.callerHeaders, +}) { + return runExtraParamsCase({ + applyModelId: params.modelId, + applyProvider: params.provider, + callerHeaders: params.callerHeaders, + cfg: params.cfg ?? TEST_CFG, + model: { + api: "openai-completions", + provider: params.provider, + id: params.modelId, + } as Model<"openai-completions">, + payload: {}, }); +} - return captured; +function applyAndCaptureReasoning(params: { + cfg?: OpenClawConfig; + modelId: string; + initialPayload?: Record; + thinkingLevel?: "minimal" | "low" | "medium" | "high"; +}) { + return runExtraParamsCase({ + applyModelId: params.modelId, + applyProvider: "kilocode", + cfg: params.cfg ?? TEST_CFG, + model: { + api: "openai-completions", + provider: "kilocode", + id: params.modelId, + } as Model<"openai-completions">, + payload: { ...params.initialPayload }, + thinkingLevel: params.thinkingLevel ?? "high", + }).payload; } describe("extra-params: Kilocode wrapper", () => { @@ -121,27 +123,10 @@ describe("extra-params: Kilocode wrapper", () => { describe("extra-params: Kilocode kilo/auto reasoning", () => { it("does not inject reasoning.effort for kilo/auto", () => { - let capturedPayload: Record | undefined; - - const baseStreamFn: StreamFn = (model, _context, options) => { - const payload: Record = { reasoning_effort: "high" }; - options?.onPayload?.(payload, model); - capturedPayload = payload; - return createAssistantMessageEventStream(); - }; - const agent = { streamFn: baseStreamFn }; - - // Pass thinking level explicitly (6th parameter) to trigger reasoning injection - applyExtraParamsToAgent(agent, TEST_CFG, "kilocode", "kilo/auto", undefined, "high"); - - const model = { - api: "openai-completions", - provider: "kilocode", - id: "kilo/auto", - } as Model<"openai-completions">; - const context: Context = { messages: [] }; - - void agent.streamFn?.(model, context, {}); + const capturedPayload = applyAndCaptureReasoning({ + modelId: "kilo/auto", + initialPayload: { reasoning_effort: "high" }, + }) as Record; // kilo/auto should not have reasoning injected expect(capturedPayload?.reasoning).toBeUndefined(); @@ -149,95 +134,40 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { }); it("injects reasoning.effort for non-auto kilocode models", () => { - let capturedPayload: Record | undefined; - - const baseStreamFn: StreamFn = (model, _context, options) => { - const payload: Record = {}; - options?.onPayload?.(payload, model); - capturedPayload = payload; - return createAssistantMessageEventStream(); - }; - const agent = { streamFn: baseStreamFn }; - - applyExtraParamsToAgent( - agent, - TEST_CFG, - "kilocode", - "anthropic/claude-sonnet-4", - undefined, - "high", - ); - - const model = { - api: "openai-completions", - provider: "kilocode", - id: "anthropic/claude-sonnet-4", - } as Model<"openai-completions">; - const context: Context = { messages: [] }; - - void agent.streamFn?.(model, context, {}); + const capturedPayload = applyAndCaptureReasoning({ + modelId: "anthropic/claude-sonnet-4", + }) as Record; // Non-auto models should have reasoning injected expect(capturedPayload?.reasoning).toEqual({ effort: "high" }); }); it("still normalizes reasoning for Kilocode under restrictive plugins.allow", () => { - let capturedPayload: Record | undefined; - - const baseStreamFn: StreamFn = (model, _context, options) => { - const payload: Record = {}; - options?.onPayload?.(payload, model); - capturedPayload = payload; - return createAssistantMessageEventStream(); - }; - const agent = { streamFn: baseStreamFn }; - - applyExtraParamsToAgent( - agent, - { + const capturedPayload = applyAndCaptureReasoning({ + cfg: { plugins: { allow: ["openrouter"], }, }, - "kilocode", - "anthropic/claude-sonnet-4", - undefined, - "high", - ); - - const model = { - api: "openai-completions", - provider: "kilocode", - id: "anthropic/claude-sonnet-4", - } as Model<"openai-completions">; - const context: Context = { messages: [] }; - - void agent.streamFn?.(model, context, {}); + modelId: "anthropic/claude-sonnet-4", + }) as Record; expect(capturedPayload?.reasoning).toEqual({ effort: "high" }); }); it("does not inject reasoning.effort for x-ai models", () => { - let capturedPayload: Record | undefined; - - const baseStreamFn: StreamFn = (model, _context, options) => { - const payload: Record = { reasoning_effort: "high" }; - options?.onPayload?.(payload, model); - capturedPayload = payload; - return createAssistantMessageEventStream(); - }; - const agent = { streamFn: baseStreamFn }; - - applyExtraParamsToAgent(agent, TEST_CFG, "kilocode", "x-ai/grok-3", undefined, "high"); - - const model = { - api: "openai-completions", - provider: "kilocode", - id: "x-ai/grok-3", - } as Model<"openai-completions">; - const context: Context = { messages: [] }; - - void agent.streamFn?.(model, context, {}); + const capturedPayload = runExtraParamsCase({ + applyModelId: "x-ai/grok-3", + applyProvider: "kilocode", + cfg: TEST_CFG, + model: { + api: "openai-completions", + provider: "kilocode", + id: "x-ai/grok-3", + } as Model<"openai-completions">, + payload: { reasoning_effort: "high" }, + thinkingLevel: "high", + }).payload as Record; // x-ai models reject reasoning.effort — should be skipped expect(capturedPayload?.reasoning).toBeUndefined(); diff --git a/src/agents/pi-embedded-runner/extra-params.openai.test.ts b/src/agents/pi-embedded-runner/extra-params.openai.test.ts index 92e26c95ee0..f7f033f5827 100644 --- a/src/agents/pi-embedded-runner/extra-params.openai.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.openai.test.ts @@ -1,41 +1,26 @@ -import type { StreamFn } from "@mariozechner/pi-agent-core"; -import type { Context, Model } from "@mariozechner/pi-ai"; -import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; +import type { Model } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; import { captureEnv } from "../../test-utils/env.js"; -import { applyExtraParamsToAgent } from "./extra-params.js"; - -type CapturedCall = { - headers?: Record; -}; +import { runExtraParamsCase } from "./extra-params.test-support.js"; function applyAndCapture(params: { provider: string; modelId: string; baseUrl?: string; callerHeaders?: Record; -}): CapturedCall { - const captured: CapturedCall = {}; - const baseStreamFn: StreamFn = (model, _context, options) => { - captured.headers = options?.headers; - options?.onPayload?.({}, model); - return createAssistantMessageEventStream(); - }; - const agent = { streamFn: baseStreamFn }; - - applyExtraParamsToAgent(agent, undefined, params.provider, params.modelId); - - const model = { - api: "openai-responses", - provider: params.provider, - id: params.modelId, - baseUrl: params.baseUrl, - } as Model<"openai-responses">; - const context: Context = { messages: [] }; - - void agent.streamFn?.(model, context, { headers: params.callerHeaders }); - - return captured; +}) { + return runExtraParamsCase({ + applyModelId: params.modelId, + applyProvider: params.provider, + callerHeaders: params.callerHeaders, + model: { + api: "openai-responses", + provider: params.provider, + id: params.modelId, + baseUrl: params.baseUrl, + } as Model<"openai-responses">, + payload: {}, + }); } describe("extra-params: OpenAI attribution", () => { diff --git a/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts b/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts index 8a09d9af547..08010bb0b20 100644 --- a/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts @@ -1,9 +1,6 @@ -import type { StreamFn } from "@mariozechner/pi-agent-core"; -import type { Context, Model } from "@mariozechner/pi-ai"; -import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; +import type { Model } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { applyExtraParamsToAgent } from "./extra-params.js"; +import { runExtraParamsCase } from "./extra-params.test-support.js"; type StreamPayload = { messages: Array<{ @@ -13,31 +10,23 @@ type StreamPayload = { }; function runOpenRouterPayload(payload: StreamPayload, modelId: string) { - const baseStreamFn: StreamFn = (model, _context, options) => { - options?.onPayload?.(payload, model); - return createAssistantMessageEventStream(); - }; - const agent = { streamFn: baseStreamFn }; - const cfg = { - plugins: { - entries: { - openrouter: { - enabled: true, + runExtraParamsCase({ + cfg: { + plugins: { + entries: { + openrouter: { + enabled: true, + }, }, }, }, - } satisfies OpenClawConfig; - - applyExtraParamsToAgent(agent, cfg, "openrouter", modelId); - - const model = { - api: "openai-completions", - provider: "openrouter", - id: modelId, - } as Model<"openai-completions">; - const context: Context = { messages: [] }; - - void agent.streamFn?.(model, context, {}); + model: { + api: "openai-completions", + provider: "openrouter", + id: modelId, + } as Model<"openai-completions">, + payload, + }); } describe("extra-params: OpenRouter Anthropic cache_control", () => { diff --git a/src/agents/pi-embedded-runner/extra-params.test-support.ts b/src/agents/pi-embedded-runner/extra-params.test-support.ts new file mode 100644 index 00000000000..ae4fdb9edc3 --- /dev/null +++ b/src/agents/pi-embedded-runner/extra-params.test-support.ts @@ -0,0 +1,56 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai"; +import type { OpenClawConfig } from "../../config/config.js"; +import { applyExtraParamsToAgent } from "./extra-params.js"; + +export type ExtraParamsCapture> = { + headers?: Record; + payload: TPayload; +}; + +type RunExtraParamsCaseParams< + TApi extends "openai-completions" | "openai-responses", + TPayload extends Record, +> = { + applyModelId?: string; + applyProvider?: string; + callerHeaders?: Record; + cfg?: OpenClawConfig; + model: Model; + options?: SimpleStreamOptions; + payload: TPayload; + thinkingLevel?: "minimal" | "low" | "medium" | "high"; +}; + +export function runExtraParamsCase< + TApi extends "openai-completions" | "openai-responses", + TPayload extends Record, +>(params: RunExtraParamsCaseParams): ExtraParamsCapture { + const captured: ExtraParamsCapture = { + payload: params.payload, + }; + + const baseStreamFn: StreamFn = (model, _context, options) => { + captured.headers = options?.headers; + options?.onPayload?.(params.payload, model); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent( + agent, + params.cfg, + params.applyProvider ?? params.model.provider, + params.applyModelId ?? params.model.id, + undefined, + params.thinkingLevel, + ); + + const context: Context = { messages: [] }; + void agent.streamFn?.(params.model, context, { + ...params.options, + headers: params.callerHeaders ?? params.options?.headers, + }); + + return captured; +} diff --git a/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts b/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts index f7262a66798..ca22149990f 100644 --- a/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts @@ -1,7 +1,7 @@ -import type { StreamFn } from "@mariozechner/pi-agent-core"; -import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai"; +import type { Model, SimpleStreamOptions } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; -import { applyExtraParamsToAgent } from "./extra-params.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { runExtraParamsCase } from "./extra-params.test-support.js"; // Mock streamSimple for testing vi.mock("@mariozechner/pi-ai", () => ({ @@ -15,24 +15,19 @@ type ToolStreamCase = { applyProvider: string; applyModelId: string; model: Model<"openai-completions">; - cfg?: Parameters[1]; + cfg?: OpenClawConfig; options?: SimpleStreamOptions; }; function runToolStreamCase(params: ToolStreamCase) { - const payload: Record = { model: params.model.id, messages: [] }; - const baseStreamFn: StreamFn = (model, _context, options) => { - options?.onPayload?.(payload, model); - return {} as ReturnType; - }; - const agent = { streamFn: baseStreamFn }; - - applyExtraParamsToAgent(agent, params.cfg, params.applyProvider, params.applyModelId); - - const context: Context = { messages: [] }; - void agent.streamFn?.(params.model, context, params.options ?? {}); - - return payload; + return runExtraParamsCase({ + applyModelId: params.applyModelId, + applyProvider: params.applyProvider, + cfg: params.cfg, + model: params.model, + options: params.options, + payload: { model: params.model.id, messages: [] }, + }).payload as Record; } describe("extra-params: Z.AI tool_stream support", () => { diff --git a/src/agents/pi-embedded-runner/google.test.ts b/src/agents/pi-embedded-runner/google.test.ts index d0a04665c68..efea86819b2 100644 --- a/src/agents/pi-embedded-runner/google.test.ts +++ b/src/agents/pi-embedded-runner/google.test.ts @@ -11,6 +11,18 @@ describe("sanitizeToolsForGoogle", () => { execute: async () => ({ ok: true, content: [] }), }) as unknown as AgentTool; + const createSchemaToolWithFormat = () => + createTool({ + type: "object", + additionalProperties: false, + properties: { + foo: { + type: "string", + format: "uuid", + }, + }, + }); + const expectFormatRemoved = ( sanitized: AgentTool, key: "additionalProperties" | "patternProperties", @@ -25,16 +37,7 @@ describe("sanitizeToolsForGoogle", () => { }; it("strips unsupported schema keywords for Google providers", () => { - const tool = createTool({ - type: "object", - additionalProperties: false, - properties: { - foo: { - type: "string", - format: "uuid", - }, - }, - }); + const tool = createSchemaToolWithFormat(); const [sanitized] = sanitizeToolsForGoogle({ tools: [tool], provider: "google-gemini-cli", @@ -43,16 +46,7 @@ describe("sanitizeToolsForGoogle", () => { }); it("returns original tools for non-google providers", () => { - const tool = createTool({ - type: "object", - additionalProperties: false, - properties: { - foo: { - type: "string", - format: "uuid", - }, - }, - }); + const tool = createSchemaToolWithFormat(); const sanitized = sanitizeToolsForGoogle({ tools: [tool], provider: "openai", diff --git a/src/agents/pi-embedded-runner/kilocode.test.ts b/src/agents/pi-embedded-runner/kilocode.test.ts index cbb626d8ba7..71b84f06e32 100644 --- a/src/agents/pi-embedded-runner/kilocode.test.ts +++ b/src/agents/pi-embedded-runner/kilocode.test.ts @@ -2,12 +2,10 @@ import { describe, expect, it } from "vitest"; import { isCacheTtlEligibleProvider } from "./cache-ttl.js"; describe("kilocode cache-ttl eligibility", () => { - it("is eligible when model starts with anthropic/", () => { - expect(isCacheTtlEligibleProvider("kilocode", "anthropic/claude-opus-4.6")).toBe(true); - }); - - it("is eligible with other anthropic models", () => { - expect(isCacheTtlEligibleProvider("kilocode", "anthropic/claude-sonnet-4")).toBe(true); + it("allows anthropic models", () => { + for (const modelId of ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4"] as const) { + expect(isCacheTtlEligibleProvider("kilocode", modelId)).toBe(true); + } }); it("is not eligible for non-anthropic models on kilocode", () => { @@ -15,7 +13,11 @@ describe("kilocode cache-ttl eligibility", () => { }); it("is case-insensitive for provider name", () => { - expect(isCacheTtlEligibleProvider("Kilocode", "anthropic/claude-opus-4.6")).toBe(true); - expect(isCacheTtlEligibleProvider("KILOCODE", "Anthropic/claude-opus-4.6")).toBe(true); + for (const [provider, modelId] of [ + ["Kilocode", "anthropic/claude-opus-4.6"], + ["KILOCODE", "Anthropic/claude-opus-4.6"], + ] as const) { + expect(isCacheTtlEligibleProvider(provider, modelId)).toBe(true); + } }); }); diff --git a/src/agents/pi-embedded-runner/lanes.test.ts b/src/agents/pi-embedded-runner/lanes.test.ts index f3625ddc6ec..c0294dd5b9d 100644 --- a/src/agents/pi-embedded-runner/lanes.test.ts +++ b/src/agents/pi-embedded-runner/lanes.test.ts @@ -5,40 +5,52 @@ import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; describe("resolveGlobalLane", () => { it("defaults to main lane when no lane is provided", () => { expect(resolveGlobalLane()).toBe(CommandLane.Main); - expect(resolveGlobalLane("")).toBe(CommandLane.Main); - expect(resolveGlobalLane(" ")).toBe(CommandLane.Main); + for (const lane of ["", " "]) { + expect(resolveGlobalLane(lane)).toBe(CommandLane.Main); + } }); it("maps cron lane to nested lane to prevent deadlocks", () => { // When cron jobs trigger nested agent runs, the outer execution holds // the cron lane slot. Inner work must use a separate lane to avoid // deadlock. See: https://github.com/openclaw/openclaw/issues/44805 - expect(resolveGlobalLane("cron")).toBe(CommandLane.Nested); - expect(resolveGlobalLane(" cron ")).toBe(CommandLane.Nested); + for (const lane of ["cron", " cron "]) { + expect(resolveGlobalLane(lane)).toBe(CommandLane.Nested); + } }); it("preserves other lanes as-is", () => { - expect(resolveGlobalLane("main")).toBe(CommandLane.Main); - expect(resolveGlobalLane("subagent")).toBe(CommandLane.Subagent); - expect(resolveGlobalLane("nested")).toBe(CommandLane.Nested); - expect(resolveGlobalLane("custom-lane")).toBe("custom-lane"); - expect(resolveGlobalLane(" custom ")).toBe("custom"); + for (const [lane, expected] of [ + ["main", CommandLane.Main], + ["subagent", CommandLane.Subagent], + ["nested", CommandLane.Nested], + ["custom-lane", "custom-lane"], + [" custom ", "custom"], + ] as const) { + expect(resolveGlobalLane(lane)).toBe(expected); + } }); }); describe("resolveSessionLane", () => { it("defaults to main lane and prefixes with session:", () => { - expect(resolveSessionLane("")).toBe("session:main"); - expect(resolveSessionLane(" ")).toBe("session:main"); + for (const lane of ["", " "]) { + expect(resolveSessionLane(lane)).toBe("session:main"); + } }); it("adds session: prefix if not present", () => { - expect(resolveSessionLane("abc123")).toBe("session:abc123"); - expect(resolveSessionLane(" xyz ")).toBe("session:xyz"); + for (const [lane, expected] of [ + ["abc123", "session:abc123"], + [" xyz ", "session:xyz"], + ] as const) { + expect(resolveSessionLane(lane)).toBe(expected); + } }); it("preserves existing session: prefix", () => { - expect(resolveSessionLane("session:abc")).toBe("session:abc"); - expect(resolveSessionLane("session:main")).toBe("session:main"); + for (const lane of ["session:abc", "session:main"]) { + expect(resolveSessionLane(lane)).toBe(lane); + } }); }); diff --git a/src/agents/pi-embedded-runner/message-action-discovery-input.test.ts b/src/agents/pi-embedded-runner/message-action-discovery-input.test.ts new file mode 100644 index 00000000000..7b2acd199c0 --- /dev/null +++ b/src/agents/pi-embedded-runner/message-action-discovery-input.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { buildEmbeddedMessageActionDiscoveryInput } from "./message-action-discovery-input.js"; + +describe("buildEmbeddedMessageActionDiscoveryInput", () => { + it("maps sender and routing scope into message-action discovery context", () => { + expect( + buildEmbeddedMessageActionDiscoveryInput({ + channel: "telegram", + currentChannelId: "chat-1", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + accountId: "acct-1", + sessionKey: "agent:main:thread:1", + sessionId: "session-1", + agentId: "main", + senderId: "user-123", + }), + ).toEqual({ + cfg: undefined, + channel: "telegram", + currentChannelId: "chat-1", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + accountId: "acct-1", + sessionKey: "agent:main:thread:1", + sessionId: "session-1", + agentId: "main", + requesterSenderId: "user-123", + }); + }); + + it("normalizes nullable routing fields to undefined", () => { + expect( + buildEmbeddedMessageActionDiscoveryInput({ + channel: "slack", + currentChannelId: null, + currentThreadTs: null, + currentMessageId: null, + accountId: null, + sessionKey: null, + sessionId: null, + agentId: null, + senderId: null, + }), + ).toEqual({ + cfg: undefined, + channel: "slack", + currentChannelId: undefined, + currentThreadTs: undefined, + currentMessageId: undefined, + accountId: undefined, + sessionKey: undefined, + sessionId: undefined, + agentId: undefined, + requesterSenderId: undefined, + }); + }); +}); diff --git a/src/agents/pi-embedded-runner/message-action-discovery-input.ts b/src/agents/pi-embedded-runner/message-action-discovery-input.ts new file mode 100644 index 00000000000..3002e90d357 --- /dev/null +++ b/src/agents/pi-embedded-runner/message-action-discovery-input.ts @@ -0,0 +1,27 @@ +import type { OpenClawConfig } from "../../config/config.js"; + +export function buildEmbeddedMessageActionDiscoveryInput(params: { + cfg?: OpenClawConfig; + channel: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + senderId?: string | null; +}) { + return { + cfg: params.cfg, + channel: params.channel, + currentChannelId: params.currentChannelId ?? undefined, + currentThreadTs: params.currentThreadTs ?? undefined, + currentMessageId: params.currentMessageId ?? undefined, + accountId: params.accountId ?? undefined, + sessionKey: params.sessionKey ?? undefined, + sessionId: params.sessionId ?? undefined, + agentId: params.agentId ?? undefined, + requesterSenderId: params.senderId ?? undefined, + }; +} diff --git a/src/agents/pi-embedded-runner/model.test-harness.ts b/src/agents/pi-embedded-runner/model.test-harness.ts index 21434557c79..b91ca8b8c5f 100644 --- a/src/agents/pi-embedded-runner/model.test-harness.ts +++ b/src/agents/pi-embedded-runner/model.test-harness.ts @@ -25,14 +25,18 @@ export const OPENAI_CODEX_TEMPLATE_MODEL = { maxTokens: 128000, }; -export function mockOpenAICodexTemplateModel(): void { +function mockTemplateModel(provider: string, modelId: string, templateModel: unknown): void { mockDiscoveredModel({ - provider: "openai-codex", - modelId: "gpt-5.2-codex", - templateModel: OPENAI_CODEX_TEMPLATE_MODEL, + provider, + modelId, + templateModel, }); } +export function mockOpenAICodexTemplateModel(): void { + mockTemplateModel("openai-codex", "gpt-5.2-codex", OPENAI_CODEX_TEMPLATE_MODEL); +} + export function buildOpenAICodexForwardCompatExpectation( id: string = "gpt-5.3-codex", ): Partial & { @@ -85,19 +89,19 @@ export const GOOGLE_GEMINI_CLI_FLASH_TEMPLATE_MODEL = { }; export function mockGoogleGeminiCliProTemplateModel(): void { - mockDiscoveredModel({ - provider: "google-gemini-cli", - modelId: "gemini-3-pro-preview", - templateModel: GOOGLE_GEMINI_CLI_PRO_TEMPLATE_MODEL, - }); + mockTemplateModel( + "google-gemini-cli", + "gemini-3-pro-preview", + GOOGLE_GEMINI_CLI_PRO_TEMPLATE_MODEL, + ); } export function mockGoogleGeminiCliFlashTemplateModel(): void { - mockDiscoveredModel({ - provider: "google-gemini-cli", - modelId: "gemini-3-flash-preview", - templateModel: GOOGLE_GEMINI_CLI_FLASH_TEMPLATE_MODEL, - }); + mockTemplateModel( + "google-gemini-cli", + "gemini-3-flash-preview", + GOOGLE_GEMINI_CLI_FLASH_TEMPLATE_MODEL, + ); } export function resetMockDiscoverModels(): void { diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index a66cb697cb4..b733e3a3f5f 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -43,6 +43,7 @@ function buildForwardCompatTemplate(params: { provider: string; api: "anthropic-messages" | "google-gemini-cli" | "openai-completions" | "openai-responses"; baseUrl: string; + reasoning?: boolean; input?: readonly ["text"] | readonly ["text", "image"]; cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow?: number; @@ -54,7 +55,7 @@ function buildForwardCompatTemplate(params: { provider: params.provider, api: params.api, baseUrl: params.baseUrl, - reasoning: true, + reasoning: params.reasoning ?? true, input: params.input ?? (["text", "image"] as const), cost: params.cost ?? { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, contextWindow: params.contextWindow ?? 200000, @@ -757,6 +758,70 @@ describe("resolveModel", () => { }); }); + it("builds an openai fallback for gpt-5.4 mini from the gpt-5-mini template", () => { + mockDiscoveredModel({ + provider: "openai", + modelId: "gpt-5-mini", + templateModel: buildForwardCompatTemplate({ + id: "gpt-5-mini", + name: "GPT-5 mini", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: 400_000, + maxTokens: 128_000, + }), + }); + + const result = resolveModel("openai", "gpt-5.4-mini", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + provider: "openai", + id: "gpt-5.4-mini", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: 400_000, + maxTokens: 128_000, + }); + }); + + it("builds an openai fallback for gpt-5.4 nano from the gpt-5-nano template", () => { + mockDiscoveredModel({ + provider: "openai", + modelId: "gpt-5-nano", + templateModel: buildForwardCompatTemplate({ + id: "gpt-5-nano", + name: "GPT-5 nano", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: 400_000, + maxTokens: 128_000, + }), + }); + + const result = resolveModel("openai", "gpt-5.4-nano", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + provider: "openai", + id: "gpt-5.4-nano", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: 400_000, + maxTokens: 128_000, + }); + }); + it("normalizes stale native openai gpt-5.4 completions transport to responses", () => { mockDiscoveredModel({ provider: "openai", diff --git a/src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts b/src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts index aa830c13d4d..a2bca6a30e4 100644 --- a/src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts +++ b/src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts @@ -3,6 +3,16 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +async function withOpenRouterStateDir(run: (stateDir: string) => Promise) { + const stateDir = mkdtempSync(join(tmpdir(), "openclaw-openrouter-capabilities-")); + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + await run(stateDir); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +} + describe("openrouter-model-capabilities", () => { afterEach(() => { vi.resetModules(); @@ -11,46 +21,42 @@ describe("openrouter-model-capabilities", () => { }); it("uses top-level OpenRouter max token fields when top_provider is absent", async () => { - const stateDir = mkdtempSync(join(tmpdir(), "openclaw-openrouter-capabilities-")); - process.env.OPENCLAW_STATE_DIR = stateDir; + await withOpenRouterStateDir(async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: "acme/top-level-max-completion", + name: "Top Level Max Completion", + architecture: { modality: "text+image->text" }, + supported_parameters: ["reasoning"], + context_length: 65432, + max_completion_tokens: 12345, + pricing: { prompt: "0.000001", completion: "0.000002" }, + }, + { + id: "acme/top-level-max-output", + name: "Top Level Max Output", + modality: "text+image->text", + context_length: 54321, + max_output_tokens: 23456, + pricing: { prompt: "0.000003", completion: "0.000004" }, + }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ), + ); - vi.stubGlobal( - "fetch", - vi.fn( - async () => - new Response( - JSON.stringify({ - data: [ - { - id: "acme/top-level-max-completion", - name: "Top Level Max Completion", - architecture: { modality: "text+image->text" }, - supported_parameters: ["reasoning"], - context_length: 65432, - max_completion_tokens: 12345, - pricing: { prompt: "0.000001", completion: "0.000002" }, - }, - { - id: "acme/top-level-max-output", - name: "Top Level Max Output", - modality: "text+image->text", - context_length: 54321, - max_output_tokens: 23456, - pricing: { prompt: "0.000003", completion: "0.000004" }, - }, - ], - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ), - ), - ); - - const module = await import("./openrouter-model-capabilities.js"); - - try { + const module = await import("./openrouter-model-capabilities.js"); await module.loadOpenRouterModelCapabilities("acme/top-level-max-completion"); expect(module.getOpenRouterModelCapabilities("acme/top-level-max-completion")).toMatchObject({ @@ -65,47 +71,39 @@ describe("openrouter-model-capabilities", () => { contextWindow: 54321, maxTokens: 23456, }); - } finally { - rmSync(stateDir, { recursive: true, force: true }); - } + }); }); it("does not refetch immediately after an awaited miss for the same model id", async () => { - const stateDir = mkdtempSync(join(tmpdir(), "openclaw-openrouter-capabilities-")); - process.env.OPENCLAW_STATE_DIR = stateDir; + await withOpenRouterStateDir(async () => { + const fetchSpy = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: "acme/known-model", + name: "Known Model", + architecture: { modality: "text->text" }, + context_length: 1234, + }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + vi.stubGlobal("fetch", fetchSpy); - const fetchSpy = vi.fn( - async () => - new Response( - JSON.stringify({ - data: [ - { - id: "acme/known-model", - name: "Known Model", - architecture: { modality: "text->text" }, - context_length: 1234, - }, - ], - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ), - ); - vi.stubGlobal("fetch", fetchSpy); - - const module = await import("./openrouter-model-capabilities.js"); - - try { + const module = await import("./openrouter-model-capabilities.js"); await module.loadOpenRouterModelCapabilities("acme/missing-model"); expect(module.getOpenRouterModelCapabilities("acme/missing-model")).toBeUndefined(); expect(fetchSpy).toHaveBeenCalledTimes(1); expect(module.getOpenRouterModelCapabilities("acme/missing-model")).toBeUndefined(); expect(fetchSpy).toHaveBeenCalledTimes(2); - } finally { - rmSync(stateDir, { recursive: true, force: true }); - } + }); }); }); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts new file mode 100644 index 00000000000..9e7853ef7d5 --- /dev/null +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts @@ -0,0 +1,406 @@ +import { vi, type Mock } from "vitest"; +import type { ThinkLevel } from "../../auto-reply/thinking.js"; +import type { + PluginHookAgentContext, + PluginHookBeforeAgentStartResult, + PluginHookBeforeModelResolveResult, + PluginHookBeforePromptBuildResult, +} from "../../plugins/types.js"; +import type { EmbeddedRunAttemptResult } from "./run/types.js"; + +type MockCompactionResult = + | { + ok: true; + compacted: true; + result: { + summary: string; + firstKeptEntryId?: string; + tokensBefore?: number; + tokensAfter?: number; + }; + reason?: string; + } + | { + ok: false; + compacted: false; + reason: string; + result?: undefined; + }; + +export const mockedGlobalHookRunner = { + hasHooks: vi.fn((_hookName: string) => false), + runBeforeAgentStart: vi.fn( + async ( + _event: { prompt: string; messages?: unknown[] }, + _ctx: PluginHookAgentContext, + ): Promise => undefined, + ), + runBeforePromptBuild: vi.fn( + async ( + _event: { prompt: string; messages: unknown[] }, + _ctx: PluginHookAgentContext, + ): Promise => undefined, + ), + runBeforeModelResolve: vi.fn( + async ( + _event: { prompt: string }, + _ctx: PluginHookAgentContext, + ): Promise => undefined, + ), + runBeforeCompaction: vi.fn(async () => undefined), + runAfterCompaction: vi.fn(async () => undefined), +}; + +export const mockedContextEngine = { + info: { ownsCompaction: false as boolean }, + compact: vi.fn<(params: unknown) => Promise>(async () => ({ + ok: false as const, + compacted: false as const, + reason: "nothing to compact", + })), +}; + +export const mockedContextEngineCompact = mockedContextEngine.compact; +export const mockedCompactDirect = mockedContextEngine.compact; +export const mockedEnsureRuntimePluginsLoaded = vi.fn<(params?: unknown) => void>(); +export const mockedPrepareProviderRuntimeAuth = vi.fn(async () => undefined); +export const mockedRunEmbeddedAttempt = + vi.fn<(params: unknown) => Promise>(); +export const mockedSessionLikelyHasOversizedToolResults = vi.fn(() => false); +export const mockedTruncateOversizedToolResultsInSession = vi.fn< + () => Promise +>(async () => ({ + truncated: false, + truncatedCount: 0, + reason: "no oversized tool results", +})); + +type MockFailoverErrorDescription = { + message: string; + reason: string | undefined; + status: number | undefined; + code: string | undefined; +}; + +type MockCoerceToFailoverError = ( + err: unknown, + params?: { provider?: string; model?: string; profileId?: string }, +) => unknown; +type MockDescribeFailoverError = (err: unknown) => MockFailoverErrorDescription; +type MockResolveFailoverStatus = (reason: string) => number | undefined; +type MockTruncateOversizedToolResultsResult = { + truncated: boolean; + truncatedCount: number; + reason?: string; +}; + +export const mockedCoerceToFailoverError = vi.fn(); +export const mockedDescribeFailoverError = vi.fn( + (err: unknown): MockFailoverErrorDescription => ({ + message: err instanceof Error ? err.message : String(err), + reason: undefined, + status: undefined, + code: undefined, + }), +); +export const mockedResolveFailoverStatus = vi.fn(); + +export const mockedLog: { + debug: Mock<(...args: unknown[]) => void>; + info: Mock<(...args: unknown[]) => void>; + warn: Mock<(...args: unknown[]) => void>; + error: Mock<(...args: unknown[]) => void>; + isEnabled: Mock<(level?: string) => boolean>; +} = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + isEnabled: vi.fn(() => false), +}; + +export const mockedClassifyFailoverReason = vi.fn(() => null); +export const mockedExtractObservedOverflowTokenCount = vi.fn((msg?: string) => { + const match = msg?.match(/prompt is too long:\s*([\d,]+)\s+tokens\s*>\s*[\d,]+\s+maximum/i); + return match?.[1] ? Number(match[1].replaceAll(",", "")) : undefined; +}); +export const mockedIsCompactionFailureError = vi.fn(() => false); +export const mockedIsLikelyContextOverflowError = vi.fn((msg?: string) => { + const lower = (msg ?? "").toLowerCase(); + return ( + lower.includes("request_too_large") || + lower.includes("context window exceeded") || + lower.includes("prompt is too long") + ); +}); +export const mockedPickFallbackThinkingLevel = vi.fn<(params?: unknown) => ThinkLevel | null>( + () => null, +); + +export const overflowBaseRunParams = { + sessionId: "test-session", + sessionKey: "test-key", + sessionFile: "/tmp/session.json", + workspaceDir: "/tmp/workspace", + prompt: "hello", + timeoutMs: 30000, + runId: "run-1", +} as const; + +export function resetRunOverflowCompactionHarnessMocks(): void { + mockedGlobalHookRunner.hasHooks.mockReset(); + mockedGlobalHookRunner.hasHooks.mockReturnValue(false); + mockedGlobalHookRunner.runBeforeAgentStart.mockReset(); + mockedGlobalHookRunner.runBeforeAgentStart.mockResolvedValue(undefined); + mockedGlobalHookRunner.runBeforePromptBuild.mockReset(); + mockedGlobalHookRunner.runBeforePromptBuild.mockResolvedValue(undefined); + mockedGlobalHookRunner.runBeforeModelResolve.mockReset(); + mockedGlobalHookRunner.runBeforeModelResolve.mockResolvedValue(undefined); + mockedGlobalHookRunner.runBeforeCompaction.mockReset(); + mockedGlobalHookRunner.runBeforeCompaction.mockResolvedValue(undefined); + mockedGlobalHookRunner.runAfterCompaction.mockReset(); + mockedGlobalHookRunner.runAfterCompaction.mockResolvedValue(undefined); + + mockedContextEngine.info.ownsCompaction = false; + mockedContextEngineCompact.mockReset(); + mockedContextEngineCompact.mockResolvedValue({ + ok: false, + compacted: false, + reason: "nothing to compact", + }); + + mockedEnsureRuntimePluginsLoaded.mockReset(); + mockedPrepareProviderRuntimeAuth.mockReset(); + mockedPrepareProviderRuntimeAuth.mockResolvedValue(undefined); + mockedRunEmbeddedAttempt.mockReset(); + mockedSessionLikelyHasOversizedToolResults.mockReset(); + mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false); + mockedTruncateOversizedToolResultsInSession.mockReset(); + mockedTruncateOversizedToolResultsInSession.mockResolvedValue({ + truncated: false, + truncatedCount: 0, + reason: "no oversized tool results", + }); + + mockedCoerceToFailoverError.mockReset(); + mockedCoerceToFailoverError.mockReturnValue(null); + mockedDescribeFailoverError.mockReset(); + mockedDescribeFailoverError.mockImplementation( + (err: unknown): MockFailoverErrorDescription => ({ + message: err instanceof Error ? err.message : String(err), + reason: undefined, + status: undefined, + code: undefined, + }), + ); + mockedResolveFailoverStatus.mockReset(); + mockedResolveFailoverStatus.mockReturnValue(undefined); + + mockedLog.debug.mockReset(); + mockedLog.info.mockReset(); + mockedLog.warn.mockReset(); + mockedLog.error.mockReset(); + mockedLog.isEnabled.mockReset(); + mockedLog.isEnabled.mockReturnValue(false); + + mockedClassifyFailoverReason.mockReset(); + mockedClassifyFailoverReason.mockReturnValue(null); + mockedExtractObservedOverflowTokenCount.mockReset(); + mockedExtractObservedOverflowTokenCount.mockImplementation((msg?: string) => { + const match = msg?.match(/prompt is too long:\s*([\d,]+)\s+tokens\s*>\s*[\d,]+\s+maximum/i); + return match?.[1] ? Number(match[1].replaceAll(",", "")) : undefined; + }); + mockedIsCompactionFailureError.mockReset(); + mockedIsCompactionFailureError.mockReturnValue(false); + mockedIsLikelyContextOverflowError.mockReset(); + mockedIsLikelyContextOverflowError.mockImplementation((msg?: string) => { + const lower = (msg ?? "").toLowerCase(); + return ( + lower.includes("request_too_large") || + lower.includes("context window exceeded") || + lower.includes("prompt is too long") + ); + }); + mockedPickFallbackThinkingLevel.mockReset(); + mockedPickFallbackThinkingLevel.mockReturnValue(null); +} + +export async function loadRunOverflowCompactionHarness(): Promise<{ + runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; +}> { + resetRunOverflowCompactionHarnessMocks(); + vi.resetModules(); + + vi.doMock("../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: vi.fn(() => mockedGlobalHookRunner), + })); + + vi.doMock("../../context-engine/index.js", () => ({ + ensureContextEnginesInitialized: vi.fn(), + resolveContextEngine: vi.fn(async () => mockedContextEngine), + })); + + vi.doMock("../runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded: mockedEnsureRuntimePluginsLoaded, + })); + + vi.doMock("../../plugins/provider-runtime.js", () => ({ + prepareProviderRuntimeAuth: mockedPrepareProviderRuntimeAuth, + })); + + vi.doMock("../auth-profiles.js", () => ({ + isProfileInCooldown: vi.fn(() => false), + markAuthProfileFailure: vi.fn(async () => {}), + markAuthProfileGood: vi.fn(async () => {}), + markAuthProfileUsed: vi.fn(async () => {}), + resolveProfilesUnavailableReason: vi.fn(() => undefined), + })); + + vi.doMock("../usage.js", () => ({ + normalizeUsage: vi.fn((usage?: unknown) => + usage && typeof usage === "object" ? usage : undefined, + ), + derivePromptTokens: vi.fn( + (usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => + usage + ? (() => { + const sum = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); + return sum > 0 ? sum : undefined; + })() + : undefined, + ), + })); + + vi.doMock("../workspace-run.js", () => ({ + resolveRunWorkspaceDir: vi.fn((params: { workspaceDir: string }) => ({ + workspaceDir: params.workspaceDir, + usedFallback: false, + fallbackReason: undefined, + agentId: "main", + })), + redactRunIdentifier: vi.fn((value?: string) => value ?? ""), + })); + + vi.doMock("../pi-embedded-helpers.js", () => ({ + formatBillingErrorMessage: vi.fn(() => ""), + classifyFailoverReason: mockedClassifyFailoverReason, + extractObservedOverflowTokenCount: mockedExtractObservedOverflowTokenCount, + formatAssistantErrorText: vi.fn(() => ""), + isAuthAssistantError: vi.fn(() => false), + isBillingAssistantError: vi.fn(() => false), + isCompactionFailureError: mockedIsCompactionFailureError, + isLikelyContextOverflowError: mockedIsLikelyContextOverflowError, + isFailoverAssistantError: vi.fn(() => false), + isFailoverErrorMessage: vi.fn(() => false), + parseImageSizeError: vi.fn(() => null), + parseImageDimensionError: vi.fn(() => null), + isRateLimitAssistantError: vi.fn(() => false), + isTimeoutErrorMessage: vi.fn(() => false), + pickFallbackThinkingLevel: mockedPickFallbackThinkingLevel, + })); + + vi.doMock("./run/attempt.js", () => ({ + runEmbeddedAttempt: mockedRunEmbeddedAttempt, + })); + + vi.doMock("./model.js", () => ({ + resolveModelAsync: vi.fn(async () => ({ + model: { + id: "test-model", + provider: "anthropic", + contextWindow: 200000, + api: "messages", + }, + error: null, + authStorage: { + setRuntimeApiKey: vi.fn(), + }, + modelRegistry: {}, + })), + })); + + vi.doMock("../model-auth.js", () => ({ + applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), + ensureAuthProfileStore: vi.fn(() => ({})), + getApiKeyForModel: vi.fn(async () => ({ + apiKey: "test-key", + profileId: "test-profile", + source: "test", + })), + resolveAuthProfileOrder: vi.fn(() => []), + })); + + vi.doMock("../models-config.js", () => ({ + ensureOpenClawModelsJson: vi.fn(async () => {}), + })); + + vi.doMock("../context-window-guard.js", () => ({ + CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000, + CONTEXT_WINDOW_WARN_BELOW_TOKENS: 5000, + evaluateContextWindowGuard: vi.fn(() => ({ + shouldWarn: false, + shouldBlock: false, + tokens: 200000, + source: "model", + })), + resolveContextWindowInfo: vi.fn(() => ({ + tokens: 200000, + source: "model", + })), + })); + + vi.doMock("../../process/command-queue.js", () => ({ + enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()), + })); + + vi.doMock("../../utils/message-channel.js", () => ({ + isMarkdownCapableMessageChannel: vi.fn(() => true), + })); + + vi.doMock("../agent-paths.js", () => ({ + resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent-dir"), + })); + + vi.doMock("../defaults.js", () => ({ + DEFAULT_CONTEXT_TOKENS: 200000, + DEFAULT_MODEL: "test-model", + DEFAULT_PROVIDER: "anthropic", + })); + + vi.doMock("../failover-error.js", () => ({ + FailoverError: class extends Error {}, + coerceToFailoverError: mockedCoerceToFailoverError, + describeFailoverError: mockedDescribeFailoverError, + resolveFailoverStatus: mockedResolveFailoverStatus, + })); + + vi.doMock("./lanes.js", () => ({ + resolveSessionLane: vi.fn(() => "session-lane"), + resolveGlobalLane: vi.fn(() => "global-lane"), + })); + + vi.doMock("./logger.js", () => ({ + log: mockedLog, + })); + + vi.doMock("./run/payloads.js", () => ({ + buildEmbeddedRunPayloads: vi.fn(() => []), + })); + + vi.doMock("./tool-result-truncation.js", () => ({ + truncateOversizedToolResultsInSession: mockedTruncateOversizedToolResultsInSession, + sessionLikelyHasOversizedToolResults: mockedSessionLikelyHasOversizedToolResults, + })); + + vi.doMock("./utils.js", () => ({ + describeUnknownError: vi.fn((err: unknown) => { + if (err instanceof Error) { + return err.message; + } + return String(err); + }), + })); + + const { runEmbeddedPiAgent } = await import("./run.js"); + return { runEmbeddedPiAgent }; +} diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts index 7a2550ba1e9..f74b14c56df 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts @@ -1,17 +1,4 @@ -import "./run.overflow-compaction.mocks.shared.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { isCompactionFailureError, isLikelyContextOverflowError } from "../pi-embedded-helpers.js"; - -vi.mock(import("../../utils.js"), async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveUserPath: vi.fn((p: string) => p), - }; -}); - -import { log } from "./logger.js"; -import { runEmbeddedPiAgent } from "./run.js"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { makeAttemptResult, makeCompactionSuccess, @@ -20,26 +7,38 @@ import { queueOverflowAttemptWithOversizedToolOutput, } from "./run.overflow-compaction.fixture.js"; import { + loadRunOverflowCompactionHarness, mockedContextEngine, mockedCompactDirect, + mockedIsCompactionFailureError, + mockedIsLikelyContextOverflowError, + mockedLog, mockedRunEmbeddedAttempt, mockedSessionLikelyHasOversizedToolResults, mockedTruncateOversizedToolResultsInSession, overflowBaseRunParams as baseParams, -} from "./run.overflow-compaction.shared-test.js"; +} from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; -const mockedIsCompactionFailureError = vi.mocked(isCompactionFailureError); -const mockedIsLikelyContextOverflowError = vi.mocked(isLikelyContextOverflowError); +let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; describe("overflow compaction in run loop", () => { + beforeAll(async () => { + ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + }); + beforeEach(() => { - vi.clearAllMocks(); mockedRunEmbeddedAttempt.mockReset(); mockedCompactDirect.mockReset(); mockedSessionLikelyHasOversizedToolResults.mockReset(); mockedTruncateOversizedToolResultsInSession.mockReset(); mockedContextEngine.info.ownsCompaction = false; + mockedLog.debug.mockReset(); + mockedLog.info.mockReset(); + mockedLog.warn.mockReset(); + mockedLog.error.mockReset(); + mockedLog.isEnabled.mockReset(); + mockedLog.isEnabled.mockReturnValue(false); mockedIsCompactionFailureError.mockImplementation((msg?: string) => { if (!msg) { return false; @@ -87,12 +86,14 @@ describe("overflow compaction in run loop", () => { }), ); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(log.warn).toHaveBeenCalledWith( + expect(mockedLog.warn).toHaveBeenCalledWith( expect.stringContaining( "context overflow detected (attempt 1/3); attempting auto-compaction", ), ); - expect(log.info).toHaveBeenCalledWith(expect.stringContaining("auto-compaction succeeded")); + expect(mockedLog.info).toHaveBeenCalledWith( + expect.stringContaining("auto-compaction succeeded"), + ); // Should not be an error result expect(result.meta.error).toBeUndefined(); }); @@ -116,7 +117,7 @@ describe("overflow compaction in run loop", () => { expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("source=promptError")); + expect(mockedLog.warn).toHaveBeenCalledWith(expect.stringContaining("source=promptError")); expect(result.meta.error).toBeUndefined(); }); @@ -137,7 +138,7 @@ describe("overflow compaction in run loop", () => { expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); expect(result.meta.error?.kind).toBe("context_overflow"); expect(result.payloads?.[0]?.isError).toBe(true); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("auto-compaction failed")); + expect(mockedLog.warn).toHaveBeenCalledWith(expect.stringContaining("auto-compaction failed")); }); it("falls back to tool-result truncation and retries when oversized results are detected", async () => { @@ -165,7 +166,9 @@ describe("overflow compaction in run loop", () => { expect.objectContaining({ sessionFile: "/tmp/session.json" }), ); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(log.info).toHaveBeenCalledWith(expect.stringContaining("Truncated 1 tool result(s)")); + expect(mockedLog.info).toHaveBeenCalledWith( + expect.stringContaining("Truncated 1 tool result(s)"), + ); expect(result.meta.error).toBeUndefined(); }); @@ -284,7 +287,7 @@ describe("overflow compaction in run loop", () => { expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("source=assistantError")); + expect(mockedLog.warn).toHaveBeenCalledWith(expect.stringContaining("source=assistantError")); expect(result.meta.error).toBeUndefined(); }); @@ -302,7 +305,9 @@ describe("overflow compaction in run loop", () => { await expect(runEmbeddedPiAgent(baseParams)).rejects.toThrow("transport disconnected"); expect(mockedCompactDirect).not.toHaveBeenCalled(); - expect(log.warn).not.toHaveBeenCalledWith(expect.stringContaining("source=assistantError")); + expect(mockedLog.warn).not.toHaveBeenCalledWith( + expect.stringContaining("source=assistantError"), + ); }); it("returns an explicit timeout payload when the run times out before producing any reply", async () => { diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts deleted file mode 100644 index 8451ef54994..00000000000 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { vi } from "vitest"; -import type { - PluginHookAgentContext, - PluginHookBeforeAgentStartResult, - PluginHookBeforeModelResolveResult, - PluginHookBeforePromptBuildResult, -} from "../../plugins/types.js"; - -type MockCompactionResult = - | { - ok: true; - compacted: true; - result: { - summary: string; - firstKeptEntryId?: string; - tokensBefore?: number; - tokensAfter?: number; - }; - reason?: string; - } - | { - ok: false; - compacted: false; - reason: string; - result?: undefined; - }; - -export const mockedGlobalHookRunner = { - hasHooks: vi.fn((_hookName: string) => false), - runBeforeAgentStart: vi.fn( - async ( - _event: { prompt: string; messages?: unknown[] }, - _ctx: PluginHookAgentContext, - ): Promise => undefined, - ), - runBeforePromptBuild: vi.fn( - async ( - _event: { prompt: string; messages: unknown[] }, - _ctx: PluginHookAgentContext, - ): Promise => undefined, - ), - runBeforeModelResolve: vi.fn( - async ( - _event: { prompt: string }, - _ctx: PluginHookAgentContext, - ): Promise => undefined, - ), - runBeforeCompaction: vi.fn(async () => undefined), - runAfterCompaction: vi.fn(async () => undefined), -}; - -export const mockedContextEngine = { - info: { ownsCompaction: false as boolean }, - compact: vi.fn<(params: unknown) => Promise>(async () => ({ - ok: false as const, - compacted: false as const, - reason: "nothing to compact", - })), -}; - -export const mockedContextEngineCompact = vi.mocked(mockedContextEngine.compact); -export const mockedEnsureRuntimePluginsLoaded: (...args: unknown[]) => void = vi.fn(); - -vi.mock("../../plugins/hook-runner-global.js", () => ({ - getGlobalHookRunner: vi.fn(() => mockedGlobalHookRunner), -})); - -vi.mock("../../context-engine/index.js", () => ({ - ensureContextEnginesInitialized: vi.fn(), - resolveContextEngine: vi.fn(async () => mockedContextEngine), -})); - -vi.mock("../runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: mockedEnsureRuntimePluginsLoaded, -})); - -vi.mock("../auth-profiles.js", () => ({ - isProfileInCooldown: vi.fn(() => false), - markAuthProfileFailure: vi.fn(async () => {}), - markAuthProfileGood: vi.fn(async () => {}), - markAuthProfileUsed: vi.fn(async () => {}), -})); - -vi.mock("../usage.js", () => ({ - normalizeUsage: vi.fn((usage?: unknown) => - usage && typeof usage === "object" ? usage : undefined, - ), - derivePromptTokens: vi.fn((usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => - usage - ? (() => { - const sum = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); - return sum > 0 ? sum : undefined; - })() - : undefined, - ), - hasNonzeroUsage: vi.fn(() => false), -})); - -vi.mock("../workspace-run.js", () => ({ - resolveRunWorkspaceDir: vi.fn((params: { workspaceDir: string }) => ({ - workspaceDir: params.workspaceDir, - usedFallback: false, - fallbackReason: undefined, - agentId: "main", - })), - redactRunIdentifier: vi.fn((value?: string) => value ?? ""), -})); - -vi.mock("../pi-embedded-helpers.js", () => ({ - formatBillingErrorMessage: vi.fn(() => ""), - classifyFailoverReason: vi.fn(() => null), - extractObservedOverflowTokenCount: vi.fn((msg?: string) => { - const match = msg?.match(/prompt is too long:\s*([\d,]+)\s+tokens\s*>\s*[\d,]+\s+maximum/i); - return match?.[1] ? Number(match[1].replaceAll(",", "")) : undefined; - }), - formatAssistantErrorText: vi.fn(() => ""), - isAuthAssistantError: vi.fn(() => false), - isBillingAssistantError: vi.fn(() => false), - isCompactionFailureError: vi.fn(() => false), - isLikelyContextOverflowError: vi.fn((msg?: string) => { - const lower = (msg ?? "").toLowerCase(); - return ( - lower.includes("request_too_large") || - lower.includes("context window exceeded") || - lower.includes("prompt is too long") - ); - }), - isFailoverAssistantError: vi.fn(() => false), - isFailoverErrorMessage: vi.fn(() => false), - parseImageSizeError: vi.fn(() => null), - parseImageDimensionError: vi.fn(() => null), - isRateLimitAssistantError: vi.fn(() => false), - isTimeoutErrorMessage: vi.fn(() => false), - pickFallbackThinkingLevel: vi.fn(() => null), -})); - -vi.mock("./run/attempt.js", () => ({ - runEmbeddedAttempt: vi.fn(), -})); - -vi.mock("./compact.js", () => ({ - compactEmbeddedPiSessionDirect: vi.fn(), -})); - -vi.mock("./model.js", () => ({ - resolveModel: vi.fn(() => ({ - model: { - id: "test-model", - provider: "anthropic", - contextWindow: 200000, - api: "messages", - }, - error: null, - authStorage: { - setRuntimeApiKey: vi.fn(), - }, - modelRegistry: {}, - })), - resolveModelAsync: vi.fn(async () => ({ - model: { - id: "test-model", - provider: "anthropic", - contextWindow: 200000, - api: "messages", - }, - error: null, - authStorage: { - setRuntimeApiKey: vi.fn(), - }, - modelRegistry: {}, - })), -})); - -vi.mock("../model-auth.js", () => ({ - ensureAuthProfileStore: vi.fn(() => ({})), - getApiKeyForModel: vi.fn(async () => ({ - apiKey: "test-key", - profileId: "test-profile", - source: "test", - })), - resolveAuthProfileOrder: vi.fn(() => []), -})); - -vi.mock("../models-config.js", () => ({ - ensureOpenClawModelsJson: vi.fn(async () => {}), -})); - -vi.mock("../context-window-guard.js", () => ({ - CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000, - CONTEXT_WINDOW_WARN_BELOW_TOKENS: 5000, - evaluateContextWindowGuard: vi.fn(() => ({ - shouldWarn: false, - shouldBlock: false, - tokens: 200000, - source: "model", - })), - resolveContextWindowInfo: vi.fn(() => ({ - tokens: 200000, - source: "model", - })), -})); - -vi.mock("../../process/command-queue.js", () => ({ - enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()), -})); - -vi.mock(import("../../utils/message-channel.js"), async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - isMarkdownCapableMessageChannel: vi.fn(() => true), - }; -}); - -vi.mock("../agent-paths.js", () => ({ - resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent-dir"), -})); - -vi.mock("../defaults.js", () => ({ - DEFAULT_CONTEXT_TOKENS: 200000, - DEFAULT_MODEL: "test-model", - DEFAULT_PROVIDER: "anthropic", -})); - -type MockFailoverErrorDescription = { - message: string; - reason: string | undefined; - status: number | undefined; - code: string | undefined; -}; - -type MockCoerceToFailoverError = ( - err: unknown, - params?: { provider?: string; model?: string; profileId?: string }, -) => unknown; -type MockDescribeFailoverError = (err: unknown) => MockFailoverErrorDescription; -type MockResolveFailoverStatus = (reason: string) => number | undefined; - -export const mockedCoerceToFailoverError = vi.fn(); -export const mockedDescribeFailoverError = vi.fn( - (err: unknown): MockFailoverErrorDescription => ({ - message: err instanceof Error ? err.message : String(err), - reason: undefined, - status: undefined, - code: undefined, - }), -); -export const mockedResolveFailoverStatus = vi.fn(); - -vi.mock("../failover-error.js", () => ({ - FailoverError: class extends Error {}, - coerceToFailoverError: mockedCoerceToFailoverError, - describeFailoverError: mockedDescribeFailoverError, - resolveFailoverStatus: mockedResolveFailoverStatus, -})); - -vi.mock("./lanes.js", () => ({ - resolveSessionLane: vi.fn(() => "session-lane"), - resolveGlobalLane: vi.fn(() => "global-lane"), -})); - -vi.mock("./logger.js", () => ({ - log: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - isEnabled: vi.fn(() => false), - }, -})); - -vi.mock("./run/payloads.js", () => ({ - buildEmbeddedRunPayloads: vi.fn(() => []), -})); - -vi.mock("./tool-result-truncation.js", () => ({ - truncateOversizedToolResultsInSession: vi.fn(async () => ({ - truncated: false, - truncatedCount: 0, - reason: "no oversized tool results", - })), - sessionLikelyHasOversizedToolResults: vi.fn(() => false), -})); - -vi.mock("./utils.js", () => ({ - describeUnknownError: vi.fn((err: unknown) => { - if (err instanceof Error) { - return err.message; - } - return String(err); - }), -})); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts deleted file mode 100644 index c697ac9526a..00000000000 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { vi } from "vitest"; -import { - mockedContextEngine, - mockedContextEngineCompact, -} from "./run.overflow-compaction.mocks.shared.js"; -import { runEmbeddedAttempt } from "./run/attempt.js"; -import { - sessionLikelyHasOversizedToolResults, - truncateOversizedToolResultsInSession, -} from "./tool-result-truncation.js"; - -export const mockedRunEmbeddedAttempt = vi.mocked(runEmbeddedAttempt); -export const mockedCompactDirect = mockedContextEngineCompact; -export const mockedSessionLikelyHasOversizedToolResults = vi.mocked( - sessionLikelyHasOversizedToolResults, -); -export const mockedTruncateOversizedToolResultsInSession = vi.mocked( - truncateOversizedToolResultsInSession, -); -export { mockedContextEngine }; - -export const overflowBaseRunParams = { - sessionId: "test-session", - sessionKey: "test-key", - sessionFile: "/tmp/session.json", - workspaceDir: "/tmp/workspace", - prompt: "hello", - timeoutMs: 30000, - runId: "run-1", -} as const; diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index d18123a4ae2..1f5f0b6de35 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -1,7 +1,4 @@ -import "./run.overflow-compaction.mocks.shared.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { pickFallbackThinkingLevel } from "../pi-embedded-helpers.js"; -import { runEmbeddedPiAgent } from "./run.js"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { makeAttemptResult, makeCompactionSuccess, @@ -10,24 +7,33 @@ import { queueOverflowAttemptWithOversizedToolOutput, } from "./run.overflow-compaction.fixture.js"; import { + loadRunOverflowCompactionHarness, mockedCoerceToFailoverError, mockedDescribeFailoverError, mockedGlobalHookRunner, + mockedPickFallbackThinkingLevel, mockedResolveFailoverStatus, -} from "./run.overflow-compaction.mocks.shared.js"; -import { mockedContextEngine, mockedCompactDirect, mockedRunEmbeddedAttempt, + resetRunOverflowCompactionHarnessMocks, mockedSessionLikelyHasOversizedToolResults, mockedTruncateOversizedToolResultsInSession, overflowBaseRunParams, -} from "./run.overflow-compaction.shared-test.js"; -const mockedPickFallbackThinkingLevel = vi.mocked(pickFallbackThinkingLevel); +} from "./run.overflow-compaction.harness.js"; + +let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { + beforeAll(async () => { + ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + }); + + beforeEach(() => { + resetRunOverflowCompactionHarnessMocks(); + }); + beforeEach(() => { - vi.clearAllMocks(); mockedRunEmbeddedAttempt.mockReset(); mockedCompactDirect.mockReset(); mockedCoerceToFailoverError.mockReset(); @@ -257,7 +263,8 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { it("returns retry_limit when repeated retries never converge", async () => { mockedRunEmbeddedAttempt.mockClear(); mockedCompactDirect.mockClear(); - mockedPickFallbackThinkingLevel.mockClear(); + mockedPickFallbackThinkingLevel.mockReset(); + mockedPickFallbackThinkingLevel.mockReturnValue(null); mockedRunEmbeddedAttempt.mockResolvedValue( makeAttemptResult({ promptError: new Error("unsupported reasoning mode") }), ); @@ -288,15 +295,15 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { status: 429, }); - mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError })); - mockedCoerceToFailoverError.mockReturnValueOnce(normalized); + mockedRunEmbeddedAttempt.mockResolvedValue(makeAttemptResult({ promptError })); + mockedCoerceToFailoverError.mockReturnValue(normalized); mockedDescribeFailoverError.mockImplementation((err: unknown) => ({ message: err instanceof Error ? err.message : String(err), reason: err === normalized ? "rate_limit" : undefined, status: err === normalized ? 429 : undefined, code: undefined, })); - mockedResolveFailoverStatus.mockReturnValueOnce(429); + mockedResolveFailoverStatus.mockReturnValue(429); await expect( runEmbeddedPiAgent({ diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 3f41357f0e5..a35c03d98ca 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -65,6 +65,7 @@ import { import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js"; import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; +import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; import { resolveModelAsync } from "./model.js"; @@ -1141,24 +1142,30 @@ export async function runEmbeddedPiAgent( force: true, compactionTarget: "budget", runtimeContext: { - sessionKey: params.sessionKey, - messageChannel: params.messageChannel, - messageProvider: params.messageProvider, - agentAccountId: params.agentAccountId, - authProfileId: lastProfileId, - workspaceDir: resolvedWorkspace, - agentDir, - config: params.config, - skillsSnapshot: params.skillsSnapshot, - senderIsOwner: params.senderIsOwner, - provider, - model: modelId, + ...buildEmbeddedCompactionRuntimeContext({ + sessionKey: params.sessionKey, + messageChannel: params.messageChannel, + messageProvider: params.messageProvider, + agentAccountId: params.agentAccountId, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + authProfileId: lastProfileId, + workspaceDir: resolvedWorkspace, + agentDir, + config: params.config, + skillsSnapshot: params.skillsSnapshot, + senderIsOwner: params.senderIsOwner, + senderId: params.senderId, + provider, + modelId, + thinkLevel, + reasoningLevel: params.reasoningLevel, + bashElevated: params.bashElevated, + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + }), runId: params.runId, - thinkLevel, - reasoningLevel: params.reasoningLevel, - bashElevated: params.bashElevated, - extraSystemPrompt: params.extraSystemPrompt, - ownerNumbers: params.ownerNumbers, trigger: "overflow", ...(observedOverflowTokens !== undefined ? { currentTokenCount: observedOverflowTokens } diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 1953099cf7b..20bf752587b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; +import { appendBootstrapPromptWarning } from "../../bootstrap-budget.js"; import { resolveOllamaBaseUrlForRun } from "../../ollama-stream.js"; +import { buildAgentSystemPrompt } from "../../system-prompt.js"; import { buildAfterTurnRuntimeContext, composeSystemPromptWithHookContext, @@ -17,6 +19,11 @@ import { wrapStreamFnTrimToolCallNames, } from "./attempt.js"; +type FakeWrappedStream = { + result: () => Promise; + [Symbol.asyncIterator]: () => AsyncIterator; +}; + function createOllamaProviderConfig(injectNumCtxForOpenAICompat: boolean): OpenClawConfig { return { models: { @@ -32,6 +39,34 @@ function createOllamaProviderConfig(injectNumCtxForOpenAICompat: boolean): OpenC }; } +function createFakeStream(params: { + events: unknown[]; + resultMessage: unknown; +}): FakeWrappedStream { + return { + async result() { + return params.resultMessage; + }, + [Symbol.asyncIterator]() { + return (async function* () { + for (const event of params.events) { + yield event; + } + })(); + }, + }; +} + +async function invokeWrappedTestStream( + wrap: ( + baseFn: (...args: never[]) => unknown, + ) => (...args: never[]) => FakeWrappedStream | Promise, + baseFn: (...args: never[]) => unknown, +): Promise { + const wrappedFn = wrap(baseFn); + return await Promise.resolve(wrappedFn({} as never, {} as never, {} as never)); +} + describe("resolvePromptBuildHookResult", () => { function createLegacyOnlyHookRunner() { return { @@ -129,6 +164,42 @@ describe("composeSystemPromptWithHookContext", () => { }), ).toBe("append only"); }); + + it("keeps hook-composed system prompt stable when bootstrap warnings only change the user prompt", () => { + const baseSystemPrompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + contextFiles: [{ path: "AGENTS.md", content: "Follow AGENTS guidance." }], + toolNames: ["read"], + }); + const composedSystemPrompt = composeSystemPromptWithHookContext({ + baseSystemPrompt, + appendSystemContext: "hook system context", + }); + const turns = [ + { + systemPrompt: composedSystemPrompt, + prompt: appendBootstrapPromptWarning("hello", ["AGENTS.md: 200 raw -> 0 injected"]), + }, + { + systemPrompt: composedSystemPrompt, + prompt: appendBootstrapPromptWarning("hello again", []), + }, + { + systemPrompt: composedSystemPrompt, + prompt: appendBootstrapPromptWarning("hello once more", [ + "AGENTS.md: 200 raw -> 0 injected", + ]), + }, + ]; + + expect(turns[0]?.systemPrompt).toBe(turns[1]?.systemPrompt); + expect(turns[1]?.systemPrompt).toBe(turns[2]?.systemPrompt); + expect(turns[0]?.prompt.startsWith("hello")).toBe(true); + expect(turns[1]?.prompt).toBe("hello again"); + expect(turns[2]?.prompt.startsWith("hello once more")).toBe(true); + expect(turns[0]?.prompt).toContain("[Bootstrap truncation warning]"); + expect(turns[2]?.prompt).toContain("[Bootstrap truncation warning]"); + }); }); describe("resolvePromptModeForSession", () => { @@ -190,30 +261,14 @@ describe("resolveAttemptFsWorkspaceOnly", () => { }); }); describe("wrapStreamFnTrimToolCallNames", () => { - function createFakeStream(params: { events: unknown[]; resultMessage: unknown }): { - result: () => Promise; - [Symbol.asyncIterator]: () => AsyncIterator; - } { - return { - async result() { - return params.resultMessage; - }, - [Symbol.asyncIterator]() { - return (async function* () { - for (const event of params.events) { - yield event; - } - })(); - }, - }; - } - async function invokeWrappedStream( baseFn: (...args: never[]) => unknown, allowedToolNames?: Set, ) { - const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never, allowedToolNames); - return await wrappedFn({} as never, {} as never, {} as never); + return await invokeWrappedTestStream( + (innerBaseFn) => wrapStreamFnTrimToolCallNames(innerBaseFn as never, allowedToolNames), + baseFn, + ); } function createEventStream(params: { @@ -725,27 +780,11 @@ describe("wrapStreamFnTrimToolCallNames", () => { }); describe("wrapStreamFnRepairMalformedToolCallArguments", () => { - function createFakeStream(params: { events: unknown[]; resultMessage: unknown }): { - result: () => Promise; - [Symbol.asyncIterator]: () => AsyncIterator; - } { - return { - async result() { - return params.resultMessage; - }, - [Symbol.asyncIterator]() { - return (async function* () { - for (const event of params.events) { - yield event; - } - })(); - }, - }; - } - async function invokeWrappedStream(baseFn: (...args: never[]) => unknown) { - const wrappedFn = wrapStreamFnRepairMalformedToolCallArguments(baseFn as never); - return await wrappedFn({} as never, {} as never, {} as never); + return await invokeWrappedTestStream( + (innerBaseFn) => wrapStreamFnRepairMalformedToolCallArguments(innerBaseFn as never), + baseFn, + ); } it("repairs anthropic-compatible tool arguments when trailing junk follows valid JSON", async () => { @@ -1210,4 +1249,38 @@ describe("buildAfterTurnRuntimeContext", () => { agentDir: "/tmp/agent", }); }); + + it("preserves sender and channel routing context for scoped compaction discovery", () => { + const legacy = buildAfterTurnRuntimeContext({ + attempt: { + sessionKey: "agent:main:session:abc", + messageChannel: "slack", + messageProvider: "slack", + agentAccountId: "acct-1", + currentChannelId: "C123", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + authProfileId: "openai:p1", + config: {} as OpenClawConfig, + skillsSnapshot: undefined, + senderIsOwner: true, + senderId: "user-123", + provider: "openai-codex", + modelId: "gpt-5.3-codex", + thinkLevel: "off", + reasoningLevel: "on", + extraSystemPrompt: "extra", + ownerNumbers: ["+15555550123"], + }, + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + }); + + expect(legacy).toMatchObject({ + senderId: "user-123", + currentChannelId: "C123", + currentThreadTs: "thread-9", + currentMessageId: "msg-42", + }); + }); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 73b7d0fbff6..9a46beca5d2 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -101,6 +101,7 @@ import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; import { isRunnerAbortError } from "../abort.js"; import { appendCacheTtlTimestamp, isCacheTtlEligibleProvider } from "../cache-ttl.js"; import type { CompactEmbeddedPiSessionParams } from "../compact.js"; +import { buildEmbeddedCompactionRuntimeContext } from "../compaction-runtime-context.js"; import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js"; import { buildEmbeddedExtensionFactories } from "../extensions.js"; import { applyExtraParamsToAgent } from "../extra-params.js"; @@ -111,6 +112,7 @@ import { } from "../google.js"; import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js"; import { log } from "../logger.js"; +import { buildEmbeddedMessageActionDiscoveryInput } from "../message-action-discovery-input.js"; import { buildModelAliasLines } from "../model.js"; import { clearActiveEmbeddedRun, @@ -1280,9 +1282,13 @@ export function buildAfterTurnRuntimeContext(params: { | "messageChannel" | "messageProvider" | "agentAccountId" + | "currentChannelId" + | "currentThreadTs" + | "currentMessageId" | "config" | "skillsSnapshot" | "senderIsOwner" + | "senderId" | "provider" | "modelId" | "thinkLevel" @@ -1295,25 +1301,29 @@ export function buildAfterTurnRuntimeContext(params: { workspaceDir: string; agentDir: string; }): Partial { - return { + return buildEmbeddedCompactionRuntimeContext({ sessionKey: params.attempt.sessionKey, messageChannel: params.attempt.messageChannel, messageProvider: params.attempt.messageProvider, agentAccountId: params.attempt.agentAccountId, + currentChannelId: params.attempt.currentChannelId, + currentThreadTs: params.attempt.currentThreadTs, + currentMessageId: params.attempt.currentMessageId, authProfileId: params.attempt.authProfileId, workspaceDir: params.workspaceDir, agentDir: params.agentDir, config: params.attempt.config, skillsSnapshot: params.attempt.skillsSnapshot, senderIsOwner: params.attempt.senderIsOwner, + senderId: params.attempt.senderId, provider: params.attempt.provider, - model: params.attempt.modelId, + modelId: params.attempt.modelId, thinkLevel: params.attempt.thinkLevel, reasoningLevel: params.attempt.reasoningLevel, bashElevated: params.attempt.bashElevated, extraSystemPrompt: params.attempt.extraSystemPrompt, ownerNumbers: params.attempt.ownerNumbers, - }; + }); } function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } { @@ -1620,10 +1630,20 @@ export async function runEmbeddedAttempt( const reasoningTagHint = isReasoningTagProvider(params.provider); // Resolve channel-specific message actions for system prompt const channelActions = runtimeChannel - ? listChannelSupportedActions({ - cfg: params.config, - channel: runtimeChannel, - }) + ? listChannelSupportedActions( + buildEmbeddedMessageActionDiscoveryInput({ + cfg: params.config, + channel: runtimeChannel, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.agentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: sessionAgentId, + senderId: params.senderId, + }), + ) : undefined; const messageToolHints = runtimeChannel ? resolveChannelMessageToolHints({ 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 5e1088c3155..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 @@ -1,6 +1,10 @@ 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(); try { @@ -11,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 { @@ -20,30 +24,39 @@ function expectClearedTimeoutState(onTimeout: ReturnType, timedOut expect(vi.getTimerCount()).toBe(0); } +function buildAggregateTimeoutParams( + overrides: Partial & + Pick, +): AggregateTimeoutParams & { onTimeout: TimeoutCallbackMock } { + const onTimeout = + (overrides.onTimeout as TimeoutCallbackMock | undefined) ?? vi.fn(); + return { + waitForCompactionRetry: overrides.waitForCompactionRetry, + abortable: overrides.abortable ?? (async (promise) => await promise), + aggregateTimeoutMs: overrides.aggregateTimeoutMs ?? 60_000, + isCompactionStillInFlight: overrides.isCompactionStillInFlight, + onTimeout, + }; +} + describe("waitForCompactionRetryWithAggregateTimeout", () => { it("times out and fires callback when compaction retry never resolves", async () => { await withFakeTimers(async () => { - const onTimeout = vi.fn(); const waitForCompactionRetry = vi.fn(async () => await new Promise(() => {})); + const params = buildAggregateTimeoutParams({ waitForCompactionRetry }); - const resultPromise = waitForCompactionRetryWithAggregateTimeout({ - waitForCompactionRetry, - abortable: async (promise) => await promise, - aggregateTimeoutMs: 60_000, - onTimeout, - }); + const resultPromise = waitForCompactionRetryWithAggregateTimeout(params); await vi.advanceTimersByTimeAsync(60_000); const result = await resultPromise; expect(result.timedOut).toBe(true); - expectClearedTimeoutState(onTimeout, true); + expectClearedTimeoutState(params.onTimeout, true); }); }); it("keeps waiting while compaction remains in flight", async () => { await withFakeTimers(async () => { - const onTimeout = vi.fn(); let compactionInFlight = true; const waitForCompactionRetry = vi.fn( async () => @@ -54,62 +67,52 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { }, 170_000); }), ); - - const resultPromise = waitForCompactionRetryWithAggregateTimeout({ + const params = buildAggregateTimeoutParams({ waitForCompactionRetry, - abortable: async (promise) => await promise, - aggregateTimeoutMs: 60_000, - onTimeout, isCompactionStillInFlight: () => compactionInFlight, }); + const resultPromise = waitForCompactionRetryWithAggregateTimeout(params); + await vi.advanceTimersByTimeAsync(170_000); const result = await resultPromise; expect(result.timedOut).toBe(false); - expectClearedTimeoutState(onTimeout, false); + expectClearedTimeoutState(params.onTimeout, false); }); }); it("times out after an idle timeout window", async () => { await withFakeTimers(async () => { - const onTimeout = vi.fn(); let compactionInFlight = true; const waitForCompactionRetry = vi.fn(async () => await new Promise(() => {})); setTimeout(() => { compactionInFlight = false; }, 90_000); - - const resultPromise = waitForCompactionRetryWithAggregateTimeout({ + const params = buildAggregateTimeoutParams({ waitForCompactionRetry, - abortable: async (promise) => await promise, - aggregateTimeoutMs: 60_000, - onTimeout, isCompactionStillInFlight: () => compactionInFlight, }); + const resultPromise = waitForCompactionRetryWithAggregateTimeout(params); + await vi.advanceTimersByTimeAsync(120_000); const result = await resultPromise; expect(result.timedOut).toBe(true); - expectClearedTimeoutState(onTimeout, true); + expectClearedTimeoutState(params.onTimeout, true); }); }); it("does not time out when compaction retry resolves", async () => { await withFakeTimers(async () => { - const onTimeout = vi.fn(); const waitForCompactionRetry = vi.fn(async () => {}); + const params = buildAggregateTimeoutParams({ waitForCompactionRetry }); - const result = await waitForCompactionRetryWithAggregateTimeout({ - waitForCompactionRetry, - abortable: async (promise) => await promise, - aggregateTimeoutMs: 60_000, - onTimeout, - }); + const result = await waitForCompactionRetryWithAggregateTimeout(params); expect(result.timedOut).toBe(false); - expectClearedTimeoutState(onTimeout, false); + expectClearedTimeoutState(params.onTimeout, false); }); }); @@ -117,21 +120,17 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { await withFakeTimers(async () => { const abortError = new Error("aborted"); abortError.name = "AbortError"; - const onTimeout = vi.fn(); const waitForCompactionRetry = vi.fn(async () => await new Promise(() => {})); + const params = buildAggregateTimeoutParams({ + waitForCompactionRetry, + abortable: async () => { + throw abortError; + }, + }); - await expect( - waitForCompactionRetryWithAggregateTimeout({ - waitForCompactionRetry, - abortable: async () => { - throw abortError; - }, - aggregateTimeoutMs: 60_000, - onTimeout, - }), - ).rejects.toThrow("aborted"); + await expect(waitForCompactionRetryWithAggregateTimeout(params)).rejects.toThrow("aborted"); - expectClearedTimeoutState(onTimeout, false); + expectClearedTimeoutState(params.onTimeout, false); }); }); }); diff --git a/src/agents/pi-embedded-runner/run/compaction-timeout.test.ts b/src/agents/pi-embedded-runner/run/compaction-timeout.test.ts index 3853e0ebd25..54d6320297c 100644 --- a/src/agents/pi-embedded-runner/run/compaction-timeout.test.ts +++ b/src/agents/pi-embedded-runner/run/compaction-timeout.test.ts @@ -7,6 +7,30 @@ import { shouldFlagCompactionTimeout, } from "./compaction-timeout.js"; +function expectSelectedSnapshot(params: { + currentSessionId: string; + currentSnapshot: Parameters[0]["currentSnapshot"]; + expectedSessionIdUsed: string; + expectedSnapshot: ReadonlyArray>; + expectedSource: "current" | "pre-compaction"; + preCompactionSessionId: string; + preCompactionSnapshot: Parameters< + typeof selectCompactionTimeoutSnapshot + >[0]["preCompactionSnapshot"]; + timedOutDuringCompaction: boolean; +}) { + const selected = selectCompactionTimeoutSnapshot({ + timedOutDuringCompaction: params.timedOutDuringCompaction, + preCompactionSnapshot: params.preCompactionSnapshot, + preCompactionSessionId: params.preCompactionSessionId, + currentSnapshot: params.currentSnapshot, + currentSessionId: params.currentSessionId, + }); + expect(selected.source).toBe(params.expectedSource); + expect(selected.sessionIdUsed).toBe(params.expectedSessionIdUsed); + expect(selected.messagesSnapshot).toEqual(params.expectedSnapshot); +} + describe("compaction-timeout helpers", () => { it("flags compaction timeout consistently for internal and external timeout sources", () => { const internalTimer = shouldFlagCompactionTimeout({ @@ -75,29 +99,29 @@ describe("compaction-timeout helpers", () => { it("uses pre-compaction snapshot when compaction timeout occurs", () => { const pre = [castAgentMessage({ role: "assistant", content: "pre" })] as const; const current = [castAgentMessage({ role: "assistant", content: "current" })] as const; - const selected = selectCompactionTimeoutSnapshot({ + expectSelectedSnapshot({ timedOutDuringCompaction: true, preCompactionSnapshot: [...pre], preCompactionSessionId: "session-pre", currentSnapshot: [...current], currentSessionId: "session-current", + expectedSource: "pre-compaction", + expectedSessionIdUsed: "session-pre", + expectedSnapshot: pre, }); - expect(selected.source).toBe("pre-compaction"); - expect(selected.sessionIdUsed).toBe("session-pre"); - expect(selected.messagesSnapshot).toEqual(pre); }); it("falls back to current snapshot when pre-compaction snapshot is unavailable", () => { const current = [castAgentMessage({ role: "assistant", content: "current" })] as const; - const selected = selectCompactionTimeoutSnapshot({ + expectSelectedSnapshot({ timedOutDuringCompaction: true, preCompactionSnapshot: null, preCompactionSessionId: "session-pre", currentSnapshot: [...current], currentSessionId: "session-current", + expectedSource: "current", + expectedSessionIdUsed: "session-current", + expectedSnapshot: current, }); - expect(selected.source).toBe("current"); - expect(selected.sessionIdUsed).toBe("session-current"); - expect(selected.messagesSnapshot).toEqual(current); }); }); diff --git a/src/agents/pi-embedded-runner/run/failover-observation.test.ts b/src/agents/pi-embedded-runner/run/failover-observation.test.ts index 763540f9ca7..71363915b46 100644 --- a/src/agents/pi-embedded-runner/run/failover-observation.test.ts +++ b/src/agents/pi-embedded-runner/run/failover-observation.test.ts @@ -1,21 +1,31 @@ import { describe, expect, it } from "vitest"; import { normalizeFailoverDecisionObservationBase } from "./failover-observation.js"; +function normalizeObservation( + overrides: Partial[0]>, +) { + return normalizeFailoverDecisionObservationBase({ + stage: "assistant", + runId: "run:base", + rawError: "", + failoverReason: null, + profileFailureReason: null, + provider: "openai", + model: "mock-1", + profileId: "openai:p1", + fallbackConfigured: false, + timedOut: false, + aborted: false, + ...overrides, + }); +} + describe("normalizeFailoverDecisionObservationBase", () => { it("fills timeout observation reasons for deadline timeouts without provider error text", () => { expect( - normalizeFailoverDecisionObservationBase({ - stage: "assistant", + normalizeObservation({ runId: "run:timeout", - rawError: "", - failoverReason: null, - profileFailureReason: null, - provider: "openai", - model: "mock-1", - profileId: "openai:p1", - fallbackConfigured: false, timedOut: true, - aborted: false, }), ).toMatchObject({ failoverReason: "timeout", @@ -26,18 +36,13 @@ describe("normalizeFailoverDecisionObservationBase", () => { it("preserves explicit failover reasons", () => { expect( - normalizeFailoverDecisionObservationBase({ - stage: "assistant", + normalizeObservation({ runId: "run:overloaded", rawError: '{"error":{"type":"overloaded_error"}}', failoverReason: "overloaded", profileFailureReason: "overloaded", - provider: "openai", - model: "mock-1", - profileId: "openai:p1", fallbackConfigured: true, timedOut: true, - aborted: false, }), ).toMatchObject({ failoverReason: "overloaded", diff --git a/src/agents/pi-embedded-runner/run/history-image-prune.test.ts b/src/agents/pi-embedded-runner/run/history-image-prune.test.ts index dbed0335435..03e532eda2e 100644 --- a/src/agents/pi-embedded-runner/run/history-image-prune.test.ts +++ b/src/agents/pi-embedded-runner/run/history-image-prune.test.ts @@ -4,6 +4,28 @@ import { describe, expect, it } from "vitest"; import { castAgentMessage } from "../../test-helpers/agent-message-fixtures.js"; import { PRUNED_HISTORY_IMAGE_MARKER, pruneProcessedHistoryImages } from "./history-image-prune.js"; +function expectArrayMessageContent( + message: AgentMessage | undefined, + errorMessage: string, +): Array<{ type: string; text?: string; data?: string }> { + if (!message || !("content" in message) || !Array.isArray(message.content)) { + throw new Error(errorMessage); + } + return message.content as Array<{ type: string; text?: string; data?: string }>; +} + +function expectPrunedImageMessage( + messages: AgentMessage[], + errorMessage: string, +): Array<{ type: string; text?: string; data?: string }> { + const didMutate = pruneProcessedHistoryImages(messages); + expect(didMutate).toBe(true); + const content = expectArrayMessageContent(messages[0], errorMessage); + expect(content).toHaveLength(2); + expect(content[1]).toMatchObject({ type: "text", text: PRUNED_HISTORY_IMAGE_MARKER }); + return content; +} + describe("pruneProcessedHistoryImages", () => { const image: ImageContent = { type: "image", data: "abc", mimeType: "image/png" }; @@ -19,15 +41,8 @@ describe("pruneProcessedHistoryImages", () => { }), ]; - const didMutate = pruneProcessedHistoryImages(messages); - - expect(didMutate).toBe(true); - const firstUser = messages[0] as Extract | undefined; - expect(Array.isArray(firstUser?.content)).toBe(true); - const content = firstUser?.content as Array<{ type: string; text?: string; data?: string }>; - expect(content).toHaveLength(2); + const content = expectPrunedImageMessage(messages, "expected user array content"); expect(content[0]?.type).toBe("text"); - expect(content[1]).toMatchObject({ type: "text", text: PRUNED_HISTORY_IMAGE_MARKER }); }); it("does not prune latest user message when no assistant response exists yet", () => { @@ -41,12 +56,9 @@ describe("pruneProcessedHistoryImages", () => { const didMutate = pruneProcessedHistoryImages(messages); expect(didMutate).toBe(false); - const first = messages[0] as Extract | undefined; - if (!first || !Array.isArray(first.content)) { - throw new Error("expected array content"); - } - expect(first.content).toHaveLength(2); - expect(first.content[1]).toMatchObject({ type: "image", data: "abc" }); + const content = expectArrayMessageContent(messages[0], "expected user array content"); + expect(content).toHaveLength(2); + expect(content[1]).toMatchObject({ type: "image", data: "abc" }); }); it("prunes image blocks from toolResult messages that already have assistant replies", () => { @@ -62,15 +74,7 @@ describe("pruneProcessedHistoryImages", () => { }), ]; - const didMutate = pruneProcessedHistoryImages(messages); - - expect(didMutate).toBe(true); - const firstTool = messages[0] as Extract | undefined; - if (!firstTool || !Array.isArray(firstTool.content)) { - throw new Error("expected toolResult array content"); - } - expect(firstTool.content).toHaveLength(2); - expect(firstTool.content[1]).toMatchObject({ type: "text", text: PRUNED_HISTORY_IMAGE_MARKER }); + expectPrunedImageMessage(messages, "expected toolResult array content"); }); it("does not change messages when no assistant turn exists", () => { diff --git a/src/agents/pi-embedded-runner/run/images.test.ts b/src/agents/pi-embedded-runner/run/images.test.ts index 8a879a1bb36..59b3673e90f 100644 --- a/src/agents/pi-embedded-runner/run/images.test.ts +++ b/src/agents/pi-embedded-runner/run/images.test.ts @@ -11,13 +11,34 @@ import { modelSupportsImages, } from "./images.js"; +function expectNoPromptImages(result: { detectedRefs: unknown[]; images: unknown[] }) { + expect(result.detectedRefs).toHaveLength(0); + expect(result.images).toHaveLength(0); +} + +function expectNoImageReferences(prompt: string) { + const refs = detectImageReferences(prompt); + expect(refs).toHaveLength(0); +} + +function expectImageReferenceCount(prompt: string, count: number) { + const refs = detectImageReferences(prompt); + expect(refs).toHaveLength(count); + return refs; +} + +function expectSingleImageReference(prompt: string) { + const refs = expectImageReferenceCount(prompt, 1); + return refs[0]; +} + describe("detectImageReferences", () => { it("detects absolute file paths with common extensions", () => { - const prompt = "Check this image /path/to/screenshot.png and tell me what you see"; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference( + "Check this image /path/to/screenshot.png and tell me what you see", + ); - expect(refs).toHaveLength(1); - expect(refs[0]).toEqual({ + expect(ref).toEqual({ raw: "/path/to/screenshot.png", type: "path", resolved: "/path/to/screenshot.png", @@ -25,43 +46,38 @@ describe("detectImageReferences", () => { }); it("detects relative paths starting with ./", () => { - const prompt = "Look at ./images/photo.jpg"; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference("Look at ./images/photo.jpg"); - expect(refs).toHaveLength(1); - expect(refs[0]?.raw).toBe("./images/photo.jpg"); - expect(refs[0]?.type).toBe("path"); + expect(ref?.raw).toBe("./images/photo.jpg"); + expect(ref?.type).toBe("path"); }); it("detects relative paths starting with ../", () => { - const prompt = "The file is at ../screenshots/test.jpeg"; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference("The file is at ../screenshots/test.jpeg"); - expect(refs).toHaveLength(1); - expect(refs[0]?.raw).toBe("../screenshots/test.jpeg"); - expect(refs[0]?.type).toBe("path"); + expect(ref?.raw).toBe("../screenshots/test.jpeg"); + expect(ref?.type).toBe("path"); }); it("detects home directory paths starting with ~/", () => { - const prompt = "My photo is at ~/Pictures/vacation.png"; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference("My photo is at ~/Pictures/vacation.png"); - expect(refs).toHaveLength(1); - expect(refs[0]?.raw).toBe("~/Pictures/vacation.png"); - expect(refs[0]?.type).toBe("path"); + expect(ref?.raw).toBe("~/Pictures/vacation.png"); + expect(ref?.type).toBe("path"); // Resolved path should expand ~ - expect(refs[0]?.resolved?.startsWith("~")).toBe(false); + expect(ref?.resolved?.startsWith("~")).toBe(false); }); it("detects multiple image references in a prompt", () => { - const prompt = ` + const refs = expectImageReferenceCount( + ` Compare these two images: 1. /home/user/photo1.png 2. https://mysite.com/photo2.jpg - `; - const refs = detectImageReferences(prompt); + `, + 1, + ); - expect(refs).toHaveLength(1); expect(refs.some((r) => r.type === "path")).toBe(true); }); @@ -76,121 +92,103 @@ describe("detectImageReferences", () => { }); it("deduplicates repeated image references", () => { - const prompt = "Look at /path/image.png and also /path/image.png again"; - const refs = detectImageReferences(prompt); - - expect(refs).toHaveLength(1); + expectImageReferenceCount("Look at /path/image.png and also /path/image.png again", 1); }); it("dedupe casing follows host filesystem conventions", () => { - const prompt = "Look at /tmp/Image.png and /tmp/image.png"; - const refs = detectImageReferences(prompt); - if (process.platform === "win32") { - expect(refs).toHaveLength(1); + expectImageReferenceCount("Look at /tmp/Image.png and /tmp/image.png", 1); return; } - expect(refs).toHaveLength(2); + expectImageReferenceCount("Look at /tmp/Image.png and /tmp/image.png", 2); }); it("returns empty array when no images found", () => { - const prompt = "Just some text without any image references"; - const refs = detectImageReferences(prompt); - - expect(refs).toHaveLength(0); + expectNoImageReferences("Just some text without any image references"); }); it("ignores non-image file extensions", () => { - const prompt = "Check /path/to/document.pdf and /code/file.ts"; - const refs = detectImageReferences(prompt); - - expect(refs).toHaveLength(0); + expectNoImageReferences("Check /path/to/document.pdf and /code/file.ts"); }); it("handles paths inside quotes (without spaces)", () => { - const prompt = 'The file is at "/path/to/image.png"'; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference('The file is at "/path/to/image.png"'); - expect(refs).toHaveLength(1); - expect(refs[0]?.raw).toBe("/path/to/image.png"); + expect(ref?.raw).toBe("/path/to/image.png"); }); it("handles paths in parentheses", () => { - const prompt = "See the image (./screenshot.png) for details"; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference("See the image (./screenshot.png) for details"); - expect(refs).toHaveLength(1); - expect(refs[0]?.raw).toBe("./screenshot.png"); + expect(ref?.raw).toBe("./screenshot.png"); }); it("detects [Image: source: ...] format from messaging systems", () => { - const prompt = `What does this image show? -[Image: source: /Users/tyleryust/Library/Messages/Attachments/IMG_0043.jpeg]`; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference(`What does this image show? +[Image: source: /Users/tyleryust/Library/Messages/Attachments/IMG_0043.jpeg]`); - expect(refs).toHaveLength(1); - expect(refs[0]?.raw).toBe("/Users/tyleryust/Library/Messages/Attachments/IMG_0043.jpeg"); - expect(refs[0]?.type).toBe("path"); + expect(ref?.raw).toBe("/Users/tyleryust/Library/Messages/Attachments/IMG_0043.jpeg"); + expect(ref?.type).toBe("path"); }); it("handles complex message attachment paths", () => { - const prompt = `[Image: source: /Users/tyleryust/Library/Messages/Attachments/23/03/AA4726EA-DB27-4269-BA56-1436936CC134/5E3E286A-F585-4E5E-9043-5BC2AFAFD81BIMG_0043.jpeg]`; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference( + "[Image: source: /Users/tyleryust/Library/Messages/Attachments/23/03/AA4726EA-DB27-4269-BA56-1436936CC134/5E3E286A-F585-4E5E-9043-5BC2AFAFD81BIMG_0043.jpeg]", + ); - expect(refs).toHaveLength(1); - expect(refs[0]?.resolved).toContain("IMG_0043.jpeg"); + expect(ref?.resolved).toContain("IMG_0043.jpeg"); }); it("detects multiple images in [media attached: ...] format", () => { // Multi-file format uses separate brackets on separate lines - const prompt = `[media attached: 2 files] + const refs = expectImageReferenceCount( + `[media attached: 2 files] [media attached 1/2: /Users/tyleryust/.openclaw/media/IMG_6430.jpeg (image/jpeg)] [media attached 2/2: /Users/tyleryust/.openclaw/media/IMG_6431.jpeg (image/jpeg)] -what about these images?`; - const refs = detectImageReferences(prompt); +what about these images?`, + 2, + ); - expect(refs).toHaveLength(2); expect(refs[0]?.resolved).toContain("IMG_6430.jpeg"); expect(refs[1]?.resolved).toContain("IMG_6431.jpeg"); }); it("does not double-count path and url in same bracket", () => { // Single file with URL (| separates path from url, not multiple files) - const prompt = `[media attached: /cache/IMG_6430.jpeg (image/jpeg) | /cache/IMG_6430.jpeg]`; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference( + "[media attached: /cache/IMG_6430.jpeg (image/jpeg) | /cache/IMG_6430.jpeg]", + ); - expect(refs).toHaveLength(1); - expect(refs[0]?.resolved).toContain("IMG_6430.jpeg"); + expect(ref?.resolved).toContain("IMG_6430.jpeg"); }); it("ignores remote URLs entirely (local-only)", () => { - const prompt = `To send an image: MEDIA:https://example.com/image.jpg + const refs = expectImageReferenceCount( + `To send an image: MEDIA:https://example.com/image.jpg Here is my actual image: /path/to/real.png -Also https://cdn.mysite.com/img.jpg`; - const refs = detectImageReferences(prompt); +Also https://cdn.mysite.com/img.jpg`, + 1, + ); - expect(refs).toHaveLength(1); expect(refs[0]?.raw).toBe("/path/to/real.png"); }); it("handles single file format with URL (no index)", () => { - const prompt = `[media attached: /cache/photo.jpeg (image/jpeg) | https://example.com/url] -what is this?`; - const refs = detectImageReferences(prompt); + const ref = + expectSingleImageReference(`[media attached: /cache/photo.jpeg (image/jpeg) | https://example.com/url] +what is this?`); - expect(refs).toHaveLength(1); - expect(refs[0]?.resolved).toContain("photo.jpeg"); + expect(ref?.resolved).toContain("photo.jpeg"); }); it("handles paths with spaces in filename", () => { // URL after | is https, not a local path, so only the local path should be detected - const prompt = `[media attached: /Users/test/.openclaw/media/ChatGPT Image Apr 21, 2025.png (image/png) | https://example.com/same.png] -what is this?`; - const refs = detectImageReferences(prompt); + const ref = + expectSingleImageReference(`[media attached: /Users/test/.openclaw/media/ChatGPT Image Apr 21, 2025.png (image/png) | https://example.com/same.png] +what is this?`); // Only 1 ref - the local path (example.com URLs are skipped) - expect(refs).toHaveLength(1); - expect(refs[0]?.resolved).toContain("ChatGPT Image Apr 21, 2025.png"); + expect(ref?.resolved).toContain("ChatGPT Image Apr 21, 2025.png"); }); }); @@ -262,8 +260,7 @@ describe("detectAndLoadPromptImages", () => { existingImages: [{ type: "image", data: "abc", mimeType: "image/png" }], }); - expect(result.images).toHaveLength(0); - expect(result.detectedRefs).toHaveLength(0); + expectNoPromptImages(result); }); it("returns no detected refs when prompt has no image references", async () => { @@ -273,8 +270,7 @@ describe("detectAndLoadPromptImages", () => { model: { input: ["text", "image"] }, }); - expect(result.detectedRefs).toHaveLength(0); - expect(result.images).toHaveLength(0); + expectNoPromptImages(result); }); it("blocks prompt image refs outside workspace when sandbox workspaceOnly is enabled", async () => { diff --git a/src/agents/pi-embedded-runner/run/payloads.errors.test.ts b/src/agents/pi-embedded-runner/run/payloads.errors.test.ts index a2e7873aedf..5aa8dfe7fd6 100644 --- a/src/agents/pi-embedded-runner/run/payloads.errors.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.errors.test.ts @@ -40,8 +40,25 @@ describe("buildEmbeddedRunPayloads", () => { expect(payloads[0]?.text).toBe(OVERLOADED_FALLBACK_TEXT); }; + function expectSinglePayloadSummary( + payloads: ReturnType, + expected: { text: string; isError?: boolean }, + ) { + expectSinglePayloadText(payloads, expected.text); + if (expected.isError === undefined) { + expect(payloads[0]?.isError).toBeUndefined(); + return; + } + expect(payloads[0]?.isError).toBe(expected.isError); + } + + function expectNoPayloads(params: Parameters[0]) { + const payloads = buildPayloads(params); + expect(payloads).toHaveLength(0); + } + function expectNoSyntheticCompletionForSession(sessionKey: string) { - const payloads = buildPayloads({ + expectNoPayloads({ sessionKey, toolMetas: [{ toolName: "write", meta: "/tmp/out.md" }], lastAssistant: makeAssistant({ @@ -50,7 +67,6 @@ describe("buildEmbeddedRunPayloads", () => { content: [], }), }); - expect(payloads).toHaveLength(0); } it("suppresses raw API error JSON when the assistant errored", () => { @@ -96,9 +112,10 @@ describe("buildEmbeddedRunPayloads", () => { model: "claude-3-5-sonnet", }); - expect(payloads).toHaveLength(1); - expect(payloads[0]?.text).toBe(formatBillingErrorMessage("Anthropic", "claude-3-5-sonnet")); - expect(payloads[0]?.isError).toBe(true); + expectSinglePayloadSummary(payloads, { + text: formatBillingErrorMessage("Anthropic", "claude-3-5-sonnet"), + isError: true, + }); }); it("does not emit a synthetic billing error for successful turns with stale errorMessage", () => { @@ -155,13 +172,11 @@ describe("buildEmbeddedRunPayloads", () => { }); it("does not add synthetic completion text when tools run without final assistant text", () => { - const payloads = buildPayloads({ + expectNoPayloads({ sessionKey: "agent:main:discord:direct:u123", toolMetas: [{ toolName: "write", meta: "/tmp/out.md" }], lastAssistant: makeStoppedAssistant(), }); - - expect(payloads).toHaveLength(0); }); it("does not add synthetic completion text for channel sessions", () => { @@ -173,7 +188,7 @@ describe("buildEmbeddedRunPayloads", () => { }); it("does not add synthetic completion text when messaging tool already delivered output", () => { - const payloads = buildPayloads({ + expectNoPayloads({ sessionKey: "agent:main:discord:direct:u123", toolMetas: [{ toolName: "message_send", meta: "sent to #ops" }], didSendViaMessagingTool: true, @@ -183,25 +198,19 @@ describe("buildEmbeddedRunPayloads", () => { content: [], }), }); - - expect(payloads).toHaveLength(0); }); it("does not add synthetic completion text when the run still has a tool error", () => { - const payloads = buildPayloads({ + expectNoPayloads({ toolMetas: [{ toolName: "browser", meta: "open https://example.com" }], lastToolError: { toolName: "browser", error: "url required" }, }); - - expect(payloads).toHaveLength(0); }); it("does not add synthetic completion text when no tools ran", () => { - const payloads = buildPayloads({ + expectNoPayloads({ lastAssistant: makeStoppedAssistant(), }); - - expect(payloads).toHaveLength(0); }); it("adds tool error fallback when the assistant only invoked tools and verbose mode is on", () => { @@ -246,52 +255,32 @@ describe("buildEmbeddedRunPayloads", () => { lastToolError: { toolName: "browser", error: "connection timeout" }, }); - expect(payloads).toHaveLength(1); - expect(payloads[0]?.isError).toBeUndefined(); - expect(payloads[0]?.text).toContain("recovered"); - }); - - it("suppresses recoverable tool errors containing 'required' for non-mutating tools", () => { - const payloads = buildPayloads({ - lastToolError: { toolName: "browser", error: "url required" }, + expectSinglePayloadSummary(payloads, { + text: "Checked the page and recovered with final answer.", }); - - // Recoverable errors should not be sent to the user - expect(payloads).toHaveLength(0); }); - it("suppresses recoverable tool errors containing 'missing' for non-mutating tools", () => { - const payloads = buildPayloads({ - lastToolError: { toolName: "browser", error: "url missing" }, - }); - - expect(payloads).toHaveLength(0); - }); - - it("suppresses recoverable tool errors containing 'invalid' for non-mutating tools", () => { - const payloads = buildPayloads({ - lastToolError: { toolName: "browser", error: "invalid parameter: url" }, - }); - - expect(payloads).toHaveLength(0); - }); + it.each(["url required", "url missing", "invalid parameter: url"])( + "suppresses recoverable non-mutating tool error: %s", + (error) => { + expectNoPayloads({ + lastToolError: { toolName: "browser", error }, + }); + }, + ); it("suppresses non-mutating non-recoverable tool errors when messages.suppressToolErrors is enabled", () => { - const payloads = buildPayloads({ + expectNoPayloads({ lastToolError: { toolName: "browser", error: "connection timeout" }, config: { messages: { suppressToolErrors: true } }, }); - - expect(payloads).toHaveLength(0); }); it("suppresses mutating tool errors when suppressToolErrorWarnings is enabled", () => { - const payloads = buildPayloads({ + expectNoPayloads({ lastToolError: { toolName: "exec", error: "command not found" }, suppressToolErrorWarnings: true, }); - - expect(payloads).toHaveLength(0); }); it.each([ @@ -350,8 +339,7 @@ describe("buildEmbeddedRunPayloads", () => { }, }); - expect(payloads).toHaveLength(1); - expect(payloads[0]?.text).toBe("Status loaded."); + expectSinglePayloadSummary(payloads, { text: "Status loaded." }); }); it("dedupes identical tool warning text already present in assistant output", () => { @@ -375,8 +363,7 @@ describe("buildEmbeddedRunPayloads", () => { }, }); - expect(payloads).toHaveLength(1); - expect(payloads[0]?.text).toBe(warningText); + expectSinglePayloadSummary(payloads, { text: warningText ?? "" }); }); it("includes non-recoverable tool error details when verbose mode is on", () => { diff --git a/src/agents/pi-embedded-runner/run/payloads.test.ts b/src/agents/pi-embedded-runner/run/payloads.test.ts index 6c81fb12150..5fa54d5f57c 100644 --- a/src/agents/pi-embedded-runner/run/payloads.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.test.ts @@ -2,13 +2,16 @@ import { describe, expect, it } from "vitest"; import { buildPayloads, expectSingleToolErrorPayload } from "./payloads.test-helpers.js"; describe("buildEmbeddedRunPayloads tool-error warnings", () => { + function expectNoPayloads(params: Parameters[0]) { + const payloads = buildPayloads(params); + expect(payloads).toHaveLength(0); + } + it("suppresses exec tool errors when verbose mode is off", () => { - const payloads = buildPayloads({ + expectNoPayloads({ lastToolError: { toolName: "exec", error: "command failed" }, verboseLevel: "off", }); - - expect(payloads).toHaveLength(0); }); it("shows exec tool errors when verbose mode is on", () => { @@ -61,34 +64,30 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => { }); }); - it("suppresses sessions_send errors to avoid leaking transient relay failures", () => { - const payloads = buildPayloads({ + it.each([ + { + name: "default relay failure", lastToolError: { toolName: "sessions_send", error: "delivery timeout" }, - verboseLevel: "on", - }); - - expect(payloads).toHaveLength(0); - }); - - it("suppresses sessions_send errors even when marked mutating", () => { - const payloads = buildPayloads({ + }, + { + name: "mutating relay failure", lastToolError: { toolName: "sessions_send", error: "delivery timeout", mutatingAction: true, }, + }, + ])("suppresses sessions_send errors for $name", ({ lastToolError }) => { + expectNoPayloads({ + lastToolError, verboseLevel: "on", }); - - expect(payloads).toHaveLength(0); }); it("suppresses assistant text when a deterministic exec approval prompt was already delivered", () => { - const payloads = buildPayloads({ + expectNoPayloads({ assistantTexts: ["Approval is needed. Please run /approve abc allow-once"], didSendDeterministicApprovalPrompt: true, }); - - expect(payloads).toHaveLength(0); }); }); diff --git a/src/agents/pi-embedded-runner/runs.test.ts b/src/agents/pi-embedded-runner/runs.test.ts index 3a4eb6d3743..82baac1ca1e 100644 --- a/src/agents/pi-embedded-runner/runs.test.ts +++ b/src/agents/pi-embedded-runner/runs.test.ts @@ -10,6 +10,20 @@ import { waitForActiveEmbeddedRuns, } from "./runs.js"; +type RunHandle = Parameters[1]; + +function createRunHandle( + overrides: { isCompacting?: boolean; abort?: () => void } = {}, +): RunHandle { + const abort = overrides.abort ?? (() => {}); + return { + queueMessage: async () => {}, + isStreaming: () => true, + isCompacting: () => overrides.isCompacting ?? false, + abort, + }; +} + describe("pi-embedded runner run registry", () => { afterEach(() => { __testing.resetActiveEmbeddedRuns(); @@ -20,19 +34,12 @@ describe("pi-embedded runner run registry", () => { const abortCompacting = vi.fn(); const abortNormal = vi.fn(); - setActiveEmbeddedRun("session-compacting", { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => true, - abort: abortCompacting, - }); + setActiveEmbeddedRun( + "session-compacting", + createRunHandle({ isCompacting: true, abort: abortCompacting }), + ); - setActiveEmbeddedRun("session-normal", { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => false, - abort: abortNormal, - }); + setActiveEmbeddedRun("session-normal", createRunHandle({ abort: abortNormal })); const aborted = abortEmbeddedPiRun(undefined, { mode: "compacting" }); expect(aborted).toBe(true); @@ -44,19 +51,9 @@ describe("pi-embedded runner run registry", () => { const abortA = vi.fn(); const abortB = vi.fn(); - setActiveEmbeddedRun("session-a", { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => true, - abort: abortA, - }); + setActiveEmbeddedRun("session-a", createRunHandle({ isCompacting: true, abort: abortA })); - setActiveEmbeddedRun("session-b", { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => false, - abort: abortB, - }); + setActiveEmbeddedRun("session-b", createRunHandle({ abort: abortB })); const aborted = abortEmbeddedPiRun(undefined, { mode: "all" }); expect(aborted).toBe(true); @@ -67,12 +64,7 @@ describe("pi-embedded runner run registry", () => { it("waits for active runs to drain", async () => { vi.useFakeTimers(); try { - const handle = { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => false, - abort: vi.fn(), - }; + const handle = createRunHandle(); setActiveEmbeddedRun("session-a", handle); setTimeout(() => { clearActiveEmbeddedRun("session-a", handle); @@ -92,12 +84,7 @@ describe("pi-embedded runner run registry", () => { it("returns drained=false when timeout elapses", async () => { vi.useFakeTimers(); try { - setActiveEmbeddedRun("session-a", { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => false, - abort: vi.fn(), - }); + setActiveEmbeddedRun("session-a", createRunHandle()); const waitPromise = waitForActiveEmbeddedRuns(1_000, { pollMs: 100 }); await vi.advanceTimersByTimeAsync(1_000); @@ -118,12 +105,7 @@ describe("pi-embedded runner run registry", () => { import.meta.url, "./runs.js?scope=shared-b", ); - const handle = { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => false, - abort: vi.fn(), - }; + const handle = createRunHandle(); runsA.__testing.resetActiveEmbeddedRuns(); runsB.__testing.resetActiveEmbeddedRuns(); @@ -141,12 +123,7 @@ describe("pi-embedded runner run registry", () => { }); it("tracks and clears per-session transcript snapshots for active runs", () => { - const handle = { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => false, - abort: vi.fn(), - }; + const handle = createRunHandle(); setActiveEmbeddedRun("session-snapshot", handle); updateActiveEmbeddedRunSnapshot("session-snapshot", { diff --git a/src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts b/src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts index e05ffd19cbf..69a81d129fb 100644 --- a/src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts +++ b/src/agents/pi-embedded-runner/sessions-yield.orchestration.test.ts @@ -3,20 +3,25 @@ * with no pending tool calls, so the parent session is idle when subagent * results arrive. */ -import "./run.overflow-compaction.mocks.shared.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { runEmbeddedPiAgent } from "./run.js"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; -import { mockedGlobalHookRunner } from "./run.overflow-compaction.mocks.shared.js"; import { + loadRunOverflowCompactionHarness, + mockedGlobalHookRunner, mockedRunEmbeddedAttempt, overflowBaseRunParams, -} from "./run.overflow-compaction.shared-test.js"; +} from "./run.overflow-compaction.harness.js"; import { isEmbeddedPiRunActive, queueEmbeddedPiMessage } from "./runs.js"; +let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; + describe("sessions_yield orchestration", () => { + beforeAll(async () => { + ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + }); + beforeEach(() => { - vi.clearAllMocks(); + mockedRunEmbeddedAttempt.mockReset(); mockedGlobalHookRunner.hasHooks.mockImplementation(() => false); }); diff --git a/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts b/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts index 8d42b061b81..437b021cdd7 100644 --- a/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts +++ b/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts @@ -31,6 +31,14 @@ async function setupBundledDiffsPlugin() { return { bundledPluginsDir, workspaceDir }; } +async function resolveBundledDiffsSkillEntries(config?: OpenClawConfig) { + const { bundledPluginsDir, workspaceDir } = await setupBundledDiffsPlugin(); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledPluginsDir; + clearPluginManifestRegistryCache(); + + return resolveEmbeddedRunSkillEntries({ workspaceDir, ...(config ? { config } : {}) }); +} + afterEach(async () => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledDir; clearPluginManifestRegistryCache(); @@ -41,10 +49,6 @@ afterEach(async () => { describe("resolveEmbeddedRunSkillEntries (integration)", () => { it("loads bundled diffs skill when explicitly enabled in config", async () => { - const { bundledPluginsDir, workspaceDir } = await setupBundledDiffsPlugin(); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledPluginsDir; - clearPluginManifestRegistryCache(); - const config: OpenClawConfig = { plugins: { entries: { @@ -53,23 +57,14 @@ describe("resolveEmbeddedRunSkillEntries (integration)", () => { }, }; - const result = resolveEmbeddedRunSkillEntries({ - workspaceDir, - config, - }); + const result = await resolveBundledDiffsSkillEntries(config); expect(result.shouldLoadSkillEntries).toBe(true); expect(result.skillEntries.map((entry) => entry.skill.name)).toContain("diffs"); }); it("skips bundled diffs skill when config is missing", async () => { - const { bundledPluginsDir, workspaceDir } = await setupBundledDiffsPlugin(); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledPluginsDir; - clearPluginManifestRegistryCache(); - - const result = resolveEmbeddedRunSkillEntries({ - workspaceDir, - }); + const result = await resolveBundledDiffsSkillEntries(); expect(result.shouldLoadSkillEntries).toBe(true); expect(result.skillEntries.map((entry) => entry.skill.name)).not.toContain("diffs"); diff --git a/src/agents/pi-embedded-runner/system-prompt.test.ts b/src/agents/pi-embedded-runner/system-prompt.test.ts index 355b2c67ae9..b50565eb738 100644 --- a/src/agents/pi-embedded-runner/system-prompt.test.ts +++ b/src/agents/pi-embedded-runner/system-prompt.test.ts @@ -2,50 +2,63 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; import { applySystemPromptOverrideToSession, createSystemPromptOverride } from "./system-prompt.js"; -function createMockSession() { - const setSystemPrompt = vi.fn(); +type MutableSession = { + _baseSystemPrompt?: string; + _rebuildSystemPrompt?: (toolNames: string[]) => string; +}; + +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 }; } +function applyAndGetMutableSession( + prompt: Parameters[1], +) { + const { session, setSystemPrompt } = createMockSession(); + applySystemPromptOverrideToSession(session as unknown as AgentSession, prompt); + return { + mutable: session, + setSystemPrompt, + }; +} + describe("applySystemPromptOverrideToSession", () => { it("applies a string override to the session system prompt", () => { - const { session, setSystemPrompt } = createMockSession(); const prompt = "You are a helpful assistant with custom context."; - - applySystemPromptOverrideToSession(session, prompt); + const { mutable, setSystemPrompt } = applyAndGetMutableSession(prompt); expect(setSystemPrompt).toHaveBeenCalledWith(prompt); - const mutable = session as unknown as { _baseSystemPrompt?: string }; expect(mutable._baseSystemPrompt).toBe(prompt); }); it("trims whitespace from string overrides", () => { - const { session, setSystemPrompt } = createMockSession(); - - applySystemPromptOverrideToSession(session, " padded prompt "); + const { setSystemPrompt } = applyAndGetMutableSession(" padded prompt "); expect(setSystemPrompt).toHaveBeenCalledWith("padded prompt"); }); it("applies a function override to the session system prompt", () => { - const { session, setSystemPrompt } = createMockSession(); const override = createSystemPromptOverride("function-based prompt"); - - applySystemPromptOverrideToSession(session, override); + const { setSystemPrompt } = applyAndGetMutableSession(override); expect(setSystemPrompt).toHaveBeenCalledWith("function-based prompt"); }); it("sets _rebuildSystemPrompt that returns the override", () => { - const { session } = createMockSession(); - applySystemPromptOverrideToSession(session, "rebuild test"); - - const mutable = session as unknown as { - _rebuildSystemPrompt?: (toolNames: string[]) => string; - }; + const { mutable } = applyAndGetMutableSession("rebuild test"); expect(mutable._rebuildSystemPrompt?.(["tool1"])).toBe("rebuild test"); }); }); diff --git a/src/agents/pi-embedded-runner/thinking.test.ts b/src/agents/pi-embedded-runner/thinking.test.ts index 6a2481748a1..e3d0a8291b6 100644 --- a/src/agents/pi-embedded-runner/thinking.test.ts +++ b/src/agents/pi-embedded-runner/thinking.test.ts @@ -3,6 +3,22 @@ import { describe, expect, it } from "vitest"; import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js"; import { dropThinkingBlocks, isAssistantMessageWithContent } from "./thinking.js"; +function dropSingleAssistantContent(content: Array>) { + const messages: AgentMessage[] = [ + castAgentMessage({ + role: "assistant", + content, + }), + ]; + + const result = dropThinkingBlocks(messages); + return { + assistant: result[0] as Extract, + messages, + result, + }; +} + describe("isAssistantMessageWithContent", () => { it("accepts assistant messages with array content and rejects others", () => { const assistant = castAgentMessage({ @@ -30,32 +46,18 @@ describe("dropThinkingBlocks", () => { }); it("drops thinking blocks while preserving non-thinking assistant content", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ - role: "assistant", - content: [ - { type: "thinking", thinking: "internal" }, - { type: "text", text: "final" }, - ], - }), - ]; - - const result = dropThinkingBlocks(messages); - const assistant = result[0] as Extract; + const { assistant, messages, result } = dropSingleAssistantContent([ + { type: "thinking", thinking: "internal" }, + { type: "text", text: "final" }, + ]); expect(result).not.toBe(messages); expect(assistant.content).toEqual([{ type: "text", text: "final" }]); }); it("keeps assistant turn structure when all content blocks were thinking", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ - role: "assistant", - content: [{ type: "thinking", thinking: "internal-only" }], - }), - ]; - - const result = dropThinkingBlocks(messages); - const assistant = result[0] as Extract; + const { assistant } = dropSingleAssistantContent([ + { type: "thinking", thinking: "internal-only" }, + ]); expect(assistant.content).toEqual([{ type: "text", text: "" }]); }); }); diff --git a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts index 2dce36ed076..b65ed0a65e8 100644 --- a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts +++ b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts @@ -44,6 +44,14 @@ function makeAssistantMessage(text: string): AssistantMessage { }); } +function getFirstToolResultText(message: AgentMessage | ToolResultMessage): string { + if (message.role !== "toolResult") { + return ""; + } + const firstBlock = message.content[0]; + return firstBlock && "text" in firstBlock ? firstBlock.text : ""; +} + describe("truncateToolResultText", () => { it("returns text unchanged when under limit", () => { const text = "hello world"; @@ -134,12 +142,7 @@ describe("truncateToolResultMessage", () => { if (result.role !== "toolResult") { throw new Error("expected toolResult"); } - - const firstBlock = result.content[0]; - expect(firstBlock?.type).toBe("text"); - expect(firstBlock && "text" in firstBlock ? firstBlock.text : "").toContain( - "[persist-truncated]", - ); + expect(getFirstToolResultText(result)).toContain("[persist-truncated]"); }); }); @@ -209,10 +212,7 @@ describe("truncateOversizedToolResultsInMessages", () => { expect(truncatedCount).toBe(1); const toolResult = result[2]; expect(toolResult?.role).toBe("toolResult"); - const firstBlock = - toolResult && toolResult.role === "toolResult" ? toolResult.content[0] : undefined; - expect(firstBlock?.type).toBe("text"); - const text = firstBlock && "text" in firstBlock ? firstBlock.text : ""; + const text = toolResult ? getFirstToolResultText(toolResult) : ""; expect(text.length).toBeLessThan(bigContent.length); expect(text).toContain("truncated"); }); @@ -242,8 +242,7 @@ describe("truncateOversizedToolResultsInMessages", () => { expect(truncatedCount).toBe(2); for (const msg of result.slice(2)) { expect(msg.role).toBe("toolResult"); - const firstBlock = msg.role === "toolResult" ? msg.content[0] : undefined; - const text = firstBlock && "text" in firstBlock ? firstBlock.text : ""; + const text = getFirstToolResultText(msg); expect(text.length).toBeLessThan(500_000); } }); diff --git a/src/agents/pi-embedded-runner/usage-reporting.test.ts b/src/agents/pi-embedded-runner/usage-reporting.test.ts index 7c29c5f99cf..f748ac3b9b5 100644 --- a/src/agents/pi-embedded-runner/usage-reporting.test.ts +++ b/src/agents/pi-embedded-runner/usage-reporting.test.ts @@ -1,22 +1,20 @@ -import "./run.overflow-compaction.mocks.shared.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + loadRunOverflowCompactionHarness, + mockedEnsureRuntimePluginsLoaded, + mockedRunEmbeddedAttempt, +} from "./run.overflow-compaction.harness.js"; -const runtimePluginMocks = vi.hoisted(() => ({ - ensureRuntimePluginsLoaded: vi.fn(), -})); - -vi.mock("../runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded, -})); - -import { runEmbeddedPiAgent } from "./run.js"; -import { runEmbeddedAttempt } from "./run/attempt.js"; - -const mockedRunEmbeddedAttempt = vi.mocked(runEmbeddedAttempt); +let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent; describe("runEmbeddedPiAgent usage reporting", () => { + beforeAll(async () => { + ({ runEmbeddedPiAgent } = await loadRunOverflowCompactionHarness()); + }); + beforeEach(() => { - vi.clearAllMocks(); + mockedEnsureRuntimePluginsLoaded.mockReset(); + mockedRunEmbeddedAttempt.mockReset(); }); it("bootstraps runtime plugins with the resolved workspace before running", async () => { @@ -39,7 +37,7 @@ describe("runEmbeddedPiAgent usage reporting", () => { runId: "run-plugin-bootstrap", }); - expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + expect(mockedEnsureRuntimePluginsLoaded).toHaveBeenCalledWith({ config: undefined, workspaceDir: "/tmp/workspace", }); @@ -66,7 +64,7 @@ describe("runEmbeddedPiAgent usage reporting", () => { allowGatewaySubagentBinding: true, }); - expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + expect(mockedEnsureRuntimePluginsLoaded).toHaveBeenCalledWith({ config: undefined, workspaceDir: "/tmp/workspace", allowGatewaySubagentBinding: true, 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-embedded-subscribe.tools.media.test.ts b/src/agents/pi-embedded-subscribe.tools.media.test.ts index a07ed71473d..7cf51bb7c1c 100644 --- a/src/agents/pi-embedded-subscribe.tools.media.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.media.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { extractToolResultMediaPaths } from "./pi-embedded-subscribe.tools.js"; +import { + extractToolResultMediaPaths, + isToolResultMediaTrusted, +} from "./pi-embedded-subscribe.tools.js"; describe("extractToolResultMediaPaths", () => { it("returns empty array for null/undefined", () => { @@ -229,4 +232,8 @@ describe("extractToolResultMediaPaths", () => { }; expect(extractToolResultMediaPaths(result)).toEqual(["/tmp/page1.png", "/tmp/page2.png"]); }); + + it("trusts image_generate local MEDIA paths", () => { + expect(isToolResultMediaTrusted("image_generate")).toBe(true); + }); }); diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/pi-embedded-subscribe.tools.ts index 08a5e5f80c4..925f56fa6ee 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/pi-embedded-subscribe.tools.ts @@ -142,6 +142,7 @@ const TRUSTED_TOOL_RESULT_MEDIA = new Set([ "exec", "gateway", "image", + "image_generate", "memory_get", "memory_search", "message", 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/pi-tools.before-tool-call.runtime.ts b/src/agents/pi-tools.before-tool-call.runtime.ts index b78a58231a2..95126670e31 100644 --- a/src/agents/pi-tools.before-tool-call.runtime.ts +++ b/src/agents/pi-tools.before-tool-call.runtime.ts @@ -1,7 +1,15 @@ -export { getDiagnosticSessionState } from "../logging/diagnostic-session-state.js"; -export { logToolLoopAction } from "../logging/diagnostic.js"; -export { +import { getDiagnosticSessionState } from "../logging/diagnostic-session-state.js"; +import { logToolLoopAction } from "../logging/diagnostic.js"; +import { detectToolCallLoop, recordToolCall, recordToolCallOutcome, } from "./tool-loop-detection.js"; + +export const beforeToolCallRuntime = { + getDiagnosticSessionState, + logToolLoopAction, + detectToolCallLoop, + recordToolCall, + recordToolCallOutcome, +}; diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index 99a470e8bd0..62bf0e0fb59 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -2,6 +2,7 @@ import type { ToolLoopDetectionConfig } from "../config/types.tools.js"; import type { SessionState } from "../logging/diagnostic-session-state.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { isPlainObject } from "../utils.js"; import { normalizeToolName } from "./tool-policy.js"; import type { AnyAgentTool } from "./tools/common.js"; @@ -23,14 +24,11 @@ const adjustedParamsByToolCallId = new Map(); const MAX_TRACKED_ADJUSTED_PARAMS = 1024; const LOOP_WARNING_BUCKET_SIZE = 10; const MAX_LOOP_WARNING_KEYS = 256; -let beforeToolCallRuntimePromise: Promise< - typeof import("./pi-tools.before-tool-call.runtime.js") -> | null = null; -function loadBeforeToolCallRuntime() { - beforeToolCallRuntimePromise ??= import("./pi-tools.before-tool-call.runtime.js"); - return beforeToolCallRuntimePromise; -} +const loadBeforeToolCallRuntime = createLazyRuntimeSurface( + () => import("./pi-tools.before-tool-call.runtime.js"), + ({ beforeToolCallRuntime }) => beforeToolCallRuntime, +); function buildAdjustedParamsKey(params: { runId?: string; toolCallId: string }): string { if (params.runId && params.runId.trim()) { diff --git a/src/agents/prompt-composition-scenarios.ts b/src/agents/prompt-composition-scenarios.ts new file mode 100644 index 00000000000..dff66c2c2b5 --- /dev/null +++ b/src/agents/prompt-composition-scenarios.ts @@ -0,0 +1,651 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + buildInboundMetaSystemPrompt, + buildInboundUserContextPrefix, +} from "../auto-reply/reply/inbound-meta.js"; +import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js"; +import { + appendBootstrapPromptWarning, + analyzeBootstrapBudget, + buildBootstrapPromptWarning, +} from "./bootstrap-budget.js"; +import { buildAgentSystemPrompt } from "./system-prompt.js"; +import { buildToolSummaryMap } from "./tool-summaries.js"; + +export type PromptScenarioTurn = { + id: string; + label: string; + systemPrompt: string; + bodyPrompt: string; + notes: string[]; +}; + +export type PromptScenario = { + scenario: string; + focus: string; + expectedStableSystemAfterTurnIds: string[]; + turns: PromptScenarioTurn[]; +}; + +type TemplateCtx = { + Provider: string; + Surface?: string; + OriginatingChannel?: string; + OriginatingTo?: string; + AccountId?: string; + ChatType?: string; + GroupSubject?: string; + GroupChannel?: string; + GroupSpace?: string; + SenderId?: string; + SenderName?: string; + SenderUsername?: string; + SenderE164?: string; + MessageSid?: string; + ReplyToId?: string; + ReplyToBody?: string; + WasMentioned?: boolean; + InboundHistory?: Array<{ sender: string; timestamp: number; body: string }>; + Body?: string; + BodyStripped?: string; +}; + +type BootstrapInjectionStat = { + name: string; + path: string; + missing: boolean; + rawChars: number; + injectedChars: number; + truncated: boolean; +}; + +function buildCommonSystemParams(workspaceDir: string) { + const toolNames = [ + "bash", + "read", + "edit", + "grep", + "glob", + "message", + "memory_search", + "memory_get", + "web_search", + "web_fetch", + ]; + const toolSummaries = buildToolSummaryMap( + toolNames.map((name) => ({ name, description: `${name} tool` }) as never), + ); + return { + runtimeInfo: { + agentId: "main", + host: "cache-lab", + repoRoot: workspaceDir, + os: "Darwin 24.0.0", + arch: "arm64", + node: process.version, + model: "anthropic/claude-sonnet-4-5", + defaultModel: "anthropic/claude-sonnet-4-5", + shell: "zsh", + }, + userTimezone: "America/Los_Angeles", + userTime: "Monday, March 16th, 2026 — 9:00 PM", + userTimeFormat: "12" as const, + toolNames, + toolSummaries, + }; +} + +function buildSystemPrompt(params: { + workspaceDir: string; + extraSystemPrompt?: string; + skillsPrompt?: string; + reactionGuidance?: { level: "minimal" | "extensive"; channel: string }; + contextFiles?: Array<{ path: string; content: string }>; +}) { + const { runtimeInfo, userTimezone, userTime, userTimeFormat, toolNames, toolSummaries } = + buildCommonSystemParams(params.workspaceDir); + return buildAgentSystemPrompt({ + workspaceDir: params.workspaceDir, + extraSystemPrompt: params.extraSystemPrompt, + runtimeInfo, + userTimezone, + userTime, + userTimeFormat, + toolNames, + toolSummaries, + modelAliasLines: [], + promptMode: "full", + acpEnabled: true, + skillsPrompt: params.skillsPrompt, + reactionGuidance: params.reactionGuidance, + contextFiles: params.contextFiles, + }); +} + +function buildAutoReplyBody(params: { ctx: TemplateCtx; body: string; eventLine?: string }) { + return [params.eventLine, buildInboundUserContextPrefix(params.ctx as never), params.body] + .filter(Boolean) + .join("\n\n"); +} + +function createDirectScenario(workspaceDir: string): PromptScenario { + const baseCtx: TemplateCtx = { + Provider: "slack", + Surface: "slack", + OriginatingChannel: "slack", + OriginatingTo: "D123", + AccountId: "A1", + ChatType: "direct", + SenderId: "U1", + SenderName: "Alice", + Body: "hi", + BodyStripped: "hi", + }; + return { + scenario: "auto-reply-direct", + focus: + "Normal direct-chat turns with ids, reply context, think hint, and runtime event body injection", + expectedStableSystemAfterTurnIds: ["t2", "t3", "t4"], + turns: [ + { + id: "t1", + label: "Direct turn with reply context", + systemPrompt: buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: buildInboundMetaSystemPrompt({ + ...baseCtx, + MessageSid: "m1", + ReplyToId: "r1", + ReplyToBody: "prior message", + WasMentioned: true, + } as never), + }), + bodyPrompt: buildAutoReplyBody({ + ctx: { + ...baseCtx, + MessageSid: "m1", + ReplyToId: "r1", + ReplyToBody: "prior message", + WasMentioned: true, + }, + body: "Please summarize yesterday's decision.", + }), + notes: ["Direct chat baseline", "Per-message ids and reply context change in body only"], + }, + { + id: "t2", + label: "Direct turn with new message id", + systemPrompt: buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: buildInboundMetaSystemPrompt({ + ...baseCtx, + MessageSid: "m2", + ReplyToId: "r2", + } as never), + }), + bodyPrompt: buildAutoReplyBody({ + ctx: { + ...baseCtx, + MessageSid: "m2", + ReplyToId: "r2", + }, + body: "Now open the read tool and inspect AGENTS.md.", + }), + notes: ["Steady-state direct turn", "No runtime event"], + }, + { + id: "t3", + label: "Direct turn with runtime event and think hint", + systemPrompt: buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: buildInboundMetaSystemPrompt({ + ...baseCtx, + MessageSid: "m3", + ReplyToId: "r3", + } as never), + }), + bodyPrompt: buildAutoReplyBody({ + ctx: { + ...baseCtx, + MessageSid: "m3", + ReplyToId: "r3", + }, + eventLine: "System: [t] Model switched.", + body: "low use tools if needed and tell me which file controls startup behavior", + }), + notes: ["Touches runtime event body path", "Touches think-hint parsing path"], + }, + { + id: "t4", + label: "Direct turn after runtime event", + systemPrompt: buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: buildInboundMetaSystemPrompt({ + ...baseCtx, + MessageSid: "m4", + ReplyToId: "r4", + } as never), + }), + bodyPrompt: buildAutoReplyBody({ + ctx: { + ...baseCtx, + MessageSid: "m4", + ReplyToId: "r4", + }, + body: "Repeat the startup file path only.", + }), + notes: ["Checks steady-state after event turn"], + }, + ], + }; +} + +function createGroupScenario(workspaceDir: string): PromptScenario { + const baseCtx: TemplateCtx = { + Provider: "slack", + Surface: "slack", + OriginatingChannel: "slack", + OriginatingTo: "C123", + AccountId: "A1", + ChatType: "group", + GroupSubject: "ops", + GroupChannel: "#ops", + SenderId: "U2", + SenderName: "Bob", + Body: "hi", + BodyStripped: "hi", + }; + const inbound1 = buildInboundMetaSystemPrompt({ + ...baseCtx, + MessageSid: "g1", + WasMentioned: true, + InboundHistory: [{ sender: "Cara", timestamp: 1, body: "status?" }], + } as never); + const inboundLater = buildInboundMetaSystemPrompt({ + ...baseCtx, + MessageSid: "g2", + WasMentioned: false, + InboundHistory: [ + { sender: "Cara", timestamp: 1, body: "status?" }, + { sender: "Dan", timestamp: 2, body: "please help" }, + ], + } as never); + return { + scenario: "auto-reply-group", + focus: "Group chat bootstrap, steady state, and runtime event turns", + expectedStableSystemAfterTurnIds: ["t3"], + turns: [ + { + id: "t1", + label: "First group turn with one-time intro", + systemPrompt: buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: [inbound1, "GROUP_INTRO: You were just activated in this room."].join( + "\n\n", + ), + }), + bodyPrompt: buildAutoReplyBody({ + ctx: { + ...baseCtx, + MessageSid: "g1", + WasMentioned: true, + InboundHistory: [{ sender: "Cara", timestamp: 1, body: "status?" }], + }, + body: "Can you investigate this issue?", + }), + notes: ["Expected first-turn bootstrap churn", "Not steady-state"], + }, + { + id: "t2", + label: "Steady-state group turn", + systemPrompt: buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: inboundLater, + }), + bodyPrompt: buildAutoReplyBody({ + ctx: { + ...baseCtx, + MessageSid: "g2", + WasMentioned: false, + InboundHistory: [ + { sender: "Cara", timestamp: 1, body: "status?" }, + { sender: "Dan", timestamp: 2, body: "please help" }, + ], + }, + body: "Give a short update.", + }), + notes: ["One-time intro gone", "Should settle afterward"], + }, + { + id: "t3", + label: "Group turn with runtime event", + systemPrompt: buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: inboundLater, + }), + bodyPrompt: buildAutoReplyBody({ + ctx: { + ...baseCtx, + MessageSid: "g3", + WasMentioned: true, + InboundHistory: [ + { sender: "Cara", timestamp: 1, body: "status?" }, + { sender: "Dan", timestamp: 2, body: "please help" }, + { sender: "Eve", timestamp: 3, body: "what changed?" }, + ], + }, + eventLine: "System: [t] Node connected.", + body: "Tell the room whether tools are available.", + }), + notes: ["Runtime event lands in body", "System prompt should stay stable vs t2"], + }, + ], + }; +} + +async function createToolRichScenario(workspaceDir: string): Promise { + const skillsPrompt = [ + "", + "checksRun checks before landing changes./skills/checks/SKILL.md", + "releaseRelease OpenClaw safely./skills/release/SKILL.md", + "", + ].join("\n"); + const contextFiles = [ + { + path: "AGENTS.md", + content: await fs.readFile(path.join(workspaceDir, "AGENTS.md"), "utf-8"), + }, + { + path: "TOOLS.md", + content: await fs.readFile(path.join(workspaceDir, "TOOLS.md"), "utf-8"), + }, + { + path: "SOUL.md", + content: await fs.readFile(path.join(workspaceDir, "SOUL.md"), "utf-8"), + }, + ]; + const systemPrompt = buildSystemPrompt({ + workspaceDir, + skillsPrompt, + reactionGuidance: { level: "extensive", channel: "Telegram" }, + contextFiles, + }); + return { + scenario: "tool-rich-agent-run", + focus: + "Tool-enabled system prompt with skills, reactions, workspace bootstrap, and a follow-up after fictional tool calls", + expectedStableSystemAfterTurnIds: ["t2"], + turns: [ + { + id: "t1", + label: "Tool-rich turn asking for search, read, and file edits", + systemPrompt, + bodyPrompt: [ + "Conversation info (untrusted metadata):", + "```json", + JSON.stringify({ message_id: "tool-1", sender_id: "U9", was_mentioned: true }, null, 2), + "```", + "", + "high Search the workspace, read AGENTS.md, inspect the failing test, and propose a patch.", + ].join("\n"), + notes: ["Touches tool list in system prompt", "Touches high-thinking hint in body"], + }, + { + id: "t2", + label: "Follow-up after a fictional tool call", + systemPrompt, + bodyPrompt: [ + "Conversation info (untrusted metadata):", + "```json", + JSON.stringify({ message_id: "tool-2", sender_id: "U9" }, null, 2), + "```", + "", + "Tool transcript summary (untrusted, for context):", + "```json", + JSON.stringify( + [ + { role: "assistant", action: "tool_use", name: "read", target: "AGENTS.md" }, + { role: "tool", name: "read", result: "Loaded AGENTS.md" }, + { role: "assistant", action: "tool_use", name: "grep", target: "failing test" }, + { role: "tool", name: "grep", result: "Matched src/foo.ts:42" }, + ], + null, + 2, + ), + "```", + "", + "Continue and explain the root cause.", + ].join("\n"), + notes: ["Simulates tool-call-heavy conversation", "System prompt should stay stable"], + }, + ], + }; +} + +async function createBootstrapWarningScenario(workspaceDir: string): Promise { + const largeAgents = "# AGENTS.md\n\n" + "Rules.\n".repeat(5_000); + const largeTools = "# TOOLS.md\n\n" + "Notes.\n".repeat(3_000); + await writeWorkspaceFile({ dir: workspaceDir, name: "AGENTS.md", content: largeAgents }); + await writeWorkspaceFile({ dir: workspaceDir, name: "TOOLS.md", content: largeTools }); + const contextFiles = [ + { + path: "AGENTS.md", + content: await fs.readFile(path.join(workspaceDir, "AGENTS.md"), "utf-8"), + }, + { + path: "TOOLS.md", + content: await fs.readFile(path.join(workspaceDir, "TOOLS.md"), "utf-8"), + }, + ]; + const bootstrapStats: BootstrapInjectionStat[] = contextFiles.map((file, index) => ({ + name: path.basename(file.path), + path: file.path, + missing: false, + rawChars: file.content.length, + injectedChars: index === 0 ? 1500 : 700, + truncated: true, + })); + const analysis = analyzeBootstrapBudget({ + files: bootstrapStats, + bootstrapMaxChars: 1500, + bootstrapTotalMaxChars: 2200, + }); + const warningFirst = buildBootstrapPromptWarning({ + analysis, + mode: "once", + seenSignatures: [], + }); + const warningSeen = buildBootstrapPromptWarning({ + analysis, + mode: "once", + seenSignatures: warningFirst.warningSignaturesSeen, + previousSignature: warningFirst.signature, + }); + const warningAlways = buildBootstrapPromptWarning({ + analysis, + mode: "always", + seenSignatures: warningFirst.warningSignaturesSeen, + previousSignature: warningFirst.signature, + }); + return { + scenario: "bootstrap-warning", + focus: "Workspace bootstrap truncation warnings inside # Project Context", + expectedStableSystemAfterTurnIds: ["t2", "t3"], + turns: [ + { + id: "t1", + label: "First warning emission", + systemPrompt: buildSystemPrompt({ + workspaceDir, + contextFiles, + }), + bodyPrompt: appendBootstrapPromptWarning("hello", warningFirst.lines), + notes: ["Warning is appended to the turn body", "System prompt should stay stable"], + }, + { + id: "t2", + label: "Same truncation signature after once-mode dedupe", + systemPrompt: buildSystemPrompt({ + workspaceDir, + contextFiles, + }), + bodyPrompt: appendBootstrapPromptWarning("hello again", warningSeen.lines), + notes: ["Once-mode removes warning lines", "Only the body tail changes now"], + }, + { + id: "t3", + label: "Always-mode warning", + systemPrompt: buildSystemPrompt({ + workspaceDir, + contextFiles, + }), + bodyPrompt: appendBootstrapPromptWarning("one more turn", warningAlways.lines), + notes: [ + "Always-mode keeps warning in the body prompt tail", + "System prompt remains stable", + ], + }, + ], + }; +} + +async function createMaintenanceScenario(workspaceDir: string): Promise { + await writeWorkspaceFile({ + dir: workspaceDir, + name: "AGENTS.md", + content: [ + "## Session Startup", + "Read AGENTS.md and MEMORY.md before responding.", + "", + "## Red Lines", + "Do not delete production data.", + "", + "## Safety", + "Never reveal secrets.", + ].join("\n"), + }); + const memoryFlushPrompt = [ + "Pre-compaction memory flush.", + "Store durable memories only in memory/2026-03-15.md (create memory/ if needed).", + "Treat workspace bootstrap/reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them.", + "If nothing to store, reply with NO_REPLY.", + "Current time: Sunday, March 15th, 2026 — 9:30 PM (America/Los_Angeles) / 2026-03-16 04:30 UTC", + ].join("\n"); + const memoryFlushSystemPrompt = buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: [ + "Pre-compaction memory flush turn.", + "The session is near auto-compaction; capture durable memories to disk.", + "Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed).", + "You may reply, but usually NO_REPLY is correct.", + ].join(" "), + }); + const postCompaction = [ + "[Post-compaction context refresh]", + "", + "Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence.", + "", + "Critical rules from AGENTS.md:", + "", + "## Session Startup", + "Read AGENTS.md and MEMORY.md before responding.", + "", + "## Red Lines", + "Do not delete production data.", + "", + "Current time: Sunday, March 15th, 2026 — 9:30 PM (America/Los_Angeles) / 2026-03-16 04:30 UTC", + ].join("\n"); + const postCompactionSystemPrompt = buildSystemPrompt({ + workspaceDir, + extraSystemPrompt: buildInboundMetaSystemPrompt({ + Provider: "slack", + Surface: "slack", + OriginatingChannel: "slack", + OriginatingTo: "D123", + AccountId: "A1", + ChatType: "direct", + } as never), + }); + return { + scenario: "maintenance-prompts", + focus: "Memory flush and post-compaction maintenance prompts", + expectedStableSystemAfterTurnIds: [], + turns: [ + { + id: "t1", + label: "Pre-compaction memory flush run", + systemPrompt: memoryFlushSystemPrompt, + bodyPrompt: memoryFlushPrompt, + notes: [ + "Writes to memory/2026-03-15.md", + "Separate maintenance run; expected to differ from normal user turns", + ], + }, + { + id: "t2", + label: "Post-compaction refresh context run", + systemPrompt: postCompactionSystemPrompt, + bodyPrompt: postCompaction, + notes: [ + "Separate maintenance context payload", + "Expected to differ from normal user turns", + ], + }, + ], + }; +} + +export async function createWorkspaceWithPromptCompositionFiles(): Promise { + const workspaceDir = await makeTempWorkspace("openclaw-prompt-cache-"); + await writeWorkspaceFile({ + dir: workspaceDir, + name: "AGENTS.md", + content: [ + "# AGENTS.md", + "", + "## Session Startup", + "Read AGENTS.md and TOOLS.md before making changes.", + "", + "## Red Lines", + "Do not rewrite user commits.", + ].join("\n"), + }); + await writeWorkspaceFile({ + dir: workspaceDir, + name: "TOOLS.md", + content: "# TOOLS.md\n\nUse rg before grep.\n", + }); + await writeWorkspaceFile({ + dir: workspaceDir, + name: "SOUL.md", + content: "# SOUL.md\n\nBe concise but kind.\n", + }); + return workspaceDir; +} + +export async function createPromptCompositionScenarios(): Promise<{ + workspaceDir: string; + warningWorkspaceDir: string; + scenarios: PromptScenario[]; + cleanup: () => Promise; +}> { + const workspaceDir = await createWorkspaceWithPromptCompositionFiles(); + const warningWorkspaceDir = await makeTempWorkspace("openclaw-prompt-cache-warning-"); + const scenarios = [ + createDirectScenario(workspaceDir), + createGroupScenario(workspaceDir), + await createToolRichScenario(workspaceDir), + await createBootstrapWarningScenario(warningWorkspaceDir), + await createMaintenanceScenario(workspaceDir), + ]; + return { + workspaceDir, + warningWorkspaceDir, + scenarios, + cleanup: async () => { + await fs.rm(workspaceDir, { recursive: true, force: true }); + await fs.rm(warningWorkspaceDir, { recursive: true, force: true }); + }, + }; +} diff --git a/src/agents/prompt-composition.test.ts b/src/agents/prompt-composition.test.ts new file mode 100644 index 00000000000..ee0a3fa4655 --- /dev/null +++ b/src/agents/prompt-composition.test.ts @@ -0,0 +1,66 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + createPromptCompositionScenarios, + type PromptScenario, +} from "./prompt-composition-scenarios.js"; + +type ScenarioFixture = Awaited>; + +function getTurn(scenario: PromptScenario, id: string) { + const turn = scenario.turns.find((entry) => entry.id === id); + expect(turn, `${scenario.scenario}:${id}`).toBeDefined(); + return turn!; +} + +describe("prompt composition invariants", () => { + let fixture: ScenarioFixture; + + beforeAll(async () => { + fixture = await createPromptCompositionScenarios(); + }); + + afterAll(async () => { + await fixture.cleanup(); + }); + + it("keeps the system prompt stable after warmup for normal user-turn scenarios", () => { + for (const scenario of fixture.scenarios) { + if (scenario.expectedStableSystemAfterTurnIds.length === 0) { + continue; + } + for (const turnId of scenario.expectedStableSystemAfterTurnIds) { + const current = getTurn(scenario, turnId); + const index = scenario.turns.findIndex((entry) => entry.id === turnId); + const previous = scenario.turns[index - 1]; + expect(previous, `${scenario.scenario}:${turnId}:previous`).toBeDefined(); + expect(current.systemPrompt, `${scenario.scenario}:${turnId}`).toBe(previous.systemPrompt); + } + } + }); + + it("keeps bootstrap warnings out of the system prompt and preserves the original user prompt prefix", () => { + const scenario = fixture.scenarios.find((entry) => entry.scenario === "bootstrap-warning"); + expect(scenario).toBeDefined(); + const first = getTurn(scenario!, "t1"); + const deduped = getTurn(scenario!, "t2"); + const always = getTurn(scenario!, "t3"); + + expect(first.systemPrompt).not.toContain("[Bootstrap truncation warning]"); + expect(first.bodyPrompt.startsWith("hello")).toBe(true); + expect(first.bodyPrompt).toContain("[Bootstrap truncation warning]"); + + expect(deduped.bodyPrompt).toBe("hello again"); + expect(always.bodyPrompt.startsWith("one more turn")).toBe(true); + expect(always.bodyPrompt).toContain("[Bootstrap truncation warning]"); + }); + + it("documents the intentional global exceptions so future churn is explicit", () => { + const groupScenario = fixture.scenarios.find((entry) => entry.scenario === "auto-reply-group"); + const maintenanceScenario = fixture.scenarios.find( + (entry) => entry.scenario === "maintenance-prompts", + ); + + expect(groupScenario?.expectedStableSystemAfterTurnIds).toEqual(["t3"]); + expect(maintenanceScenario?.expectedStableSystemAfterTurnIds).toEqual([]); + }); +}); diff --git a/src/agents/sandbox/ssh.test.ts b/src/agents/sandbox/ssh.test.ts index c2c07a3bf11..09ef1ee75fd 100644 --- a/src/agents/sandbox/ssh.test.ts +++ b/src/agents/sandbox/ssh.test.ts @@ -39,10 +39,52 @@ describe("sandbox ssh helpers", () => { expect(config).toContain("UpdateHostKeys no"); const configDir = session.configPath.slice(0, session.configPath.lastIndexOf("/")); - expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe("PRIVATE KEY"); - expect(await fs.readFile(`${configDir}/certificate.pub`, "utf8")).toBe("SSH CERT"); + expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe("PRIVATE KEY\n"); + expect(await fs.readFile(`${configDir}/certificate.pub`, "utf8")).toBe("SSH CERT\n"); expect(await fs.readFile(`${configDir}/known_hosts`, "utf8")).toBe( - "example.com ssh-ed25519 AAAATEST", + "example.com ssh-ed25519 AAAATEST\n", + ); + }); + + it("normalizes CRLF and escaped-newline private keys before writing temp files", async () => { + const session = await createSshSandboxSessionFromSettings({ + command: "ssh", + target: "peter@example.com:2222", + strictHostKeyChecking: true, + updateHostKeys: false, + identityData: + "-----BEGIN OPENSSH PRIVATE KEY-----\\nbGluZTE=\\r\\nbGluZTI=\\r\\n-----END OPENSSH PRIVATE KEY-----", + knownHostsData: "example.com ssh-ed25519 AAAATEST", + }); + sessions.push(session); + + const configDir = session.configPath.slice(0, session.configPath.lastIndexOf("/")); + expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe( + "-----BEGIN OPENSSH PRIVATE KEY-----\n" + + "bGluZTE=\n" + + "bGluZTI=\n" + + "-----END OPENSSH PRIVATE KEY-----\n", + ); + }); + + it("normalizes mixed real and escaped newlines in private keys", async () => { + const session = await createSshSandboxSessionFromSettings({ + command: "ssh", + target: "peter@example.com:2222", + strictHostKeyChecking: true, + updateHostKeys: false, + identityData: + "-----BEGIN OPENSSH PRIVATE KEY-----\nline-1\\nline-2\n-----END OPENSSH PRIVATE KEY-----", + knownHostsData: "example.com ssh-ed25519 AAAATEST", + }); + sessions.push(session); + + const configDir = session.configPath.slice(0, session.configPath.lastIndexOf("/")); + expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe( + "-----BEGIN OPENSSH PRIVATE KEY-----\n" + + "line-1\n" + + "line-2\n" + + "-----END OPENSSH PRIVATE KEY-----\n", ); }); diff --git a/src/agents/sandbox/ssh.ts b/src/agents/sandbox/ssh.ts index 1590b515e8f..d4884b44a3a 100644 --- a/src/agents/sandbox/ssh.ts +++ b/src/agents/sandbox/ssh.ts @@ -35,6 +35,35 @@ export type RunSshSandboxCommandParams = { tty?: boolean; }; +function normalizeInlineSshMaterial(contents: string, filename: string): string { + const withoutBom = contents.replace(/^\uFEFF/, ""); + const normalizedNewlines = withoutBom.replace(/\r\n?/g, "\n"); + const normalizedEscapedNewlines = normalizedNewlines + .replace(/\\r\\n/g, "\\n") + .replace(/\\r/g, "\\n"); + const expanded = + filename === "identity" || filename === "certificate.pub" + ? normalizedEscapedNewlines.replace(/\\n/g, "\n") + : normalizedEscapedNewlines; + return expanded.endsWith("\n") ? expanded : `${expanded}\n`; +} + +function buildSshFailureMessage(stderr: string, exitCode?: number): string { + const trimmed = stderr.trim(); + if ( + trimmed.includes("error in libcrypto") && + (trimmed.includes('Load key "') || trimmed.includes("Permission denied (publickey)")) + ) { + return `${trimmed}\nSSH sandbox failed to load the configured identity. The private key contents may be malformed (for example CRLF or escaped newlines). Prefer identityFile when possible.`; + } + return ( + trimmed || + (exitCode !== undefined + ? `ssh exited with code ${exitCode}` + : "ssh exited with a non-zero status") + ); +} + export function shellEscape(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } @@ -201,14 +230,11 @@ export async function runSshSandboxCommand( const exitCode = code ?? 0; if (exitCode !== 0 && !params.allowFailure) { reject( - Object.assign( - new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`), - { - code: exitCode, - stdout, - stderr, - }, - ), + Object.assign(new Error(buildSshFailureMessage(stderr.toString("utf8"), exitCode)), { + code: exitCode, + stdout, + stderr, + }), ); return; } @@ -328,7 +354,10 @@ async function writeSecretMaterial( contents: string, ): Promise { const pathname = path.join(dir, filename); - await fs.writeFile(pathname, contents, { encoding: "utf8", mode: 0o600 }); + await fs.writeFile(pathname, normalizeInlineSshMaterial(contents, filename), { + encoding: "utf8", + mode: 0o600, + }); await fs.chmod(pathname, 0o600); return pathname; } diff --git a/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts b/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts index 1f4da5163e1..b09571f540f 100644 --- a/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts @@ -200,30 +200,30 @@ describe("buildWorkspaceSkillsPrompt", () => { }); it("filters skills based on env/config gates", async () => { const workspaceDir = await createCaseDir("workspace"); - const skillDir = path.join(workspaceDir, "skills", "nano-banana-pro"); + const skillDir = path.join(workspaceDir, "skills", "image-lab"); await writeSkill({ dir: skillDir, - name: "nano-banana-pro", + name: "image-lab", description: "Generates images", metadata: '{"openclaw":{"requires":{"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY"}}', - body: "# Nano Banana\n", + body: "# Image Lab\n", }); withEnv({ GEMINI_API_KEY: undefined }, () => { const missingPrompt = buildPrompt(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), - config: { skills: { entries: { "nano-banana-pro": { apiKey: "" } } } }, + config: { skills: { entries: { "image-lab": { apiKey: "" } } } }, }); - expect(missingPrompt).not.toContain("nano-banana-pro"); + expect(missingPrompt).not.toContain("image-lab"); const enabledPrompt = buildPrompt(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), config: { - skills: { entries: { "nano-banana-pro": { apiKey: "test-key" } } }, // pragma: allowlist secret + skills: { entries: { "image-lab": { apiKey: "test-key" } } }, // pragma: allowlist secret }, }); - expect(enabledPrompt).toContain("nano-banana-pro"); + expect(enabledPrompt).toContain("image-lab"); }); }); it("applies skill filters, including empty lists", async () => { diff --git a/src/agents/skills/env-overrides.runtime.ts b/src/agents/skills/env-overrides.runtime.ts index ab8c4b305fb..6f5ebf3947a 100644 --- a/src/agents/skills/env-overrides.runtime.ts +++ b/src/agents/skills/env-overrides.runtime.ts @@ -1 +1,9 @@ -export { getActiveSkillEnvKeys } from "./env-overrides.js"; +import { getActiveSkillEnvKeys as getActiveSkillEnvKeysImpl } from "./env-overrides.js"; + +type GetActiveSkillEnvKeys = typeof import("./env-overrides.js").getActiveSkillEnvKeys; + +export function getActiveSkillEnvKeys( + ...args: Parameters +): ReturnType { + return getActiveSkillEnvKeysImpl(...args); +} 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/system-prompt.ts b/src/agents/system-prompt.ts index 5f4ee932bd7..3ee438db2d4 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -268,6 +268,7 @@ export function buildAgentSystemPrompt(params: { session_status: "Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (šŸ“Š session_status); optional per-session model override", image: "Analyze an image with the configured image model", + image_generate: "Generate images with the configured image-generation model", }; const toolOrder = [ @@ -295,6 +296,7 @@ export function buildAgentSystemPrompt(params: { "subagents", "session_status", "image", + "image_generate", ]; const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim()); diff --git a/src/agents/tool-catalog.test.ts b/src/agents/tool-catalog.test.ts index 120a744432c..2f7fa0fc5d6 100644 --- a/src/agents/tool-catalog.test.ts +++ b/src/agents/tool-catalog.test.ts @@ -7,5 +7,6 @@ describe("tool-catalog", () => { expect(policy).toBeDefined(); expect(policy!.allow).toContain("web_search"); expect(policy!.allow).toContain("web_fetch"); + expect(policy!.allow).toContain("image_generate"); }); }); diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index 445cdc5f10b..0d58c066928 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -233,6 +233,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ profiles: ["coding"], includeInOpenClawGroup: true, }, + { + id: "image_generate", + label: "image_generate", + description: "Image generation", + sectionId: "media", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, { id: "tts", label: "tts", diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts new file mode 100644 index 00000000000..86f5aaf07d9 --- /dev/null +++ b/src/agents/tools/image-generate-tool.test.ts @@ -0,0 +1,329 @@ +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, + 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", () => { + 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("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", + model: "gpt-image-1", + attempts: [], + images: [ + { + buffer: Buffer.from("png-1"), + mimeType: "image/png", + fileName: "cat-one.png", + }, + { + buffer: Buffer.from("png-2"), + mimeType: "image/png", + fileName: "cat-two.png", + revisedPrompt: "A more cinematic cat", + }, + ], + }); + const saveMediaBuffer = vi.spyOn(mediaStore, "saveMediaBuffer"); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/generated-1.png", + id: "generated-1.png", + size: 5, + contentType: "image/png", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/generated-2.png", + id: "generated-2.png", + size: 5, + contentType: "image/png", + }); + + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "openai/gpt-image-1", + }, + }, + }, + }, + agentDir: "/tmp/agent", + }); + + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + const result = await tool.execute("call-1", { + prompt: "A cat wearing sunglasses", + model: "openai/gpt-image-1", + count: 2, + size: "1024x1024", + }); + + expect(generateImage).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: { + agents: { + defaults: { + imageGenerationModel: { + primary: "openai/gpt-image-1", + }, + }, + }, + }, + prompt: "A cat wearing sunglasses", + agentDir: "/tmp/agent", + modelOverride: "openai/gpt-image-1", + size: "1024x1024", + count: 2, + inputImages: [], + }), + ); + expect(saveMediaBuffer).toHaveBeenNthCalledWith( + 1, + Buffer.from("png-1"), + "image/png", + "tool-image-generation", + undefined, + "cat-one.png", + ); + expect(saveMediaBuffer).toHaveBeenNthCalledWith( + 2, + Buffer.from("png-2"), + "image/png", + "tool-image-generation", + undefined, + "cat-two.png", + ); + expect(result).toMatchObject({ + content: [ + { + type: "text", + text: expect.stringContaining("Generated 2 images with openai/gpt-image-1."), + }, + ], + details: { + provider: "openai", + model: "gpt-image-1", + count: 2, + paths: ["/tmp/generated-1.png", "/tmp/generated-2.png"], + revisedPrompts: ["A more cinematic cat"], + }, + }); + const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; + expect(text).toContain("MEDIA:/tmp/generated-1.png"); + expect(text).toContain("MEDIA:/tmp/generated-2.png"); + }); + + it("rejects counts outside the supported range", async () => { + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3.1-flash-image-preview", + }, + }, + }, + }, + }); + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + await expect(tool.execute("call-2", { prompt: "too many cats", count: 5 })).rejects.toThrow( + "count must be between 1 and 4", + ); + }); + + it("forwards reference images and inferred resolution for edit mode", async () => { + const generateImage = vi.spyOn(imageGenerationRuntime, "generateImage").mockResolvedValue({ + provider: "google", + model: "gemini-3-pro-image-preview", + attempts: [], + images: [ + { + buffer: Buffer.from("png-out"), + mimeType: "image/png", + fileName: "edited.png", + }, + ], + }); + vi.spyOn(webMedia, "loadWebMedia").mockResolvedValue({ + kind: "image", + buffer: Buffer.from("input-image"), + contentType: "image/png", + }); + vi.spyOn(imageOps, "getImageMetadata").mockResolvedValue({ + width: 3200, + height: 1800, + }); + vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValue({ + path: "/tmp/edited.png", + id: "edited.png", + size: 7, + contentType: "image/png", + }); + + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3-pro-image-preview", + }, + }, + }, + }, + workspaceDir: process.cwd(), + }); + + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + await tool.execute("call-edit", { + prompt: "Add a dramatic stormy sky but keep everything else identical.", + image: "./fixtures/reference.png", + }); + + expect(generateImage).toHaveBeenCalledWith( + expect.objectContaining({ + resolution: "4K", + inputImages: [ + expect.objectContaining({ + buffer: Buffer.from("input-image"), + mimeType: "image/png", + }), + ], + }), + ); + }); + + it("lists registered provider and model options", async () => { + stubImageGenerationProviders(); + + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3.1-flash-image-preview", + }, + }, + }, + }, + }); + + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + const result = await tool.execute("call-list", { action: "list" }); + const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; + + expect(text).toContain("google (default gemini-3.1-flash-image-preview)"); + expect(text).toContain("gemini-3.1-flash-image-preview"); + expect(text).toContain("gemini-3-pro-image-preview"); + expect(text).toContain("editing"); + expect(result).toMatchObject({ + details: { + providers: expect.arrayContaining([ + expect.objectContaining({ + id: "google", + defaultModel: "gemini-3.1-flash-image-preview", + models: expect.arrayContaining([ + "gemini-3.1-flash-image-preview", + "gemini-3-pro-image-preview", + ]), + }), + ]), + }, + }); + }); +}); diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts new file mode 100644 index 00000000000..057b9013100 --- /dev/null +++ b/src/agents/tools/image-generate-tool.ts @@ -0,0 +1,478 @@ +import { Type } from "@sinclair/typebox"; +import type { OpenClawConfig } from "../../config/config.js"; +import { loadConfig } from "../../config/config.js"; +import { + generateImage, + listRuntimeImageGenerationProviders, +} from "../../image-generation/runtime.js"; +import type { + ImageGenerationResolution, + ImageGenerationSourceImage, +} from "../../image-generation/types.js"; +import { getImageMetadata } from "../../media/image-ops.js"; +import { saveMediaBuffer } from "../../media/store.js"; +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 { + applyImageGenerationModelConfigDefaults, + resolveMediaToolLocalRoots, +} from "./media-tool-shared.js"; +import { + buildToolModelConfigFromCandidates, + coerceToolModelConfig, + hasToolModelConfig, + resolveDefaultModelRef, + type ToolModelConfig, +} from "./model-config.helpers.js"; +import { + createSandboxBridgeReadFile, + resolveSandboxedBridgeMediaPath, + type AnyAgentTool, + type SandboxFsBridge, + type ToolFsPolicy, +} from "./tool-runtime.helpers.js"; + +const DEFAULT_COUNT = 1; +const MAX_COUNT = 4; +const MAX_INPUT_IMAGES = 4; +const DEFAULT_RESOLUTION: ImageGenerationResolution = "1K"; + +const ImageGenerateToolSchema = Type.Object({ + action: Type.Optional( + Type.String({ + description: + 'Optional action: "generate" (default) or "list" to inspect available providers/models.', + }), + ), + prompt: Type.Optional(Type.String({ description: "Image generation prompt." })), + image: Type.Optional( + Type.String({ + description: "Optional reference image path or URL for edit mode.", + }), + ), + images: Type.Optional( + Type.Array(Type.String(), { + description: `Optional reference images for edit mode (up to ${MAX_INPUT_IMAGES}).`, + }), + ), + model: Type.Optional( + Type.String({ description: "Optional provider/model override, e.g. openai/gpt-image-1." }), + ), + size: Type.Optional( + Type.String({ + description: + "Optional size hint like 1024x1024, 1536x1024, 1024x1536, 1024x1792, or 1792x1024.", + }), + ), + resolution: Type.Optional( + Type.String({ + description: + "Optional resolution hint: 1K, 2K, or 4K. Useful for Google edit/generation flows.", + }), + ), + count: Type.Optional( + Type.Number({ + description: `Optional number of images to request (1-${MAX_COUNT}).`, + minimum: 1, + maximum: MAX_COUNT, + }), + ), +}); + +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}`); + } + + 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 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" { + const raw = readStringParam(args, "action"); + if (!raw) { + return "generate"; + } + const normalized = raw.trim().toLowerCase(); + if (normalized === "generate" || normalized === "list") { + return normalized; + } + throw new ToolInputError('action must be "generate" or "list"'); +} + +function resolveRequestedCount(args: Record): number { + const count = readNumberParam(args, "count", { integer: true }); + if (count === undefined) { + return DEFAULT_COUNT; + } + if (count < 1 || count > MAX_COUNT) { + throw new ToolInputError(`count must be between 1 and ${MAX_COUNT}`); + } + return count; +} + +function normalizeResolution(raw: string | undefined): ImageGenerationResolution | undefined { + const normalized = raw?.trim().toUpperCase(); + if (!normalized) { + return undefined; + } + if (normalized === "1K" || normalized === "2K" || normalized === "4K") { + return normalized; + } + throw new ToolInputError("resolution must be one of 1K, 2K, or 4K"); +} + +function normalizeReferenceImages(args: Record): string[] { + const imageCandidates: string[] = []; + if (typeof args.image === "string") { + imageCandidates.push(args.image); + } + if (Array.isArray(args.images)) { + imageCandidates.push( + ...args.images.filter((value): value is string => typeof value === "string"), + ); + } + + const seen = new Set(); + const normalized: string[] = []; + for (const candidate of imageCandidates) { + const trimmed = candidate.trim(); + const dedupe = trimmed.startsWith("@") ? trimmed.slice(1).trim() : trimmed; + if (!dedupe || seen.has(dedupe)) { + continue; + } + seen.add(dedupe); + normalized.push(trimmed); + } + if (normalized.length > MAX_INPUT_IMAGES) { + throw new ToolInputError( + `Too many reference images: ${normalized.length} provided, maximum is ${MAX_INPUT_IMAGES}.`, + ); + } + return normalized; +} + +type ImageGenerateSandboxConfig = { + root: string; + bridge: SandboxFsBridge; +}; + +async function loadReferenceImages(params: { + imageInputs: string[]; + maxBytes?: number; + localRoots: string[]; + sandboxConfig: { root: string; bridge: SandboxFsBridge; workspaceOnly: boolean } | null; +}): Promise< + Array<{ + sourceImage: ImageGenerationSourceImage; + resolvedImage: string; + rewrittenFrom?: string; + }> +> { + const loaded: Array<{ + sourceImage: ImageGenerationSourceImage; + resolvedImage: string; + rewrittenFrom?: string; + }> = []; + + for (const imageRawInput of params.imageInputs) { + const trimmed = imageRawInput.trim(); + const imageRaw = trimmed.startsWith("@") ? trimmed.slice(1).trim() : trimmed; + if (!imageRaw) { + throw new ToolInputError("image required (empty string in array)"); + } + const looksLikeWindowsDrivePath = /^[a-zA-Z]:[\\/]/.test(imageRaw); + const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(imageRaw); + const isFileUrl = /^file:/i.test(imageRaw); + const isHttpUrl = /^https?:\/\//i.test(imageRaw); + const isDataUrl = /^data:/i.test(imageRaw); + if (hasScheme && !looksLikeWindowsDrivePath && !isFileUrl && !isHttpUrl && !isDataUrl) { + throw new ToolInputError( + `Unsupported image reference: ${imageRawInput}. Use a file path, a file:// URL, a data: URL, or an http(s) URL.`, + ); + } + if (params.sandboxConfig && isHttpUrl) { + throw new ToolInputError("Sandboxed image_generate does not allow remote URLs."); + } + + const resolvedImage = (() => { + if (params.sandboxConfig) { + return imageRaw; + } + if (imageRaw.startsWith("~")) { + return resolveUserPath(imageRaw); + } + return imageRaw; + })(); + + const resolvedPathInfo: { resolved: string; rewrittenFrom?: string } = isDataUrl + ? { resolved: "" } + : params.sandboxConfig + ? await resolveSandboxedBridgeMediaPath({ + sandbox: params.sandboxConfig, + mediaPath: resolvedImage, + inboundFallbackDir: "media/inbound", + }) + : { + resolved: resolvedImage.startsWith("file://") + ? resolvedImage.slice("file://".length) + : resolvedImage, + }; + const resolvedPath = isDataUrl ? null : resolvedPathInfo.resolved; + + const media = isDataUrl + ? decodeDataUrl(resolvedImage) + : params.sandboxConfig + ? await loadWebMedia(resolvedPath ?? resolvedImage, { + maxBytes: params.maxBytes, + sandboxValidated: true, + readFile: createSandboxBridgeReadFile({ sandbox: params.sandboxConfig }), + }) + : await loadWebMedia(resolvedPath ?? resolvedImage, { + maxBytes: params.maxBytes, + localRoots: params.localRoots, + }); + if (media.kind !== "image") { + throw new ToolInputError(`Unsupported media type: ${media.kind}`); + } + + const mimeType = + ("contentType" in media && media.contentType) || + ("mimeType" in media && media.mimeType) || + "image/png"; + + loaded.push({ + sourceImage: { + buffer: media.buffer, + mimeType, + }, + resolvedImage, + ...(resolvedPathInfo.rewrittenFrom ? { rewrittenFrom: resolvedPathInfo.rewrittenFrom } : {}), + }); + } + + return loaded; +} + +async function inferResolutionFromInputImages( + images: ImageGenerationSourceImage[], +): Promise { + let maxDimension = 0; + for (const image of images) { + const meta = await getImageMetadata(image.buffer); + const dimension = Math.max(meta?.width ?? 0, meta?.height ?? 0); + maxDimension = Math.max(maxDimension, dimension); + } + if (maxDimension >= 3000) { + return "4K"; + } + if (maxDimension >= 1500) { + return "2K"; + } + return DEFAULT_RESOLUTION; +} + +export function createImageGenerateTool(options?: { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + sandbox?: ImageGenerateSandboxConfig; + fsPolicy?: ToolFsPolicy; +}): AnyAgentTool | null { + const cfg = options?.config ?? loadConfig(); + 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, + }); + const sandboxConfig = + options?.sandbox && options.sandbox.root.trim() + ? { + root: options.sandbox.root.trim(), + bridge: options.sandbox.bridge, + workspaceOnly: options.fsPolicy?.workspaceOnly === true, + } + : null; + + return { + label: "Image Generation", + name: "image_generate", + description: + '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: 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) { + caps.push("editing"); + } + if ((provider.supportedResolutions?.length ?? 0) > 0) { + caps.push(`resolutions ${provider.supportedResolutions?.join("/")}`); + } + if ((provider.supportedSizes?.length ?? 0) > 0) { + caps.push(`sizes ${provider.supportedSizes?.join(", ")}`); + } + const modelLine = + provider.models.length > 0 + ? `models: ${provider.models.join(", ")}` + : "models: unknown"; + return [ + `${provider.id}${provider.defaultModel ? ` (default ${provider.defaultModel})` : ""}`, + ` ${modelLine}`, + ...(caps.length > 0 ? [` capabilities: ${caps.join("; ")}`] : []), + ]; + }); + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { providers }, + }; + } + + const prompt = readStringParam(params, "prompt", { required: true }); + const imageInputs = normalizeReferenceImages(params); + const model = readStringParam(params, "model"); + const size = readStringParam(params, "size"); + const explicitResolution = normalizeResolution(readStringParam(params, "resolution")); + const count = resolveRequestedCount(params); + const loadedReferenceImages = await loadReferenceImages({ + imageInputs, + localRoots, + sandboxConfig, + }); + const inputImages = loadedReferenceImages.map((entry) => entry.sourceImage); + const resolution = + explicitResolution ?? + (size + ? undefined + : inputImages.length > 0 + ? await inferResolutionFromInputImages(inputImages) + : undefined); + + const result = await generateImage({ + cfg: effectiveCfg, + prompt, + agentDir: options?.agentDir, + modelOverride: model, + size, + resolution, + count, + inputImages, + }); + + const savedImages = await Promise.all( + result.images.map((image) => + saveMediaBuffer( + image.buffer, + image.mimeType, + "tool-image-generation", + undefined, + image.fileName, + ), + ), + ); + + const revisedPrompts = result.images + .map((image) => image.revisedPrompt?.trim()) + .filter((entry): entry is string => Boolean(entry)); + const lines = [ + `Generated ${savedImages.length} image${savedImages.length === 1 ? "" : "s"} with ${result.provider}/${result.model}.`, + ...savedImages.map((image) => `MEDIA:${image.path}`), + ]; + + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { + provider: result.provider, + model: result.model, + count: savedImages.length, + paths: savedImages.map((image) => image.path), + ...(imageInputs.length === 1 + ? { + image: loadedReferenceImages[0]?.resolvedImage, + ...(loadedReferenceImages[0]?.rewrittenFrom + ? { rewrittenFrom: loadedReferenceImages[0].rewrittenFrom } + : {}), + } + : imageInputs.length > 1 + ? { + images: loadedReferenceImages.map((entry) => ({ + image: entry.resolvedImage, + ...(entry.rewrittenFrom ? { rewrittenFrom: entry.rewrittenFrom } : {}), + })), + } + : {}), + ...(resolution ? { resolution } : {}), + ...(size ? { size } : {}), + attempts: result.attempts, + metadata: result.metadata, + ...(revisedPrompts.length > 0 ? { revisedPrompts } : {}), + }, + }; + }, + }; +} 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/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 88062eacaa7..d6c03cabf75 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -1,10 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; +import { + createDiscordMessageToolComponentsSchema, + createMessageToolButtonsSchema, + createSlackMessageToolBlocksSchema, + createTelegramPollExtraToolSchemas, +} from "../../channels/plugins/message-tool-schema.js"; import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js"; import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { createMessageTool } from "./message-tool.js"; +type CreateMessageTool = typeof import("./message-tool.js").createMessageTool; +type SetActivePluginRegistry = typeof import("../../plugins/runtime.js").setActivePluginRegistry; +type CreateTestRegistry = typeof import("../../test-utils/channel-plugins.js").createTestRegistry; + +let createMessageTool: CreateMessageTool; +let setActivePluginRegistry: SetActivePluginRegistry; +let createTestRegistry: CreateTestRegistry; const mocks = vi.hoisted(() => ({ runMessageAction: vi.fn(), @@ -50,7 +60,7 @@ function mockSendResult(overrides: { channel?: string; to?: string } = {}) { } satisfies MessageActionRunResult); } -function getToolProperties(tool: ReturnType) { +function getToolProperties(tool: ReturnType) { return (tool.parameters as { properties?: Record }).properties ?? {}; } @@ -58,13 +68,17 @@ function getActionEnum(properties: Record) { return (properties.action as { enum?: string[] } | undefined)?.enum ?? []; } -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); mocks.runMessageAction.mockReset(); mocks.loadConfig.mockReset().mockReturnValue({}); mocks.resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({ resolvedConfig: config, diagnostics: [], })); + ({ setActivePluginRegistry } = await import("../../plugins/runtime.js")); + ({ createTestRegistry } = await import("../../test-utils/channel-plugins.js")); + ({ createMessageTool } = await import("./message-tool.js")); }); function createChannelPlugin(params: { @@ -72,9 +86,11 @@ function createChannelPlugin(params: { label: string; docsPath: string; blurb: string; + aliases?: string[]; actions?: ChannelMessageActionName[]; listActions?: NonNullable["listActions"]>; capabilities?: readonly ChannelMessageCapability[]; + toolSchema?: NonNullable["getToolSchema"]>; messaging?: ChannelPlugin["messaging"]; }): ChannelPlugin { const actionCapabilities = params.capabilities; @@ -86,6 +102,7 @@ function createChannelPlugin(params: { selectionLabel: params.label, docsPath: params.docsPath, blurb: params.blurb, + aliases: params.aliases, }, capabilities: { chatTypes: ["direct", "group"], media: true }, config: { @@ -102,6 +119,7 @@ function createChannelPlugin(params: { ...(actionCapabilities ? { getCapabilities: (_params: { cfg: unknown }) => actionCapabilities } : {}), + ...(params.toolSchema ? { getToolSchema: params.toolSchema } : {}), }, }; } @@ -219,6 +237,17 @@ describe("message tool schema scoping", () => { blurb: "Telegram test plugin.", actions: ["send", "react", "poll"], capabilities: ["interactive", "buttons"], + toolSchema: () => [ + { + properties: { + buttons: createMessageToolButtonsSchema(), + }, + }, + { + properties: createTelegramPollExtraToolSchemas(), + visibility: "all-configured", + }, + ], }); const discordPlugin = createChannelPlugin({ @@ -228,6 +257,11 @@ describe("message tool schema scoping", () => { blurb: "Discord test plugin.", actions: ["send", "poll", "poll-vote"], capabilities: ["interactive", "components"], + toolSchema: () => ({ + properties: { + components: createDiscordMessageToolComponentsSchema(), + }, + }), }); const slackPlugin = createChannelPlugin({ @@ -237,6 +271,11 @@ describe("message tool schema scoping", () => { blurb: "Slack test plugin.", actions: ["send", "react"], capabilities: ["interactive", "blocks"], + toolSchema: () => ({ + properties: { + blocks: createSlackMessageToolBlocksSchema(), + }, + }), }); afterEach(() => { @@ -365,6 +404,25 @@ describe("message tool schema scoping", () => { return telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"]; }, capabilities: ["interactive", "buttons"], + toolSchema: ({ cfg }) => { + const telegramCfg = (cfg as { channels?: { telegram?: { actions?: { poll?: boolean } } } }) + .channels?.telegram; + return [ + { + properties: { + buttons: createMessageToolButtonsSchema(), + }, + }, + ...(telegramCfg?.actions?.poll === false + ? [] + : [ + { + properties: createTelegramPollExtraToolSchemas(), + visibility: "all-configured" as const, + }, + ]), + ]; + }, }); setActivePluginRegistry( @@ -393,6 +451,138 @@ describe("message tool schema scoping", () => { expect(properties.pollAnonymous).toBeUndefined(); expect(properties.pollPublic).toBeUndefined(); }); + + it("uses discovery account scope for capability-gated shared fields", () => { + const scopedInteractivePlugin = createChannelPlugin({ + id: "telegram", + label: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram test plugin.", + actions: ["send"], + toolSchema: () => null, + }); + scopedInteractivePlugin.actions = { + ...scopedInteractivePlugin.actions, + getCapabilities: ({ accountId }) => (accountId === "ops" ? ["interactive"] : []), + }; + + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "telegram", source: "test", plugin: scopedInteractivePlugin }, + ]), + ); + + const scopedTool = createMessageTool({ + config: {} as never, + currentChannelProvider: "telegram", + agentAccountId: "ops", + }); + const unscopedTool = createMessageTool({ + config: {} as never, + currentChannelProvider: "telegram", + }); + + expect(getToolProperties(scopedTool).interactive).toBeDefined(); + expect(getToolProperties(unscopedTool).interactive).toBeUndefined(); + }); + + it("uses discovery account scope for other configured channel actions", () => { + const currentPlugin = createChannelPlugin({ + id: "discord", + label: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test plugin.", + actions: ["send"], + }); + const scopedOtherPlugin = createChannelPlugin({ + id: "telegram", + label: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram test plugin.", + actions: ["send"], + }); + scopedOtherPlugin.actions = { + ...scopedOtherPlugin.actions, + listActions: ({ accountId }) => (accountId === "ops" ? ["react"] : []), + }; + + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "discord", source: "test", plugin: currentPlugin }, + { pluginId: "telegram", source: "test", plugin: scopedOtherPlugin }, + ]), + ); + + const scopedTool = createMessageTool({ + config: {} as never, + currentChannelProvider: "discord", + agentAccountId: "ops", + }); + const unscopedTool = createMessageTool({ + config: {} as never, + currentChannelProvider: "discord", + }); + + expect(getActionEnum(getToolProperties(scopedTool))).toContain("react"); + expect(getActionEnum(getToolProperties(unscopedTool))).not.toContain("react"); + expect(scopedTool.description).toContain("telegram (react, send)"); + expect(unscopedTool.description).not.toContain("telegram (react, send)"); + }); + + it("routes full discovery context into plugin action discovery", () => { + const seenContexts: Record[] = []; + const contextPlugin = createChannelPlugin({ + id: "discord", + label: "Discord", + docsPath: "/channels/discord", + blurb: "Discord context plugin.", + listActions: (ctx) => { + seenContexts.push({ phase: "listActions", ...ctx }); + return ["send", "react"]; + }, + toolSchema: (ctx) => { + seenContexts.push({ phase: "getToolSchema", ...ctx }); + return null; + }, + }); + contextPlugin.actions = { + ...contextPlugin.actions, + getCapabilities: (ctx) => { + seenContexts.push({ phase: "getCapabilities", ...ctx }); + return ["interactive"]; + }, + }; + + setActivePluginRegistry( + createTestRegistry([{ pluginId: "discord", source: "test", plugin: contextPlugin }]), + ); + + createMessageTool({ + config: {} as never, + currentChannelProvider: "discord", + currentChannelId: "channel:123", + currentThreadTs: "thread-456", + currentMessageId: "msg-789", + agentAccountId: "ops", + agentSessionKey: "agent:alpha:main", + sessionId: "session-123", + requesterSenderId: "user-42", + }); + + expect(seenContexts).toContainEqual( + expect.objectContaining({ + currentChannelProvider: "discord", + currentChannelId: "channel:123", + currentThreadTs: "thread-456", + currentMessageId: "msg-789", + accountId: "ops", + sessionKey: "agent:alpha:main", + sessionId: "session-123", + agentId: "alpha", + requesterSenderId: "user-42", + }), + ); + }); }); describe("message tool description", () => { @@ -405,7 +595,27 @@ describe("message tool description", () => { label: "BlueBubbles", docsPath: "/channels/bluebubbles", blurb: "BlueBubbles test plugin.", - actions: ["react", "renameGroup", "addParticipant", "removeParticipant", "leaveGroup"], + listActions: ({ currentChannelId }) => { + const all: ChannelMessageActionName[] = [ + "react", + "renameGroup", + "addParticipant", + "removeParticipant", + "leaveGroup", + ]; + const lowered = currentChannelId?.toLowerCase() ?? ""; + const isDmTarget = + lowered.includes("chat_guid:imessage;-;") || lowered.includes("chat_guid:sms;-;"); + return isDmTarget + ? all.filter( + (action) => + action !== "renameGroup" && + action !== "addParticipant" && + action !== "removeParticipant" && + action !== "leaveGroup", + ) + : all; + }, messaging: { normalizeTarget: (raw) => { const trimmed = raw.trim().replace(/^bluebubbles:/i, ""); @@ -476,6 +686,28 @@ describe("message tool description", () => { expect(tool.description).toContain("telegram (delete, edit, react, send, topic-create)"); }); + it("normalizes channel aliases before building the current channel description", () => { + const signalPlugin = createChannelPlugin({ + id: "signal", + label: "Signal", + docsPath: "/channels/signal", + blurb: "Signal test plugin.", + aliases: ["sig"], + actions: ["send", "react"], + }); + + setActivePluginRegistry( + createTestRegistry([{ pluginId: "signal", source: "test", plugin: signalPlugin }]), + ); + + const tool = createMessageTool({ + config: {} as never, + currentChannelProvider: "sig", + }); + + expect(tool.description).toContain("Current channel (signal) supports: react, send."); + }); + it("does not include 'Other configured channels' when only one channel is configured", () => { setActivePluginRegistry( createTestRegistry([{ pluginId: "bluebubbles", source: "test", plugin: bluebubblesPlugin }]), diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 1dcaf04e1f0..77703d8ee75 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -1,11 +1,11 @@ -import { Type } from "@sinclair/typebox"; -import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js"; +import { Type, type TSchema } from "@sinclair/typebox"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { channelSupportsMessageCapability, channelSupportsMessageCapabilityForChannel, listChannelMessageActions, -} from "../../channels/plugins/message-actions.js"; + resolveChannelMessageToolSchemaProperties, +} from "../../channels/plugins/message-action-discovery.js"; import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; import { CHANNEL_MESSAGE_ACTION_NAMES, @@ -18,7 +18,6 @@ import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js"; -import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; import { POLL_CREATION_PARAM_DEFS, POLL_CREATION_PARAM_NAMES } from "../../poll-params.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; @@ -53,116 +52,6 @@ function buildRoutingSchema() { }; } -const discordComponentEmojiSchema = Type.Object({ - name: Type.String(), - id: Type.Optional(Type.String()), - animated: Type.Optional(Type.Boolean()), -}); - -const discordComponentOptionSchema = Type.Object({ - label: Type.String(), - value: Type.String(), - description: Type.Optional(Type.String()), - emoji: Type.Optional(discordComponentEmojiSchema), - default: Type.Optional(Type.Boolean()), -}); - -const discordComponentButtonSchema = Type.Object({ - label: Type.String(), - style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), - url: Type.Optional(Type.String()), - emoji: Type.Optional(discordComponentEmojiSchema), - disabled: Type.Optional(Type.Boolean()), - allowedUsers: Type.Optional( - Type.Array( - Type.String({ - description: "Discord user ids or names allowed to interact with this button.", - }), - ), - ), -}); - -const discordComponentSelectSchema = Type.Object({ - type: Type.Optional(stringEnum(["string", "user", "role", "mentionable", "channel"])), - placeholder: Type.Optional(Type.String()), - minValues: Type.Optional(Type.Number()), - maxValues: Type.Optional(Type.Number()), - options: Type.Optional(Type.Array(discordComponentOptionSchema)), -}); - -const discordComponentBlockSchema = Type.Object({ - type: Type.String(), - text: Type.Optional(Type.String()), - texts: Type.Optional(Type.Array(Type.String())), - accessory: Type.Optional( - Type.Object({ - type: Type.String(), - url: Type.Optional(Type.String()), - button: Type.Optional(discordComponentButtonSchema), - }), - ), - spacing: Type.Optional(stringEnum(["small", "large"])), - divider: Type.Optional(Type.Boolean()), - buttons: Type.Optional(Type.Array(discordComponentButtonSchema)), - select: Type.Optional(discordComponentSelectSchema), - items: Type.Optional( - Type.Array( - Type.Object({ - url: Type.String(), - description: Type.Optional(Type.String()), - spoiler: Type.Optional(Type.Boolean()), - }), - ), - ), - file: Type.Optional(Type.String()), - spoiler: Type.Optional(Type.Boolean()), -}); - -const discordComponentModalFieldSchema = Type.Object({ - type: Type.String(), - name: Type.Optional(Type.String()), - label: Type.String(), - description: Type.Optional(Type.String()), - placeholder: Type.Optional(Type.String()), - required: Type.Optional(Type.Boolean()), - options: Type.Optional(Type.Array(discordComponentOptionSchema)), - minValues: Type.Optional(Type.Number()), - maxValues: Type.Optional(Type.Number()), - minLength: Type.Optional(Type.Number()), - maxLength: Type.Optional(Type.Number()), - style: Type.Optional(stringEnum(["short", "paragraph"])), -}); - -const discordComponentModalSchema = Type.Object({ - title: Type.String(), - triggerLabel: Type.Optional(Type.String()), - triggerStyle: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), - fields: Type.Array(discordComponentModalFieldSchema), -}); - -const discordComponentMessageSchema = Type.Object( - { - text: Type.Optional(Type.String()), - reusable: Type.Optional( - Type.Boolean({ - description: "Allow components to be used multiple times until they expire.", - }), - ), - container: Type.Optional( - Type.Object({ - accentColor: Type.Optional(Type.String()), - spoiler: Type.Optional(Type.Boolean()), - }), - ), - blocks: Type.Optional(Type.Array(discordComponentBlockSchema)), - modal: Type.Optional(discordComponentModalSchema), - }, - { - description: - "Discord components v2 payload. Set reusable=true to keep buttons, selects, and forms active until expiry.", - }, -); - const interactiveOptionSchema = Type.Object({ label: Type.String(), value: Type.String(), @@ -192,14 +81,8 @@ const interactiveMessageSchema = Type.Object( }, ); -function buildSendSchema(options: { - includeInteractive: boolean; - includeButtons: boolean; - includeCards: boolean; - includeComponents: boolean; - includeBlocks: boolean; -}) { - const props: Record = { +function buildSendSchema(options: { includeInteractive: boolean }) { + const props: Record = { message: Type.Optional(Type.String()), effectId: Type.Optional( Type.String({ @@ -240,57 +123,10 @@ function buildSendSchema(options: { }), ), interactive: Type.Optional(interactiveMessageSchema), - buttons: Type.Optional( - Type.Array( - Type.Array( - Type.Object({ - text: Type.String(), - callback_data: Type.String(), - style: Type.Optional(stringEnum(["danger", "success", "primary"])), - }), - ), - { - description: "Telegram inline keyboard buttons (array of button rows)", - }, - ), - ), - card: Type.Optional( - Type.Object( - {}, - { - additionalProperties: true, - description: "Adaptive Card JSON object (when supported by the channel)", - }, - ), - ), - components: Type.Optional(discordComponentMessageSchema), - blocks: Type.Optional( - Type.Array( - Type.Object( - {}, - { - additionalProperties: true, - description: "Slack Block Kit payload blocks (Slack only).", - }, - ), - ), - ), }; - if (!options.includeButtons) { - delete props.buttons; - } if (!options.includeInteractive) { delete props.interactive; } - if (!options.includeCards) { - delete props.card; - } - if (!options.includeComponents) { - delete props.components; - } - if (!options.includeBlocks) { - delete props.blocks; - } return props; } @@ -330,8 +166,8 @@ function buildFetchSchema() { }; } -function buildPollSchema(options?: { includeTelegramExtras?: boolean }) { - const props: Record = { +function buildPollSchema() { + const props: Record = { pollId: Type.Optional(Type.String()), pollOptionId: Type.Optional( Type.String({ @@ -363,7 +199,7 @@ function buildPollSchema(options?: { includeTelegramExtras?: boolean }) { }; for (const name of POLL_CREATION_PARAM_NAMES) { const def = POLL_CREATION_PARAM_DEFS[name]; - if (def.telegramOnly && !options?.includeTelegramExtras) { + if (def.telegramOnly) { continue; } switch (def.kind) { @@ -510,18 +346,14 @@ function buildChannelManagementSchema() { function buildMessageToolSchemaProps(options: { includeInteractive: boolean; - includeButtons: boolean; - includeCards: boolean; - includeComponents: boolean; - includeBlocks: boolean; - includeTelegramPollExtras: boolean; + extraProperties?: Record; }) { return { ...buildRoutingSchema(), ...buildSendSchema(options), ...buildReactionSchema(), ...buildFetchSchema(), - ...buildPollSchema({ includeTelegramExtras: options.includeTelegramPollExtras }), + ...buildPollSchema(), ...buildChannelTargetSchema(), ...buildStickerSchema(), ...buildThreadSchema(), @@ -530,6 +362,7 @@ function buildMessageToolSchemaProps(options: { ...buildGatewaySchema(), ...buildChannelManagementSchema(), ...buildPresenceSchema(), + ...options.extraProperties, }; } @@ -537,11 +370,7 @@ function buildMessageToolSchemaFromActions( actions: readonly string[], options: { includeInteractive: boolean; - includeButtons: boolean; - includeCards: boolean; - includeComponents: boolean; - includeBlocks: boolean; - includeTelegramPollExtras: boolean; + extraProperties?: Record; }, ) { const props = buildMessageToolSchemaProps(options); @@ -553,16 +382,12 @@ function buildMessageToolSchemaFromActions( const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, { includeInteractive: true, - includeButtons: true, - includeCards: true, - includeComponents: true, - includeBlocks: true, - includeTelegramPollExtras: true, }); type MessageToolOptions = { agentAccountId?: string; agentSessionKey?: string; + sessionId?: string; config?: OpenClawConfig; currentChannelId?: string; currentChannelProvider?: string; @@ -579,16 +404,27 @@ function resolveMessageToolSchemaActions(params: { cfg: OpenClawConfig; currentChannelProvider?: string; currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }): string[] { const currentChannel = normalizeMessageChannel(params.currentChannelProvider); if (currentChannel) { - const scopedActions = filterActionsForContext({ - actions: listChannelSupportedActions({ - cfg: params.cfg, - channel: currentChannel, - }), + const scopedActions = listChannelSupportedActions({ + cfg: params.cfg, channel: currentChannel, currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.currentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, }); const allActions = new Set(["send", ...scopedActions]); // Include actions from other configured channels so isolated/cron agents @@ -597,7 +433,18 @@ function resolveMessageToolSchemaActions(params: { if (plugin.id === currentChannel) { continue; } - for (const action of listChannelSupportedActions({ cfg: params.cfg, channel: plugin.id })) { + for (const action of listChannelSupportedActions({ + cfg: params.cfg, + channel: plugin.id, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.currentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + })) { allActions.add(action); } } @@ -611,6 +458,14 @@ function resolveIncludeCapability( params: { cfg: OpenClawConfig; currentChannelProvider?: string; + currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }, capability: ChannelMessageCapability, ): boolean { @@ -620,6 +475,14 @@ function resolveIncludeCapability( { cfg: params.cfg, channel: currentChannel, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.currentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, }, capability, ); @@ -627,70 +490,50 @@ function resolveIncludeCapability( return channelSupportsMessageCapability(params.cfg, capability); } -function resolveIncludeComponents(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return resolveIncludeCapability(params, "components"); -} - function resolveIncludeInteractive(params: { cfg: OpenClawConfig; currentChannelProvider?: string; + currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }): boolean { return resolveIncludeCapability(params, "interactive"); } -function resolveIncludeButtons(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return resolveIncludeCapability(params, "buttons"); -} - -function resolveIncludeCards(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return resolveIncludeCapability(params, "cards"); -} - -function resolveIncludeBlocks(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return resolveIncludeCapability(params, "blocks"); -} - -function resolveIncludeTelegramPollExtras(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; -}): boolean { - return listChannelSupportedActions({ - cfg: params.cfg, - channel: "telegram", - }).includes("poll"); -} - function buildMessageToolSchema(params: { cfg: OpenClawConfig; currentChannelProvider?: string; currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }) { const actions = resolveMessageToolSchemaActions(params); const includeInteractive = resolveIncludeInteractive(params); - const includeButtons = resolveIncludeButtons(params); - const includeCards = resolveIncludeCards(params); - const includeComponents = resolveIncludeComponents(params); - const includeBlocks = resolveIncludeBlocks(params); - const includeTelegramPollExtras = resolveIncludeTelegramPollExtras(params); + const extraProperties = resolveChannelMessageToolSchemaProperties({ + cfg: params.cfg, + channel: normalizeMessageChannel(params.currentChannelProvider), + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.currentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + }); return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], { includeInteractive, - includeButtons, - includeCards, - includeComponents, - includeBlocks, - includeTelegramPollExtras, + extraProperties, }); } @@ -702,63 +545,60 @@ function resolveAgentAccountId(value?: string): string | undefined { return normalizeAccountId(trimmed); } -function filterActionsForContext(params: { - actions: ChannelMessageActionName[]; - channel?: string; - currentChannelId?: string; -}): ChannelMessageActionName[] { - const channel = normalizeMessageChannel(params.channel); - if (!channel || channel !== "bluebubbles") { - return params.actions; - } - const currentChannelId = params.currentChannelId?.trim(); - if (!currentChannelId) { - return params.actions; - } - const normalizedTarget = - normalizeTargetForProvider(channel, currentChannelId) ?? currentChannelId; - const lowered = normalizedTarget.trim().toLowerCase(); - const isGroupTarget = - lowered.startsWith("chat_guid:") || - lowered.startsWith("chat_id:") || - lowered.startsWith("chat_identifier:") || - lowered.startsWith("group:"); - if (isGroupTarget) { - return params.actions; - } - return params.actions.filter((action) => !BLUEBUBBLES_GROUP_ACTIONS.has(action)); -} - function buildMessageToolDescription(options?: { config?: OpenClawConfig; currentChannel?: string; currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; }): string { const baseDescription = "Send, delete, and manage messages via channel plugins."; + const resolvedOptions = options ?? {}; + const currentChannel = normalizeMessageChannel(resolvedOptions.currentChannel); // If we have a current channel, show its actions and list other configured channels - if (options?.currentChannel) { - const channelActions = filterActionsForContext({ - actions: listChannelSupportedActions({ - cfg: options.config, - channel: options.currentChannel, - }), - channel: options.currentChannel, - currentChannelId: options.currentChannelId, + if (currentChannel) { + const channelActions = listChannelSupportedActions({ + cfg: resolvedOptions.config, + channel: currentChannel, + currentChannelId: resolvedOptions.currentChannelId, + currentThreadTs: resolvedOptions.currentThreadTs, + currentMessageId: resolvedOptions.currentMessageId, + accountId: resolvedOptions.currentAccountId, + sessionKey: resolvedOptions.sessionKey, + sessionId: resolvedOptions.sessionId, + agentId: resolvedOptions.agentId, + requesterSenderId: resolvedOptions.requesterSenderId, }); if (channelActions.length > 0) { // Always include "send" as a base action const allActions = new Set(["send", ...channelActions]); const actionList = Array.from(allActions).toSorted().join(", "); - let desc = `${baseDescription} Current channel (${options.currentChannel}) supports: ${actionList}.`; + let desc = `${baseDescription} Current channel (${currentChannel}) supports: ${actionList}.`; // Include other configured channels so cron/isolated agents can discover them const otherChannels: string[] = []; for (const plugin of listChannelPlugins()) { - if (plugin.id === options.currentChannel) { + if (plugin.id === currentChannel) { continue; } - const actions = listChannelSupportedActions({ cfg: options.config, channel: plugin.id }); + const actions = listChannelSupportedActions({ + cfg: resolvedOptions.config, + channel: plugin.id, + currentChannelId: resolvedOptions.currentChannelId, + currentThreadTs: resolvedOptions.currentThreadTs, + currentMessageId: resolvedOptions.currentMessageId, + accountId: resolvedOptions.currentAccountId, + sessionKey: resolvedOptions.sessionKey, + sessionId: resolvedOptions.sessionId, + agentId: resolvedOptions.agentId, + requesterSenderId: resolvedOptions.requesterSenderId, + }); if (actions.length > 0) { const all = new Set(["send", ...actions]); otherChannels.push(`${plugin.id} (${Array.from(all).toSorted().join(", ")})`); @@ -773,8 +613,8 @@ function buildMessageToolDescription(options?: { } // Fallback to generic description with all configured actions - if (options?.config) { - const actions = listChannelMessageActions(options.config); + if (resolvedOptions.config) { + const actions = listChannelMessageActions(resolvedOptions.config); if (actions.length > 0) { return `${baseDescription} Supports actions: ${actions.join(", ")}.`; } @@ -785,17 +625,37 @@ function buildMessageToolDescription(options?: { export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const agentAccountId = resolveAgentAccountId(options?.agentAccountId); + const resolvedAgentId = options?.agentSessionKey + ? resolveSessionAgentId({ + sessionKey: options.agentSessionKey, + config: options?.config, + }) + : undefined; const schema = options?.config ? buildMessageToolSchema({ cfg: options.config, currentChannelProvider: options.currentChannelProvider, currentChannelId: options.currentChannelId, + currentThreadTs: options.currentThreadTs, + currentMessageId: options.currentMessageId, + currentAccountId: agentAccountId, + sessionKey: options.agentSessionKey, + sessionId: options.sessionId, + agentId: resolvedAgentId, + requesterSenderId: options.requesterSenderId, }) : MessageToolSchema; const description = buildMessageToolDescription({ config: options?.config, currentChannel: options?.currentChannelProvider, currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, + currentMessageId: options?.currentMessageId, + currentAccountId: agentAccountId, + sessionKey: options?.agentSessionKey, + sessionId: options?.sessionId, + agentId: resolvedAgentId, + requesterSenderId: options?.requesterSenderId, }); return { @@ -917,9 +777,8 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { gateway, toolContext, sessionKey: options?.agentSessionKey, - agentId: options?.agentSessionKey - ? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg }) - : undefined, + sessionId: options?.sessionId, + agentId: resolvedAgentId, sandboxRoot: options?.sandboxRoot, abortSignal: signal, }); 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.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts index f5cd484fba4..c3c1c073e24 100644 --- a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts @@ -232,14 +232,19 @@ describe("directive behavior", () => { expect(text).toContain("Current thinking level: high"); expect(text).toContain("Options: off, minimal, low, medium, high, adaptive."); - for (const model of ["openai-codex/gpt-5.2-codex", "openai/gpt-5.2"]) { + for (const model of [ + "openai-codex/gpt-5.2-codex", + "openai/gpt-5.2", + "openai/gpt-5.4-mini", + "openai/gpt-5.4-nano", + ]) { const texts = await runThinkingDirective(home, model); expect(texts).toContain("Thinking level set to xhigh."); } const unsupportedModelTexts = await runThinkingDirective(home, "openai/gpt-4.1-mini"); expect(unsupportedModelTexts).toContain( - 'Thinking level "xhigh" is only supported for openai/gpt-5.4, openai/gpt-5.4-pro, openai/gpt-5.2, openai-codex/gpt-5.4, openai-codex/gpt-5.3-codex, openai-codex/gpt-5.3-codex-spark, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.', + 'Thinking level "xhigh" is only supported for openai/gpt-5.4, openai/gpt-5.4-pro, openai/gpt-5.4-mini, openai/gpt-5.4-nano, openai/gpt-5.2, openai-codex/gpt-5.4, openai-codex/gpt-5.3-codex, openai-codex/gpt-5.3-codex-spark, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.', ); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); 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 { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + }, + async cleanupWorkspaces() { + await Promise.all( + tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }, + }; +} diff --git a/src/auto-reply/reply/commands-mcp.test.ts b/src/auto-reply/reply/commands-mcp.test.ts index 24d7f15f34b..f70f167a80b 100644 --- a/src/auto-reply/reply/commands-mcp.test.ts +++ b/src/auto-reply/reply/commands-mcp.test.ts @@ -1,19 +1,11 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { withTempHome } from "../../config/home-env.test-harness.js"; import { handleCommands } from "./commands-core.js"; +import { createCommandWorkspaceHarness } from "./commands-filesystem.test-support.js"; import { buildCommandTestParams } from "./commands.test-harness.js"; -const tempDirs: string[] = []; - -async function createWorkspace(): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-command-mcp-")); - tempDirs.push(dir); - return dir; -} +const workspaceHarness = createCommandWorkspaceHarness("openclaw-command-mcp-"); function buildCfg(): OpenClawConfig { return { @@ -26,14 +18,12 @@ function buildCfg(): OpenClawConfig { describe("handleCommands /mcp", () => { afterEach(async () => { - await Promise.all( - tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); + await workspaceHarness.cleanupWorkspaces(); }); it("writes MCP config and shows it back", async () => { await withTempHome("openclaw-command-mcp-home-", async () => { - const workspaceDir = await createWorkspace(); + const workspaceDir = await workspaceHarness.createWorkspace(); const setParams = buildCommandTestParams( '/mcp set context7={"command":"uvx","args":["context7-mcp"]}', buildCfg(), @@ -57,7 +47,7 @@ describe("handleCommands /mcp", () => { it("rejects internal writes without operator.admin", async () => { await withTempHome("openclaw-command-mcp-home-", async () => { - const workspaceDir = await createWorkspace(); + const workspaceDir = await workspaceHarness.createWorkspace(); const params = buildCommandTestParams( '/mcp set context7={"command":"uvx","args":["context7-mcp"]}', buildCfg(), @@ -77,7 +67,7 @@ describe("handleCommands /mcp", () => { it("accepts non-stdio MCP config at the config layer", async () => { await withTempHome("openclaw-command-mcp-home-", async () => { - const workspaceDir = await createWorkspace(); + const workspaceDir = await workspaceHarness.createWorkspace(); const params = buildCommandTestParams( '/mcp set remote={"url":"https://example.com/mcp"}', buildCfg(), diff --git a/src/auto-reply/reply/commands-plugins.test.ts b/src/auto-reply/reply/commands-plugins.test.ts index 133a8021d3c..1bf3feb772b 100644 --- a/src/auto-reply/reply/commands-plugins.test.ts +++ b/src/auto-reply/reply/commands-plugins.test.ts @@ -1,19 +1,13 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { withTempHome } from "../../config/home-env.test-harness.js"; import { handleCommands } from "./commands-core.js"; +import { createCommandWorkspaceHarness } from "./commands-filesystem.test-support.js"; import { buildCommandTestParams } from "./commands.test-harness.js"; -const tempDirs: string[] = []; - -async function createWorkspace(): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-command-plugins-")); - tempDirs.push(dir); - return dir; -} +const workspaceHarness = createCommandWorkspaceHarness("openclaw-command-plugins-"); async function createClaudeBundlePlugin(params: { workspaceDir: string; pluginId: string }) { const pluginDir = path.join(params.workspaceDir, ".openclaw", "extensions", params.pluginId); @@ -38,14 +32,12 @@ function buildCfg(): OpenClawConfig { describe("handleCommands /plugins", () => { afterEach(async () => { - await Promise.all( - tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); + 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 createWorkspace(); + const workspaceDir = await workspaceHarness.createWorkspace(); await createClaudeBundlePlugin({ workspaceDir, pluginId: "superpowers" }); const listParams = buildCommandTestParams("/plugins list", buildCfg(), undefined, { @@ -57,19 +49,39 @@ 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"'); }); }); it("enables and disables a discovered plugin", async () => { await withTempHome("openclaw-command-plugins-home-", async () => { - const workspaceDir = await createWorkspace(); + const workspaceDir = await workspaceHarness.createWorkspace(); await createClaudeBundlePlugin({ workspaceDir, pluginId: "superpowers" }); const enableParams = buildCommandTestParams( @@ -113,7 +125,7 @@ describe("handleCommands /plugins", () => { it("rejects internal writes without operator.admin", async () => { await withTempHome("openclaw-command-plugins-home-", async () => { - const workspaceDir = await createWorkspace(); + const workspaceDir = await workspaceHarness.createWorkspace(); await createClaudeBundlePlugin({ workspaceDir, pluginId: "superpowers" }); const params = buildCommandTestParams( 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 cd33be0a3e2..f1ff9c36dfd 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -7,11 +7,11 @@ const sendReactionSignal = vi.fn(async (..._args: unknown[]) => ({ ok: true })); const removeReactionSignal = vi.fn(async (..._args: unknown[]) => ({ ok: true })); const handleSlackAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../../../agents/tools/discord-actions.js", () => ({ +vi.mock("../../../../extensions/discord/src/actions/runtime.js", () => ({ handleDiscordAction, })); -vi.mock("../../../agents/tools/telegram-actions.js", () => ({ +vi.mock("../../../../extensions/telegram/src/action-runtime.js", () => ({ handleTelegramAction, })); @@ -20,15 +20,15 @@ vi.mock("../../../../extensions/signal/src/send-reactions.js", () => ({ removeReactionSignal, })); -vi.mock("../../../agents/tools/slack-actions.js", () => ({ +vi.mock("../../../../extensions/slack/runtime-api.js", () => ({ handleSlackAction, })); -const { discordMessageActions } = await import("./discord.js"); -const { handleDiscordMessageAction } = await import("./discord/handle-action.js"); -const { telegramMessageActions } = await import("./telegram.js"); -const { signalMessageActions } = await import("./signal.js"); -const { createSlackActions } = await import("../slack.actions.js"); +let discordMessageActions: typeof import("./discord.js").discordMessageActions; +let handleDiscordMessageAction: typeof import("./discord/handle-action.js").handleDiscordMessageAction; +let telegramMessageActions: typeof import("./telegram.js").telegramMessageActions; +let signalMessageActions: typeof import("./signal.js").signalMessageActions; +let createSlackActions: typeof import("../slack.actions.js").createSlackActions; function telegramCfg(): OpenClawConfig { return { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; @@ -191,93 +191,114 @@ async function expectSlackSendRejected(params: Record, error: R expect(handleSlackAction).not.toHaveBeenCalled(); } -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); + ({ discordMessageActions } = await import("./discord.js")); + ({ handleDiscordMessageAction } = await import("./discord/handle-action.js")); + ({ telegramMessageActions } = await import("./telegram.js")); + ({ signalMessageActions } = await import("./signal.js")); + ({ createSlackActions } = await import("../slack.actions.js")); vi.clearAllMocks(); }); 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", () => { @@ -477,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", () => { @@ -839,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; @@ -904,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 = [ @@ -1098,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; @@ -1106,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, @@ -1119,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: { @@ -1245,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: "", @@ -1267,6 +1315,7 @@ describe("slack actions adapter", () => { }, { name: "empty blocks", + action: "send" as const, params: { to: "channel:C1", message: "", @@ -1276,6 +1325,7 @@ describe("slack actions adapter", () => { }, { name: "blocks with media", + action: "send" as const, params: { to: "channel:C1", message: "", @@ -1284,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 eadb1913544..f4f3ffa0a87 100644 --- a/src/channels/plugins/contracts/inbound.contract.test.ts +++ b/src/channels/plugins/contracts/inbound.contract.test.ts @@ -1,50 +1,42 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ResolvedSlackAccount } from "../../../../extensions/slack/src/accounts.js"; -import { prepareSlackMessage } from "../../../../extensions/slack/src/monitor/message-handler/prepare.js"; -import { createInboundSlackTestContext } from "../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js"; import type { SlackMessageEvent } from "../../../../extensions/slack/src/types.js"; import type { MsgContext } from "../../../auto-reply/templating.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { inboundCtxCapture } from "./inbound-testkit.js"; import { expectChannelInboundContextContract } from "./suites.js"; -const signalCapture = vi.hoisted(() => ({ ctx: undefined as MsgContext | undefined })); -const bufferedReplyCapture = vi.hoisted(() => ({ - ctx: undefined as MsgContext | undefined, -})); const dispatchInboundMessageMock = vi.hoisted(() => vi.fn( async (params: { ctx: MsgContext; replyOptions?: { onReplyStart?: () => void | Promise }; }) => { - signalCapture.ctx = params.ctx; await Promise.resolve(params.replyOptions?.onReplyStart?.()); return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; }, ), ); -vi.mock("../../../auto-reply/dispatch.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - dispatchInboundMessage: dispatchInboundMessageMock, - dispatchInboundMessageWithDispatcher: dispatchInboundMessageMock, - dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessageMock, + dispatchInboundMessage: vi.fn(async (params: { ctx: MsgContext }) => { + inboundCtxCapture.ctx = params.ctx; + return await dispatchInboundMessageMock(params); + }), + dispatchInboundMessageWithDispatcher: vi.fn(async (params: { ctx: MsgContext }) => { + inboundCtxCapture.ctx = params.ctx; + return await dispatchInboundMessageMock(params); + }), + dispatchInboundMessageWithBufferedDispatcher: vi.fn(async (params: { ctx: MsgContext }) => { + inboundCtxCapture.ctx = params.ctx; + return await dispatchInboundMessageMock(params); + }), }; }); -vi.mock("../../../auto-reply/reply/provider-dispatcher.js", () => ({ - dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: { ctx: MsgContext }) => { - bufferedReplyCapture.ctx = params.ctx; - return { queuedFinal: false }; - }), -})); - vi.mock("../../../../extensions/signal/src/send.js", () => ({ sendMessageSignal: vi.fn(), sendTypingSignal: vi.fn(async () => true), @@ -74,12 +66,13 @@ 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 { createSignalEventHandler } = - await import("../../../../extensions/signal/src/monitor/event-handler.js"); -const { createBaseSignalEventHandlerDeps, createSignalReceiveEvent } = - await import("../../../../extensions/signal/src/monitor/event-handler.test-harness.js"); -const { processMessage } = - await import("../../../../extensions/whatsapp/src/auto-reply/monitor/process-message.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 { @@ -106,80 +99,12 @@ function createSlackMessage(overrides: Partial): SlackMessage } as SlackMessageEvent; } -function makeWhatsAppProcessArgs(sessionStorePath: string) { - return { - // oxlint-disable-next-line typescript/no-explicit-any - cfg: { messages: {}, session: { store: sessionStorePath } } as any, - // oxlint-disable-next-line typescript/no-explicit-any - msg: { - id: "msg1", - from: "123@g.us", - to: "+15550001111", - chatType: "group", - body: "hi", - senderName: "Alice", - senderJid: "alice@s.whatsapp.net", - senderE164: "+15550002222", - groupSubject: "Test Group", - groupParticipants: [], - } as unknown as Record, - route: { - agentId: "main", - accountId: "default", - sessionKey: "agent:main:whatsapp:group:123", - // oxlint-disable-next-line typescript/no-explicit-any - } as any, - groupHistoryKey: "123@g.us", - groupHistories: new Map(), - groupMemberNames: new Map(), - connectionId: "conn", - verbose: false, - maxMediaBytes: 1, - // oxlint-disable-next-line typescript/no-explicit-any - replyResolver: (async () => undefined) as any, - // oxlint-disable-next-line typescript/no-explicit-any - replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any, - backgroundTasks: new Set>(), - rememberSentText: () => {}, - echoHas: () => false, - echoForget: () => {}, - buildCombinedEchoKey: () => "echo", - groupHistory: [], - // oxlint-disable-next-line typescript/no-explicit-any - } as any; -} - -async function removeDirEventually(dir: string) { - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - await fs.rm(dir, { recursive: true, force: true }); - return; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOTEMPTY" || attempt === 2) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - } -} - describe("channel inbound contract", () => { - let whatsappSessionDir = ""; - beforeEach(() => { inboundCtxCapture.ctx = undefined; - signalCapture.ctx = undefined; - bufferedReplyCapture.ctx = undefined; dispatchInboundMessageMock.mockClear(); }); - afterEach(async () => { - if (whatsappSessionDir) { - await removeDirEventually(whatsappSessionDir); - whatsappSessionDir = ""; - } - }); - it("keeps Discord inbound context finalized", async () => { const messageCtx = await createBaseDiscordMessageContext({ cfg: { messages: {} }, @@ -194,26 +119,30 @@ describe("channel inbound contract", () => { }); it("keeps Signal inbound context finalized", async () => { - const handler = createSignalEventHandler( - createBaseSignalEventHandlerDeps({ - // oxlint-disable-next-line typescript/no-explicit-any - cfg: { messages: { inbound: { debounceMs: 0 } } } as any, - historyLimit: 0, - }), - ); + const ctx = finalizeInboundContext({ + Body: "Alice: hi", + BodyForAgent: "hi", + RawBody: "hi", + CommandBody: "hi", + BodyForCommands: "hi", + From: "group:g1", + To: "group:g1", + SessionKey: "agent:main:signal:group:g1", + AccountId: "default", + ChatType: "group", + ConversationLabel: "Alice", + GroupSubject: "Test Group", + SenderName: "Alice", + SenderId: "+15550001111", + Provider: "signal", + Surface: "signal", + MessageSid: "1700000000000", + OriginatingChannel: "signal", + OriginatingTo: "group:g1", + CommandAuthorized: true, + }); - await handler( - createSignalReceiveEvent({ - dataMessage: { - message: "hi", - attachments: [], - groupInfo: { groupId: "g1", groupName: "Test Group" }, - }, - }), - ); - - expect(signalCapture.ctx).toBeTruthy(); - expectChannelInboundContextContract(signalCapture.ctx!); + expectChannelInboundContextContract(ctx); }); it("keeps Slack inbound context finalized", async () => { @@ -237,35 +166,20 @@ describe("channel inbound contract", () => { }); it("keeps Telegram inbound context finalized", async () => { - const { getLoadConfigMock, getOnHandler, onSpy, sendMessageSpy } = - await import("../../../../extensions/telegram/src/bot.create-telegram-bot.test-harness.js"); - const { resetInboundDedupe } = await import("../../../auto-reply/reply/inbound-dedupe.js"); - - resetInboundDedupe(); - onSpy.mockReset(); - sendMessageSpy.mockReset(); - sendMessageSpy.mockResolvedValue({ message_id: 77 }); - getLoadConfigMock().mockReset(); - getLoadConfigMock().mockReturnValue({ - agents: { - defaults: { - envelopeTimezone: "utc", + const context = await buildTelegramMessageContextForTest({ + cfg: { + agents: { + defaults: { + envelopeTimezone: "utc", + }, }, - }, - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, }, - }, - } satisfies OpenClawConfig); - - const { createTelegramBot } = await import("../../../../extensions/telegram/src/bot.js"); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ + } satisfies OpenClawConfig, message: { chat: { id: 42, type: "group", title: "Ops" }, text: "hello", @@ -278,22 +192,38 @@ describe("channel inbound contract", () => { username: "ada", }, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), }); - const payload = bufferedReplyCapture.ctx; + const payload = context?.ctxPayload; expect(payload).toBeTruthy(); expectChannelInboundContextContract(payload!); }); it("keeps WhatsApp inbound context finalized", async () => { - whatsappSessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-whatsapp-contract-")); - const sessionStorePath = path.join(whatsappSessionDir, "sessions.json"); + const ctx = finalizeInboundContext({ + Body: "Alice: hi", + BodyForAgent: "hi", + RawBody: "hi", + CommandBody: "hi", + BodyForCommands: "hi", + From: "123@g.us", + To: "+15550001111", + SessionKey: "agent:main:whatsapp:group:123", + AccountId: "default", + ChatType: "group", + ConversationLabel: "123@g.us", + GroupSubject: "Test Group", + SenderName: "Alice", + SenderId: "alice@s.whatsapp.net", + SenderE164: "+15550002222", + Provider: "whatsapp", + Surface: "whatsapp", + MessageSid: "msg1", + OriginatingChannel: "whatsapp", + OriginatingTo: "123@g.us", + CommandAuthorized: true, + }); - await processMessage(makeWhatsAppProcessArgs(sessionStorePath)); - - expect(bufferedReplyCapture.ctx).toBeTruthy(); - expectChannelInboundContextContract(bufferedReplyCapture.ctx!); + expectChannelInboundContextContract(ctx); }); }); diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 324ba095406..fd2d84e8b70 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"; +} from "../../../../extensions/discord/runtime-api.js"; +import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js"; import { setMatrixRuntime } from "../../../../extensions/matrix/src/runtime.js"; -import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/src/thread-bindings.js"; +import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { getSessionBindingService, @@ -133,6 +133,47 @@ type SessionBindingContractEntry = { cleanup: () => Promise | void; }; +function expectResolvedSessionBinding(params: { + channel: string; + accountId: string; + conversationId: string; + targetSessionKey: string; +}) { + expect( + getSessionBindingService().resolveByConversation({ + channel: params.channel, + accountId: params.accountId, + conversationId: params.conversationId, + }), + )?.toMatchObject({ + targetSessionKey: params.targetSessionKey, + }); +} + +async function unbindAndExpectClearedSessionBinding(binding: SessionBindingRecord) { + const service = getSessionBindingService(); + const removed = await service.unbind({ + bindingId: binding.bindingId, + reason: "contract-test", + }); + expect(removed.map((entry) => entry.bindingId)).toContain(binding.bindingId); + expect(service.resolveByConversation(binding.conversation)).toBeNull(); +} + +function expectClearedSessionBinding(params: { + channel: string; + accountId: string; + conversationId: string; +}) { + expect( + getSessionBindingService().resolveByConversation({ + channel: params.channel, + accountId: params.accountId, + conversationId: params.conversationId, + }), + ).toBeNull(); +} + const telegramListActionsMock = vi.fn(); const telegramGetCapabilitiesMock = vi.fn(); const discordListActionsMock = vi.fn(); @@ -617,26 +658,15 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [ label: "codex-discord", }, }); - expect( - service.resolveByConversation({ - channel: "discord", - accountId: "default", - conversationId: "channel:123456789012345678", - }), - )?.toMatchObject({ + expectResolvedSessionBinding({ + channel: "discord", + accountId: "default", + conversationId: "channel:123456789012345678", targetSessionKey: "agent:discord:child:thread-1", }); return binding; }, - unbindAndVerify: async (binding) => { - const service = getSessionBindingService(); - const removed = await service.unbind({ - bindingId: binding.bindingId, - reason: "contract-test", - }); - expect(removed.map((entry) => entry.bindingId)).toContain(binding.bindingId); - expect(service.resolveByConversation(binding.conversation)).toBeNull(); - }, + unbindAndVerify: unbindAndExpectClearedSessionBinding, cleanup: async () => { const manager = createDiscordThreadBindingManager({ accountId: "default", @@ -645,13 +675,11 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [ }); manager.stop(); discordThreadBindingTesting.resetThreadBindingsForTests(); - expect( - getSessionBindingService().resolveByConversation({ - channel: "discord", - accountId: "default", - conversationId: "channel:123456789012345678", - }), - ).toBeNull(); + expectClearedSessionBinding({ + channel: "discord", + accountId: "default", + conversationId: "channel:123456789012345678", + }); }, }, { @@ -687,39 +715,26 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [ label: "codex-main", }, }); - expect( - service.resolveByConversation({ - channel: "feishu", - accountId: "default", - conversationId: "oc_group_chat:topic:om_topic_root", - }), - )?.toMatchObject({ + expectResolvedSessionBinding({ + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123", }); return binding; }, - unbindAndVerify: async (binding) => { - const service = getSessionBindingService(); - const removed = await service.unbind({ - bindingId: binding.bindingId, - reason: "contract-test", - }); - expect(removed.map((entry) => entry.bindingId)).toContain(binding.bindingId); - expect(service.resolveByConversation(binding.conversation)).toBeNull(); - }, + unbindAndVerify: unbindAndExpectClearedSessionBinding, cleanup: async () => { const manager = createFeishuThreadBindingManager({ cfg: baseSessionBindingCfg, accountId: "default", }); manager.stop(); - expect( - getSessionBindingService().resolveByConversation({ - channel: "feishu", - accountId: "default", - conversationId: "oc_group_chat:topic:om_topic_root", - }), - ).toBeNull(); + expectClearedSessionBinding({ + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + }); }, }, { @@ -761,26 +776,15 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [ boundBy: "user-1", }, }); - expect( - service.resolveByConversation({ - channel: "telegram", - accountId: "default", - conversationId: "-100200300:topic:77", - }), - )?.toMatchObject({ + expectResolvedSessionBinding({ + channel: "telegram", + accountId: "default", + conversationId: "-100200300:topic:77", targetSessionKey: "agent:main:subagent:child-1", }); return binding; }, - unbindAndVerify: async (binding) => { - const service = getSessionBindingService(); - const removed = await service.unbind({ - bindingId: binding.bindingId, - reason: "contract-test", - }); - expect(removed.map((entry) => entry.bindingId)).toContain(binding.bindingId); - expect(service.resolveByConversation(binding.conversation)).toBeNull(); - }, + unbindAndVerify: unbindAndExpectClearedSessionBinding, cleanup: async () => { const manager = createTelegramThreadBindingManager({ accountId: "default", @@ -788,13 +792,11 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [ enableSweeper: false, }); manager.stop(); - expect( - getSessionBindingService().resolveByConversation({ - channel: "telegram", - accountId: "default", - conversationId: "-100200300:topic:77", - }), - ).toBeNull(); + expectClearedSessionBinding({ + channel: "telegram", + accountId: "default", + conversationId: "-100200300:topic:77", + }); }, }, ]; diff --git a/src/channels/plugins/message-action-discovery.ts b/src/channels/plugins/message-action-discovery.ts new file mode 100644 index 00000000000..d54aec45679 --- /dev/null +++ b/src/channels/plugins/message-action-discovery.ts @@ -0,0 +1,378 @@ +import type { TSchema } from "@sinclair/typebox"; +import type { OpenClawConfig } from "../../config/config.js"; +import { defaultRuntime } from "../../runtime.js"; +import { normalizeAnyChannelId } from "../registry.js"; +import { getChannelPlugin, listChannelPlugins } from "./index.js"; +import type { ChannelMessageCapability } from "./message-capabilities.js"; +import type { + ChannelMessageActionDiscoveryContext, + ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelMessageToolSchemaContribution, +} from "./types.js"; + +export type ChannelMessageActionDiscoveryInput = { + cfg?: OpenClawConfig; + channel?: string | null; + currentChannelProvider?: string | null; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; +}; + +type ChannelActions = NonNullable>["actions"]>; + +const loggedMessageActionErrors = new Set(); + +export function resolveMessageActionDiscoveryChannelId(raw?: string | null): string | undefined { + const normalized = normalizeAnyChannelId(raw); + if (normalized) { + return normalized; + } + const trimmed = raw?.trim(); + return trimmed || undefined; +} + +export function createMessageActionDiscoveryContext( + params: ChannelMessageActionDiscoveryInput, +): ChannelMessageActionDiscoveryContext { + const currentChannelProvider = resolveMessageActionDiscoveryChannelId( + params.channel ?? params.currentChannelProvider, + ); + return { + cfg: params.cfg ?? ({} as OpenClawConfig), + currentChannelId: params.currentChannelId, + currentChannelProvider, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.accountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + }; +} + +function logMessageActionError(params: { + pluginId: string; + operation: "describeMessageTool" | "getCapabilities" | "getToolSchema" | "listActions"; + error: unknown; +}) { + const message = params.error instanceof Error ? params.error.message : String(params.error); + const key = `${params.pluginId}:${params.operation}:${message}`; + if (loggedMessageActionErrors.has(key)) { + return; + } + loggedMessageActionErrors.add(key); + const stack = params.error instanceof Error && params.error.stack ? params.error.stack : null; + defaultRuntime.error?.( + `[message-action-discovery] ${params.pluginId}.actions.${params.operation} failed: ${stack ?? message}`, + ); +} + +function runListActionsSafely(params: { + pluginId: string; + context: ChannelMessageActionDiscoveryContext; + listActions: NonNullable; +}): ChannelMessageActionName[] { + try { + const listed = params.listActions(params.context); + return Array.isArray(listed) ? listed : []; + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "listActions", + error, + }); + return []; + } +} + +function describeMessageToolSafely(params: { + pluginId: string; + context: ChannelMessageActionDiscoveryContext; + describeMessageTool: NonNullable; +}): ChannelMessageToolDiscovery | null { + try { + return params.describeMessageTool(params.context) ?? null; + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "describeMessageTool", + error, + }); + return null; + } +} + +function listCapabilitiesSafely(params: { + pluginId: string; + actions: ChannelActions; + context: ChannelMessageActionDiscoveryContext; +}): readonly ChannelMessageCapability[] { + try { + return params.actions.getCapabilities?.(params.context) ?? []; + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "getCapabilities", + error, + }); + return []; + } +} + +function runGetToolSchemaSafely(params: { + pluginId: string; + context: ChannelMessageActionDiscoveryContext; + getToolSchema: NonNullable; +}): + | ChannelMessageToolSchemaContribution + | ChannelMessageToolSchemaContribution[] + | null + | undefined { + try { + return params.getToolSchema(params.context); + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "getToolSchema", + error, + }); + return null; + } +} + +function normalizeToolSchemaContributions( + value: + | ChannelMessageToolSchemaContribution + | ChannelMessageToolSchemaContribution[] + | null + | undefined, +): ChannelMessageToolSchemaContribution[] { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +type ResolvedChannelMessageActionDiscovery = { + actions: ChannelMessageActionName[]; + capabilities: readonly ChannelMessageCapability[]; + schemaContributions: ChannelMessageToolSchemaContribution[]; +}; + +export function resolveMessageActionDiscoveryForPlugin(params: { + pluginId: string; + actions?: ChannelActions; + context: ChannelMessageActionDiscoveryContext; + includeActions?: boolean; + includeCapabilities?: boolean; + includeSchema?: boolean; +}): ResolvedChannelMessageActionDiscovery { + const adapter = params.actions; + if (!adapter) { + return { + actions: [], + capabilities: [], + schemaContributions: [], + }; + } + + if (adapter.describeMessageTool) { + const described = describeMessageToolSafely({ + pluginId: params.pluginId, + context: params.context, + describeMessageTool: adapter.describeMessageTool, + }); + return { + actions: + params.includeActions && Array.isArray(described?.actions) ? [...described.actions] : [], + capabilities: + params.includeCapabilities && Array.isArray(described?.capabilities) + ? described.capabilities + : [], + schemaContributions: params.includeSchema + ? normalizeToolSchemaContributions(described?.schema) + : [], + }; + } + + return { + actions: + params.includeActions && adapter.listActions + ? runListActionsSafely({ + pluginId: params.pluginId, + context: params.context, + listActions: adapter.listActions, + }) + : [], + capabilities: + params.includeCapabilities && adapter.getCapabilities + ? listCapabilitiesSafely({ + pluginId: params.pluginId, + actions: adapter, + context: params.context, + }) + : [], + schemaContributions: + params.includeSchema && adapter.getToolSchema + ? normalizeToolSchemaContributions( + runGetToolSchemaSafely({ + pluginId: params.pluginId, + context: params.context, + getToolSchema: adapter.getToolSchema, + }), + ) + : [], + }; +} + +export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { + const actions = new Set(["send", "broadcast"]); + for (const plugin of listChannelPlugins()) { + for (const action of resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: { cfg }, + includeActions: true, + }).actions) { + actions.add(action); + } + } + return Array.from(actions); +} + +export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] { + const capabilities = new Set(); + for (const plugin of listChannelPlugins()) { + for (const capability of resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: { cfg }, + includeCapabilities: true, + }).capabilities) { + capabilities.add(capability); + } + } + return Array.from(capabilities); +} + +export function listChannelMessageCapabilitiesForChannel(params: { + cfg: OpenClawConfig; + channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; +}): ChannelMessageCapability[] { + const channelId = resolveMessageActionDiscoveryChannelId(params.channel); + if (!channelId) { + return []; + } + const plugin = getChannelPlugin(channelId as Parameters[0]); + return plugin?.actions + ? Array.from( + resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: createMessageActionDiscoveryContext(params), + includeCapabilities: true, + }).capabilities, + ) + : []; +} + +function mergeToolSchemaProperties( + target: Record, + source: Record | undefined, +) { + if (!source) { + return; + } + for (const [name, schema] of Object.entries(source)) { + if (!(name in target)) { + target[name] = schema; + } + } +} + +export function resolveChannelMessageToolSchemaProperties(params: { + cfg: OpenClawConfig; + channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; +}): Record { + const properties: Record = {}; + const currentChannel = resolveMessageActionDiscoveryChannelId(params.channel); + const discoveryBase = createMessageActionDiscoveryContext(params); + + for (const plugin of listChannelPlugins()) { + if (!plugin.actions) { + continue; + } + for (const contribution of resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: discoveryBase, + includeSchema: true, + }).schemaContributions) { + const visibility = contribution.visibility ?? "current-channel"; + if (currentChannel) { + if (visibility === "all-configured" || plugin.id === currentChannel) { + mergeToolSchemaProperties(properties, contribution.properties); + } + continue; + } + mergeToolSchemaProperties(properties, contribution.properties); + } + } + + return properties; +} + +export function channelSupportsMessageCapability( + cfg: OpenClawConfig, + capability: ChannelMessageCapability, +): boolean { + return listChannelMessageCapabilities(cfg).includes(capability); +} + +export function channelSupportsMessageCapabilityForChannel( + params: { + cfg: OpenClawConfig; + channel?: string; + currentChannelId?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; + }, + capability: ChannelMessageCapability, +): boolean { + return listChannelMessageCapabilitiesForChannel(params).includes(capability); +} + +export const __testing = { + resetLoggedMessageActionErrors() { + loggedMessageActionErrors.clear(); + }, +}; diff --git a/src/channels/plugins/message-action-dispatch.ts b/src/channels/plugins/message-action-dispatch.ts new file mode 100644 index 00000000000..ab1ddef5235 --- /dev/null +++ b/src/channels/plugins/message-action-dispatch.ts @@ -0,0 +1,31 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { getChannelPlugin } from "./index.js"; +import type { ChannelMessageActionContext } from "./types.js"; + +function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean { + const plugin = getChannelPlugin(ctx.channel); + return Boolean( + plugin?.actions?.requiresTrustedRequesterSender?.({ + action: ctx.action, + toolContext: ctx.toolContext, + }), + ); +} + +export async function dispatchChannelMessageAction( + ctx: ChannelMessageActionContext, +): Promise | null> { + if (requiresTrustedRequesterSender(ctx) && !ctx.requesterSenderId?.trim()) { + throw new Error( + `Trusted sender identity is required for ${ctx.channel}:${ctx.action} in tool-driven contexts.`, + ); + } + const plugin = getChannelPlugin(ctx.channel); + if (!plugin?.actions?.handleAction) { + return null; + } + if (plugin.actions.supportsAction && !plugin.actions.supportsAction({ action: ctx.action })) { + return null; + } + return await plugin.actions.handleAction(ctx); +} diff --git a/src/channels/plugins/message-actions.security.test.ts b/src/channels/plugins/message-actions.security.test.ts index b8b62afdecd..ed178a9e2fa 100644 --- a/src/channels/plugins/message-actions.security.test.ts +++ b/src/channels/plugins/message-actions.security.test.ts @@ -6,7 +6,7 @@ import { createChannelTestPluginBase, createTestRegistry, } from "../../test-utils/channel-plugins.js"; -import { dispatchChannelMessageAction } from "./message-actions.js"; +import { dispatchChannelMessageAction } from "./message-action-dispatch.js"; import type { ChannelPlugin } from "./types.js"; const handleAction = vi.fn(async () => jsonResult({ ok: true })); diff --git a/src/channels/plugins/message-actions.test.ts b/src/channels/plugins/message-actions.test.ts index 17fdf8fe193..396b82a498c 100644 --- a/src/channels/plugins/message-actions.test.ts +++ b/src/channels/plugins/message-actions.test.ts @@ -1,3 +1,4 @@ +import { Type } from "@sinclair/typebox"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; @@ -13,7 +14,8 @@ import { listChannelMessageActions, listChannelMessageCapabilities, listChannelMessageCapabilitiesForChannel, -} from "./message-actions.js"; + resolveChannelMessageToolSchemaProperties, +} from "./message-action-discovery.js"; import type { ChannelMessageCapability } from "./message-capabilities.js"; import type { ChannelPlugin } from "./types.js"; @@ -22,16 +24,22 @@ const emptyRegistry = createTestRegistry([]); function createMessageActionsPlugin(params: { id: "discord" | "telegram"; capabilities: readonly ChannelMessageCapability[]; + aliases?: string[]; }): ChannelPlugin { + const base = createChannelTestPluginBase({ + id: params.id, + label: params.id === "discord" ? "Discord" : "Telegram", + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + }, + }); return { - ...createChannelTestPluginBase({ - id: params.id, - label: params.id === "discord" ? "Discord" : "Telegram", - capabilities: { chatTypes: ["direct", "group"] }, - config: { - listAccountIds: () => ["default"], - }, - }), + ...base, + meta: { + ...base.meta, + ...(params.aliases ? { aliases: params.aliases } : {}), + }, actions: { listActions: () => ["send"], getCapabilities: () => params.capabilities, @@ -130,6 +138,80 @@ describe("message action capability checks", () => { ); }); + it("normalizes channel aliases for per-channel capability checks", () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + source: "test", + plugin: createMessageActionsPlugin({ + id: "telegram", + aliases: ["tg"], + capabilities: ["cards"], + }), + }, + ]), + ); + + expect( + listChannelMessageCapabilitiesForChannel({ + cfg: {} as OpenClawConfig, + channel: "tg", + }), + ).toEqual(["cards"]); + }); + + it("prefers unified message tool discovery over legacy discovery methods", () => { + const legacyListActions = vi.fn(() => { + throw new Error("legacy listActions should not run"); + }); + const legacyCapabilities = vi.fn(() => { + throw new Error("legacy getCapabilities should not run"); + }); + const legacySchema = vi.fn(() => { + throw new Error("legacy getToolSchema should not run"); + }); + const unifiedPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "discord", + label: "Discord", + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + }, + }), + actions: { + describeMessageTool: () => ({ + actions: ["react"], + capabilities: ["interactive"], + schema: { + properties: { + components: Type.Array(Type.String()), + }, + }, + }), + listActions: legacyListActions, + getCapabilities: legacyCapabilities, + getToolSchema: legacySchema, + }, + }; + setActivePluginRegistry( + createTestRegistry([{ pluginId: "discord", source: "test", plugin: unifiedPlugin }]), + ); + + expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast", "react"]); + expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual(["interactive"]); + expect( + resolveChannelMessageToolSchemaProperties({ + cfg: {} as OpenClawConfig, + channel: "discord", + }), + ).toHaveProperty("components"); + expect(legacyListActions).not.toHaveBeenCalled(); + expect(legacyCapabilities).not.toHaveBeenCalled(); + expect(legacySchema).not.toHaveBeenCalled(); + }); + it("skips crashing action/capability discovery paths and logs once", () => { const crashingPlugin: ChannelPlugin = { ...createChannelTestPluginBase({ diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts deleted file mode 100644 index 07d08171582..00000000000 --- a/src/channels/plugins/message-actions.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { OpenClawConfig } from "../../config/config.js"; -import { defaultRuntime } from "../../runtime.js"; -import { getChannelPlugin, listChannelPlugins } from "./index.js"; -import type { ChannelMessageCapability } from "./message-capabilities.js"; -import type { ChannelMessageActionContext, ChannelMessageActionName } from "./types.js"; - -type ChannelActions = NonNullable>["actions"]>; - -function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean { - const plugin = getChannelPlugin(ctx.channel); - return Boolean( - plugin?.actions?.requiresTrustedRequesterSender?.({ - action: ctx.action, - toolContext: ctx.toolContext, - }), - ); -} - -const loggedMessageActionErrors = new Set(); - -function logMessageActionError(params: { - pluginId: string; - operation: "listActions" | "getCapabilities"; - error: unknown; -}) { - const message = params.error instanceof Error ? params.error.message : String(params.error); - const key = `${params.pluginId}:${params.operation}:${message}`; - if (loggedMessageActionErrors.has(key)) { - return; - } - loggedMessageActionErrors.add(key); - const stack = params.error instanceof Error && params.error.stack ? params.error.stack : null; - defaultRuntime.error?.( - `[message-actions] ${params.pluginId}.actions.${params.operation} failed: ${stack ?? message}`, - ); -} - -function runListActionsSafely(params: { - pluginId: string; - cfg: OpenClawConfig; - listActions: NonNullable; -}): ChannelMessageActionName[] { - try { - const listed = params.listActions({ cfg: params.cfg }); - return Array.isArray(listed) ? listed : []; - } catch (error) { - logMessageActionError({ - pluginId: params.pluginId, - operation: "listActions", - error, - }); - return []; - } -} - -export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { - const actions = new Set(["send", "broadcast"]); - for (const plugin of listChannelPlugins()) { - if (!plugin.actions?.listActions) { - continue; - } - const list = runListActionsSafely({ - pluginId: plugin.id, - cfg, - listActions: plugin.actions.listActions, - }); - for (const action of list) { - actions.add(action); - } - } - return Array.from(actions); -} - -function listCapabilities(params: { - pluginId: string; - actions: ChannelActions; - cfg: OpenClawConfig; -}): readonly ChannelMessageCapability[] { - try { - return params.actions.getCapabilities?.({ cfg: params.cfg }) ?? []; - } catch (error) { - logMessageActionError({ - pluginId: params.pluginId, - operation: "getCapabilities", - error, - }); - return []; - } -} - -export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] { - const capabilities = new Set(); - for (const plugin of listChannelPlugins()) { - if (!plugin.actions) { - continue; - } - for (const capability of listCapabilities({ - pluginId: plugin.id, - actions: plugin.actions, - cfg, - })) { - capabilities.add(capability); - } - } - return Array.from(capabilities); -} - -export function listChannelMessageCapabilitiesForChannel(params: { - cfg: OpenClawConfig; - channel?: string; -}): ChannelMessageCapability[] { - if (!params.channel) { - return []; - } - const plugin = getChannelPlugin(params.channel as Parameters[0]); - return plugin?.actions - ? Array.from( - listCapabilities({ - pluginId: plugin.id, - actions: plugin.actions, - cfg: params.cfg, - }), - ) - : []; -} - -export function channelSupportsMessageCapability( - cfg: OpenClawConfig, - capability: ChannelMessageCapability, -): boolean { - return listChannelMessageCapabilities(cfg).includes(capability); -} - -export function channelSupportsMessageCapabilityForChannel( - params: { - cfg: OpenClawConfig; - channel?: string; - }, - capability: ChannelMessageCapability, -): boolean { - return listChannelMessageCapabilitiesForChannel(params).includes(capability); -} - -export async function dispatchChannelMessageAction( - ctx: ChannelMessageActionContext, -): Promise | null> { - if (requiresTrustedRequesterSender(ctx) && !ctx.requesterSenderId?.trim()) { - throw new Error( - `Trusted sender identity is required for ${ctx.channel}:${ctx.action} in tool-driven contexts.`, - ); - } - const plugin = getChannelPlugin(ctx.channel); - if (!plugin?.actions?.handleAction) { - return null; - } - if (plugin.actions.supportsAction && !plugin.actions.supportsAction({ action: ctx.action })) { - return null; - } - return await plugin.actions.handleAction(ctx); -} - -export const __testing = { - resetLoggedMessageActionErrors() { - loggedMessageActionErrors.clear(); - }, -}; 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/message-tool-legacy.ts b/src/channels/plugins/message-tool-legacy.ts new file mode 100644 index 00000000000..2c74213439f --- /dev/null +++ b/src/channels/plugins/message-tool-legacy.ts @@ -0,0 +1,13 @@ +import type { ChannelMessageActionAdapter } from "./types.js"; + +export function createLegacyMessageToolDiscoveryMethods( + describeMessageTool: NonNullable, +): Pick { + const describe = (ctx: Parameters[0]) => + describeMessageTool(ctx) ?? null; + return { + listActions: (ctx) => [...(describe(ctx)?.actions ?? [])], + getCapabilities: (ctx) => [...(describe(ctx)?.capabilities ?? [])], + getToolSchema: (ctx) => describe(ctx)?.schema ?? null, + }; +} diff --git a/src/channels/plugins/message-tool-schema.ts b/src/channels/plugins/message-tool-schema.ts new file mode 100644 index 00000000000..790b2118ee9 --- /dev/null +++ b/src/channels/plugins/message-tool-schema.ts @@ -0,0 +1,161 @@ +import { Type } from "@sinclair/typebox"; +import type { TSchema } from "@sinclair/typebox"; +import { stringEnum } from "../../agents/schema/typebox.js"; + +const discordComponentEmojiSchema = Type.Object({ + name: Type.String(), + id: Type.Optional(Type.String()), + animated: Type.Optional(Type.Boolean()), +}); + +const discordComponentOptionSchema = Type.Object({ + label: Type.String(), + value: Type.String(), + description: Type.Optional(Type.String()), + emoji: Type.Optional(discordComponentEmojiSchema), + default: Type.Optional(Type.Boolean()), +}); + +const discordComponentButtonSchema = Type.Object({ + label: Type.String(), + style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), + url: Type.Optional(Type.String()), + emoji: Type.Optional(discordComponentEmojiSchema), + disabled: Type.Optional(Type.Boolean()), + allowedUsers: Type.Optional( + Type.Array( + Type.String({ + description: "Discord user ids or names allowed to interact with this button.", + }), + ), + ), +}); + +const discordComponentSelectSchema = Type.Object({ + type: Type.Optional(stringEnum(["string", "user", "role", "mentionable", "channel"])), + placeholder: Type.Optional(Type.String()), + minValues: Type.Optional(Type.Number()), + maxValues: Type.Optional(Type.Number()), + options: Type.Optional(Type.Array(discordComponentOptionSchema)), +}); + +const discordComponentBlockSchema = Type.Object({ + type: Type.String(), + text: Type.Optional(Type.String()), + texts: Type.Optional(Type.Array(Type.String())), + accessory: Type.Optional( + Type.Object({ + type: Type.String(), + url: Type.Optional(Type.String()), + button: Type.Optional(discordComponentButtonSchema), + }), + ), + spacing: Type.Optional(stringEnum(["small", "large"])), + divider: Type.Optional(Type.Boolean()), + buttons: Type.Optional(Type.Array(discordComponentButtonSchema)), + select: Type.Optional(discordComponentSelectSchema), + items: Type.Optional( + Type.Array( + Type.Object({ + url: Type.String(), + description: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), + }), + ), + ), + file: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), +}); + +const discordComponentModalFieldSchema = Type.Object({ + type: Type.String(), + name: Type.Optional(Type.String()), + label: Type.String(), + description: Type.Optional(Type.String()), + placeholder: Type.Optional(Type.String()), + required: Type.Optional(Type.Boolean()), + options: Type.Optional(Type.Array(discordComponentOptionSchema)), + minValues: Type.Optional(Type.Number()), + maxValues: Type.Optional(Type.Number()), + minLength: Type.Optional(Type.Number()), + maxLength: Type.Optional(Type.Number()), + style: Type.Optional(stringEnum(["short", "paragraph"])), +}); + +const discordComponentModalSchema = Type.Object({ + title: Type.String(), + triggerLabel: Type.Optional(Type.String()), + triggerStyle: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), + fields: Type.Array(discordComponentModalFieldSchema), +}); + +export function createMessageToolButtonsSchema(): TSchema { + return Type.Array( + Type.Array( + Type.Object({ + text: Type.String(), + callback_data: Type.String(), + style: Type.Optional(stringEnum(["danger", "success", "primary"])), + }), + ), + { + description: "Button rows for channels that support button-style actions.", + }, + ); +} + +export function createMessageToolCardSchema(): TSchema { + return Type.Object( + {}, + { + additionalProperties: true, + description: "Structured card payload for channels that support card-style messages.", + }, + ); +} + +export function createDiscordMessageToolComponentsSchema(): TSchema { + return Type.Object( + { + text: Type.Optional(Type.String()), + reusable: Type.Optional( + Type.Boolean({ + description: "Allow components to be used multiple times until they expire.", + }), + ), + container: Type.Optional( + Type.Object({ + accentColor: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), + }), + ), + blocks: Type.Optional(Type.Array(discordComponentBlockSchema)), + modal: Type.Optional(discordComponentModalSchema), + }, + { + description: + "Discord components v2 payload. Set reusable=true to keep buttons, selects, and forms active until expiry.", + }, + ); +} + +export function createSlackMessageToolBlocksSchema(): TSchema { + return Type.Array( + Type.Object( + {}, + { + additionalProperties: true, + description: "Slack Block Kit payload blocks (Slack only).", + }, + ), + ); +} + +export function createTelegramPollExtraToolSchemas(): Record { + return { + pollDurationHours: Type.Optional(Type.Number()), + pollDurationSeconds: Type.Optional(Type.Number()), + pollAnonymous: Type.Optional(Type.Boolean()), + pollPublic: Type.Optional(Type.Boolean()), + }; +} diff --git a/src/channels/plugins/setup-wizard-helpers.runtime.ts b/src/channels/plugins/setup-wizard-helpers.runtime.ts index 8c1808f5d40..9fcdf661643 100644 --- a/src/channels/plugins/setup-wizard-helpers.runtime.ts +++ b/src/channels/plugins/setup-wizard-helpers.runtime.ts @@ -1 +1,8 @@ -export { promptResolvedAllowFrom } from "./setup-wizard-helpers.js"; +import type { promptResolvedAllowFrom as promptResolvedAllowFromType } from "./setup-wizard-helpers.js"; + +export async function promptResolvedAllowFrom( + ...args: Parameters +): ReturnType { + const runtime = await import("./setup-wizard-helpers.js"); + return runtime.promptResolvedAllowFrom(...args); +} diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index 483b4db7df9..c9cf3e9d883 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -1,5 +1,8 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { handleSlackAction, type SlackActionContext } from "../../agents/tools/slack-actions.js"; +import { + handleSlackAction, + type SlackActionContext, +} from "../../../extensions/slack/runtime-api.js"; import { extractSlackToolSend, isSlackInteractiveRepliesEnabled, @@ -7,7 +10,9 @@ import { resolveSlackChannelId, handleSlackMessageAction, } from "../../plugin-sdk/slack.js"; -import type { ChannelMessageActionAdapter } from "./types.js"; +import { createLegacyMessageToolDiscoveryMethods } from "./message-tool-legacy.js"; +import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js"; +import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery } from "./types.js"; type SlackActionInvoke = ( action: Record, @@ -19,18 +24,35 @@ export function createSlackActions( providerId: string, options?: { invoke?: SlackActionInvoke }, ): ChannelMessageActionAdapter { + function describeMessageTool({ + cfg, + }: Parameters< + NonNullable + >[0]): ChannelMessageToolDiscovery { + const actions = listSlackMessageActions(cfg); + const capabilities = new Set<"blocks" | "interactive">(); + if (actions.includes("send")) { + capabilities.add("blocks"); + } + if (isSlackInteractiveRepliesEnabled({ cfg })) { + capabilities.add("interactive"); + } + return { + actions, + capabilities: Array.from(capabilities), + schema: actions.includes("send") + ? { + properties: { + blocks: createSlackMessageToolBlocksSchema(), + }, + } + : null, + }; + } + return { - listActions: ({ cfg }) => listSlackMessageActions(cfg), - getCapabilities: ({ cfg }) => { - const capabilities = new Set<"interactive" | "blocks">(); - if (listSlackMessageActions(cfg).includes("send")) { - capabilities.add("blocks"); - } - if (isSlackInteractiveRepliesEnabled({ cfg })) { - capabilities.add("interactive"); - } - return Array.from(capabilities); - }, + describeMessageTool, + ...createLegacyMessageToolDiscoveryMethods(describeMessageTool), extractToolSend: ({ args }) => extractSlackToolSend(args), handleAction: async (ctx) => { return await handleSlackMessageAction({ 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.core.ts b/src/channels/plugins/types.core.ts index a43dbb42876..1699b8024a5 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -21,6 +21,43 @@ export type ChannelAgentTool = AgentTool & { export type ChannelAgentToolFactory = (params: { cfg?: OpenClawConfig }) => ChannelAgentTool[]; +/** + * Discovery-time inputs passed to channel action adapters when the core is + * asking what an agent should be allowed to see. This is intentionally + * smaller than execution context: it carries routing/account scope, but no + * tool params or runtime handles. + */ +export type ChannelMessageActionDiscoveryContext = { + cfg: OpenClawConfig; + currentChannelId?: string | null; + currentChannelProvider?: string | null; + currentThreadTs?: string | null; + currentMessageId?: string | number | null; + accountId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; + requesterSenderId?: string | null; +}; + +/** + * Plugin-owned schema fragments for the shared `message` tool. + * `current-channel` means expose the fields only when that provider is the + * active runtime channel. `all-configured` keeps the fields visible even while + * another configured channel is active, which is useful for cross-channel + * sends from cron or isolated agents. + */ +export type ChannelMessageToolSchemaContribution = { + properties: Record; + visibility?: "current-channel" | "all-configured"; +}; + +export type ChannelMessageToolDiscovery = { + actions?: readonly ChannelMessageActionName[] | null; + capabilities?: readonly ChannelMessageCapability[] | null; + schema?: ChannelMessageToolSchemaContribution | ChannelMessageToolSchemaContribution[] | null; +}; + export type ChannelSetupInput = { name?: string; token?: string; @@ -424,6 +461,9 @@ export type ChannelMessageActionContext = { * never be sourced from tool/model-controlled params. */ requesterSenderId?: string | null; + sessionKey?: string | null; + sessionId?: string | null; + agentId?: string | null; gateway?: { url?: string; token?: string; @@ -443,15 +483,39 @@ export type ChannelToolSend = { }; export type ChannelMessageActionAdapter = { + /** + * Preferred unified discovery surface for the shared `message` tool. + * When provided, this is authoritative and should return the scoped actions, + * capabilities, and schema fragments together so they cannot drift. + */ + describeMessageTool?: ( + params: ChannelMessageActionDiscoveryContext, + ) => ChannelMessageToolDiscovery | null | undefined; /** * Advertise agent-discoverable actions for this channel. + * Legacy fallback used when `describeMessageTool` is not implemented. * Keep this aligned with any gated capability checks. Poll discovery is * not inferred from `outbound.sendPoll`, so channels that want agents to * create polls should include `"poll"` here when enabled. */ - listActions?: (params: { cfg: OpenClawConfig }) => ChannelMessageActionName[]; + listActions?: (params: ChannelMessageActionDiscoveryContext) => ChannelMessageActionName[]; supportsAction?: (params: { action: ChannelMessageActionName }) => boolean; - getCapabilities?: (params: { cfg: OpenClawConfig }) => readonly ChannelMessageCapability[]; + getCapabilities?: ( + params: ChannelMessageActionDiscoveryContext, + ) => readonly ChannelMessageCapability[]; + /** + * Extend the shared `message` tool schema with channel-owned fields. + * Legacy fallback used when `describeMessageTool` is not implemented. + * Keep this aligned with `listActions` and `getCapabilities` so the exposed + * schema matches what the channel can actually execute in the current scope. + */ + getToolSchema?: ( + params: ChannelMessageActionDiscoveryContext, + ) => + | ChannelMessageToolSchemaContribution + | ChannelMessageToolSchemaContribution[] + | null + | undefined; requiresTrustedRequesterSender?: (params: { action: ChannelMessageActionName; toolContext?: ChannelThreadingToolContext; 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 ({ - loadSessionStore: vi.fn(), - resolveStorePath: vi.fn(() => "/tmp/test-sessions.json"), -})); - -vi.mock("../../pairing/pairing-store.js", () => ({ - readChannelAllowFromStoreSync: vi.fn(() => []), -})); - import type { OpenClawConfig } from "../../config/config.js"; -import { loadSessionStore } from "../../config/sessions.js"; -import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js"; -import { resolveWhatsAppHeartbeatRecipients } from "./whatsapp-heartbeat.js"; + +const loadSessionStoreMock = vi.hoisted(() => vi.fn()); +const readChannelAllowFromStoreSyncMock = vi.hoisted(() => vi.fn<() => string[]>(() => [])); + +type WhatsAppHeartbeatModule = typeof import("./whatsapp-heartbeat.js"); + +let resolveWhatsAppHeartbeatRecipients: WhatsAppHeartbeatModule["resolveWhatsAppHeartbeatRecipients"]; function makeCfg(overrides?: Partial): OpenClawConfig { return { @@ -23,12 +17,12 @@ function makeCfg(overrides?: Partial): OpenClawConfig { } describe("resolveWhatsAppHeartbeatRecipients", () => { - function setSessionStore(store: ReturnType) { - vi.mocked(loadSessionStore).mockReturnValue(store); + function setSessionStore(store: Record) { + loadSessionStoreMock.mockReturnValue(store); } function setAllowFromStore(entries: string[]) { - vi.mocked(readChannelAllowFromStoreSync).mockReturnValue(entries); + readChannelAllowFromStoreSyncMock.mockReturnValue(entries); } function resolveWith( @@ -45,9 +39,18 @@ describe("resolveWhatsAppHeartbeatRecipients", () => { setAllowFromStore(["+15550000001"]); } - beforeEach(() => { - vi.mocked(loadSessionStore).mockClear(); - vi.mocked(readChannelAllowFromStoreSync).mockClear(); + beforeEach(async () => { + vi.resetModules(); + loadSessionStoreMock.mockReset(); + readChannelAllowFromStoreSyncMock.mockReset(); + vi.doMock("../../config/sessions.js", () => ({ + loadSessionStore: loadSessionStoreMock, + resolveStorePath: vi.fn(() => "/tmp/test-sessions.json"), + })); + vi.doMock("../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStoreSync: readChannelAllowFromStoreSyncMock, + })); + ({ resolveWhatsAppHeartbeatRecipients } = await import("./whatsapp-heartbeat.js")); setAllowFromStore([]); }); diff --git a/src/channels/session.test.ts b/src/channels/session.test.ts index b1415bbb53d..530346bddb4 100644 --- a/src/channels/session.test.ts +++ b/src/channels/session.test.ts @@ -9,6 +9,10 @@ vi.mock("../config/sessions.js", () => ({ updateLastRoute: (args: unknown) => updateLastRouteMock(args), })); +type SessionModule = typeof import("./session.js"); + +let recordInboundSession: SessionModule["recordInboundSession"]; + describe("recordInboundSession", () => { const ctx: MsgContext = { Provider: "telegram", @@ -17,14 +21,14 @@ describe("recordInboundSession", () => { OriginatingTo: "telegram:1234", }; - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ recordInboundSession } = await import("./session.js")); recordSessionMetaFromInboundMock.mockClear(); updateLastRouteMock.mockClear(); }); it("does not pass ctx when updating a different session key", async () => { - const { recordInboundSession } = await import("./session.js"); - await recordInboundSession({ storePath: "/tmp/openclaw-session-store.json", sessionKey: "agent:main:telegram:1234:thread:42", @@ -50,8 +54,6 @@ describe("recordInboundSession", () => { }); it("passes ctx when updating the same session key", async () => { - const { recordInboundSession } = await import("./session.js"); - await recordInboundSession({ storePath: "/tmp/openclaw-session-store.json", sessionKey: "agent:main:telegram:1234:thread:42", @@ -77,8 +79,6 @@ describe("recordInboundSession", () => { }); it("normalizes mixed-case session keys before recording and route updates", async () => { - const { recordInboundSession } = await import("./session.js"); - await recordInboundSession({ storePath: "/tmp/openclaw-session-store.json", sessionKey: "Agent:Main:Telegram:1234:Thread:42", @@ -105,7 +105,6 @@ describe("recordInboundSession", () => { }); it("skips last-route updates when main DM owner pin mismatches sender", async () => { - const { recordInboundSession } = await import("./session.js"); const onSkip = vi.fn(); await recordInboundSession({ diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index 6a2dff29582..87e171d7ce4 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; const callGateway = vi.fn(); @@ -7,7 +7,13 @@ vi.mock("../gateway/call.js", () => ({ callGateway, })); -const { resolveCommandSecretRefsViaGateway } = await import("./command-secret-gateway.js"); +let resolveCommandSecretRefsViaGateway: typeof import("./command-secret-gateway.js").resolveCommandSecretRefsViaGateway; + +beforeEach(async () => { + vi.resetModules(); + callGateway.mockReset(); + ({ resolveCommandSecretRefsViaGateway } = await import("./command-secret-gateway.js")); +}); describe("resolveCommandSecretRefsViaGateway", () => { function makeTalkApiKeySecretRefConfig(envKey: string): OpenClawConfig { diff --git a/src/cli/command-secret-resolution.coverage.test.ts b/src/cli/command-secret-resolution.coverage.test.ts index fea0fb35eec..362bd3b0b55 100644 --- a/src/cli/command-secret-resolution.coverage.test.ts +++ b/src/cli/command-secret-resolution.coverage.test.ts @@ -14,6 +14,18 @@ const SECRET_TARGET_CALLSITES = [ "src/commands/status.scan.ts", ] as const; +async function readCommandSource(relativePath: string): Promise { + const absolutePath = path.join(process.cwd(), relativePath); + const source = await fs.readFile(absolutePath, "utf8"); + const reexportMatch = source.match(/^export \* from "(?[^"]+)";$/m)?.groups?.target; + if (!reexportMatch) { + return source; + } + const resolvedTarget = path.join(path.dirname(absolutePath), reexportMatch); + const tsResolvedTarget = resolvedTarget.replace(/\.js$/u, ".ts"); + return await fs.readFile(tsResolvedTarget, "utf8"); +} + function hasSupportedTargetIdsWiring(source: string): boolean { return ( /targetIds:\s*get[A-Za-z0-9_]+\(\)/m.test(source) || @@ -25,8 +37,7 @@ describe("command secret resolution coverage", () => { it.each(SECRET_TARGET_CALLSITES)( "routes target-id command path through shared gateway resolver: %s", async (relativePath) => { - const absolutePath = path.join(process.cwd(), relativePath); - const source = await fs.readFile(absolutePath, "utf8"); + const source = await readCommandSource(relativePath); expect(source).toContain("resolveCommandSecretRefsViaGateway"); expect(hasSupportedTargetIdsWiring(source)).toBe(true); expect(source).toContain("resolveCommandSecretRefsViaGateway({"); diff --git a/src/cli/config-cli.integration.test.ts b/src/cli/config-cli.integration.test.ts new file mode 100644 index 00000000000..ed749019c34 --- /dev/null +++ b/src/cli/config-cli.integration.test.ts @@ -0,0 +1,330 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import JSON5 from "json5"; +import { describe, expect, it } from "vitest"; +import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; +import { captureEnv } from "../test-utils/env.js"; +import { runConfigSet } from "./config-cli.js"; + +function createTestRuntime() { + const logs: string[] = []; + const errors: string[] = []; + return { + logs, + errors, + runtime: { + log: (...args: unknown[]) => logs.push(args.map((arg) => String(arg)).join(" ")), + error: (...args: unknown[]) => errors.push(args.map((arg) => String(arg)).join(" ")), + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, + }, + }; +} + +function createExecDryRunBatch(params: { markerPath: string }) { + const response = JSON.stringify({ + protocolVersion: 1, + values: { + dryrun_id: "ok", + }, + }); + const script = [ + 'const fs = require("node:fs");', + `fs.writeFileSync(${JSON.stringify(params.markerPath)}, "dryrun\\n", "utf8");`, + `process.stdout.write(${JSON.stringify(response)});`, + ].join(""); + return [ + { + path: "secrets.providers.runner", + provider: { + source: "exec", + command: process.execPath, + args: ["-e", script], + allowInsecurePath: true, + }, + }, + { + path: "channels.discord.token", + ref: { + source: "exec", + provider: "runner", + id: "dryrun_id", + }, + }, + ]; +} + +describe("config cli integration", () => { + it("supports batch-file dry-run and then writes real config changes", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-")); + const configPath = path.join(tempDir, "openclaw.json"); + const batchPath = path.join(tempDir, "batch.json"); + const envSnapshot = captureEnv([ + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_TEST_FAST", + "DISCORD_BOT_TOKEN", + ]); + try { + fs.writeFileSync( + configPath, + `${JSON.stringify( + { + gateway: { port: 18789 }, + }, + null, + 2, + )}\n`, + "utf8", + ); + fs.writeFileSync( + batchPath, + `${JSON.stringify( + [ + { + path: "secrets.providers.default", + provider: { source: "env" }, + }, + { + path: "channels.discord.token", + ref: { + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }, + }, + ], + null, + 2, + )}\n`, + "utf8", + ); + + process.env.OPENCLAW_TEST_FAST = "1"; + process.env.OPENCLAW_CONFIG_PATH = configPath; + process.env.DISCORD_BOT_TOKEN = "test-token"; + clearConfigCache(); + clearRuntimeConfigSnapshot(); + + const runtime = createTestRuntime(); + const before = fs.readFileSync(configPath, "utf8"); + await runConfigSet({ + cliOptions: { + batchFile: batchPath, + dryRun: true, + }, + runtime: runtime.runtime, + }); + const afterDryRun = fs.readFileSync(configPath, "utf8"); + expect(afterDryRun).toBe(before); + expect(runtime.errors).toEqual([]); + expect(runtime.logs.some((line) => line.includes("Dry run successful: 2 update(s)"))).toBe( + true, + ); + + await runConfigSet({ + cliOptions: { + batchFile: batchPath, + }, + runtime: runtime.runtime, + }); + const afterWrite = JSON5.parse(fs.readFileSync(configPath, "utf8")); + expect(afterWrite.secrets?.providers?.default).toEqual({ + source: "env", + }); + expect(afterWrite.channels?.discord?.token).toEqual({ + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }); + } finally { + envSnapshot.restore(); + clearConfigCache(); + clearRuntimeConfigSnapshot(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("keeps file unchanged when real-file dry-run fails and reports JSON error payload", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-fail-")); + const configPath = path.join(tempDir, "openclaw.json"); + const envSnapshot = captureEnv([ + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_TEST_FAST", + "MISSING_TEST_SECRET", + ]); + try { + fs.writeFileSync( + configPath, + `${JSON.stringify( + { + gateway: { port: 18789 }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + process.env.OPENCLAW_TEST_FAST = "1"; + process.env.OPENCLAW_CONFIG_PATH = configPath; + delete process.env.MISSING_TEST_SECRET; + clearConfigCache(); + clearRuntimeConfigSnapshot(); + + const runtime = createTestRuntime(); + const before = fs.readFileSync(configPath, "utf8"); + await expect( + runConfigSet({ + path: "channels.discord.token", + cliOptions: { + refProvider: "default", + refSource: "env", + refId: "MISSING_TEST_SECRET", + dryRun: true, + json: true, + }, + runtime: runtime.runtime, + }), + ).rejects.toThrow("__exit__:1"); + const after = fs.readFileSync(configPath, "utf8"); + expect(after).toBe(before); + expect(runtime.errors).toEqual([]); + const raw = runtime.logs.at(-1); + expect(raw).toBeTruthy(); + const payload = JSON.parse(raw ?? "{}") as { + ok?: boolean; + checks?: { schema?: boolean; resolvability?: boolean }; + errors?: Array<{ kind?: string; ref?: string }>; + }; + expect(payload.ok).toBe(false); + expect(payload.checks?.resolvability).toBe(true); + expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true); + expect(payload.errors?.some((entry) => entry.ref?.includes("MISSING_TEST_SECRET"))).toBe( + true, + ); + } finally { + envSnapshot.restore(); + clearConfigCache(); + clearRuntimeConfigSnapshot(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("skips exec provider execution during dry-run by default", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-exec-skip-")); + const configPath = path.join(tempDir, "openclaw.json"); + const batchPath = path.join(tempDir, "batch.json"); + const markerPath = path.join(tempDir, "marker.txt"); + const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH", "OPENCLAW_TEST_FAST"]); + try { + fs.writeFileSync( + configPath, + `${JSON.stringify( + { + gateway: { port: 18789 }, + }, + null, + 2, + )}\n`, + "utf8", + ); + fs.writeFileSync( + batchPath, + `${JSON.stringify(createExecDryRunBatch({ markerPath }), null, 2)}\n`, + "utf8", + ); + + process.env.OPENCLAW_TEST_FAST = "1"; + process.env.OPENCLAW_CONFIG_PATH = configPath; + clearConfigCache(); + clearRuntimeConfigSnapshot(); + + const runtime = createTestRuntime(); + const before = fs.readFileSync(configPath, "utf8"); + await runConfigSet({ + cliOptions: { + batchFile: batchPath, + dryRun: true, + }, + runtime: runtime.runtime, + }); + const after = fs.readFileSync(configPath, "utf8"); + + expect(after).toBe(before); + expect(fs.existsSync(markerPath)).toBe(false); + expect( + runtime.logs.some((line) => + line.includes("Dry run note: skipped 1 exec SecretRef resolvability check(s)."), + ), + ).toBe(true); + } finally { + envSnapshot.restore(); + clearConfigCache(); + clearRuntimeConfigSnapshot(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("executes exec providers during dry-run when --allow-exec is set", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-exec-allow-")); + const configPath = path.join(tempDir, "openclaw.json"); + const batchPath = path.join(tempDir, "batch.json"); + const markerPath = path.join(tempDir, "marker.txt"); + const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH", "OPENCLAW_TEST_FAST"]); + try { + fs.writeFileSync( + configPath, + `${JSON.stringify( + { + gateway: { port: 18789 }, + }, + null, + 2, + )}\n`, + "utf8", + ); + fs.writeFileSync( + batchPath, + `${JSON.stringify(createExecDryRunBatch({ markerPath }), null, 2)}\n`, + "utf8", + ); + + process.env.OPENCLAW_TEST_FAST = "1"; + process.env.OPENCLAW_CONFIG_PATH = configPath; + clearConfigCache(); + clearRuntimeConfigSnapshot(); + + const runtime = createTestRuntime(); + const before = fs.readFileSync(configPath, "utf8"); + await runConfigSet({ + cliOptions: { + batchFile: batchPath, + dryRun: true, + allowExec: true, + }, + runtime: runtime.runtime, + }); + const after = fs.readFileSync(configPath, "utf8"); + + expect(after).toBe(before); + expect(fs.existsSync(markerPath)).toBe(true); + expect( + runtime.logs.some((line) => + line.includes("Dry run note: skipped 1 exec SecretRef resolvability check(s)."), + ), + ).toBe(false); + } finally { + envSnapshot.restore(); + clearConfigCache(); + clearRuntimeConfigSnapshot(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 8ee785df189..ded6ad806da 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { Command } from "commander"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js"; @@ -12,6 +15,7 @@ const mockReadConfigFileSnapshot = vi.fn<() => Promise>(); const mockWriteConfigFile = vi.fn< (cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) => Promise >(async () => {}); +const mockResolveSecretRefValue = vi.fn(); vi.mock("../config/config.js", () => ({ readConfigFileSnapshot: () => mockReadConfigFileSnapshot(), @@ -19,6 +23,10 @@ vi.mock("../config/config.js", () => ({ mockWriteConfigFile(cfg, options), })); +vi.mock("../secrets/resolve.js", () => ({ + resolveSecretRefValue: (...args: unknown[]) => mockResolveSecretRefValue(...args), +})); + const mockLog = vi.fn(); const mockError = vi.fn(); const mockExit = vi.fn((code: number) => { @@ -123,6 +131,7 @@ describe("config cli", () => { beforeEach(() => { vi.clearAllMocks(); + mockResolveSecretRefValue.mockResolvedValue("resolved-secret"); }); describe("config set - issue #6070", () => { @@ -345,6 +354,23 @@ describe("config cli", () => { expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled(); }); + it("accepts --strict-json with batch mode and applies batch payload", async () => { + const resolved: OpenClawConfig = { gateway: { port: 18789 } }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "--batch-json", + '[{"path":"gateway.auth.mode","value":"token"}]', + "--strict-json", + ]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.gateway?.auth).toEqual({ mode: "token" }); + }); + it("shows --strict-json and keeps --json as a legacy alias in help", async () => { const program = new Command(); registerConfigCli(program); @@ -356,6 +382,766 @@ describe("config cli", () => { expect(helpText).toContain("--strict-json"); expect(helpText).toContain("--json"); expect(helpText).toContain("Legacy alias for --strict-json"); + expect(helpText).toContain("--ref-provider"); + expect(helpText).toContain("--provider-source"); + expect(helpText).toContain("--batch-json"); + expect(helpText).toContain("--dry-run"); + expect(helpText).toContain("--allow-exec"); + expect(helpText).toContain("openclaw config set gateway.port 19001 --strict-json"); + expect(helpText).toContain( + "openclaw config set channels.discord.token --ref-provider default --ref-source", + ); + expect(helpText).toContain("--ref-id DISCORD_BOT_TOKEN"); + expect(helpText).toContain( + "openclaw config set --batch-file ./config-set.batch.json --dry-run", + ); + }); + }); + + describe("config set builders and dry-run", () => { + it("supports SecretRef builder mode without requiring a value argument", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + ]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.channels?.discord?.token).toEqual({ + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }); + }); + + it("supports provider builder mode under secrets.providers.", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "secrets.providers.vaultfile", + "--provider-source", + "file", + "--provider-path", + "/tmp/vault.json", + "--provider-mode", + "json", + ]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.secrets?.providers?.vaultfile).toEqual({ + source: "file", + path: "/tmp/vault.json", + mode: "json", + }); + }); + + it("runs resolvability checks in builder dry-run mode without writing", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + "--dry-run", + ]); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1); + expect(mockResolveSecretRefValue).toHaveBeenCalledWith( + { + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }, + expect.objectContaining({ + env: expect.any(Object), + }), + ); + }); + + it("requires schema validation in JSON dry-run mode", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + }; + setSnapshot(resolved, resolved); + + await expect( + runConfigCommand([ + "config", + "set", + "gateway.port", + '"not-a-number"', + "--strict-json", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("Dry run failed: config schema validation failed."), + ); + }); + + it("logs a dry-run note when value mode performs no validation checks", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand(["config", "set", "gateway.port", "19001", "--dry-run"]); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining( + "Dry run note: value mode does not run schema/resolvability checks.", + ), + ); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining("Dry run successful: 1 update(s) validated"), + ); + }); + + it("supports batch mode for refs/providers in dry-run", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "--batch-json", + '[{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}},{"path":"secrets.providers.default","provider":{"source":"env"}}]', + "--dry-run", + ]); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1); + }); + + it("skips exec SecretRef resolvability checks in dry-run by default", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + runner: { + source: "exec", + command: "/usr/bin/env", + allowInsecurePath: true, + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + ]); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining( + "Dry run note: skipped 1 exec SecretRef resolvability check(s). Re-run with --allow-exec", + ), + ); + }); + + it("allows exec SecretRef resolvability checks in dry-run when --allow-exec is set", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + runner: { + source: "exec", + command: "/usr/bin/env", + allowInsecurePath: true, + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + "--allow-exec", + ]); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1); + expect(mockResolveSecretRefValue).toHaveBeenCalledWith( + expect.objectContaining({ + source: "exec", + provider: "runner", + id: "openai", + }), + expect.any(Object), + ); + expect(mockLog).not.toHaveBeenCalledWith( + expect.stringContaining("Dry run note: skipped 1 exec SecretRef resolvability check(s)."), + ); + }); + + it("rejects --allow-exec without --dry-run", async () => { + const nonexistentBatchPath = path.join( + os.tmpdir(), + `openclaw-config-batch-nonexistent-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + await expect( + runConfigCommand(["config", "set", "--batch-file", nonexistentBatchPath, "--allow-exec"]), + ).rejects.toThrow("__exit__:1"); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("config set mode error: --allow-exec requires --dry-run."), + ); + }); + + it("fails dry-run when skipped exec refs use an unconfigured provider", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: {}, + }, + }; + setSnapshot(resolved, resolved); + + await expect( + runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining('Secret provider "runner" is not configured'), + ); + }); + + it("fails dry-run when skipped exec refs use a provider with mismatched source", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + runner: { + source: "env", + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await expect( + runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining( + 'Secret provider "runner" has source "env" but ref requests "exec".', + ), + ); + }); + + it("writes sibling SecretRef paths when target uses sibling-ref shape", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + channels: { + googlechat: { + enabled: true, + } as never, + } as never, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.googlechat.serviceAccount", + "--ref-provider", + "vaultfile", + "--ref-source", + "file", + "--ref-id", + "/providers/googlechat/serviceAccount", + ]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.channels?.googlechat?.serviceAccountRef).toEqual({ + source: "file", + provider: "vaultfile", + id: "/providers/googlechat/serviceAccount", + }); + expect(written.channels?.googlechat?.serviceAccount).toBeUndefined(); + }); + + it("rejects mixing ref-builder and provider-builder flags", async () => { + await expect( + runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + "--provider-source", + "env", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("config set mode error: choose exactly one mode"), + ); + }); + + it("rejects mixing batch mode with builder flags", async () => { + await expect( + runConfigCommand([ + "config", + "set", + "--batch-json", + "[]", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining( + "config set mode error: batch mode (--batch-json/--batch-file) cannot be combined", + ), + ); + }); + + it("supports batch-file mode", async () => { + const resolved: OpenClawConfig = { gateway: { port: 18789 } }; + setSnapshot(resolved, resolved); + + const pathname = path.join( + os.tmpdir(), + `openclaw-config-batch-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + fs.writeFileSync(pathname, '[{"path":"gateway.auth.mode","value":"token"}]', "utf8"); + try { + await runConfigCommand(["config", "set", "--batch-file", pathname]); + } finally { + fs.rmSync(pathname, { force: true }); + } + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.gateway?.auth).toEqual({ mode: "token" }); + }); + + it("rejects malformed batch-file payloads", async () => { + const pathname = path.join( + os.tmpdir(), + `openclaw-config-batch-invalid-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + fs.writeFileSync(pathname, '{"path":"gateway.auth.mode","value":"token"}', "utf8"); + try { + await expect(runConfigCommand(["config", "set", "--batch-file", pathname])).rejects.toThrow( + "__exit__:1", + ); + } finally { + fs.rmSync(pathname, { force: true }); + } + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("--batch-file must be a JSON array."), + ); + }); + + it("rejects malformed batch entries with mixed operation keys", async () => { + await expect( + runConfigCommand([ + "config", + "set", + "--batch-json", + '[{"path":"channels.discord.token","value":"x","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}}]', + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("must include exactly one of: value, ref, provider"), + ); + }); + + it("fails dry-run when a builder-assigned SecretRef is unresolved", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + setSnapshot(resolved, resolved); + mockResolveSecretRefValue.mockRejectedValueOnce(new Error("missing env var")); + + await expect( + runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."), + ); + }); + + it("emits structured JSON for --dry-run --json success", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + "--dry-run", + "--json", + ]); + + const raw = mockLog.mock.calls.at(-1)?.[0]; + expect(typeof raw).toBe("string"); + const payload = JSON.parse(String(raw)) as { + ok: boolean; + checks: { schema: boolean; resolvability: boolean; resolvabilityComplete: boolean }; + refsChecked: number; + skippedExecRefs: number; + operations: number; + }; + expect(payload.ok).toBe(true); + expect(payload.operations).toBe(1); + expect(payload.refsChecked).toBe(1); + expect(payload.skippedExecRefs).toBe(0); + expect(payload.checks).toEqual({ + schema: false, + resolvability: true, + resolvabilityComplete: true, + }); + }); + + it("emits skipped exec metadata for --dry-run --json success", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + runner: { + source: "exec", + command: "/usr/bin/env", + allowInsecurePath: true, + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "runner", + "--ref-source", + "exec", + "--ref-id", + "openai", + "--dry-run", + "--json", + ]); + + const raw = mockLog.mock.calls.at(-1)?.[0]; + expect(typeof raw).toBe("string"); + const payload = JSON.parse(String(raw)) as { + ok: boolean; + checks: { resolvability: boolean; resolvabilityComplete: boolean }; + refsChecked: number; + skippedExecRefs: number; + }; + expect(payload.ok).toBe(true); + expect(payload.checks.resolvability).toBe(true); + expect(payload.checks.resolvabilityComplete).toBe(false); + expect(payload.refsChecked).toBe(0); + expect(payload.skippedExecRefs).toBe(1); + }); + + it("emits structured JSON for --dry-run --json failure", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + setSnapshot(resolved, resolved); + mockResolveSecretRefValue.mockRejectedValueOnce(new Error("missing env var")); + + await expect( + runConfigCommand([ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + "--dry-run", + "--json", + ]), + ).rejects.toThrow("__exit__:1"); + + const raw = mockLog.mock.calls.at(-1)?.[0]; + expect(typeof raw).toBe("string"); + const payload = JSON.parse(String(raw)) as { + ok: boolean; + errors?: Array<{ kind: string; message: string; ref?: string }>; + }; + expect(payload.ok).toBe(false); + expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true); + expect( + payload.errors?.some((entry) => entry.ref?.includes("default:DISCORD_BOT_TOKEN")), + ).toBe(true); + }); + + it("aggregates schema and resolvability failures in --dry-run --json mode", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + setSnapshot(resolved, resolved); + mockResolveSecretRefValue.mockRejectedValue(new Error("missing env var")); + + await expect( + runConfigCommand([ + "config", + "set", + "--batch-json", + '[{"path":"gateway.port","value":"not-a-number"},{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}}]', + "--dry-run", + "--json", + ]), + ).rejects.toThrow("__exit__:1"); + + const raw = mockLog.mock.calls.at(-1)?.[0]; + expect(typeof raw).toBe("string"); + const payload = JSON.parse(String(raw)) as { + ok: boolean; + errors?: Array<{ kind: string; message: string; ref?: string }>; + }; + expect(payload.ok).toBe(false); + expect(payload.errors?.some((entry) => entry.kind === "schema")).toBe(true); + expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true); + expect( + payload.errors?.some((entry) => entry.ref?.includes("default:DISCORD_BOT_TOKEN")), + ).toBe(true); + }); + + it("fails dry-run when provider updates make existing refs unresolvable", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + vaultfile: { source: "file", path: "/tmp/secrets.json", mode: "json" }, + }, + }, + tools: { + web: { + search: { + enabled: true, + apiKey: { + source: "file", + provider: "vaultfile", + id: "/providers/search/apiKey", + }, + }, + }, + } as never, + }; + setSnapshot(resolved, resolved); + mockResolveSecretRefValue.mockImplementationOnce(async () => { + throw new Error("provider mismatch"); + }); + + await expect( + runConfigCommand([ + "config", + "set", + "secrets.providers.vaultfile", + "--provider-source", + "env", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."), + ); + expect(mockError).toHaveBeenCalledWith(expect.stringContaining("provider mismatch")); + }); + + it("fails dry-run for nested provider edits that make existing refs unresolvable", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + vaultfile: { source: "file", path: "/tmp/secrets.json", mode: "json" }, + }, + }, + tools: { + web: { + search: { + enabled: true, + apiKey: { + source: "file", + provider: "vaultfile", + id: "/providers/search/apiKey", + }, + }, + }, + } as never, + }; + setSnapshot(resolved, resolved); + mockResolveSecretRefValue.mockImplementationOnce(async () => { + throw new Error("provider mismatch"); + }); + + await expect( + runConfigCommand([ + "config", + "set", + "secrets.providers.vaultfile.path", + '"/tmp/other-secrets.json"', + "--strict-json", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockResolveSecretRefValue).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "vaultfile", + id: "/providers/search/apiKey", + }), + expect.any(Object), + ); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."), + ); + expect(mockError).toHaveBeenCalledWith(expect.stringContaining("provider mismatch")); }); }); diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 5167658040a..8ec98f1804d 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -1,26 +1,102 @@ import type { Command } from "commander"; import JSON5 from "json5"; import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; +import type { OpenClawConfig } from "../config/config.js"; import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js"; import { CONFIG_PATH } from "../config/paths.js"; import { isBlockedObjectKey } from "../config/prototype-keys.js"; import { redactConfigObject } from "../config/redact-snapshot.js"; +import { + coerceSecretRef, + isValidEnvSecretRefId, + resolveSecretInputRef, + type SecretProviderConfig, + type SecretRef, + type SecretRefSource, +} from "../config/types.secrets.js"; +import { validateConfigObjectRaw } from "../config/validation.js"; +import { SecretProviderSchema } from "../config/zod-schema.core.js"; import { danger, info, success } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; +import { + formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, + isValidFileSecretRefId, + isValidSecretProviderAlias, + secretRefKey, + validateExecSecretRefId, +} from "../secrets/ref-contract.js"; +import { resolveSecretRefValue } from "../secrets/resolve.js"; +import { + discoverConfigSecretTargets, + resolveConfigSecretTargetByPath, +} from "../secrets/target-registry.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +import type { + ConfigSetDryRunError, + ConfigSetDryRunInputMode, + ConfigSetDryRunResult, +} from "./config-set-dryrun.js"; +import { + hasBatchMode, + hasProviderBuilderOptions, + hasRefBuilderOptions, + parseBatchSource, + type ConfigSetBatchEntry, + type ConfigSetOptions, +} from "./config-set-input.js"; +import { resolveConfigSetMode } from "./config-set-parser.js"; type PathSegment = string; type ConfigSetParseOpts = { strictJson?: boolean; }; +type ConfigSetInputMode = ConfigSetDryRunInputMode; +type ConfigSetOperation = { + inputMode: ConfigSetInputMode; + requestedPath: PathSegment[]; + setPath: PathSegment[]; + value: unknown; + touchedSecretTargetPath?: string; + touchedProviderAlias?: string; + assignedRef?: SecretRef; +}; const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"]; const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"]; +const SECRET_PROVIDER_PATH_PREFIX: PathSegment[] = ["secrets", "providers"]; +const CONFIG_SET_EXAMPLE_VALUE = formatCliCommand( + "openclaw config set gateway.port 19001 --strict-json", +); +const CONFIG_SET_EXAMPLE_REF = formatCliCommand( + "openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN", +); +const CONFIG_SET_EXAMPLE_PROVIDER = formatCliCommand( + "openclaw config set secrets.providers.vault --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json", +); +const CONFIG_SET_EXAMPLE_BATCH = formatCliCommand( + "openclaw config set --batch-file ./config-set.batch.json --dry-run", +); +const CONFIG_SET_DESCRIPTION = [ + "Set config values by path (value mode, ref/provider builder mode, or batch JSON mode).", + "Examples:", + CONFIG_SET_EXAMPLE_VALUE, + CONFIG_SET_EXAMPLE_REF, + CONFIG_SET_EXAMPLE_PROVIDER, + CONFIG_SET_EXAMPLE_BATCH, +].join("\n"); + +class ConfigSetDryRunValidationError extends Error { + constructor(readonly result: ConfigSetDryRunResult) { + super("config set dry-run validation failed"); + this.name = "ConfigSetDryRunValidationError"; + } +} function isIndexSegment(raw: string): boolean { return /^[0-9]+$/.test(raw); @@ -276,6 +352,729 @@ function ensureValidOllamaProviderForApiKeySet( }); } +function toDotPath(path: PathSegment[]): string { + return path.join("."); +} + +function parseSecretRefSource(raw: string, label: string): SecretRefSource { + const source = raw.trim(); + if (source === "env" || source === "file" || source === "exec") { + return source; + } + throw new Error(`${label} must be one of: env, file, exec.`); +} + +function parseSecretRefBuilder(params: { + provider: string; + source: string; + id: string; + fieldPrefix: string; +}): SecretRef { + const provider = params.provider.trim(); + if (!provider) { + throw new Error(`${params.fieldPrefix}.provider is required.`); + } + if (!isValidSecretProviderAlias(provider)) { + throw new Error( + `${params.fieldPrefix}.provider must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").`, + ); + } + + const source = parseSecretRefSource(params.source, `${params.fieldPrefix}.source`); + const id = params.id.trim(); + if (!id) { + throw new Error(`${params.fieldPrefix}.id is required.`); + } + if (source === "env" && !isValidEnvSecretRefId(id)) { + throw new Error(`${params.fieldPrefix}.id must match /^[A-Z][A-Z0-9_]{0,127}$/ for env refs.`); + } + if (source === "file" && !isValidFileSecretRefId(id)) { + throw new Error( + `${params.fieldPrefix}.id must be an absolute JSON pointer (or "value" for singleValue mode).`, + ); + } + if (source === "exec") { + const validated = validateExecSecretRefId(id); + if (!validated.ok) { + throw new Error(formatExecSecretRefIdValidationMessage()); + } + } + return { source, provider, id }; +} + +function parseOptionalPositiveInteger(raw: string | undefined, flag: string): number | undefined { + if (raw === undefined) { + return undefined; + } + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error(`${flag} must not be empty.`); + } + const parsed = Number(trimmed); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${flag} must be a positive integer.`); + } + return parsed; +} + +function parseProviderEnvEntries( + entries: string[] | undefined, +): Record | undefined { + if (!entries || entries.length === 0) { + return undefined; + } + const env: Record = {}; + for (const entry of entries) { + const separator = entry.indexOf("="); + if (separator <= 0) { + throw new Error(`--provider-env expects KEY=VALUE entries (received: "${entry}").`); + } + const key = entry.slice(0, separator).trim(); + if (!key) { + throw new Error(`--provider-env key must not be empty (received: "${entry}").`); + } + env[key] = entry.slice(separator + 1); + } + return Object.keys(env).length > 0 ? env : undefined; +} + +function parseProviderAliasPath(path: PathSegment[]): string { + const expectedPrefixMatches = + path.length === 3 && + path[0] === SECRET_PROVIDER_PATH_PREFIX[0] && + path[1] === SECRET_PROVIDER_PATH_PREFIX[1]; + if (!expectedPrefixMatches) { + throw new Error( + 'Provider builder mode requires path "secrets.providers." (example: secrets.providers.vault).', + ); + } + const alias = path[2] ?? ""; + if (!isValidSecretProviderAlias(alias)) { + throw new Error( + `Provider alias "${alias}" must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").`, + ); + } + return alias; +} + +function buildProviderFromBuilder(opts: ConfigSetOptions): SecretProviderConfig { + const sourceRaw = opts.providerSource?.trim(); + if (!sourceRaw) { + throw new Error("--provider-source is required in provider builder mode."); + } + const source = parseSecretRefSource(sourceRaw, "--provider-source"); + const timeoutMs = parseOptionalPositiveInteger(opts.providerTimeoutMs, "--provider-timeout-ms"); + const maxBytes = parseOptionalPositiveInteger(opts.providerMaxBytes, "--provider-max-bytes"); + const noOutputTimeoutMs = parseOptionalPositiveInteger( + opts.providerNoOutputTimeoutMs, + "--provider-no-output-timeout-ms", + ); + const maxOutputBytes = parseOptionalPositiveInteger( + opts.providerMaxOutputBytes, + "--provider-max-output-bytes", + ); + const providerEnv = parseProviderEnvEntries(opts.providerEnv); + + let provider: SecretProviderConfig; + if (source === "env") { + const allowlist = (opts.providerAllowlist ?? []).map((entry) => entry.trim()).filter(Boolean); + for (const envName of allowlist) { + if (!isValidEnvSecretRefId(envName)) { + throw new Error( + `--provider-allowlist entry "${envName}" must match /^[A-Z][A-Z0-9_]{0,127}$/.`, + ); + } + } + provider = { + source: "env", + ...(allowlist.length > 0 ? { allowlist } : {}), + }; + } else if (source === "file") { + const filePath = opts.providerPath?.trim(); + if (!filePath) { + throw new Error("--provider-path is required when --provider-source file is used."); + } + const modeRaw = opts.providerMode?.trim(); + if (modeRaw && modeRaw !== "singleValue" && modeRaw !== "json") { + throw new Error("--provider-mode must be one of: singleValue, json."); + } + const mode = modeRaw === "singleValue" || modeRaw === "json" ? modeRaw : undefined; + provider = { + source: "file", + path: filePath, + ...(mode ? { mode } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(maxBytes !== undefined ? { maxBytes } : {}), + }; + } else { + const command = opts.providerCommand?.trim(); + if (!command) { + throw new Error("--provider-command is required when --provider-source exec is used."); + } + provider = { + source: "exec", + command, + ...(opts.providerArg && opts.providerArg.length > 0 + ? { args: opts.providerArg.map((entry) => entry.trim()) } + : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(noOutputTimeoutMs !== undefined ? { noOutputTimeoutMs } : {}), + ...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}), + ...(opts.providerJsonOnly ? { jsonOnly: true } : {}), + ...(providerEnv ? { env: providerEnv } : {}), + ...(opts.providerPassEnv && opts.providerPassEnv.length > 0 + ? { passEnv: opts.providerPassEnv.map((entry) => entry.trim()).filter(Boolean) } + : {}), + ...(opts.providerTrustedDir && opts.providerTrustedDir.length > 0 + ? { trustedDirs: opts.providerTrustedDir.map((entry) => entry.trim()).filter(Boolean) } + : {}), + ...(opts.providerAllowInsecurePath ? { allowInsecurePath: true } : {}), + ...(opts.providerAllowSymlinkCommand ? { allowSymlinkCommand: true } : {}), + }; + } + + const validated = SecretProviderSchema.safeParse(provider); + if (!validated.success) { + const issue = validated.error.issues[0]; + const issuePath = issue?.path?.join(".") ?? ""; + const issueMessage = issue?.message ?? "Invalid provider config."; + throw new Error(`Provider builder config invalid at ${issuePath}: ${issueMessage}`); + } + return validated.data; +} + +function parseSecretRefFromUnknown(value: unknown, label: string): SecretRef { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object with source/provider/id.`); + } + const candidate = value as Record; + if ( + typeof candidate.provider !== "string" || + typeof candidate.source !== "string" || + typeof candidate.id !== "string" + ) { + throw new Error(`${label} must include string fields: source, provider, id.`); + } + return parseSecretRefBuilder({ + provider: candidate.provider, + source: candidate.source, + id: candidate.id, + fieldPrefix: label, + }); +} + +function buildRefAssignmentOperation(params: { + requestedPath: PathSegment[]; + ref: SecretRef; + inputMode: ConfigSetInputMode; +}): ConfigSetOperation { + const resolved = resolveConfigSecretTargetByPath(params.requestedPath); + if (resolved?.entry.secretShape === "sibling_ref" && resolved.refPathSegments) { + return { + inputMode: params.inputMode, + requestedPath: params.requestedPath, + setPath: resolved.refPathSegments, + value: params.ref, + touchedSecretTargetPath: toDotPath(resolved.pathSegments), + assignedRef: params.ref, + ...(resolved.providerId ? { touchedProviderAlias: resolved.providerId } : {}), + }; + } + return { + inputMode: params.inputMode, + requestedPath: params.requestedPath, + setPath: params.requestedPath, + value: params.ref, + touchedSecretTargetPath: resolved + ? toDotPath(resolved.pathSegments) + : toDotPath(params.requestedPath), + assignedRef: params.ref, + ...(resolved?.providerId ? { touchedProviderAlias: resolved.providerId } : {}), + }; +} + +function parseProviderAliasFromTargetPath(path: PathSegment[]): string | null { + if ( + path.length >= 3 && + path[0] === SECRET_PROVIDER_PATH_PREFIX[0] && + path[1] === SECRET_PROVIDER_PATH_PREFIX[1] + ) { + return path[2] ?? null; + } + return null; +} + +function buildValueAssignmentOperation(params: { + requestedPath: PathSegment[]; + value: unknown; + inputMode: ConfigSetInputMode; +}): ConfigSetOperation { + const resolved = resolveConfigSecretTargetByPath(params.requestedPath); + const providerAlias = parseProviderAliasFromTargetPath(params.requestedPath); + const coercedRef = coerceSecretRef(params.value); + return { + inputMode: params.inputMode, + requestedPath: params.requestedPath, + setPath: params.requestedPath, + value: params.value, + ...(resolved ? { touchedSecretTargetPath: toDotPath(resolved.pathSegments) } : {}), + ...(providerAlias ? { touchedProviderAlias: providerAlias } : {}), + ...(coercedRef ? { assignedRef: coercedRef } : {}), + }; +} + +function parseBatchOperations(entries: ConfigSetBatchEntry[]): ConfigSetOperation[] { + const operations: ConfigSetOperation[] = []; + for (const [index, entry] of entries.entries()) { + const path = parseRequiredPath(entry.path); + if (entry.ref !== undefined) { + const ref = parseSecretRefFromUnknown(entry.ref, `batch[${index}].ref`); + operations.push( + buildRefAssignmentOperation({ + requestedPath: path, + ref, + inputMode: "json", + }), + ); + continue; + } + if (entry.provider !== undefined) { + const alias = parseProviderAliasPath(path); + const validated = SecretProviderSchema.safeParse(entry.provider); + if (!validated.success) { + const issue = validated.error.issues[0]; + const issuePath = issue?.path?.join(".") ?? ""; + throw new Error( + `batch[${index}].provider invalid at ${issuePath}: ${issue?.message ?? ""}`, + ); + } + operations.push({ + inputMode: "json", + requestedPath: path, + setPath: path, + value: validated.data, + touchedProviderAlias: alias, + }); + continue; + } + operations.push( + buildValueAssignmentOperation({ + requestedPath: path, + value: entry.value, + inputMode: "json", + }), + ); + } + return operations; +} + +function modeError(message: string): Error { + return new Error(`config set mode error: ${message}`); +} + +function buildSingleSetOperations(params: { + path?: string; + value?: string; + opts: ConfigSetOptions; +}): ConfigSetOperation[] { + const pathProvided = typeof params.path === "string" && params.path.trim().length > 0; + const parsedPath = pathProvided ? parseRequiredPath(params.path as string) : null; + const strictJson = Boolean(params.opts.strictJson || params.opts.json); + const modeResolution = resolveConfigSetMode({ + hasBatchMode: false, + hasRefBuilderOptions: hasRefBuilderOptions(params.opts), + hasProviderBuilderOptions: hasProviderBuilderOptions(params.opts), + strictJson, + }); + if (!modeResolution.ok) { + throw modeError(modeResolution.error); + } + + if (modeResolution.mode === "ref_builder") { + if (!pathProvided || !parsedPath) { + throw modeError("ref builder mode requires ."); + } + if (params.value !== undefined) { + throw modeError("ref builder mode does not accept ."); + } + if (!params.opts.refProvider || !params.opts.refSource || !params.opts.refId) { + throw modeError( + "ref builder mode requires --ref-provider , --ref-source , and --ref-id .", + ); + } + const ref = parseSecretRefBuilder({ + provider: params.opts.refProvider, + source: params.opts.refSource, + id: params.opts.refId, + fieldPrefix: "ref", + }); + return [ + buildRefAssignmentOperation({ + requestedPath: parsedPath, + ref, + inputMode: "builder", + }), + ]; + } + + if (modeResolution.mode === "provider_builder") { + if (!pathProvided || !parsedPath) { + throw modeError("provider builder mode requires ."); + } + if (params.value !== undefined) { + throw modeError("provider builder mode does not accept ."); + } + const alias = parseProviderAliasPath(parsedPath); + const provider = buildProviderFromBuilder(params.opts); + return [ + { + inputMode: "builder", + requestedPath: parsedPath, + setPath: parsedPath, + value: provider, + touchedProviderAlias: alias, + }, + ]; + } + + if (!pathProvided || !parsedPath) { + throw modeError("value/json mode requires when batch mode is not used."); + } + if (params.value === undefined) { + throw modeError("value/json mode requires ."); + } + const parsedValue = parseValue(params.value, { strictJson }); + return [ + buildValueAssignmentOperation({ + requestedPath: parsedPath, + value: parsedValue, + inputMode: modeResolution.mode === "json" ? "json" : "value", + }), + ]; +} + +function collectDryRunRefs(params: { + config: OpenClawConfig; + operations: ConfigSetOperation[]; +}): SecretRef[] { + const refsByKey = new Map(); + const targetPaths = new Set(); + const providerAliases = new Set(); + + for (const operation of params.operations) { + if (operation.assignedRef) { + refsByKey.set(secretRefKey(operation.assignedRef), operation.assignedRef); + } + if (operation.touchedSecretTargetPath) { + targetPaths.add(operation.touchedSecretTargetPath); + } + if (operation.touchedProviderAlias) { + providerAliases.add(operation.touchedProviderAlias); + } + } + + if (targetPaths.size === 0 && providerAliases.size === 0) { + return [...refsByKey.values()]; + } + + const defaults = params.config.secrets?.defaults; + for (const target of discoverConfigSecretTargets(params.config)) { + const { ref } = resolveSecretInputRef({ + value: target.value, + refValue: target.refValue, + defaults, + }); + if (!ref) { + continue; + } + if (targetPaths.has(target.path) || providerAliases.has(ref.provider)) { + refsByKey.set(secretRefKey(ref), ref); + } + } + return [...refsByKey.values()]; +} + +async function collectDryRunResolvabilityErrors(params: { + refs: SecretRef[]; + config: OpenClawConfig; +}): Promise { + const failures: ConfigSetDryRunError[] = []; + for (const ref of params.refs) { + try { + await resolveSecretRefValue(ref, { + config: params.config, + env: process.env, + }); + } catch (err) { + failures.push({ + kind: "resolvability", + message: String(err), + ref: `${ref.source}:${ref.provider}:${ref.id}`, + }); + } + } + return failures; +} + +function collectDryRunStaticErrorsForSkippedExecRefs(params: { + refs: SecretRef[]; + config: OpenClawConfig; +}): ConfigSetDryRunError[] { + const failures: ConfigSetDryRunError[] = []; + for (const ref of params.refs) { + const id = ref.id.trim(); + const refLabel = `${ref.source}:${ref.provider}:${id}`; + if (!id) { + failures.push({ + kind: "resolvability", + message: "Error: Secret reference id is empty.", + ref: refLabel, + }); + continue; + } + if (!isValidExecSecretRefId(id)) { + failures.push({ + kind: "resolvability", + message: `Error: ${formatExecSecretRefIdValidationMessage()} (ref: ${refLabel}).`, + ref: refLabel, + }); + continue; + } + const providerConfig = params.config.secrets?.providers?.[ref.provider]; + if (!providerConfig) { + failures.push({ + kind: "resolvability", + message: `Error: Secret provider "${ref.provider}" is not configured (ref: ${refLabel}).`, + ref: refLabel, + }); + continue; + } + if (providerConfig.source !== ref.source) { + failures.push({ + kind: "resolvability", + message: `Error: Secret provider "${ref.provider}" has source "${providerConfig.source}" but ref requests "${ref.source}".`, + ref: refLabel, + }); + } + } + return failures; +} + +function selectDryRunRefsForResolution(params: { refs: SecretRef[]; allowExecInDryRun: boolean }): { + refsToResolve: SecretRef[]; + skippedExecRefs: SecretRef[]; +} { + const refsToResolve: SecretRef[] = []; + const skippedExecRefs: SecretRef[] = []; + for (const ref of params.refs) { + if (ref.source === "exec" && !params.allowExecInDryRun) { + skippedExecRefs.push(ref); + continue; + } + refsToResolve.push(ref); + } + return { refsToResolve, skippedExecRefs }; +} + +function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError[] { + const validated = validateConfigObjectRaw(config); + if (validated.ok) { + return []; + } + return formatConfigIssueLines(validated.issues, "-", { normalizeRoot: true }).map((message) => ({ + kind: "schema", + message, + })); +} + +function formatDryRunFailureMessage(params: { + errors: ConfigSetDryRunError[]; + skippedExecRefs: number; +}): string { + const { errors, skippedExecRefs } = params; + const schemaErrors = errors.filter((error) => error.kind === "schema"); + const resolveErrors = errors.filter((error) => error.kind === "resolvability"); + const lines: string[] = []; + if (schemaErrors.length > 0) { + lines.push("Dry run failed: config schema validation failed."); + lines.push(...schemaErrors.map((error) => `- ${error.message}`)); + } + if (resolveErrors.length > 0) { + lines.push( + `Dry run failed: ${resolveErrors.length} SecretRef assignment(s) could not be resolved.`, + ); + lines.push( + ...resolveErrors + .slice(0, 5) + .map((error) => `- ${error.ref ?? ""} -> ${error.message}`), + ); + if (resolveErrors.length > 5) { + lines.push(`- ... ${resolveErrors.length - 5} more`); + } + } + if (skippedExecRefs > 0) { + lines.push( + `Dry run note: skipped ${skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`, + ); + } + return lines.join("\n"); +} + +export async function runConfigSet(opts: { + path?: string; + value?: string; + cliOptions: ConfigSetOptions; + runtime?: RuntimeEnv; +}) { + const runtime = opts.runtime ?? defaultRuntime; + try { + const isBatchMode = hasBatchMode(opts.cliOptions); + const modeResolution = resolveConfigSetMode({ + hasBatchMode: isBatchMode, + hasRefBuilderOptions: hasRefBuilderOptions(opts.cliOptions), + hasProviderBuilderOptions: hasProviderBuilderOptions(opts.cliOptions), + strictJson: Boolean(opts.cliOptions.strictJson || opts.cliOptions.json), + }); + if (!modeResolution.ok) { + throw modeError(modeResolution.error); + } + if (opts.cliOptions.allowExec && !opts.cliOptions.dryRun) { + throw modeError("--allow-exec requires --dry-run."); + } + + const batchEntries = parseBatchSource(opts.cliOptions); + if (batchEntries) { + if (opts.path !== undefined || opts.value !== undefined) { + throw modeError("batch mode does not accept or arguments."); + } + } + const operations = batchEntries + ? parseBatchOperations(batchEntries) + : buildSingleSetOperations({ + path: opts.path, + value: opts.value, + opts: opts.cliOptions, + }); + const snapshot = await loadValidConfig(runtime); + // Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults) + // instead of snapshot.config (runtime-merged with defaults). + // This prevents runtime defaults from leaking into the written config file (issue #6070) + const next = structuredClone(snapshot.resolved) as Record; + for (const operation of operations) { + ensureValidOllamaProviderForApiKeySet(next, operation.setPath); + setAtPath(next, operation.setPath, operation.value); + } + const nextConfig = next as OpenClawConfig; + + if (opts.cliOptions.dryRun) { + const hasJsonMode = operations.some((operation) => operation.inputMode === "json"); + const hasBuilderMode = operations.some((operation) => operation.inputMode === "builder"); + const refs = + hasJsonMode || hasBuilderMode + ? collectDryRunRefs({ + config: nextConfig, + operations, + }) + : []; + const selectedDryRunRefs = selectDryRunRefsForResolution({ + refs, + allowExecInDryRun: Boolean(opts.cliOptions.allowExec), + }); + const errors: ConfigSetDryRunError[] = []; + if (hasJsonMode) { + errors.push(...collectDryRunSchemaErrors(nextConfig)); + } + if (hasJsonMode || hasBuilderMode) { + errors.push( + ...collectDryRunStaticErrorsForSkippedExecRefs({ + refs: selectedDryRunRefs.skippedExecRefs, + config: nextConfig, + }), + ); + errors.push( + ...(await collectDryRunResolvabilityErrors({ + refs: selectedDryRunRefs.refsToResolve, + config: nextConfig, + })), + ); + } + const dryRunResult: ConfigSetDryRunResult = { + ok: errors.length === 0, + operations: operations.length, + configPath: shortenHomePath(snapshot.path), + inputModes: [...new Set(operations.map((operation) => operation.inputMode))], + checks: { + schema: hasJsonMode, + resolvability: hasJsonMode || hasBuilderMode, + resolvabilityComplete: + (hasJsonMode || hasBuilderMode) && selectedDryRunRefs.skippedExecRefs.length === 0, + }, + refsChecked: selectedDryRunRefs.refsToResolve.length, + skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length, + ...(errors.length > 0 ? { errors } : {}), + }; + if (errors.length > 0) { + if (opts.cliOptions.json) { + throw new ConfigSetDryRunValidationError(dryRunResult); + } + throw new Error( + formatDryRunFailureMessage({ + errors, + skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length, + }), + ); + } + if (opts.cliOptions.json) { + runtime.log(JSON.stringify(dryRunResult, null, 2)); + } else { + if (!dryRunResult.checks.schema && !dryRunResult.checks.resolvability) { + runtime.log( + info( + "Dry run note: value mode does not run schema/resolvability checks. Use --strict-json, builder flags, or batch mode to enable validation checks.", + ), + ); + } + if (dryRunResult.skippedExecRefs > 0) { + runtime.log( + info( + `Dry run note: skipped ${dryRunResult.skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`, + ), + ); + } + runtime.log( + info( + `Dry run successful: ${operations.length} update(s) validated against ${shortenHomePath(snapshot.path)}.`, + ), + ); + } + return; + } + + await writeConfigFile(next); + if (operations.length === 1) { + runtime.log( + info( + `Updated ${toDotPath(operations[0]?.requestedPath ?? [])}. Restart the gateway to apply.`, + ), + ); + return; + } + runtime.log(info(`Updated ${operations.length} config paths. Restart the gateway to apply.`)); + } catch (err) { + if ( + opts.cliOptions.dryRun && + opts.cliOptions.json && + err instanceof ConfigSetDryRunValidationError + ) { + runtime.log(JSON.stringify(err.result, null, 2)); + runtime.exit(1); + return; + } + runtime.error(danger(String(err))); + runtime.exit(1); + } +} + export async function runConfigGet(opts: { path: string; json?: boolean; runtime?: RuntimeEnv }) { const runtime = opts.runtime ?? defaultRuntime; try { @@ -425,30 +1224,81 @@ export function registerConfigCli(program: Command) { cmd .command("set") - .description("Set a config value by dot path") - .argument("", "Config path (dot or bracket notation)") - .argument("", "Value (JSON5 or raw string)") + .description(CONFIG_SET_DESCRIPTION) + .argument("[path]", "Config path (dot or bracket notation)") + .argument("[value]", "Value (JSON5 or raw string)") .option("--strict-json", "Strict JSON5 parsing (error instead of raw string fallback)", false) .option("--json", "Legacy alias for --strict-json", false) - .action(async (path: string, value: string, opts) => { - try { - const parsedPath = parseRequiredPath(path); - const parsedValue = parseValue(value, { - strictJson: Boolean(opts.strictJson || opts.json), - }); - const snapshot = await loadValidConfig(); - // Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults) - // instead of snapshot.config (runtime-merged with defaults). - // This prevents runtime defaults from leaking into the written config file (issue #6070) - const next = structuredClone(snapshot.resolved) as Record; - ensureValidOllamaProviderForApiKeySet(next, parsedPath); - setAtPath(next, parsedPath, parsedValue); - await writeConfigFile(next); - defaultRuntime.log(info(`Updated ${path}. Restart the gateway to apply.`)); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } + .option( + "--dry-run", + "Validate changes without writing openclaw.json (checks run in builder/json/batch modes; exec SecretRefs are skipped unless --allow-exec is set)", + false, + ) + .option( + "--allow-exec", + "Dry-run only: allow exec SecretRef resolvability checks (may execute provider commands)", + false, + ) + .option("--ref-provider ", "SecretRef builder: provider alias") + .option("--ref-source ", "SecretRef builder: source (env|file|exec)") + .option("--ref-id ", "SecretRef builder: ref id") + .option("--provider-source ", "Provider builder: source (env|file|exec)") + .option( + "--provider-allowlist ", + "Provider builder (env): allowlist entry (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option("--provider-path ", "Provider builder (file): path") + .option("--provider-mode ", "Provider builder (file): mode (singleValue|json)") + .option("--provider-timeout-ms ", "Provider builder (file|exec): timeout ms") + .option("--provider-max-bytes ", "Provider builder (file): max bytes") + .option("--provider-command ", "Provider builder (exec): absolute command path") + .option( + "--provider-arg ", + "Provider builder (exec): command arg (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option("--provider-no-output-timeout-ms ", "Provider builder (exec): no-output timeout ms") + .option("--provider-max-output-bytes ", "Provider builder (exec): max output bytes") + .option("--provider-json-only", "Provider builder (exec): require JSON output", false) + .option( + "--provider-env ", + "Provider builder (exec): env assignment (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option( + "--provider-pass-env ", + "Provider builder (exec): pass host env var (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option( + "--provider-trusted-dir ", + "Provider builder (exec): trusted directory (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option( + "--provider-allow-insecure-path", + "Provider builder (exec): bypass strict path permission checks", + false, + ) + .option( + "--provider-allow-symlink-command", + "Provider builder (exec): allow command symlink path", + false, + ) + .option("--batch-json ", "Batch mode: JSON array of set operations") + .option("--batch-file ", "Batch mode: read JSON array of set operations from file") + .action(async (path: string | undefined, value: string | undefined, opts: ConfigSetOptions) => { + await runConfigSet({ + path, + value, + cliOptions: opts, + }); }); cmd diff --git a/src/cli/config-set-dryrun.ts b/src/cli/config-set-dryrun.ts new file mode 100644 index 00000000000..d121f25eab1 --- /dev/null +++ b/src/cli/config-set-dryrun.ts @@ -0,0 +1,22 @@ +export type ConfigSetDryRunInputMode = "value" | "json" | "builder"; + +export type ConfigSetDryRunError = { + kind: "schema" | "resolvability"; + message: string; + ref?: string; +}; + +export type ConfigSetDryRunResult = { + ok: boolean; + operations: number; + configPath: string; + inputModes: ConfigSetDryRunInputMode[]; + checks: { + schema: boolean; + resolvability: boolean; + resolvabilityComplete: boolean; + }; + refsChecked: number; + skippedExecRefs: number; + errors?: ConfigSetDryRunError[]; +}; diff --git a/src/cli/config-set-input.test.ts b/src/cli/config-set-input.test.ts new file mode 100644 index 00000000000..fd13aaea46b --- /dev/null +++ b/src/cli/config-set-input.test.ts @@ -0,0 +1,113 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseBatchSource } from "./config-set-input.js"; + +describe("config set input parsing", () => { + it("returns null when no batch options are provided", () => { + expect(parseBatchSource({})).toBeNull(); + }); + + it("rejects using both --batch-json and --batch-file", () => { + expect(() => + parseBatchSource({ + batchJson: "[]", + batchFile: "/tmp/batch.json", + }), + ).toThrow("Use either --batch-json or --batch-file, not both."); + }); + + it("parses valid --batch-json payloads", () => { + const parsed = parseBatchSource({ + batchJson: + '[{"path":"gateway.auth.mode","value":"token"},{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}},{"path":"secrets.providers.default","provider":{"source":"env"}}]', + }); + expect(parsed).toEqual([ + { + path: "gateway.auth.mode", + value: "token", + }, + { + path: "channels.discord.token", + ref: { + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }, + }, + { + path: "secrets.providers.default", + provider: { + source: "env", + }, + }, + ]); + }); + + it("rejects malformed --batch-json payloads", () => { + expect(() => + parseBatchSource({ + batchJson: "{", + }), + ).toThrow("Failed to parse --batch-json:"); + }); + + it("rejects --batch-json payloads that are not arrays", () => { + expect(() => + parseBatchSource({ + batchJson: '{"path":"gateway.auth.mode","value":"token"}', + }), + ).toThrow("--batch-json must be a JSON array."); + }); + + it("rejects batch entries without path", () => { + expect(() => + parseBatchSource({ + batchJson: '[{"value":"token"}]', + }), + ).toThrow("--batch-json[0].path is required."); + }); + + it("rejects batch entries that do not contain exactly one mode key", () => { + expect(() => + parseBatchSource({ + batchJson: '[{"path":"gateway.auth.mode","value":"token","provider":{"source":"env"}}]', + }), + ).toThrow("--batch-json[0] must include exactly one of: value, ref, provider."); + }); + + it("parses valid --batch-file payloads", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-set-input-")); + const batchPath = path.join(tempDir, "batch.json"); + fs.writeFileSync(batchPath, '[{"path":"gateway.auth.mode","value":"token"}]', "utf8"); + try { + const parsed = parseBatchSource({ + batchFile: batchPath, + }); + expect(parsed).toEqual([ + { + path: "gateway.auth.mode", + value: "token", + }, + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("rejects malformed --batch-file payloads", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-set-input-invalid-")); + const batchPath = path.join(tempDir, "batch.json"); + fs.writeFileSync(batchPath, "{}", "utf8"); + try { + expect(() => + parseBatchSource({ + batchFile: batchPath, + }), + ).toThrow("--batch-file must be a JSON array."); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/cli/config-set-input.ts b/src/cli/config-set-input.ts new file mode 100644 index 00000000000..b192422288f --- /dev/null +++ b/src/cli/config-set-input.ts @@ -0,0 +1,131 @@ +import fs from "node:fs"; +import JSON5 from "json5"; + +export type ConfigSetOptions = { + strictJson?: boolean; + json?: boolean; + dryRun?: boolean; + allowExec?: boolean; + refProvider?: string; + refSource?: string; + refId?: string; + providerSource?: string; + providerAllowlist?: string[]; + providerPath?: string; + providerMode?: string; + providerTimeoutMs?: string; + providerMaxBytes?: string; + providerCommand?: string; + providerArg?: string[]; + providerNoOutputTimeoutMs?: string; + providerMaxOutputBytes?: string; + providerJsonOnly?: boolean; + providerEnv?: string[]; + providerPassEnv?: string[]; + providerTrustedDir?: string[]; + providerAllowInsecurePath?: boolean; + providerAllowSymlinkCommand?: boolean; + batchJson?: string; + batchFile?: string; +}; + +export type ConfigSetBatchEntry = { + path: string; + value?: unknown; + ref?: unknown; + provider?: unknown; +}; + +export function hasBatchMode(opts: ConfigSetOptions): boolean { + return Boolean( + (opts.batchJson && opts.batchJson.trim().length > 0) || + (opts.batchFile && opts.batchFile.trim().length > 0), + ); +} + +export function hasRefBuilderOptions(opts: ConfigSetOptions): boolean { + return Boolean(opts.refProvider || opts.refSource || opts.refId); +} + +export function hasProviderBuilderOptions(opts: ConfigSetOptions): boolean { + return Boolean( + opts.providerSource || + opts.providerAllowlist?.length || + opts.providerPath || + opts.providerMode || + opts.providerTimeoutMs || + opts.providerMaxBytes || + opts.providerCommand || + opts.providerArg?.length || + opts.providerNoOutputTimeoutMs || + opts.providerMaxOutputBytes || + opts.providerJsonOnly || + opts.providerEnv?.length || + opts.providerPassEnv?.length || + opts.providerTrustedDir?.length || + opts.providerAllowInsecurePath || + opts.providerAllowSymlinkCommand, + ); +} + +function parseJson5Raw(raw: string, label: string): unknown { + try { + return JSON5.parse(raw); + } catch (err) { + throw new Error(`Failed to parse ${label}: ${String(err)}`, { cause: err }); + } +} + +function parseBatchEntries(raw: string, sourceLabel: string): ConfigSetBatchEntry[] { + const parsed = parseJson5Raw(raw, sourceLabel); + if (!Array.isArray(parsed)) { + throw new Error(`${sourceLabel} must be a JSON array.`); + } + const out: ConfigSetBatchEntry[] = []; + for (const [index, entry] of parsed.entries()) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`${sourceLabel}[${index}] must be an object.`); + } + const typed = entry as Record; + const path = typeof typed.path === "string" ? typed.path.trim() : ""; + if (!path) { + throw new Error(`${sourceLabel}[${index}].path is required.`); + } + const hasValue = Object.prototype.hasOwnProperty.call(typed, "value"); + const hasRef = Object.prototype.hasOwnProperty.call(typed, "ref"); + const hasProvider = Object.prototype.hasOwnProperty.call(typed, "provider"); + const modeCount = Number(hasValue) + Number(hasRef) + Number(hasProvider); + if (modeCount !== 1) { + throw new Error( + `${sourceLabel}[${index}] must include exactly one of: value, ref, provider.`, + ); + } + out.push({ + path, + ...(hasValue ? { value: typed.value } : {}), + ...(hasRef ? { ref: typed.ref } : {}), + ...(hasProvider ? { provider: typed.provider } : {}), + }); + } + return out; +} + +export function parseBatchSource(opts: ConfigSetOptions): ConfigSetBatchEntry[] | null { + const hasInline = Boolean(opts.batchJson && opts.batchJson.trim().length > 0); + const hasFile = Boolean(opts.batchFile && opts.batchFile.trim().length > 0); + if (!hasInline && !hasFile) { + return null; + } + if (hasInline && hasFile) { + throw new Error("Use either --batch-json or --batch-file, not both."); + } + if (hasInline) { + return parseBatchEntries(opts.batchJson as string, "--batch-json"); + } + const pathname = (opts.batchFile as string).trim(); + if (!pathname) { + throw new Error("--batch-file must not be empty."); + } + const raw = fs.readFileSync(pathname, "utf8"); + return parseBatchEntries(raw, "--batch-file"); +} diff --git a/src/cli/config-set-mode.test.ts b/src/cli/config-set-mode.test.ts new file mode 100644 index 00000000000..062f8f2e9aa --- /dev/null +++ b/src/cli/config-set-mode.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { resolveConfigSetMode } from "./config-set-parser.js"; + +describe("resolveConfigSetMode", () => { + it("selects value mode by default", () => { + const result = resolveConfigSetMode({ + hasBatchMode: false, + hasRefBuilderOptions: false, + hasProviderBuilderOptions: false, + strictJson: false, + }); + expect(result).toEqual({ ok: true, mode: "value" }); + }); + + it("selects json mode when strict parsing is enabled", () => { + const result = resolveConfigSetMode({ + hasBatchMode: false, + hasRefBuilderOptions: false, + hasProviderBuilderOptions: false, + strictJson: true, + }); + expect(result).toEqual({ ok: true, mode: "json" }); + }); + + it("selects ref-builder mode when ref flags are present", () => { + const result = resolveConfigSetMode({ + hasBatchMode: false, + hasRefBuilderOptions: true, + hasProviderBuilderOptions: false, + strictJson: false, + }); + expect(result).toEqual({ ok: true, mode: "ref_builder" }); + }); + + it("selects provider-builder mode when provider flags are present", () => { + const result = resolveConfigSetMode({ + hasBatchMode: false, + hasRefBuilderOptions: false, + hasProviderBuilderOptions: true, + strictJson: false, + }); + expect(result).toEqual({ ok: true, mode: "provider_builder" }); + }); + + it("returns batch mode when batch flags are present", () => { + const result = resolveConfigSetMode({ + hasBatchMode: true, + hasRefBuilderOptions: false, + hasProviderBuilderOptions: false, + strictJson: false, + }); + expect(result).toEqual({ ok: true, mode: "batch" }); + }); + + it("rejects ref-builder and provider-builder collisions", () => { + const result = resolveConfigSetMode({ + hasBatchMode: false, + hasRefBuilderOptions: true, + hasProviderBuilderOptions: true, + strictJson: false, + }); + expect(result.ok).toBe(false); + expect(result).toMatchObject({ + error: expect.stringContaining("choose exactly one mode"), + }); + }); + + it("rejects mixing batch mode with builder flags", () => { + const result = resolveConfigSetMode({ + hasBatchMode: true, + hasRefBuilderOptions: true, + hasProviderBuilderOptions: false, + strictJson: false, + }); + expect(result.ok).toBe(false); + expect(result).toMatchObject({ + error: expect.stringContaining("batch mode (--batch-json/--batch-file) cannot be combined"), + }); + }); +}); diff --git a/src/cli/config-set-parser.ts b/src/cli/config-set-parser.ts new file mode 100644 index 00000000000..a3cac0217bc --- /dev/null +++ b/src/cli/config-set-parser.ts @@ -0,0 +1,43 @@ +export type ConfigSetMode = "value" | "json" | "ref_builder" | "provider_builder" | "batch"; + +export type ConfigSetModeResolution = + | { + ok: true; + mode: ConfigSetMode; + } + | { + ok: false; + error: string; + }; + +export function resolveConfigSetMode(params: { + hasBatchMode: boolean; + hasRefBuilderOptions: boolean; + hasProviderBuilderOptions: boolean; + strictJson: boolean; +}): ConfigSetModeResolution { + if (params.hasBatchMode) { + if (params.hasRefBuilderOptions || params.hasProviderBuilderOptions) { + return { + ok: false, + error: + "batch mode (--batch-json/--batch-file) cannot be combined with ref builder (--ref-*) or provider builder (--provider-*) flags.", + }; + } + return { ok: true, mode: "batch" }; + } + if (params.hasRefBuilderOptions && params.hasProviderBuilderOptions) { + return { + ok: false, + error: + "choose exactly one mode: ref builder (--ref-provider/--ref-source/--ref-id) or provider builder (--provider-*), not both.", + }; + } + if (params.hasRefBuilderOptions) { + return { ok: true, mode: "ref_builder" }; + } + if (params.hasProviderBuilderOptions) { + return { ok: true, mode: "provider_builder" }; + } + return { ok: true, mode: params.strictJson ? "json" : "value" }; +} diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 9996c155288..1d9d6885fe2 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -1,4 +1,5 @@ import type { OutboundSendDeps } from "../infra/outbound/send-deps.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { createOutboundSendDepsFromCliSource } from "./outbound-send-mapping.js"; /** @@ -24,10 +25,11 @@ function createLazySender( channelId: string, loader: () => Promise, ): (...args: unknown[]) => Promise { + const loadRuntimeSend = createLazyRuntimeSurface(loader, ({ runtimeSend }) => runtimeSend); return async (...args: unknown[]) => { let cached = senderCache.get(channelId); if (!cached) { - cached = loader().then(({ runtimeSend }) => runtimeSend); + cached = loadRuntimeSend(); senderCache.set(channelId, cached); } const runtimeSend = await cached; diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index 2405055adc6..3738616cb2c 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -2,15 +2,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { Command } from "commander"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const getMemorySearchManager = vi.fn(); -const loadConfig = vi.fn(() => ({})); -const resolveDefaultAgentId = vi.fn(() => "main"); -const resolveCommandSecretRefsViaGateway = vi.fn(async ({ config }: { config: unknown }) => ({ - resolvedConfig: config, - diagnostics: [] as string[], -})); +const getMemorySearchManager = vi.hoisted(() => vi.fn()); +const loadConfig = vi.hoisted(() => vi.fn(() => ({}))); +const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "main")); +const resolveCommandSecretRefsViaGateway = vi.hoisted(() => + vi.fn(async ({ config }: { config: unknown }) => ({ + resolvedConfig: config, + diagnostics: [] as string[], + })), +); vi.mock("../memory/index.js", () => ({ getMemorySearchManager, @@ -33,7 +35,8 @@ let defaultRuntime: typeof import("../runtime.js").defaultRuntime; let isVerbose: typeof import("../globals.js").isVerbose; let setVerbose: typeof import("../globals.js").setVerbose; -beforeAll(async () => { +beforeEach(async () => { + vi.resetModules(); ({ registerMemoryCli } = await import("./memory-cli.js")); ({ defaultRuntime } = await import("../runtime.js")); ({ isVerbose, setVerbose } = await import("../globals.js")); diff --git a/src/cli/pairing-cli.test.ts b/src/cli/pairing-cli.test.ts index 97d9c9c7751..c05cdb61050 100644 --- a/src/cli/pairing-cli.test.ts +++ b/src/cli/pairing-cli.test.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const listChannelPairingRequests = vi.fn(); const approveChannelPairingCode = vi.fn(); @@ -47,11 +47,9 @@ vi.mock("../config/config.js", () => ({ describe("pairing cli", () => { let registerPairingCli: typeof import("./pairing-cli.js").registerPairingCli; - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); ({ registerPairingCli } = await import("./pairing-cli.js")); - }); - - beforeEach(() => { listChannelPairingRequests.mockClear(); listChannelPairingRequests.mockResolvedValue([]); approveChannelPairingCode.mockClear(); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index b4b197bf96c..412e45a6639 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -5,6 +5,7 @@ import type { Command } from "commander"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; import { resolveArchiveKind } from "../infra/archive.js"; import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; import { enablePluginInConfig } from "../plugins/enable.js"; @@ -19,7 +20,11 @@ import { import type { PluginRecord } from "../plugins/registry.js"; import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js"; -import { buildPluginStatusReport } from "../plugins/status.js"; +import { + buildAllPluginInspectReports, + buildPluginInspectReport, + buildPluginStatusReport, +} from "../plugins/status.js"; import { resolveUninstallDirectoryTarget, uninstallPlugin } from "../plugins/uninstall.js"; import { updateNpmInstalledPlugins } from "../plugins/update.js"; import { defaultRuntime } from "../runtime.js"; @@ -42,8 +47,9 @@ export type PluginsListOptions = { verbose?: boolean; }; -export type PluginInfoOptions = { +export type PluginInspectOptions = { json?: boolean; + all?: boolean; }; export type PluginUpdateOptions = { @@ -133,6 +139,67 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string { return parts.join("\n"); } +function formatInspectSection(title: string, lines: string[]): string[] { + if (lines.length === 0) { + return []; + } + return ["", theme.muted(`${title}:`), ...lines]; +} + +function formatCapabilityKinds( + capabilities: Array<{ + kind: string; + }>, +): 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/prompt.test.ts b/src/cli/prompt.test.ts index da5843dcbda..ee68e646700 100644 --- a/src/cli/prompt.test.ts +++ b/src/cli/prompt.test.ts @@ -1,12 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; -import { isYes, setVerbose, setYes } from "../globals.js"; - -vi.mock("node:readline/promises", () => { - const question = vi.fn(async () => ""); - const close = vi.fn(); - const createInterface = vi.fn(() => ({ question, close })); - return { default: { createInterface } }; -}); +import { beforeEach, describe, expect, it, vi } from "vitest"; type ReadlineMock = { default: { @@ -17,8 +9,27 @@ type ReadlineMock = { }; }; -const { promptYesNo } = await import("./prompt.js"); -const readline = (await import("node:readline/promises")) as unknown as ReadlineMock; +type PromptModule = typeof import("./prompt.js"); +type GlobalsModule = typeof import("../globals.js"); + +let promptYesNo: PromptModule["promptYesNo"]; +let readline: ReadlineMock; +let isYes: GlobalsModule["isYes"]; +let setVerbose: GlobalsModule["setVerbose"]; +let setYes: GlobalsModule["setYes"]; + +beforeEach(async () => { + vi.resetModules(); + vi.doMock("node:readline/promises", () => { + const question = vi.fn(async () => ""); + const close = vi.fn(); + const createInterface = vi.fn(() => ({ question, close })); + return { default: { createInterface } }; + }); + ({ promptYesNo } = await import("./prompt.js")); + ({ isYes, setVerbose, setYes } = await import("../globals.js")); + readline = (await import("node:readline/promises")) as unknown as ReadlineMock; +}); describe("promptYesNo", () => { it("returns true when global --yes is set", async () => { 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/message.test.ts b/src/commands/message.test.ts index 182946ba7ad..806dc2655d1 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -18,43 +18,54 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -const resolveCommandSecretRefsViaGateway = vi.fn(async ({ config }: { config: unknown }) => ({ - resolvedConfig: config, - diagnostics: [] as string[], +const { resolveCommandSecretRefsViaGateway, callGatewayMock } = vi.hoisted(() => ({ + resolveCommandSecretRefsViaGateway: vi.fn(async ({ config }: { config: unknown }) => ({ + resolvedConfig: config, + diagnostics: [] as string[], + })), + callGatewayMock: vi.fn(), })); + vi.mock("../cli/command-secret-gateway.js", () => ({ resolveCommandSecretRefsViaGateway, })); -const callGatewayMock = vi.fn(); vi.mock("../gateway/call.js", () => ({ callGateway: callGatewayMock, callGatewayLeastPrivilege: callGatewayMock, randomIdempotencyKey: () => "idem-1", })); -const webAuthExists = vi.fn(async () => false); +const webAuthExists = vi.hoisted(() => vi.fn(async () => false)); vi.mock("../../extensions/whatsapp/src/session.js", () => ({ webAuthExists, })); -const handleDiscordAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../agents/tools/discord-actions.js", () => ({ +const handleDiscordAction = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), +); +vi.mock("../../extensions/discord/src/actions/runtime.js", () => ({ handleDiscordAction, })); -const handleSlackAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../agents/tools/slack-actions.js", () => ({ +const handleSlackAction = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), +); +vi.mock("../../extensions/slack/runtime-api.js", () => ({ handleSlackAction, })); -const handleTelegramAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../agents/tools/telegram-actions.js", () => ({ +const handleTelegramAction = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), +); +vi.mock("../../extensions/telegram/src/action-runtime.js", () => ({ handleTelegramAction, })); -const handleWhatsAppAction = vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })); -vi.mock("../agents/tools/whatsapp-actions.js", () => ({ +const handleWhatsAppAction = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => ({ details: { ok: true } })), +); +vi.mock("../../extensions/whatsapp/runtime-api.js", () => ({ handleWhatsAppAction, })); @@ -66,10 +77,12 @@ const setRegistry = async (registry: ReturnType) => { }; beforeEach(async () => { + vi.resetModules(); envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN"]); process.env.TELEGRAM_BOT_TOKEN = ""; process.env.DISCORD_BOT_TOKEN = ""; testConfig = {}; + ({ messageCommand } = await import("./message.js")); await setRegistry(createTestRegistry([])); callGatewayMock.mockClear(); webAuthExists.mockClear().mockResolvedValue(false); @@ -184,7 +197,7 @@ const createTelegramPollPluginRegistration = () => ({ }), }); -const { messageCommand } = await import("./message.js"); +let messageCommand: typeof import("./message.js").messageCommand; function createTelegramSecretRawConfig() { return { diff --git a/src/commands/model-picker.runtime.ts b/src/commands/model-picker.runtime.ts index 3d033fa3e80..f527f0c5cf8 100644 --- a/src/commands/model-picker.runtime.ts +++ b/src/commands/model-picker.runtime.ts @@ -1,7 +1,15 @@ -export { +import { runProviderPluginAuthMethod } from "../plugins/provider-auth-choice.js"; +import { resolveProviderModelPickerEntries, resolveProviderPluginChoice, runProviderModelSelectedHook, } from "../plugins/provider-wizard.js"; -export { resolvePluginProviders } from "../plugins/providers.js"; -export { runProviderPluginAuthMethod } from "../plugins/provider-auth-choice.js"; +import { resolvePluginProviders } from "../plugins/providers.js"; + +export const modelPickerRuntime = { + resolveProviderModelPickerEntries, + resolveProviderPluginChoice, + runProviderModelSelectedHook, + resolvePluginProviders, + runProviderPluginAuthMethod, +}; diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index a4eb89e066c..fc09d5a7f3c 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -40,11 +40,13 @@ const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {})); const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); const runProviderPluginAuthMethod = vi.hoisted(() => vi.fn()); vi.mock("./model-picker.runtime.js", () => ({ - resolveProviderModelPickerEntries, - resolveProviderPluginChoice, - runProviderModelSelectedHook, - resolvePluginProviders, - runProviderPluginAuthMethod, + modelPickerRuntime: { + resolveProviderModelPickerEntries, + resolveProviderPluginChoice, + runProviderModelSelectedHook, + resolvePluginProviders, + runProviderPluginAuthMethod, + }, })); const OPENROUTER_CATALOG = [ diff --git a/src/commands/model-picker.ts b/src/commands/model-picker.ts index c0b67ea7d7c..cea263f7e58 100644 --- a/src/commands/model-picker.ts +++ b/src/commands/model-picker.ts @@ -13,6 +13,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import { applyPrimaryModel } from "../plugins/provider-model-primary.js"; import type { ProviderPlugin } from "../plugins/types.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; import { formatTokenK } from "./models/shared.js"; @@ -49,6 +50,11 @@ async function loadModelPickerRuntime() { return import("./model-picker.runtime.js"); } +const loadResolvedModelPickerRuntime = createLazyRuntimeSurface( + loadModelPickerRuntime, + ({ modelPickerRuntime }) => modelPickerRuntime, +); + function hasAuthForProvider( provider: string, cfg: OpenClawConfig, @@ -284,7 +290,7 @@ export async function promptDefaultModel( options.push({ value: MANUAL_VALUE, label: "Enter model manually" }); } if (includeProviderPluginSetups && agentDir) { - const { resolveProviderModelPickerEntries } = await loadModelPickerRuntime(); + const { resolveProviderModelPickerEntries } = await loadResolvedModelPickerRuntime(); options.push( ...resolveProviderModelPickerEntries({ config: cfg, @@ -343,7 +349,7 @@ export async function promptDefaultModel( if (selection.startsWith("provider-plugin:")) { pluginResolution = selection; } else if (!selection.includes("/")) { - const { resolvePluginProviders } = await loadModelPickerRuntime(); + const { resolvePluginProviders } = await loadResolvedModelPickerRuntime(); pluginProviders = resolvePluginProviders({ config: cfg, workspaceDir: params.workspaceDir, @@ -368,7 +374,7 @@ export async function promptDefaultModel( resolveProviderPluginChoice, runProviderModelSelectedHook, runProviderPluginAuthMethod, - } = await loadModelPickerRuntime(); + } = await loadResolvedModelPickerRuntime(); if (pluginProviders.length === 0) { pluginProviders = resolvePluginProviders({ config: cfg, @@ -404,7 +410,7 @@ export async function promptDefaultModel( return { model: applied.defaultModel, config: applied.config }; } const model = String(selection); - const { runProviderModelSelectedHook } = await loadModelPickerRuntime(); + const { runProviderModelSelectedHook } = await loadResolvedModelPickerRuntime(); await runProviderModelSelectedHook({ config: cfg, model, 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/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts index a02dd2f2ee2..05422a839fb 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts @@ -1,5 +1,11 @@ -export { resolveProviderPluginChoice } from "../../../plugins/provider-wizard.js"; -export { +import { resolveProviderPluginChoice } from "../../../plugins/provider-wizard.js"; +import { resolveOwningPluginIdsForProvider, resolvePluginProviders, } from "../../../plugins/providers.js"; + +export const authChoicePluginProvidersRuntime = { + resolveOwningPluginIdsForProvider, + resolveProviderPluginChoice, + resolvePluginProviders, +}; diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts index 3ccee9bbfd3..bea20a66764 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts @@ -11,10 +11,11 @@ const resolveOwningPluginIdsForProvider = vi.hoisted(() => vi.fn(() => undefined const resolveProviderPluginChoice = vi.hoisted(() => vi.fn()); const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); vi.mock("./auth-choice.plugin-providers.runtime.js", () => ({ - resolveOwningPluginIdsForProvider, - resolveProviderPluginChoice, - resolvePluginProviders, - PROVIDER_PLUGIN_CHOICE_PREFIX: "provider-plugin:", + authChoicePluginProvidersRuntime: { + resolveOwningPluginIdsForProvider, + resolveProviderPluginChoice, + resolvePluginProviders, + }, })); beforeEach(() => { diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index b7a369e4674..ad6cb853955 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -14,6 +14,7 @@ import type { ProviderResolveNonInteractiveApiKeyParams, } from "../../../plugins/types.js"; import type { RuntimeEnv } from "../../../runtime.js"; +import { createLazyRuntimeSurface } from "../../../shared/lazy-runtime.js"; import type { OnboardOptions } from "../../onboard-types.js"; const PROVIDER_PLUGIN_CHOICE_PREFIX = "provider-plugin:"; @@ -22,6 +23,11 @@ async function loadPluginProviderRuntime() { return import("./auth-choice.plugin-providers.runtime.js"); } +const loadAuthChoicePluginProvidersRuntime = createLazyRuntimeSurface( + loadPluginProviderRuntime, + ({ authChoicePluginProvidersRuntime }) => authChoicePluginProvidersRuntime, +); + function buildIsolatedProviderResolutionConfig( cfg: OpenClawConfig, providerId: string | undefined, @@ -81,7 +87,7 @@ export async function applyNonInteractivePluginProviderChoice(params: { preferredProviderId, ); const { resolveOwningPluginIdsForProvider, resolveProviderPluginChoice, resolvePluginProviders } = - await loadPluginProviderRuntime(); + await loadAuthChoicePluginProvidersRuntime(); const owningPluginIds = preferredProviderId ? resolveOwningPluginIdsForProvider({ provider: preferredProviderId, diff --git a/src/commands/status.scan.deps.runtime.ts b/src/commands/status.scan.deps.runtime.ts index ce318085541..722bcfc1599 100644 --- a/src/commands/status.scan.deps.runtime.ts +++ b/src/commands/status.scan.deps.runtime.ts @@ -6,7 +6,7 @@ import type { MemoryProviderStatus } from "../memory/types.js"; export { getTailnetHostname }; type StatusMemoryManager = { - probeVectorAvailability(): Promise; + probeVectorAvailability(): Promise; status(): MemoryProviderStatus; close?(): Promise; }; @@ -23,7 +23,7 @@ export async function getMemorySearchManager(params: { return { manager: { async probeVectorAvailability() { - await manager.probeVectorAvailability(); + return await manager.probeVectorAvailability(); }, status() { return manager.status(); diff --git a/src/commands/status.scan.runtime.ts b/src/commands/status.scan.runtime.ts index 372b31f4803..a783d0a94d6 100644 --- a/src/commands/status.scan.runtime.ts +++ b/src/commands/status.scan.runtime.ts @@ -1,2 +1,7 @@ -export { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; -export { buildChannelsTable } from "./status-all/channels.js"; +import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; +import { buildChannelsTable } from "./status-all/channels.js"; + +export const statusScanRuntime = { + collectChannelStatusIssues, + buildChannelsTable, +}; diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts index b855c85320a..6f28bcd7773 100644 --- a/src/commands/status.scan.shared.ts +++ b/src/commands/status.scan.shared.ts @@ -113,7 +113,7 @@ export async function resolveSharedMemoryStatusSnapshot(params: { purpose: "status"; }) => Promise<{ manager: { - probeVectorAvailability(): Promise; + probeVectorAvailability(): Promise; status(): MemoryProviderStatus; close?(): Promise; } | null; diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 168c2f55017..899aea2b267 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -42,8 +42,10 @@ vi.mock("./status-all/channels.js", () => ({ })); vi.mock("./status.scan.runtime.js", () => ({ - buildChannelsTable: mocks.buildChannelsTable, - collectChannelStatusIssues: vi.fn(() => []), + statusScanRuntime: { + buildChannelsTable: mocks.buildChannelsTable, + collectChannelStatusIssues: vi.fn(() => []), + }, })); vi.mock("./status.update.js", () => ({ diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 3eb6fc8ed3d..e7d05542743 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -6,14 +6,13 @@ import { withProgress } from "../cli/progress.js"; import type { OpenClawConfig } from "../config/config.js"; import { readBestEffortConfig } from "../config/config.js"; import { callGateway } from "../gateway/call.js"; +import type { collectChannelStatusIssues as collectChannelStatusIssuesFn } from "../infra/channels-status-issues.js"; import { resolveOsSummary } from "../infra/os-summary.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; +import type { buildChannelsTable as buildChannelsTableFn } from "./status-all/channels.js"; import { getAgentLocalStatuses } from "./status.agent-local.js"; -import type { - buildChannelsTable as buildChannelsTableFn, - collectChannelStatusIssues as collectChannelStatusIssuesFn, -} from "./status.scan.runtime.js"; import { buildTailscaleHttpsUrl, pickGatewaySelfPresence, @@ -30,7 +29,6 @@ import { getUpdateCheckResult } from "./status.update.js"; type DeferredResult = { ok: true; value: T } | { ok: false; error: unknown }; let pluginRegistryModulePromise: Promise | undefined; -let statusScanRuntimeModulePromise: Promise | undefined; let statusScanDepsRuntimeModulePromise: | Promise | undefined; @@ -40,10 +38,10 @@ function loadPluginRegistryModule() { return pluginRegistryModulePromise; } -function loadStatusScanRuntimeModule() { - statusScanRuntimeModulePromise ??= import("./status.scan.runtime.js"); - return statusScanRuntimeModulePromise; -} +const loadStatusScanRuntimeModule = createLazyRuntimeSurface( + () => import("./status.scan.runtime.js"), + ({ statusScanRuntime }) => statusScanRuntime, +); function loadStatusScanDepsRuntimeModule() { statusScanDepsRuntimeModulePromise ??= import("./status.scan.deps.runtime.js"); diff --git a/src/commands/status.summary.runtime.ts b/src/commands/status.summary.runtime.ts index df1ae881d4f..e4b08a49856 100644 --- a/src/commands/status.summary.runtime.ts +++ b/src/commands/status.summary.runtime.ts @@ -1,2 +1,8 @@ -export { resolveContextTokensForModel } from "../agents/context.js"; -export { classifySessionKey, resolveSessionModelRef } from "../gateway/session-utils.js"; +import { resolveContextTokensForModel } from "../agents/context.js"; +import { classifySessionKey, resolveSessionModelRef } from "../gateway/session-utils.js"; + +export const statusSummaryRuntime = { + resolveContextTokensForModel, + classifySessionKey, + resolveSessionModelRef, +}; 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/commands/status.summary.ts b/src/commands/status.summary.ts index c5c3f174547..c235765b406 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -10,14 +10,12 @@ import { listGatewayAgentsBasic } from "../gateway/agent-list.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { resolveRuntimeServiceVersion } from "../version.js"; import type { HeartbeatStatus, SessionStatus, StatusSummary } from "./status.types.js"; let channelSummaryModulePromise: Promise | undefined; let linkChannelModulePromise: Promise | undefined; -let statusSummaryRuntimeModulePromise: - | Promise - | undefined; let configIoModulePromise: Promise | undefined; function loadChannelSummaryModule() { @@ -30,10 +28,10 @@ function loadLinkChannelModule() { return linkChannelModulePromise; } -function loadStatusSummaryRuntimeModule() { - statusSummaryRuntimeModulePromise ??= import("./status.summary.runtime.js"); - return statusSummaryRuntimeModulePromise; -} +const loadStatusSummaryRuntimeModule = createLazyRuntimeSurface( + () => import("./status.summary.runtime.js"), + ({ statusSummaryRuntime }) => statusSummaryRuntime, +); function loadConfigIoModule() { configIoModulePromise ??= import("../config/io.js"); 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/logging.test.ts b/src/config/logging.test.ts index 6c55961d80d..e410c3f81ba 100644 --- a/src/config/logging.test.ts +++ b/src/config/logging.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ createConfigIO: vi.fn().mockReturnValue({ @@ -10,7 +10,13 @@ vi.mock("./io.js", () => ({ createConfigIO: mocks.createConfigIO, })); -import { formatConfigPath, logConfigUpdated } from "./logging.js"; +let formatConfigPath: typeof import("./logging.js").formatConfigPath; +let logConfigUpdated: typeof import("./logging.js").logConfigUpdated; + +beforeEach(async () => { + vi.resetModules(); + ({ formatConfigPath, logConfigUpdated } = await import("./logging.js")); +}); describe("config logging", () => { it("formats the live config path when no explicit path is provided", () => { 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 779abbb609b..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": @@ -413,9 +413,9 @@ export const FIELD_HELP: Record = { "gateway.http.endpoints.chatCompletions.images": "Image fetch/validation controls for OpenAI-compatible `image_url` parts.", "gateway.http.endpoints.chatCompletions.images.allowUrl": - "Allow server-side URL fetches for `image_url` parts (default: false; data URIs remain supported).", + "Allow server-side URL fetches for `image_url` parts (default: false; data URIs remain supported). Set this to `false` to disable URL fetching entirely.", "gateway.http.endpoints.chatCompletions.images.urlAllowlist": - "Optional hostname allowlist for `image_url` URL fetches; supports exact hosts and `*.example.com` wildcards.", + "Optional hostname allowlist for `image_url` URL fetches; supports exact hosts and `*.example.com` wildcards. Empty or omitted lists mean no hostname allowlist restriction.", "gateway.http.endpoints.chatCompletions.images.allowedMimes": "Allowed MIME types for `image_url` parts (case-insensitive list).", "gateway.http.endpoints.chatCompletions.images.maxBytes": @@ -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/schema.shared.ts b/src/config/schema.shared.ts index 148d5b3fb86..9eb6f71e052 100644 --- a/src/config/schema.shared.ts +++ b/src/config/schema.shared.ts @@ -1,4 +1,5 @@ type JsonSchemaObject = { + type?: string | string[]; properties?: Record; additionalProperties?: JsonSchemaObject | boolean; items?: JsonSchemaObject | JsonSchemaObject[]; diff --git a/src/config/sessions/delivery-info.test.ts b/src/config/sessions/delivery-info.test.ts index 23717338ea3..2f315fd807e 100644 --- a/src/config/sessions/delivery-info.test.ts +++ b/src/config/sessions/delivery-info.test.ts @@ -17,7 +17,8 @@ vi.mock("./store.js", () => ({ loadSessionStore: () => storeState.store, })); -import { extractDeliveryInfo, parseSessionThreadInfo } from "./delivery-info.js"; +let extractDeliveryInfo: typeof import("./delivery-info.js").extractDeliveryInfo; +let parseSessionThreadInfo: typeof import("./delivery-info.js").parseSessionThreadInfo; const buildEntry = (deliveryContext: SessionEntry["deliveryContext"]): SessionEntry => ({ sessionId: "session-1", @@ -25,8 +26,10 @@ const buildEntry = (deliveryContext: SessionEntry["deliveryContext"]): SessionEn deliveryContext, }); -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); storeState.store = {}; + ({ extractDeliveryInfo, parseSessionThreadInfo } = await import("./delivery-info.js")); }); describe("extractDeliveryInfo", () => { 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.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index d5cf106c520..3fde5236294 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -3,15 +3,19 @@ import fs 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 { clearSessionStoreCacheForTest, loadSessionStore, saveSessionStore } from "./store.js"; import type { SessionEntry } from "./types.js"; // Keep integration tests deterministic: never read a real openclaw.json. vi.mock("../config.js", () => ({ loadConfig: vi.fn().mockReturnValue({}), })); -const { loadConfig } = await import("../config.js"); -const mockLoadConfig = vi.mocked(loadConfig) as ReturnType; + +type StoreModule = typeof import("./store.js"); + +let clearSessionStoreCacheForTest: StoreModule["clearSessionStoreCacheForTest"]; +let loadSessionStore: StoreModule["loadSessionStore"]; +let saveSessionStore: StoreModule["saveSessionStore"]; +let mockLoadConfig: ReturnType; const DAY_MS = 24 * 60 * 60 * 1000; @@ -77,6 +81,11 @@ describe("Integration: saveSessionStore with pruning", () => { }); beforeEach(async () => { + vi.resetModules(); + ({ clearSessionStoreCacheForTest, loadSessionStore, saveSessionStore } = + await import("./store.js")); + const { loadConfig } = await import("../config.js"); + mockLoadConfig = vi.mocked(loadConfig) as ReturnType; testDir = await createCaseDir("pruning-integ"); storePath = path.join(testDir, "sessions.json"); savedCacheTtl = process.env.OPENCLAW_SESSION_CACHE_TTL_MS; 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> = {}; -vi.mock("../config/sessions.js", () => ({ - loadSessionStore: vi.fn((storePath: string) => mockStore[storePath] ?? {}), - resolveAgentMainSessionKey: vi.fn(({ agentId }: { agentId: string }) => `agent:${agentId}:main`), - resolveStorePath: vi.fn((_store: unknown, _opts: unknown) => "/mock/store.json"), -})); +type DeliveryTargetModule = typeof import("./isolated-agent/delivery-target.js"); -// Mock channel-selection to avoid real config resolution. -vi.mock("../infra/outbound/channel-selection.js", () => ({ - resolveMessageChannelSelection: vi.fn(async () => ({ channel: "telegram" })), -})); +let resolveDeliveryTarget: DeliveryTargetModule["resolveDeliveryTarget"]; -// Minimal mock for channel plugins (Telegram resolveTarget is an identity). -vi.mock("../channels/plugins/index.js", () => ({ - getChannelPlugin: vi.fn(() => ({ - meta: { label: "Telegram" }, - config: {}, - messaging: { - parseExplicitTarget: ({ raw }: { raw: string }) => { - const target = parseTelegramTarget(raw); - return { - to: target.chatId, - threadId: target.messageThreadId, - chatType: target.chatType === "unknown" ? undefined : target.chatType, - }; +beforeEach(async () => { + vi.resetModules(); + for (const key of Object.keys(mockStore)) { + delete mockStore[key]; + } + vi.doMock("../config/sessions.js", () => ({ + loadSessionStore: vi.fn((storePath: string) => mockStore[storePath] ?? {}), + resolveAgentMainSessionKey: vi.fn( + ({ agentId }: { agentId: string }) => `agent:${agentId}:main`, + ), + resolveStorePath: vi.fn((_store: unknown, _opts: unknown) => "/mock/store.json"), + })); + vi.doMock("../infra/outbound/channel-selection.js", () => ({ + resolveMessageChannelSelection: vi.fn(async () => ({ channel: "telegram" })), + })); + vi.doMock("../channels/plugins/index.js", () => ({ + getChannelPlugin: vi.fn(() => ({ + meta: { label: "Telegram" }, + config: {}, + messaging: { + parseExplicitTarget: ({ raw }: { raw: string }) => { + const target = parseTelegramTarget(raw); + return { + to: target.chatId, + threadId: target.messageThreadId, + chatType: target.chatType === "unknown" ? undefined : target.chatType, + }; + }, }, - }, - outbound: { - resolveTarget: ({ to }: { to?: string }) => - to ? { ok: true, to } : { ok: false, error: new Error("missing") }, - }, - })), - normalizeChannelId: vi.fn((id: string) => id), -})); - -const { resolveDeliveryTarget } = await import("./isolated-agent/delivery-target.js"); + outbound: { + resolveTarget: ({ to }: { to?: string }) => + to ? { ok: true, to } : { ok: false, error: new Error("missing") }, + }, + })), + normalizeChannelId: vi.fn((id: string) => id), + })); + ({ resolveDeliveryTarget } = await import("./isolated-agent/delivery-target.js")); +}); describe("resolveDeliveryTarget thread session lookup", () => { const cfg: OpenClawConfig = {}; diff --git a/src/cron/isolated-agent.test-setup.ts b/src/cron/isolated-agent.test-setup.ts index c70ea583f68..c677230f3a2 100644 --- a/src/cron/isolated-agent.test-setup.ts +++ b/src/cron/isolated-agent.test-setup.ts @@ -1,5 +1,5 @@ import { vi } from "vitest"; -import { parseTelegramTarget } from "../../extensions/telegram/src/targets.js"; +import { parseTelegramTarget } from "../../extensions/telegram/api.js"; import { signalOutbound, telegramOutbound } from "../../test/channel-outbounds.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; diff --git a/src/cron/isolated-agent/run.sandbox-config-preserved.test.ts b/src/cron/isolated-agent/run.sandbox-config-preserved.test.ts index edaee62daa6..d953185c369 100644 --- a/src/cron/isolated-agent/run.sandbox-config-preserved.test.ts +++ b/src/cron/isolated-agent/run.sandbox-config-preserved.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearFastTestEnv, loadRunCronIsolatedAgentTurn, @@ -8,8 +8,11 @@ import { runWithModelFallbackMock, } from "./run.test-harness.js"; -const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); -const { resolveSandboxConfigForAgent } = await import("../../agents/sandbox/config.js"); +type RunModule = typeof import("./run.js"); +type SandboxConfigModule = typeof import("../../agents/sandbox/config.js"); + +let runCronIsolatedAgentTurn: RunModule["runCronIsolatedAgentTurn"]; +let resolveSandboxConfigForAgent: SandboxConfigModule["resolveSandboxConfigForAgent"]; function makeJob(overrides?: Record) { return { @@ -82,7 +85,10 @@ function expectDefaultSandboxPreserved( describe("runCronIsolatedAgentTurn sandbox config preserved", () => { let previousFastTestEnv: string | undefined; - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); + ({ resolveSandboxConfigForAgent } = await import("../../agents/sandbox/config.js")); previousFastTestEnv = clearFastTestEnv(); resetRunCronIsolatedAgentTurnHarness(); }); diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index 75ffb262d4d..7b0e13e8cde 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -4,7 +4,6 @@ import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js"; import type { CronEvent, CronServiceDeps } from "./service.js"; import { CronService } from "./service.js"; import { createDeferred, createNoopLogger, installCronTestHooks } from "./service.test-harness.js"; -import { loadCronStore } from "./store.js"; const noopLogger = createNoopLogger(); installCronTestHooks({ logger: noopLogger }); @@ -60,10 +59,6 @@ async function makeStorePath() { return { storePath, cleanup: async () => {} }; } -function writeStoreFile(storePath: string, payload: unknown) { - setFile(storePath, JSON.stringify(payload, null, 2)); -} - vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); const pathMod = await import("node:path"); @@ -415,14 +410,6 @@ async function createMainOneShotJobHarness(params: { name: string; deleteAfterRu return { ...harness, atMs, job }; } -async function loadLegacyDeliveryMigrationByPayload(params: { - id: string; - payload: { provider?: string; channel?: string }; -}) { - const rawJob = createLegacyDeliveryMigrationJob(params); - return loadLegacyDeliveryMigration(rawJob); -} - async function expectNoMainSummaryForIsolatedRun(params: { runIsolatedAgentJob: CronServiceDeps["runIsolatedAgentJob"]; name: string; @@ -439,43 +426,6 @@ async function expectNoMainSummaryForIsolatedRun(params: { await stopCronAndCleanup(cron, store); } -function createLegacyDeliveryMigrationJob(options: { - id: string; - payload: { provider?: string; channel?: string }; -}) { - return { - id: options.id, - name: "legacy", - enabled: true, - createdAtMs: Date.now(), - updatedAtMs: Date.now(), - schedule: { kind: "cron", expr: "* * * * *" }, - sessionTarget: "isolated", - wakeMode: "now", - payload: { - kind: "agentTurn", - message: "hi", - deliver: true, - ...options.payload, - to: "7200373102", - }, - state: {}, - }; -} - -async function loadLegacyDeliveryMigration(rawJob: Record) { - ensureDir(fixturesRoot); - const store = await makeStorePath(); - writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] }); - - const cron = createStartedCronService(store.storePath); - await cron.start(); - cron.stop(); - const loaded = await loadCronStore(store.storePath); - const job = loaded.jobs.find((j) => j.id === rawJob.id); - return { store, cron, job }; -} - describe("CronService", () => { it("runs a one-shot main job and disables it after success when requested", async () => { const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events, atMs, job } = @@ -658,33 +608,6 @@ describe("CronService", () => { expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); }); - it("migrates legacy payload.provider to payload.channel on load", async () => { - const { store, cron, job } = await loadLegacyDeliveryMigrationByPayload({ - id: "legacy-1", - payload: { provider: " TeLeGrAm " }, - }); - // Legacy delivery fields are migrated to the top-level delivery object - const delivery = job?.delivery as unknown as Record; - expect(delivery?.channel).toBe("telegram"); - const payload = job?.payload as unknown as Record; - expect("provider" in payload).toBe(false); - expect("channel" in payload).toBe(false); - - await stopCronAndCleanup(cron, store); - }); - - it("canonicalizes payload.channel casing on load", async () => { - const { store, cron, job } = await loadLegacyDeliveryMigrationByPayload({ - id: "legacy-2", - payload: { channel: "Telegram" }, - }); - // Legacy delivery fields are migrated to the top-level delivery object - const delivery = job?.delivery as unknown as Record; - expect(delivery?.channel).toBe("telegram"); - - await stopCronAndCleanup(cron, store); - }); - it("does not post a fallback main summary when an isolated job errors", async () => { const runIsolatedAgentJob = vi.fn(async () => ({ status: "error" as const, @@ -764,60 +687,4 @@ describe("CronService", () => { cron.stop(); await store.cleanup(); }); - - it("skips invalid main jobs with agentTurn payloads from disk", async () => { - ensureDir(fixturesRoot); - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - const events = createCronEventHarness(); - - const atMs = Date.parse("2025-12-13T00:00:01.000Z"); - writeStoreFile(store.storePath, { - version: 1, - jobs: [ - { - id: "job-1", - enabled: true, - createdAtMs: Date.parse("2025-12-13T00:00:00.000Z"), - updatedAtMs: Date.parse("2025-12-13T00:00:00.000Z"), - schedule: { kind: "at", at: new Date(atMs).toISOString() }, - sessionTarget: "main", - wakeMode: "now", - payload: { kind: "agentTurn", message: "bad" }, - state: {}, - }, - ], - }); - - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async (_params: { job: unknown; message: string }) => ({ - status: "ok", - })) as unknown as CronServiceDeps["runIsolatedAgentJob"], - onEvent: events.onEvent, - }); - - await cron.start(); - - vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); - await vi.runOnlyPendingTimersAsync(); - await events.waitFor( - (evt) => evt.jobId === "job-1" && evt.action === "finished" && evt.status === "skipped", - ); - - expect(enqueueSystemEvent).not.toHaveBeenCalled(); - expect(requestHeartbeatNow).not.toHaveBeenCalled(); - - const jobs = await cron.list({ includeDisabled: true }); - expect(jobs[0]?.state.lastStatus).toBe("skipped"); - expect(jobs[0]?.state.lastError).toMatch(/main job requires/i); - - cron.stop(); - await store.cleanup(); - }); }); diff --git a/src/cron/service.store-load-invalid-main-job.test.ts b/src/cron/service.store-load-invalid-main-job.test.ts new file mode 100644 index 00000000000..39bc3588e44 --- /dev/null +++ b/src/cron/service.store-load-invalid-main-job.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CronService } from "./service.js"; +import { + createNoopLogger, + installCronTestHooks, + writeCronStoreSnapshot, +} from "./service.test-harness.js"; +import type { CronJob } from "./types.js"; + +const noopLogger = createNoopLogger(); +installCronTestHooks({ logger: noopLogger }); + +async function makeStorePath() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-store-load-")); + return { + dir, + storePath: path.join(dir, "cron", "jobs.json"), + }; +} + +describe("CronService store load", () => { + let tempDir: string | null = null; + + afterEach(async () => { + if (!tempDir) { + return; + } + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = null; + }); + + it("skips invalid main jobs with agentTurn payloads loaded from disk", async () => { + const { dir, storePath } = await makeStorePath(); + tempDir = dir; + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + + const job = { + id: "job-1", + enabled: true, + createdAtMs: Date.parse("2025-12-13T00:00:00.000Z"), + updatedAtMs: Date.parse("2025-12-13T00:00:00.000Z"), + schedule: { kind: "at", at: "2025-12-13T00:00:01.000Z" }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "agentTurn", message: "bad" }, + state: {}, + name: "bad", + } satisfies CronJob; + + await writeCronStoreSnapshot({ storePath, jobs: [job] }); + + const cron = new CronService({ + storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), + }); + + await cron.start(); + vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); + await cron.run("job-1", "due"); + + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); + + const jobs = await cron.list({ includeDisabled: true }); + expect(jobs[0]?.state.lastStatus).toBe("skipped"); + expect(jobs[0]?.state.lastError).toMatch(/main job requires/i); + + cron.stop(); + }); +}); diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index e5d60fdfc96..f8297a28554 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -257,6 +257,18 @@ describe("buildMinimalServicePath", () => { 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/input-allowlist.test.ts b/src/gateway/input-allowlist.test.ts new file mode 100644 index 00000000000..169e8ac03e2 --- /dev/null +++ b/src/gateway/input-allowlist.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; + +describe("normalizeInputHostnameAllowlist", () => { + it("treats missing and empty allowlists as unset", () => { + expect(normalizeInputHostnameAllowlist(undefined)).toBeUndefined(); + expect(normalizeInputHostnameAllowlist([])).toBeUndefined(); + }); + + it("drops whitespace-only entries and treats the result as unset", () => { + expect(normalizeInputHostnameAllowlist(["", " "])).toBeUndefined(); + }); + + it("preserves trimmed hostname patterns", () => { + expect(normalizeInputHostnameAllowlist([" cdn.example.com ", "*.assets.example.com"])).toEqual([ + "cdn.example.com", + "*.assets.example.com", + ]); + }); +}); diff --git a/src/gateway/input-allowlist.ts b/src/gateway/input-allowlist.ts index d59b3e6265c..61ad9d06cc4 100644 --- a/src/gateway/input-allowlist.ts +++ b/src/gateway/input-allowlist.ts @@ -1,3 +1,10 @@ +/** + * Normalize optional gateway URL-input hostname allowlists. + * + * Semantics are intentionally: + * - missing / empty / whitespace-only list => no hostname allowlist restriction + * - deny-all URL fetching => use the corresponding `allowUrl: false` switch + */ export function normalizeInputHostnameAllowlist( values: string[] | undefined, ): string[] | undefined { 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/image-generation/live-test-helpers.test.ts b/src/image-generation/live-test-helpers.test.ts new file mode 100644 index 00000000000..3a7058569cf --- /dev/null +++ b/src/image-generation/live-test-helpers.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + parseCaseFilter, + parseCsvFilter, + parseProviderModelMap, + redactLiveApiKey, + resolveConfiguredLiveImageModels, + resolveLiveImageAuthStore, +} from "./live-test-helpers.js"; + +describe("image-generation live-test helpers", () => { + it("parses provider filters and treats empty/all as unfiltered", () => { + expect(parseCsvFilter()).toBeNull(); + expect(parseCsvFilter("all")).toBeNull(); + expect(parseCsvFilter(" openai , google ")).toEqual(new Set(["openai", "google"])); + }); + + it("parses live case filters and treats empty/all as unfiltered", () => { + expect(parseCaseFilter()).toBeNull(); + expect(parseCaseFilter("all")).toBeNull(); + expect(parseCaseFilter(" google:flash , openai:default ")).toEqual( + new Set(["google:flash", "openai:default"]), + ); + }); + + it("parses provider model overrides by provider id", () => { + expect( + parseProviderModelMap("openai/gpt-image-1, google/gemini-3.1-flash-image-preview, invalid"), + ).toEqual( + new Map([ + ["openai", "openai/gpt-image-1"], + ["google", "google/gemini-3.1-flash-image-preview"], + ]), + ); + }); + + it("collects configured models from primary and fallbacks", () => { + const cfg = { + agents: { + defaults: { + imageGenerationModel: { + primary: "openai/gpt-image-1", + fallbacks: ["google/gemini-3.1-flash-image-preview", "invalid"], + }, + }, + }, + } as OpenClawConfig; + + expect(resolveConfiguredLiveImageModels(cfg)).toEqual( + new Map([ + ["openai", "openai/gpt-image-1"], + ["google", "google/gemini-3.1-flash-image-preview"], + ]), + ); + }); + + it("uses an empty auth store when live env keys should override stale profiles", () => { + expect( + resolveLiveImageAuthStore({ + requireProfileKeys: false, + hasLiveKeys: true, + }), + ).toEqual({ + version: 1, + profiles: {}, + }); + }); + + it("keeps profile-store mode when requested or when no live keys exist", () => { + expect( + resolveLiveImageAuthStore({ + requireProfileKeys: true, + hasLiveKeys: true, + }), + ).toBeUndefined(); + expect( + resolveLiveImageAuthStore({ + requireProfileKeys: false, + hasLiveKeys: false, + }), + ).toBeUndefined(); + }); + + it("redacts live API keys for diagnostics", () => { + expect(redactLiveApiKey(undefined)).toBe("none"); + expect(redactLiveApiKey("short-key")).toBe("short-key"); + expect(redactLiveApiKey("sk-proj-1234567890")).toBe("sk-proj-...7890"); + }); +}); diff --git a/src/image-generation/live-test-helpers.ts b/src/image-generation/live-test-helpers.ts new file mode 100644 index 00000000000..0063bab89fa --- /dev/null +++ b/src/image-generation/live-test-helpers.ts @@ -0,0 +1,96 @@ +import type { AuthProfileStore } from "../agents/auth-profiles.js"; +import type { OpenClawConfig } from "../config/config.js"; + +export const DEFAULT_LIVE_IMAGE_MODELS: Record = { + google: "google/gemini-3.1-flash-image-preview", + openai: "openai/gpt-image-1", +}; + +export function parseCaseFilter(raw?: string): Set | null { + const trimmed = raw?.trim(); + if (!trimmed || trimmed === "all") { + return null; + } + const values = trimmed + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + return values.length > 0 ? new Set(values) : null; +} + +export function redactLiveApiKey(value: string | undefined): string { + const trimmed = value?.trim(); + if (!trimmed) { + return "none"; + } + if (trimmed.length <= 12) { + return trimmed; + } + return `${trimmed.slice(0, 8)}...${trimmed.slice(-4)}`; +} + +export function parseCsvFilter(raw?: string): Set | null { + const trimmed = raw?.trim(); + if (!trimmed || trimmed === "all") { + return null; + } + const values = trimmed + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + return values.length > 0 ? new Set(values) : null; +} + +export function parseProviderModelMap(raw?: string): Map { + const entries = new Map(); + for (const token of raw?.split(",") ?? []) { + const trimmed = token.trim(); + if (!trimmed) { + continue; + } + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash === trimmed.length - 1) { + continue; + } + entries.set(trimmed.slice(0, slash).trim().toLowerCase(), trimmed); + } + return entries; +} + +export function resolveConfiguredLiveImageModels(cfg: OpenClawConfig): Map { + const resolved = new Map(); + const configured = cfg.agents?.defaults?.imageGenerationModel; + const add = (value: string | undefined) => { + const trimmed = value?.trim(); + if (!trimmed) { + return; + } + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash === trimmed.length - 1) { + return; + } + resolved.set(trimmed.slice(0, slash).trim().toLowerCase(), trimmed); + }; + if (typeof configured === "string") { + add(configured); + return resolved; + } + add(configured?.primary); + for (const fallback of configured?.fallbacks ?? []) { + add(fallback); + } + return resolved; +} + +export function resolveLiveImageAuthStore(params: { + requireProfileKeys: boolean; + hasLiveKeys: boolean; +}): AuthProfileStore | undefined { + if (params.requireProfileKeys || !params.hasLiveKeys) { + return undefined; + } + return { + version: 1, + profiles: {}, + }; +} diff --git a/src/image-generation/providers/google.live.test.ts b/src/image-generation/providers/google.live.test.ts deleted file mode 100644 index dcf2ddd1108..00000000000 --- a/src/image-generation/providers/google.live.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { isTruthyEnvValue } from "../../infra/env.js"; -import { buildGoogleImageGenerationProvider } from "./google.js"; - -const LIVE = - isTruthyEnvValue(process.env.GOOGLE_LIVE_TEST) || - isTruthyEnvValue(process.env.LIVE) || - isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST); -const HAS_KEY = Boolean(process.env.GEMINI_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim()); -const MODEL = - process.env.GOOGLE_IMAGE_GENERATION_MODEL?.trim() || - process.env.GEMINI_IMAGE_GENERATION_MODEL?.trim() || - "gemini-3.1-flash-image-preview"; -const BASE_URL = process.env.GOOGLE_IMAGE_BASE_URL?.trim(); - -const describeLive = LIVE && HAS_KEY ? describe : describe.skip; - -function buildLiveConfig(): OpenClawConfig { - if (!BASE_URL) { - return {}; - } - return { - models: { - providers: { - google: { - baseUrl: BASE_URL, - }, - }, - }, - } as unknown as OpenClawConfig; -} - -describeLive("google image-generation live", () => { - it("generates a real image", async () => { - const provider = buildGoogleImageGenerationProvider(); - const result = await provider.generateImage({ - provider: "google", - model: MODEL, - prompt: - "Create a minimal flat illustration of an orange cat face sticker on a white background.", - cfg: buildLiveConfig(), - size: "1024x1024", - }); - - expect(result.model).toBeTruthy(); - expect(result.images.length).toBeGreaterThan(0); - expect(result.images[0]?.mimeType.startsWith("image/")).toBe(true); - expect(result.images[0]?.buffer.byteLength).toBeGreaterThan(512); - }, 120_000); -}); diff --git a/src/image-generation/providers/google.test.ts b/src/image-generation/providers/google.test.ts index 83f7e565a80..224779f3429 100644 --- a/src/image-generation/providers/google.test.ts +++ b/src/image-generation/providers/google.test.ts @@ -131,4 +131,78 @@ describe("Google image-generation provider", () => { model: "gemini-3.1-flash-image-preview", }); }); + + it("sends reference images and explicit resolution for edit flows", async () => { + vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-test-key", + source: "env", + mode: "api-key", + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [ + { + content: { + parts: [ + { + inlineData: { + mimeType: "image/png", + data: Buffer.from("png-data").toString("base64"), + }, + }, + ], + }, + }, + ], + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = buildGoogleImageGenerationProvider(); + await provider.generateImage({ + provider: "google", + model: "gemini-3-pro-image-preview", + prompt: "Change only the sky to a sunset.", + cfg: {}, + resolution: "4K", + inputImages: [ + { + buffer: Buffer.from("reference-bytes"), + mimeType: "image/png", + fileName: "reference.png", + }, + ], + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + contents: [ + { + role: "user", + parts: [ + { + inlineData: { + mimeType: "image/png", + data: Buffer.from("reference-bytes").toString("base64"), + }, + }, + { text: "Change only the sky to a sunset." }, + ], + }, + ], + generationConfig: { + responseModalities: ["TEXT", "IMAGE"], + imageConfig: { + aspectRatio: "1:1", + imageSize: "4K", + }, + }, + }), + }), + ); + }); }); diff --git a/src/image-generation/providers/google.ts b/src/image-generation/providers/google.ts index 0519aef7bc3..f7469b147fa 100644 --- a/src/image-generation/providers/google.ts +++ b/src/image-generation/providers/google.ts @@ -79,11 +79,16 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProviderPlu return { id: "google", label: "Google", + defaultModel: DEFAULT_GOOGLE_IMAGE_MODEL, + models: [DEFAULT_GOOGLE_IMAGE_MODEL, "gemini-3-pro-image-preview"], + supportedResolutions: ["1K", "2K", "4K"], + supportsImageEditing: true, async generateImage(req) { const auth = await resolveApiKeyForProvider({ provider: "google", cfg: req.cfg, agentDir: req.agentDir, + store: req.authStore, }); if (!auth.apiKey) { throw new Error("Google API key missing"); @@ -98,6 +103,16 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProviderPlu const authHeaders = parseGeminiAuth(auth.apiKey); const headers = new Headers(authHeaders.headers); const imageConfig = mapSizeToImageConfig(req.size); + const inputParts = (req.inputImages ?? []).map((image) => ({ + inlineData: { + mimeType: image.mimeType, + data: image.buffer.toString("base64"), + }, + })); + const resolvedImageConfig = { + ...imageConfig, + ...(req.resolution ? { imageSize: req.resolution } : {}), + }; const { response: res, release } = await postJsonRequest({ url: `${baseUrl}/models/${model}:generateContent`, @@ -106,12 +121,14 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProviderPlu contents: [ { role: "user", - parts: [{ text: req.prompt }], + parts: [...inputParts, { text: req.prompt }], }, ], generationConfig: { responseModalities: ["TEXT", "IMAGE"], - ...(imageConfig ? { imageConfig } : {}), + ...(Object.keys(resolvedImageConfig).length > 0 + ? { imageConfig: resolvedImageConfig } + : {}), }, }, timeoutMs: 60_000, diff --git a/src/image-generation/providers/openai.test.ts b/src/image-generation/providers/openai.test.ts index a55e6107d3b..a128d6c6e04 100644 --- a/src/image-generation/providers/openai.test.ts +++ b/src/image-generation/providers/openai.test.ts @@ -8,7 +8,7 @@ describe("OpenAI image-generation provider", () => { }); it("generates PNG buffers from the OpenAI Images API", async () => { - vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ + const resolveApiKeySpy = vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "sk-test", source: "env", mode: "api-key", @@ -27,17 +27,31 @@ describe("OpenAI image-generation provider", () => { vi.stubGlobal("fetch", fetchMock); const provider = buildOpenAIImageGenerationProvider(); + const authStore = { version: 1, profiles: {} }; const result = await provider.generateImage({ provider: "openai", model: "gpt-image-1", prompt: "draw a cat", cfg: {}, + authStore, }); + expect(resolveApiKeySpy).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + store: authStore, + }), + ); expect(fetchMock).toHaveBeenCalledWith( "https://api.openai.com/v1/images/generations", expect.objectContaining({ method: "POST", + body: JSON.stringify({ + model: "gpt-image-1", + prompt: "draw a cat", + n: 1, + size: "1024x1024", + }), }), ); expect(result).toEqual({ @@ -52,4 +66,18 @@ describe("OpenAI image-generation provider", () => { model: "gpt-image-1", }); }); + + it("rejects reference-image edits for now", async () => { + const provider = buildOpenAIImageGenerationProvider(); + + await expect( + provider.generateImage({ + provider: "openai", + model: "gpt-image-1", + prompt: "Edit this image", + cfg: {}, + inputImages: [{ buffer: Buffer.from("x"), mimeType: "image/png" }], + }), + ).rejects.toThrow("does not support reference-image edits"); + }); }); diff --git a/src/image-generation/providers/openai.ts b/src/image-generation/providers/openai.ts index 0c7788fb5d5..1a0afe1f67d 100644 --- a/src/image-generation/providers/openai.ts +++ b/src/image-generation/providers/openai.ts @@ -22,12 +22,18 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProviderPlu return { id: "openai", label: "OpenAI", + defaultModel: DEFAULT_OPENAI_IMAGE_MODEL, + models: [DEFAULT_OPENAI_IMAGE_MODEL], supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], async generateImage(req) { + if ((req.inputImages?.length ?? 0) > 0) { + throw new Error("OpenAI image generation provider does not support reference-image edits"); + } const auth = await resolveApiKeyForProvider({ provider: "openai", cfg: req.cfg, agentDir: req.agentDir, + store: req.authStore, }); if (!auth.apiKey) { throw new Error("OpenAI API key missing"); @@ -44,7 +50,6 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProviderPlu prompt: req.prompt, n: req.count ?? 1, size: req.size ?? DEFAULT_SIZE, - response_format: "b64_json", }), }); diff --git a/src/image-generation/runtime.live.test.ts b/src/image-generation/runtime.live.test.ts new file mode 100644 index 00000000000..f0132414a6c --- /dev/null +++ b/src/image-generation/runtime.live.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; +import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; +import { collectProviderApiKeys } from "../agents/live-auth-keys.js"; +import { resolveApiKeyForProvider } from "../agents/model-auth.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { loadConfig } from "../config/config.js"; +import { isTruthyEnvValue } from "../infra/env.js"; +import { getShellEnvAppliedKeys, loadShellEnvFallback } from "../infra/shell-env.js"; +import { encodePngRgba, fillPixel } from "../media/png-encode.js"; +import { + imageGenerationProviderContractRegistry, + providerContractRegistry, +} from "../plugins/contracts/registry.js"; +import { + DEFAULT_LIVE_IMAGE_MODELS, + parseCaseFilter, + parseCsvFilter, + parseProviderModelMap, + redactLiveApiKey, + resolveConfiguredLiveImageModels, + resolveLiveImageAuthStore, +} from "./live-test-helpers.js"; +import { generateImage } from "./runtime.js"; + +const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST); +const REQUIRE_PROFILE_KEYS = isTruthyEnvValue(process.env.OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS); +const describeLive = LIVE ? describe : describe.skip; + +type LiveImageCase = { + id: string; + providerId: string; + modelRef: string; + prompt: string; + size?: string; + resolution?: "1K" | "2K" | "4K"; + inputImages?: Array<{ buffer: Buffer; mimeType: string; fileName?: string }>; +}; + +function createEditReferencePng(): Buffer { + const width = 192; + const height = 192; + const buf = Buffer.alloc(width * height * 4, 255); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + fillPixel(buf, x, y, width, 245, 248, 255, 255); + } + } + + for (let y = 24; y < 168; y += 1) { + for (let x = 24; x < 168; x += 1) { + fillPixel(buf, x, y, width, 255, 189, 89, 255); + } + } + + for (let y = 48; y < 144; y += 1) { + for (let x = 48; x < 144; x += 1) { + fillPixel(buf, x, y, width, 41, 47, 54, 255); + } + } + + return encodePngRgba(buf, width, height); +} + +function withPluginsEnabled(cfg: OpenClawConfig): OpenClawConfig { + return { + ...cfg, + plugins: { + ...cfg.plugins, + enabled: true, + }, + }; +} + +function resolveProviderEnvVars(providerId: string): string[] { + const entry = providerContractRegistry.find((candidate) => candidate.provider.id === providerId); + return entry?.provider.envVars ?? []; +} + +function maybeLoadShellEnvForImageProviders(providerIds: string[]): void { + const expectedKeys = [ + ...new Set(providerIds.flatMap((providerId) => resolveProviderEnvVars(providerId))), + ]; + if (expectedKeys.length === 0) { + return; + } + loadShellEnvFallback({ + enabled: true, + env: process.env, + expectedKeys, + logger: { warn: (message: string) => console.warn(message) }, + }); +} + +async function resolveLiveAuthForProvider( + provider: string, + cfg: ReturnType, + agentDir: string, +) { + const authStore = resolveLiveImageAuthStore({ + requireProfileKeys: REQUIRE_PROFILE_KEYS, + hasLiveKeys: collectProviderApiKeys(provider).length > 0, + }); + try { + const auth = await resolveApiKeyForProvider({ provider, cfg, agentDir, store: authStore }); + return { auth, authStore }; + } catch { + return null; + } +} + +describeLive("image generation live (provider sweep)", () => { + it("generates images for every configured image-generation variant with available auth", async () => { + const cfg = withPluginsEnabled(loadConfig()); + const agentDir = resolveOpenClawAgentDir(); + const providerFilter = parseCsvFilter(process.env.OPENCLAW_LIVE_IMAGE_GENERATION_PROVIDERS); + const caseFilter = parseCaseFilter(process.env.OPENCLAW_LIVE_IMAGE_GENERATION_CASES); + const envModelMap = parseProviderModelMap(process.env.OPENCLAW_LIVE_IMAGE_GENERATION_MODELS); + const configuredModels = resolveConfiguredLiveImageModels(cfg); + const availableProviders = imageGenerationProviderContractRegistry + .map((entry) => entry.provider.id) + .toSorted((left, right) => left.localeCompare(right)) + .filter((providerId) => (providerFilter ? providerFilter.has(providerId) : true)); + const liveCases: LiveImageCase[] = []; + + if (availableProviders.includes("google")) { + liveCases.push( + { + id: "google:flash-generate", + providerId: "google", + modelRef: + envModelMap.get("google") ?? + configuredModels.get("google") ?? + DEFAULT_LIVE_IMAGE_MODELS.google, + prompt: + "Create a minimal flat illustration of an orange cat face sticker on a white background.", + size: "1024x1024", + }, + { + id: "google:pro-generate", + providerId: "google", + modelRef: "google/gemini-3-pro-image-preview", + prompt: + "Create a minimal flat illustration of an orange cat face sticker on a white background.", + size: "1024x1024", + }, + { + id: "google:pro-edit", + providerId: "google", + modelRef: "google/gemini-3-pro-image-preview", + prompt: + "Change ONLY the background to a pale blue gradient. Keep the subject, framing, and style identical.", + resolution: "2K", + inputImages: [ + { + buffer: createEditReferencePng(), + mimeType: "image/png", + fileName: "reference.png", + }, + ], + }, + ); + } + if (availableProviders.includes("openai")) { + liveCases.push({ + id: "openai:default-generate", + providerId: "openai", + modelRef: + envModelMap.get("openai") ?? + configuredModels.get("openai") ?? + DEFAULT_LIVE_IMAGE_MODELS.openai, + prompt: + "Create a minimal flat illustration of an orange cat face sticker on a white background.", + size: "1024x1024", + }); + } + + const selectedCases = liveCases.filter((entry) => + caseFilter ? caseFilter.has(entry.id.toLowerCase()) : true, + ); + + maybeLoadShellEnvForImageProviders(availableProviders); + + const attempted: string[] = []; + const skipped: string[] = []; + const failures: string[] = []; + + for (const testCase of selectedCases) { + if (!testCase.modelRef) { + skipped.push(`${testCase.id}: no model configured`); + continue; + } + const resolvedAuth = await resolveLiveAuthForProvider(testCase.providerId, cfg, agentDir); + if (!resolvedAuth) { + skipped.push(`${testCase.id}: no auth`); + continue; + } + + try { + const result = await generateImage({ + cfg, + agentDir, + authStore: resolvedAuth.authStore, + modelOverride: testCase.modelRef, + prompt: testCase.prompt, + size: testCase.size, + resolution: testCase.resolution, + inputImages: testCase.inputImages, + }); + + attempted.push( + `${testCase.id}:${result.model} (${resolvedAuth.auth.source} ${redactLiveApiKey(resolvedAuth.auth.apiKey)})`, + ); + expect(result.provider).toBe(testCase.providerId); + expect(result.images.length).toBeGreaterThan(0); + expect(result.images[0]?.mimeType.startsWith("image/")).toBe(true); + expect(result.images[0]?.buffer.byteLength).toBeGreaterThan(512); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push( + `${testCase.id} (${resolvedAuth.auth.source} ${redactLiveApiKey(resolvedAuth.auth.apiKey)}): ${message}`, + ); + } + } + + console.log( + `[live:image-generation] attempted=${attempted.join(", ") || "none"} skipped=${skipped.join(", ") || "none"} failures=${failures.join(" | ") || "none"} shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`, + ); + + if (attempted.length === 0) { + console.warn("[live:image-generation] no provider had usable auth; skipping assertions"); + return; + } + expect(failures).toEqual([]); + expect(attempted.length).toBeGreaterThan(0); + }, 180_000); +}); diff --git a/src/image-generation/runtime.test.ts b/src/image-generation/runtime.test.ts index 4ef478b3349..b044c899c60 100644 --- a/src/image-generation/runtime.test.ts +++ b/src/image-generation/runtime.test.ts @@ -11,13 +11,16 @@ describe("image-generation runtime helpers", () => { it("generates images through the active image-generation registry", async () => { const pluginRegistry = createEmptyPluginRegistry(); + const authStore = { version: 1, profiles: {} } as const; + let seenAuthStore: unknown; pluginRegistry.imageGenerationProviders.push({ pluginId: "image-plugin", pluginName: "Image Plugin", source: "test", provider: { id: "image-plugin", - async generateImage() { + async generateImage(req) { + seenAuthStore = req.authStore; return { images: [ { @@ -47,11 +50,13 @@ describe("image-generation runtime helpers", () => { cfg, prompt: "draw a cat", agentDir: "/tmp/agent", + authStore, }); expect(result.provider).toBe("image-plugin"); expect(result.model).toBe("img-v1"); expect(result.attempts).toEqual([]); + expect(seenAuthStore).toEqual(authStore); expect(result.images).toEqual([ { buffer: Buffer.from("png-bytes"), @@ -69,6 +74,9 @@ describe("image-generation runtime helpers", () => { source: "test", provider: { id: "image-plugin", + defaultModel: "img-v1", + models: ["img-v1", "img-v2"], + supportedResolutions: ["1K", "2K"], generateImage: async () => ({ images: [{ buffer: Buffer.from("x"), mimeType: "image/png" }], }), @@ -76,6 +84,13 @@ describe("image-generation runtime helpers", () => { }); setActivePluginRegistry(pluginRegistry); - expect(listRuntimeImageGenerationProviders()).toMatchObject([{ id: "image-plugin" }]); + expect(listRuntimeImageGenerationProviders()).toMatchObject([ + { + id: "image-plugin", + defaultModel: "img-v1", + models: ["img-v1", "img-v2"], + supportedResolutions: ["1K", "2K"], + }, + ]); }); }); diff --git a/src/image-generation/runtime.ts b/src/image-generation/runtime.ts index 8c9104edd5d..f25048cd0b1 100644 --- a/src/image-generation/runtime.ts +++ b/src/image-generation/runtime.ts @@ -1,3 +1,4 @@ +import type { AuthProfileStore } from "../agents/auth-profiles.js"; import { describeFailoverError, isFailoverError } from "../agents/failover-error.js"; import type { FallbackAttempt } from "../agents/model-fallback.types.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -7,7 +8,12 @@ import { } from "../config/model-input.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getImageGenerationProvider, listImageGenerationProviders } from "./provider-registry.js"; -import type { GeneratedImageAsset, ImageGenerationResult } from "./types.js"; +import type { + GeneratedImageAsset, + ImageGenerationResolution, + ImageGenerationResult, + ImageGenerationSourceImage, +} from "./types.js"; const log = createSubsystemLogger("image-generation"); @@ -15,9 +21,12 @@ export type GenerateImageParams = { cfg: OpenClawConfig; prompt: string; agentDir?: string; + authStore?: AuthProfileStore; modelOverride?: string; count?: number; size?: string; + resolution?: ImageGenerationResolution; + inputImages?: ImageGenerationSourceImage[]; }; export type GenerateImageRuntimeResult = { @@ -130,8 +139,11 @@ export async function generateImage( prompt: params.prompt, cfg: params.cfg, agentDir: params.agentDir, + authStore: params.authStore, count: params.count, size: params.size, + resolution: params.resolution, + inputImages: params.inputImages, }); if (!Array.isArray(result.images) || result.images.length === 0) { throw new Error("Image generation provider returned no images."); diff --git a/src/image-generation/types.ts b/src/image-generation/types.ts index ff33d6079ee..7ea530ac2b9 100644 --- a/src/image-generation/types.ts +++ b/src/image-generation/types.ts @@ -1,3 +1,4 @@ +import type { AuthProfileStore } from "../agents/auth-profiles.js"; import type { OpenClawConfig } from "../config/config.js"; export type GeneratedImageAsset = { @@ -8,14 +9,26 @@ export type GeneratedImageAsset = { metadata?: Record; }; +export type ImageGenerationResolution = "1K" | "2K" | "4K"; + +export type ImageGenerationSourceImage = { + buffer: Buffer; + mimeType: string; + fileName?: string; + metadata?: Record; +}; + export type ImageGenerationRequest = { provider: string; model: string; prompt: string; cfg: OpenClawConfig; agentDir?: string; + authStore?: AuthProfileStore; count?: number; size?: string; + resolution?: ImageGenerationResolution; + inputImages?: ImageGenerationSourceImage[]; }; export type ImageGenerationResult = { @@ -28,6 +41,10 @@ export type ImageGenerationProvider = { id: string; aliases?: string[]; label?: string; + defaultModel?: string; + models?: string[]; supportedSizes?: string[]; + supportedResolutions?: ImageGenerationResolution[]; + supportsImageEditing?: boolean; generateImage: (req: ImageGenerationRequest) => Promise; }; diff --git a/src/infra/boundary-file-read.test.ts b/src/infra/boundary-file-read.test.ts index 2dceb0cb06a..8829fec80b8 100644 --- a/src/infra/boundary-file-read.test.ts +++ b/src/infra/boundary-file-read.test.ts @@ -14,11 +14,15 @@ vi.mock("./safe-open-sync.js", () => ({ openVerifiedFileSync: (...args: unknown[]) => openVerifiedFileSyncMock(...args), })); -const { canUseBoundaryFileOpen, openBoundaryFile, openBoundaryFileSync } = - await import("./boundary-file-read.js"); +let canUseBoundaryFileOpen: typeof import("./boundary-file-read.js").canUseBoundaryFileOpen; +let openBoundaryFile: typeof import("./boundary-file-read.js").openBoundaryFile; +let openBoundaryFileSync: typeof import("./boundary-file-read.js").openBoundaryFileSync; describe("boundary-file-read", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ canUseBoundaryFileOpen, openBoundaryFile, openBoundaryFileSync } = + await import("./boundary-file-read.js")); resolveBoundaryPathSyncMock.mockReset(); resolveBoundaryPathMock.mockReset(); openVerifiedFileSyncMock.mockReset(); diff --git a/src/infra/channel-summary.test.ts b/src/infra/channel-summary.test.ts index c0fc17ba255..12cfa8bbbae 100644 --- a/src/infra/channel-summary.test.ts +++ b/src/infra/channel-summary.test.ts @@ -1,12 +1,18 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; vi.mock("../channels/plugins/index.js", () => ({ listChannelPlugins: vi.fn(), })); -const { buildChannelSummary } = await import("./channel-summary.js"); -const { listChannelPlugins } = await import("../channels/plugins/index.js"); +let buildChannelSummary: typeof import("./channel-summary.js").buildChannelSummary; +let listChannelPlugins: typeof import("../channels/plugins/index.js").listChannelPlugins; + +beforeEach(async () => { + vi.resetModules(); + ({ buildChannelSummary } = await import("./channel-summary.js")); + ({ listChannelPlugins } = await import("../channels/plugins/index.js")); +}); function makeSlackHttpSummaryPlugin(): ChannelPlugin { return { diff --git a/src/infra/env.test.ts b/src/infra/env.test.ts index 5ee0af072fb..7cfac44bb86 100644 --- a/src/infra/env.test.ts +++ b/src/infra/env.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { withEnv } from "../test-utils/env.js"; const loggerMocks = vi.hoisted(() => ({ @@ -11,7 +11,18 @@ vi.mock("../logging/subsystem.js", () => ({ }), })); -import { isTruthyEnvValue, logAcceptedEnvOption, normalizeEnv, normalizeZaiEnv } from "./env.js"; +type EnvModule = typeof import("./env.js"); + +let isTruthyEnvValue: EnvModule["isTruthyEnvValue"]; +let logAcceptedEnvOption: EnvModule["logAcceptedEnvOption"]; +let normalizeEnv: EnvModule["normalizeEnv"]; +let normalizeZaiEnv: EnvModule["normalizeZaiEnv"]; + +beforeEach(async () => { + vi.resetModules(); + ({ isTruthyEnvValue, logAcceptedEnvOption, normalizeEnv, normalizeZaiEnv } = + await import("./env.js")); +}); describe("normalizeZaiEnv", () => { it("copies Z_AI_API_KEY to ZAI_API_KEY when missing", () => { diff --git a/src/infra/exec-approval-surface.test.ts b/src/infra/exec-approval-surface.test.ts index c4b959c5042..3e59d968670 100644 --- a/src/infra/exec-approval-surface.test.ts +++ b/src/infra/exec-approval-surface.test.ts @@ -5,51 +5,14 @@ const getChannelPluginMock = vi.hoisted(() => vi.fn()); const listChannelPluginsMock = vi.hoisted(() => vi.fn()); const normalizeMessageChannelMock = vi.hoisted(() => vi.fn()); -vi.mock("../config/config.js", () => ({ - loadConfig: (...args: unknown[]) => loadConfigMock(...args), -})); +type ExecApprovalSurfaceModule = typeof import("./exec-approval-surface.js"); -vi.mock("../channels/plugins/index.js", () => ({ - getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), - listChannelPlugins: (...args: unknown[]) => listChannelPluginsMock(...args), -})); - -vi.mock("../../extensions/discord/src/channel.js", () => ({ - discordPlugin: {}, -})); - -vi.mock("../../extensions/telegram/src/channel.js", () => ({ - telegramPlugin: {}, -})); - -vi.mock("../../extensions/slack/src/channel.js", () => ({ - slackPlugin: {}, -})); - -vi.mock("../../extensions/whatsapp/src/channel.js", () => ({ - whatsappPlugin: {}, -})); - -vi.mock("../../extensions/signal/src/channel.js", () => ({ - signalPlugin: {}, -})); - -vi.mock("../../extensions/imessage/src/channel.js", () => ({ - imessagePlugin: {}, -})); - -vi.mock("../utils/message-channel.js", () => ({ - INTERNAL_MESSAGE_CHANNEL: "web", - normalizeMessageChannel: (...args: unknown[]) => normalizeMessageChannelMock(...args), -})); - -import { - hasConfiguredExecApprovalDmRoute, - resolveExecApprovalInitiatingSurfaceState, -} from "./exec-approval-surface.js"; +let hasConfiguredExecApprovalDmRoute: ExecApprovalSurfaceModule["hasConfiguredExecApprovalDmRoute"]; +let resolveExecApprovalInitiatingSurfaceState: ExecApprovalSurfaceModule["resolveExecApprovalInitiatingSurfaceState"]; describe("resolveExecApprovalInitiatingSurfaceState", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); loadConfigMock.mockReset(); getChannelPluginMock.mockReset(); listChannelPluginsMock.mockReset(); @@ -57,6 +20,37 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => { normalizeMessageChannelMock.mockImplementation((value?: string | null) => typeof value === "string" ? value.trim().toLowerCase() : undefined, ); + vi.doMock("../config/config.js", () => ({ + loadConfig: (...args: unknown[]) => loadConfigMock(...args), + })); + vi.doMock("../channels/plugins/index.js", () => ({ + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), + listChannelPlugins: (...args: unknown[]) => listChannelPluginsMock(...args), + })); + vi.doMock("../../extensions/discord/src/channel.js", () => ({ + discordPlugin: {}, + })); + vi.doMock("../../extensions/telegram/src/channel.js", () => ({ + telegramPlugin: {}, + })); + vi.doMock("../../extensions/slack/src/channel.js", () => ({ + slackPlugin: {}, + })); + vi.doMock("../../extensions/whatsapp/src/channel.js", () => ({ + whatsappPlugin: {}, + })); + vi.doMock("../../extensions/signal/src/channel.js", () => ({ + signalPlugin: {}, + })); + vi.doMock("../../extensions/imessage/src/channel.js", () => ({ + imessagePlugin: {}, + })); + vi.doMock("../utils/message-channel.js", () => ({ + INTERNAL_MESSAGE_CHANNEL: "web", + normalizeMessageChannel: (...args: unknown[]) => normalizeMessageChannelMock(...args), + })); + ({ hasConfiguredExecApprovalDmRoute, resolveExecApprovalInitiatingSurfaceState } = + await import("./exec-approval-surface.js")); }); it("treats web UI, terminal UI, and missing channels as enabled", () => { @@ -154,8 +148,46 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => { }); describe("hasConfiguredExecApprovalDmRoute", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + loadConfigMock.mockReset(); + getChannelPluginMock.mockReset(); listChannelPluginsMock.mockReset(); + normalizeMessageChannelMock.mockReset(); + normalizeMessageChannelMock.mockImplementation((value?: string | null) => + typeof value === "string" ? value.trim().toLowerCase() : undefined, + ); + vi.doMock("../config/config.js", () => ({ + loadConfig: (...args: unknown[]) => loadConfigMock(...args), + })); + vi.doMock("../channels/plugins/index.js", () => ({ + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), + listChannelPlugins: (...args: unknown[]) => listChannelPluginsMock(...args), + })); + vi.doMock("../../extensions/discord/src/channel.js", () => ({ + discordPlugin: {}, + })); + vi.doMock("../../extensions/telegram/src/channel.js", () => ({ + telegramPlugin: {}, + })); + vi.doMock("../../extensions/slack/src/channel.js", () => ({ + slackPlugin: {}, + })); + vi.doMock("../../extensions/whatsapp/src/channel.js", () => ({ + whatsappPlugin: {}, + })); + vi.doMock("../../extensions/signal/src/channel.js", () => ({ + signalPlugin: {}, + })); + vi.doMock("../../extensions/imessage/src/channel.js", () => ({ + imessagePlugin: {}, + })); + vi.doMock("../utils/message-channel.js", () => ({ + INTERNAL_MESSAGE_CHANNEL: "web", + normalizeMessageChannel: (...args: unknown[]) => normalizeMessageChannelMock(...args), + })); + ({ hasConfiguredExecApprovalDmRoute, resolveExecApprovalInitiatingSurfaceState } = + await import("./exec-approval-surface.js")); }); it("returns true when any enabled account routes approvals to DM or both", () => { diff --git a/src/infra/exec-approvals-store.test.ts b/src/infra/exec-approvals-store.test.ts index 4dc6ab71c7e..365e40b1f1d 100644 --- a/src/infra/exec-approvals-store.test.ts +++ b/src/infra/exec-approvals-store.test.ts @@ -9,23 +9,36 @@ vi.mock("./jsonl-socket.js", () => ({ requestJsonlSocket: (...args: unknown[]) => requestJsonlSocketMock(...args), })); -import { - addAllowlistEntry, - ensureExecApprovals, - mergeExecApprovalsSocketDefaults, - normalizeExecApprovals, - readExecApprovalsSnapshot, - recordAllowlistUse, - requestExecApprovalViaSocket, - resolveExecApprovalsPath, - resolveExecApprovalsSocketPath, - type ExecApprovalsFile, -} from "./exec-approvals.js"; +import type { ExecApprovalsFile } from "./exec-approvals.js"; + +type ExecApprovalsModule = typeof import("./exec-approvals.js"); + +let addAllowlistEntry: ExecApprovalsModule["addAllowlistEntry"]; +let ensureExecApprovals: ExecApprovalsModule["ensureExecApprovals"]; +let mergeExecApprovalsSocketDefaults: ExecApprovalsModule["mergeExecApprovalsSocketDefaults"]; +let normalizeExecApprovals: ExecApprovalsModule["normalizeExecApprovals"]; +let readExecApprovalsSnapshot: ExecApprovalsModule["readExecApprovalsSnapshot"]; +let recordAllowlistUse: ExecApprovalsModule["recordAllowlistUse"]; +let requestExecApprovalViaSocket: ExecApprovalsModule["requestExecApprovalViaSocket"]; +let resolveExecApprovalsPath: ExecApprovalsModule["resolveExecApprovalsPath"]; +let resolveExecApprovalsSocketPath: ExecApprovalsModule["resolveExecApprovalsSocketPath"]; const tempDirs: string[] = []; const originalOpenClawHome = process.env.OPENCLAW_HOME; -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); + ({ + addAllowlistEntry, + ensureExecApprovals, + mergeExecApprovalsSocketDefaults, + normalizeExecApprovals, + readExecApprovalsSnapshot, + recordAllowlistUse, + requestExecApprovalViaSocket, + resolveExecApprovalsPath, + resolveExecApprovalsSocketPath, + } = await import("./exec-approvals.js")); requestJsonlSocketMock.mockReset(); }); 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.model-override.test.ts b/src/infra/heartbeat-runner.model-override.test.ts index f33e5e9fbd0..92c89e0b026 100644 --- a/src/infra/heartbeat-runner.model-override.test.ts +++ b/src/infra/heartbeat-runner.model-override.test.ts @@ -61,6 +61,34 @@ afterEach(() => { }); describe("runHeartbeatOnce – heartbeat model override", () => { + async function runHeartbeatWithSeed(params: { + seedSession: (sessionKey: string, input: SeedSessionInput) => Promise; + cfg: OpenClawConfig; + sessionKey: string; + agentId?: string; + }) { + await params.seedSession(params.sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" }); + + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); + + await runHeartbeatOnce({ + cfg: params.cfg, + agentId: params.agentId, + deps: { + getQueueSize: () => 0, + nowMs: () => 0, + }, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + return { + ctx: replySpy.mock.calls[0]?.[0], + opts: replySpy.mock.calls[0]?.[1], + replySpy, + }; + } + async function runDefaultsHeartbeat(params: { model?: string; suppressToolErrorWarnings?: boolean; @@ -86,21 +114,12 @@ describe("runHeartbeatOnce – heartbeat model override", () => { session: { store: storePath }, }; const sessionKey = resolveMainSessionKey(cfg); - await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" }); - - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); - replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); - - await runHeartbeatOnce({ + const result = await runHeartbeatWithSeed({ + seedSession, cfg, - deps: { - getQueueSize: () => 0, - nowMs: () => 0, - }, + sessionKey, }); - - expect(replySpy).toHaveBeenCalledTimes(1); - return replySpy.mock.calls[0]?.[1]; + return result.opts; }); } @@ -152,20 +171,14 @@ describe("runHeartbeatOnce – heartbeat model override", () => { session: { store: storePath }, }; const sessionKey = resolveMainSessionKey(cfg); - await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" }); - - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); - replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); - - await runHeartbeatOnce({ + const result = await runHeartbeatWithSeed({ + seedSession, cfg, - deps: { getQueueSize: () => 0, nowMs: () => 0 }, + sessionKey, }); - expect(replySpy).toHaveBeenCalledTimes(1); - const ctx = replySpy.mock.calls[0]?.[0]; // Isolated heartbeat runs use a dedicated session key with :heartbeat suffix - expect(ctx.SessionKey).toBe(`${sessionKey}:heartbeat`); + expect(result.ctx?.SessionKey).toBe(`${sessionKey}:heartbeat`); }); }); @@ -185,19 +198,13 @@ describe("runHeartbeatOnce – heartbeat model override", () => { session: { store: storePath }, }; const sessionKey = resolveMainSessionKey(cfg); - await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" }); - - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); - replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); - - await runHeartbeatOnce({ + const result = await runHeartbeatWithSeed({ + seedSession, cfg, - deps: { getQueueSize: () => 0, nowMs: () => 0 }, + sessionKey, }); - expect(replySpy).toHaveBeenCalledTimes(1); - const ctx = replySpy.mock.calls[0]?.[0]; - expect(ctx.SessionKey).toBe(sessionKey); + expect(result.ctx?.SessionKey).toBe(sessionKey); }); }); @@ -228,21 +235,14 @@ describe("runHeartbeatOnce – heartbeat model override", () => { session: { store: storePath }, }; const sessionKey = resolveAgentMainSessionKey({ cfg, agentId: "ops" }); - await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" }); - - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); - replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); - - await runHeartbeatOnce({ + const result = await runHeartbeatWithSeed({ + seedSession, cfg, agentId: "ops", - deps: { - getQueueSize: () => 0, - nowMs: () => 0, - }, + sessionKey, }); - expect(replySpy).toHaveBeenCalledWith( + expect(result.replySpy).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ isHeartbeat: true, 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 ({ fetch: undiciFetch, })); -import { - getProxyUrlFromFetch, - makeProxyFetch, - PROXY_FETCH_PROXY_URL, - resolveProxyFetchFromEnv, -} from "./proxy-fetch.js"; +let getProxyUrlFromFetch: typeof import("./proxy-fetch.js").getProxyUrlFromFetch; +let makeProxyFetch: typeof import("./proxy-fetch.js").makeProxyFetch; +let PROXY_FETCH_PROXY_URL: typeof import("./proxy-fetch.js").PROXY_FETCH_PROXY_URL; +let resolveProxyFetchFromEnv: typeof import("./proxy-fetch.js").resolveProxyFetchFromEnv; function clearProxyEnv(): void { for (const key of PROXY_ENV_KEYS) { @@ -75,7 +73,12 @@ function restoreProxyEnv(): void { } describe("makeProxyFetch", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + ({ getProxyUrlFromFetch, makeProxyFetch, PROXY_FETCH_PROXY_URL, resolveProxyFetchFromEnv } = + await import("./proxy-fetch.js")); + }); it("uses undici fetch with ProxyAgent dispatcher", async () => { const proxyUrl = "http://proxy.test:8080"; diff --git a/src/infra/net/ssrf.dispatcher.test.ts b/src/infra/net/ssrf.dispatcher.test.ts index 07b80b40465..af6fc8ae5e8 100644 --- a/src/infra/net/ssrf.dispatcher.test.ts +++ b/src/infra/net/ssrf.dispatcher.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const { agentCtor, envHttpProxyAgentCtor, proxyAgentCtor } = vi.hoisted(() => ({ agentCtor: vi.fn(function MockAgent(this: { options: unknown }, options: unknown) { @@ -21,7 +21,14 @@ vi.mock("undici", () => ({ ProxyAgent: proxyAgentCtor, })); -import { createPinnedDispatcher, type PinnedHostname } from "./ssrf.js"; +import type { PinnedHostname } from "./ssrf.js"; + +let createPinnedDispatcher: typeof import("./ssrf.js").createPinnedDispatcher; + +beforeEach(async () => { + vi.resetModules(); + ({ createPinnedDispatcher } = await import("./ssrf.js")); +}); describe("createPinnedDispatcher", () => { it("uses pinned lookup without overriding global family policy", () => { @@ -73,6 +80,58 @@ describe("createPinnedDispatcher", () => { }); }); + 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/net/undici-global-dispatcher.test.ts b/src/infra/net/undici-global-dispatcher.test.ts index 8b14c4084fc..47a97dd6fb6 100644 --- a/src/infra/net/undici-global-dispatcher.test.ts +++ b/src/infra/net/undici-global-dispatcher.test.ts @@ -62,15 +62,20 @@ vi.mock("./proxy-env.js", () => ({ })); import { hasEnvHttpProxyConfigured } from "./proxy-env.js"; -import { - DEFAULT_UNDICI_STREAM_TIMEOUT_MS, - ensureGlobalUndiciEnvProxyDispatcher, - ensureGlobalUndiciStreamTimeouts, - resetGlobalUndiciStreamTimeoutsForTests, -} from "./undici-global-dispatcher.js"; +let DEFAULT_UNDICI_STREAM_TIMEOUT_MS: typeof import("./undici-global-dispatcher.js").DEFAULT_UNDICI_STREAM_TIMEOUT_MS; +let ensureGlobalUndiciEnvProxyDispatcher: typeof import("./undici-global-dispatcher.js").ensureGlobalUndiciEnvProxyDispatcher; +let ensureGlobalUndiciStreamTimeouts: typeof import("./undici-global-dispatcher.js").ensureGlobalUndiciStreamTimeouts; +let resetGlobalUndiciStreamTimeoutsForTests: typeof import("./undici-global-dispatcher.js").resetGlobalUndiciStreamTimeoutsForTests; describe("ensureGlobalUndiciStreamTimeouts", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ + DEFAULT_UNDICI_STREAM_TIMEOUT_MS, + ensureGlobalUndiciEnvProxyDispatcher, + ensureGlobalUndiciStreamTimeouts, + resetGlobalUndiciStreamTimeoutsForTests, + } = await import("./undici-global-dispatcher.js")); vi.clearAllMocks(); resetGlobalUndiciStreamTimeoutsForTests(); setCurrentDispatcher(new Agent()); diff --git a/src/infra/openclaw-root.test.ts b/src/infra/openclaw-root.test.ts index e12b2d77f64..291280318bb 100644 --- a/src/infra/openclaw-root.test.ts +++ b/src/infra/openclaw-root.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" }; @@ -93,12 +93,10 @@ describe("resolveOpenClawPackageRoot", () => { let resolveOpenClawPackageRoot: typeof import("./openclaw-root.js").resolveOpenClawPackageRoot; let resolveOpenClawPackageRootSync: typeof import("./openclaw-root.js").resolveOpenClawPackageRootSync; - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); ({ resolveOpenClawPackageRoot, resolveOpenClawPackageRootSync } = await import("./openclaw-root.js")); - }); - - beforeEach(() => { state.entries.clear(); state.realpaths.clear(); state.realpathErrors.clear(); diff --git a/src/infra/outbound/agent-delivery.test.ts b/src/infra/outbound/agent-delivery.test.ts index b137ce2a73f..88b6776105e 100644 --- a/src/infra/outbound/agent-delivery.test.ts +++ b/src/infra/outbound/agent-delivery.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ resolveOutboundTarget: vi.fn(() => ({ ok: true as const, to: "+1999" })), @@ -13,7 +13,15 @@ vi.mock("./targets.js", async () => { }); import type { OpenClawConfig } from "../../config/config.js"; -import { resolveAgentDeliveryPlan, resolveAgentOutboundTarget } from "./agent-delivery.js"; +type AgentDeliveryModule = typeof import("./agent-delivery.js"); + +let resolveAgentDeliveryPlan: AgentDeliveryModule["resolveAgentDeliveryPlan"]; +let resolveAgentOutboundTarget: AgentDeliveryModule["resolveAgentOutboundTarget"]; + +beforeEach(async () => { + vi.resetModules(); + ({ resolveAgentDeliveryPlan, resolveAgentOutboundTarget } = await import("./agent-delivery.js")); +}); describe("agent delivery helpers", () => { it("builds a delivery plan from session delivery context", () => { diff --git a/src/infra/outbound/cfg-threading.guard.test.ts b/src/infra/outbound/cfg-threading.guard.test.ts index 3fdbb68e10b..cfdbc892db4 100644 --- a/src/infra/outbound/cfg-threading.guard.test.ts +++ b/src/infra/outbound/cfg-threading.guard.test.ts @@ -61,7 +61,7 @@ function listExtensionFiles(): { function listHighRiskRuntimeCfgFiles(): string[] { return [ - "src/agents/tools/telegram-actions.ts", + "extensions/telegram/src/action-runtime.ts", "extensions/discord/src/monitor/reply-delivery.ts", "extensions/discord/src/monitor/thread-bindings.discord-api.ts", "extensions/discord/src/monitor/thread-bindings.manager.ts", diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index 5f3ac319628..fdb4ecd4b6f 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { defaultRuntime } from "../../runtime.js"; const mocks = vi.hoisted(() => ({ listChannelPlugins: vi.fn(), @@ -14,11 +13,20 @@ vi.mock("./channel-resolution.js", () => ({ resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin, })); -import { - __testing, - listConfiguredMessageChannels, - resolveMessageChannelSelection, -} from "./channel-selection.js"; +type ChannelSelectionModule = typeof import("./channel-selection.js"); +type RuntimeModule = typeof import("../../runtime.js"); + +let __testing: ChannelSelectionModule["__testing"]; +let listConfiguredMessageChannels: ChannelSelectionModule["listConfiguredMessageChannels"]; +let resolveMessageChannelSelection: ChannelSelectionModule["resolveMessageChannelSelection"]; +let runtimeModule: RuntimeModule; + +beforeEach(async () => { + vi.resetModules(); + runtimeModule = await import("../../runtime.js"); + ({ __testing, listConfiguredMessageChannels, resolveMessageChannelSelection } = + await import("./channel-selection.js")); +}); function makePlugin(params: { id: string; @@ -40,9 +48,10 @@ function makePlugin(params: { } describe("listConfiguredMessageChannels", () => { - const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); + let errorSpy: ReturnType; beforeEach(() => { + errorSpy = vi.spyOn(runtimeModule.defaultRuntime, "error").mockImplementation(() => undefined); mocks.listChannelPlugins.mockReset(); mocks.listChannelPlugins.mockReturnValue([]); mocks.resolveOutboundChannelPlugin.mockReset(); diff --git a/src/infra/outbound/deliver.lifecycle.test.ts b/src/infra/outbound/deliver.lifecycle.test.ts index 22fa829812e..c8ce22b826b 100644 --- a/src/infra/outbound/deliver.lifecycle.test.ts +++ b/src/infra/outbound/deliver.lifecycle.test.ts @@ -15,10 +15,12 @@ import { whatsappChunkConfig, } from "./deliver.test-helpers.js"; -const { deliverOutboundPayloads } = await import("./deliver.js"); +type DeliverModule = typeof import("./deliver.js"); + +let deliverOutboundPayloads: DeliverModule["deliverOutboundPayloads"]; async function runChunkedWhatsAppDelivery(params?: { - mirror?: Parameters[0]["mirror"]; + mirror?: Parameters[0]["mirror"]; }) { return await runChunkedWhatsAppDeliveryHelper({ deliverOutboundPayloads, @@ -75,7 +77,9 @@ function expectSuccessfulWhatsAppInternalHookPayload( } describe("deliverOutboundPayloads lifecycle", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ deliverOutboundPayloads } = await import("./deliver.js")); resetDeliverTestState(); resetDeliverTestMocks({ includeSessionMocks: true }); }); diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 5323dd83e27..e72cbaa0bee 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -80,7 +80,10 @@ vi.mock("../../logging/subsystem.js", () => ({ }, })); -const { deliverOutboundPayloads, normalizeOutboundPayloads } = await import("./deliver.js"); +type DeliverModule = typeof import("./deliver.js"); + +let deliverOutboundPayloads: DeliverModule["deliverOutboundPayloads"]; +let normalizeOutboundPayloads: DeliverModule["normalizeOutboundPayloads"]; const telegramChunkConfig: OpenClawConfig = { channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } }, @@ -90,13 +93,13 @@ const whatsappChunkConfig: OpenClawConfig = { channels: { whatsapp: { textChunkLimit: 4000 } }, }; -type DeliverOutboundArgs = Parameters[0]; +type DeliverOutboundArgs = Parameters[0]; type DeliverOutboundPayload = DeliverOutboundArgs["payloads"][number]; type DeliverSession = DeliverOutboundArgs["session"]; async function deliverWhatsAppPayload(params: { sendWhatsApp: NonNullable< - NonNullable[0]["deps"]>["sendWhatsApp"] + NonNullable[0]["deps"]>["sendWhatsApp"] >; payload: DeliverOutboundPayload; cfg?: OpenClawConfig; @@ -198,7 +201,9 @@ function expectSuccessfulWhatsAppInternalHookPayload( } describe("deliverOutboundPayloads", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ deliverOutboundPayloads, normalizeOutboundPayloads } = await import("./deliver.js")); setActivePluginRegistry(defaultRegistry); mocks.appendAssistantMessageToSessionTranscript.mockClear(); hookMocks.runner.hasHooks.mockClear(); diff --git a/src/infra/outbound/identity.test.ts b/src/infra/outbound/identity.test.ts index d31d8a6dd06..e5a8ea6a808 100644 --- a/src/infra/outbound/identity.test.ts +++ b/src/infra/outbound/identity.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const resolveAgentIdentityMock = vi.hoisted(() => vi.fn()); const resolveAgentAvatarMock = vi.hoisted(() => vi.fn()); @@ -11,7 +11,15 @@ vi.mock("../../agents/identity-avatar.js", () => ({ resolveAgentAvatar: (...args: unknown[]) => resolveAgentAvatarMock(...args), })); -import { normalizeOutboundIdentity, resolveAgentOutboundIdentity } from "./identity.js"; +type IdentityModule = typeof import("./identity.js"); + +let normalizeOutboundIdentity: IdentityModule["normalizeOutboundIdentity"]; +let resolveAgentOutboundIdentity: IdentityModule["resolveAgentOutboundIdentity"]; + +beforeEach(async () => { + vi.resetModules(); + ({ normalizeOutboundIdentity, resolveAgentOutboundIdentity } = await import("./identity.js")); +}); describe("normalizeOutboundIdentity", () => { it("trims fields and drops empty identities", () => { diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index 1715ea090f2..fbbb9e6e2c8 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -1,16 +1,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { loadWebMedia } from "../../../extensions/whatsapp/src/media.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { jsonResult } from "../../agents/tools/common.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; -import { runMessageAction } from "./message-action-runner.js"; vi.mock("../../../extensions/whatsapp/src/media.js", async () => { const actual = await vi.importActual( @@ -79,8 +76,17 @@ async function expectSandboxMediaRewrite(params: { ); } -let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime; -let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime; +type MessageActionRunnerModule = typeof import("./message-action-runner.js"); +type WhatsAppMediaModule = typeof import("../../../extensions/whatsapp/src/media.js"); +type SlackChannelModule = typeof import("../../../extensions/slack/src/channel.js"); +type RuntimeIndexModule = typeof import("../../plugins/runtime/index.js"); +type SlackRuntimeModule = typeof import("../../../extensions/slack/src/runtime.js"); + +let runMessageAction: MessageActionRunnerModule["runMessageAction"]; +let loadWebMedia: WhatsAppMediaModule["loadWebMedia"]; +let slackPlugin: SlackChannelModule["slackPlugin"]; +let createPluginRuntime: RuntimeIndexModule["createPluginRuntime"]; +let setSlackRuntime: SlackRuntimeModule["setSlackRuntime"]; function installSlackRuntime() { const runtime = createPluginRuntime(); @@ -88,7 +94,11 @@ function installSlackRuntime() { } describe("runMessageAction media behavior", () => { - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); + ({ runMessageAction } = await import("./message-action-runner.js")); + ({ loadWebMedia } = await import("../../../extensions/whatsapp/src/media.js")); + ({ slackPlugin } = await import("../../../extensions/slack/src/channel.js")); ({ createPluginRuntime } = await import("../../plugins/runtime/index.js")); ({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js")); }); @@ -229,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); + } }); }); @@ -346,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.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts index 952bf16f51c..f875bb40487 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -112,6 +112,50 @@ describe("runMessageAction plugin dispatch", () => { }), ); }); + + it("routes execution context ids into plugin handleAction", async () => { + await runMessageAction({ + cfg: { + channels: { + feishu: { + enabled: true, + }, + }, + } as OpenClawConfig, + action: "pin", + params: { + channel: "feishu", + messageId: "om_123", + }, + defaultAccountId: "ops", + requesterSenderId: "trusted-user", + sessionKey: "agent:alpha:main", + sessionId: "session-123", + agentId: "alpha", + toolContext: { + currentChannelId: "chat:oc_123", + currentThreadTs: "thread-456", + currentMessageId: "msg-789", + }, + dryRun: false, + }); + + expect(handleAction).toHaveBeenLastCalledWith( + expect.objectContaining({ + action: "pin", + accountId: "ops", + requesterSenderId: "trusted-user", + sessionKey: "agent:alpha:main", + sessionId: "session-123", + agentId: "alpha", + toolContext: expect.objectContaining({ + currentChannelId: "chat:oc_123", + currentThreadTs: "thread-456", + currentMessageId: "msg-789", + }), + }), + ); + }); }); describe("media caption behavior", () => { diff --git a/src/infra/outbound/message-action-runner.poll.test.ts b/src/infra/outbound/message-action-runner.poll.test.ts index 895e47605ce..ed1beb91f5d 100644 --- a/src/infra/outbound/message-action-runner.poll.test.ts +++ b/src/infra/outbound/message-action-runner.poll.test.ts @@ -1,11 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - installMessageActionRunnerTestRegistry, - resetMessageActionRunnerTestRegistry, - slackConfig, - telegramConfig, -} from "./message-action-runner.test-helpers.js"; - const mocks = vi.hoisted(() => ({ executePollAction: vi.fn(), })); @@ -20,10 +13,18 @@ vi.mock("./outbound-send-service.js", async () => { }; }); -import { runMessageAction } from "./message-action-runner.js"; +type MessageActionRunnerModule = typeof import("./message-action-runner.js"); +type MessageActionRunnerTestHelpersModule = + typeof import("./message-action-runner.test-helpers.js"); + +let runMessageAction: MessageActionRunnerModule["runMessageAction"]; +let installMessageActionRunnerTestRegistry: MessageActionRunnerTestHelpersModule["installMessageActionRunnerTestRegistry"]; +let resetMessageActionRunnerTestRegistry: MessageActionRunnerTestHelpersModule["resetMessageActionRunnerTestRegistry"]; +let slackConfig: MessageActionRunnerTestHelpersModule["slackConfig"]; +let telegramConfig: MessageActionRunnerTestHelpersModule["telegramConfig"]; async function runPollAction(params: { - cfg: typeof slackConfig; + cfg: MessageActionRunnerTestHelpersModule["slackConfig"]; actionParams: Record; toolContext?: Record; }) { @@ -44,7 +45,15 @@ async function runPollAction(params: { | undefined; } describe("runMessageAction poll handling", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ runMessageAction } = await import("./message-action-runner.js")); + ({ + installMessageActionRunnerTestRegistry, + resetMessageActionRunnerTestRegistry, + slackConfig, + telegramConfig, + } = await import("./message-action-runner.test-helpers.js")); installMessageActionRunnerTestRegistry(); mocks.executePollAction.mockResolvedValue({ handledBy: "core", @@ -54,14 +63,14 @@ describe("runMessageAction poll handling", () => { }); afterEach(() => { - resetMessageActionRunnerTestRegistry(); + resetMessageActionRunnerTestRegistry?.(); mocks.executePollAction.mockReset(); }); it.each([ { name: "requires at least two poll options", - cfg: telegramConfig, + getCfg: () => telegramConfig, actionParams: { channel: "telegram", target: "telegram:123", @@ -72,7 +81,7 @@ describe("runMessageAction poll handling", () => { }, { name: "rejects durationSeconds outside telegram", - cfg: slackConfig, + getCfg: () => slackConfig, actionParams: { channel: "slack", target: "#C12345678", @@ -84,7 +93,7 @@ describe("runMessageAction poll handling", () => { }, { name: "rejects poll visibility outside telegram", - cfg: slackConfig, + getCfg: () => slackConfig, actionParams: { channel: "slack", target: "#C12345678", @@ -94,8 +103,8 @@ describe("runMessageAction poll handling", () => { }, message: /pollAnonymous\/pollPublic are only supported for Telegram polls/i, }, - ])("$name", async ({ cfg, actionParams, message }) => { - await expect(runPollAction({ cfg, actionParams })).rejects.toThrow(message); + ])("$name", async ({ getCfg, actionParams, message }) => { + await expect(runPollAction({ cfg: getCfg(), actionParams })).rejects.toThrow(message); expect(mocks.executePollAction).not.toHaveBeenCalled(); }); 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/message-action-runner.threading.test.ts b/src/infra/outbound/message-action-runner.threading.test.ts index 42d898b145a..7401127251a 100644 --- a/src/infra/outbound/message-action-runner.threading.test.ts +++ b/src/infra/outbound/message-action-runner.threading.test.ts @@ -1,11 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - installMessageActionRunnerTestRegistry, - resetMessageActionRunnerTestRegistry, - slackConfig, - telegramConfig, -} from "./message-action-runner.test-helpers.js"; - const mocks = vi.hoisted(() => ({ executeSendAction: vi.fn(), recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })), @@ -31,10 +24,18 @@ vi.mock("../../config/sessions.js", async () => { }; }); -import { runMessageAction } from "./message-action-runner.js"; +type MessageActionRunnerModule = typeof import("./message-action-runner.js"); +type MessageActionRunnerTestHelpersModule = + typeof import("./message-action-runner.test-helpers.js"); + +let runMessageAction: MessageActionRunnerModule["runMessageAction"]; +let installMessageActionRunnerTestRegistry: MessageActionRunnerTestHelpersModule["installMessageActionRunnerTestRegistry"]; +let resetMessageActionRunnerTestRegistry: MessageActionRunnerTestHelpersModule["resetMessageActionRunnerTestRegistry"]; +let slackConfig: MessageActionRunnerTestHelpersModule["slackConfig"]; +let telegramConfig: MessageActionRunnerTestHelpersModule["telegramConfig"]; async function runThreadingAction(params: { - cfg: typeof slackConfig; + cfg: MessageActionRunnerTestHelpersModule["slackConfig"]; actionParams: Record; toolContext?: Record; }) { @@ -65,12 +66,20 @@ const defaultTelegramToolContext = { } as const; describe("runMessageAction threading auto-injection", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ runMessageAction } = await import("./message-action-runner.js")); + ({ + installMessageActionRunnerTestRegistry, + resetMessageActionRunnerTestRegistry, + slackConfig, + telegramConfig, + } = await import("./message-action-runner.test-helpers.js")); installMessageActionRunnerTestRegistry(); }); afterEach(() => { - resetMessageActionRunnerTestRegistry(); + resetMessageActionRunnerTestRegistry?.(); mocks.executeSendAction.mockClear(); mocks.recordSessionMetaFromInbound.mockClear(); }); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 8480b962544..70646a288a2 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -7,7 +7,7 @@ import { } from "../../agents/tools/common.js"; import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; -import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; +import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js"; import type { ChannelId, ChannelMessageActionName, @@ -96,6 +96,7 @@ export type RunMessageActionParams = { params: Record; defaultAccountId?: string; requesterSenderId?: string | null; + sessionId?: string; toolContext?: ChannelThreadingToolContext; gateway?: MessageActionRunnerGateway; deps?: OutboundSendDeps; @@ -675,7 +676,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise { - const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal } = ctx; + const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal, agentId } = ctx; throwIfAborted(abortSignal); const action = input.action as Exclude; if (dryRun) { @@ -701,6 +702,9 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise) => { setActivePluginRegistry(registry); @@ -17,7 +16,12 @@ vi.mock("../../gateway/call.js", () => ({ randomIdempotencyKey: () => "idem-1", })); -beforeEach(() => { +let sendMessage: typeof import("./message.js").sendMessage; +let sendPoll: typeof import("./message.js").sendPoll; + +beforeEach(async () => { + vi.resetModules(); + ({ sendMessage, sendPoll } = await import("./message.js")); callGatewayMock.mockClear(); setRegistry(emptyRegistry); }); diff --git a/src/infra/outbound/message.test.ts b/src/infra/outbound/message.test.ts index 200d4d587e1..47a43eb8437 100644 --- a/src/infra/outbound/message.test.ts +++ b/src/infra/outbound/message.test.ts @@ -46,10 +46,13 @@ vi.mock("./deliver.js", () => ({ import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { sendMessage } from "./message.js"; + +let sendMessage: typeof import("./message.js").sendMessage; describe("sendMessage", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ sendMessage } = await import("./message.js")); setActivePluginRegistry(createTestRegistry([])); mocks.getChannelPlugin.mockClear(); mocks.resolveOutboundTarget.mockClear(); diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index d4a481a8693..3f3fd0f2fcc 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), })); -vi.mock("../../channels/plugins/message-actions.js", () => ({ +vi.mock("../../channels/plugins/message-action-dispatch.js", () => ({ dispatchChannelMessageAction: mocks.dispatchChannelMessageAction, })); @@ -32,7 +32,10 @@ vi.mock("../../config/sessions.js", () => ({ appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript, })); -import { executePollAction, executeSendAction } from "./outbound-send-service.js"; +type OutboundSendServiceModule = typeof import("./outbound-send-service.js"); + +let executePollAction: OutboundSendServiceModule["executePollAction"]; +let executeSendAction: OutboundSendServiceModule["executeSendAction"]; describe("executeSendAction", () => { function pluginActionResult(messageId: string) { @@ -88,7 +91,9 @@ describe("executeSendAction", () => { }); } - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ executePollAction, executeSendAction } = await import("./outbound-send-service.js")); mocks.dispatchChannelMessageAction.mockClear(); mocks.sendMessage.mockClear(); mocks.sendPoll.mockClear(); diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index c583a1ace91..5d518798afa 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -1,5 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; +import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js"; import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { appendAssistantMessageToSessionTranscript } from "../../config/sessions.js"; diff --git a/src/infra/outbound/session-context.test.ts b/src/infra/outbound/session-context.test.ts index a62c47fb998..1446d665f35 100644 --- a/src/infra/outbound/session-context.test.ts +++ b/src/infra/outbound/session-context.test.ts @@ -1,12 +1,19 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const resolveSessionAgentIdMock = vi.hoisted(() => vi.fn()); -vi.mock("../../agents/agent-scope.js", () => ({ - resolveSessionAgentId: (...args: unknown[]) => resolveSessionAgentIdMock(...args), -})); +type SessionContextModule = typeof import("./session-context.js"); -import { buildOutboundSessionContext } from "./session-context.js"; +let buildOutboundSessionContext: SessionContextModule["buildOutboundSessionContext"]; + +beforeEach(async () => { + vi.resetModules(); + resolveSessionAgentIdMock.mockReset(); + vi.doMock("../../agents/agent-scope.js", () => ({ + resolveSessionAgentId: (...args: unknown[]) => resolveSessionAgentIdMock(...args), + })); + ({ buildOutboundSessionContext } = await import("./session-context.js")); +}); describe("buildOutboundSessionContext", () => { it("returns undefined when both session key and agent id are blank", () => { diff --git a/src/infra/outbound/target-normalization.test.ts b/src/infra/outbound/target-normalization.test.ts index c8e6ea7e124..33b4fd8f08c 100644 --- a/src/infra/outbound/target-normalization.test.ts +++ b/src/infra/outbound/target-normalization.test.ts @@ -4,33 +4,51 @@ const normalizeChannelIdMock = vi.hoisted(() => vi.fn()); const getChannelPluginMock = vi.hoisted(() => vi.fn()); const getActivePluginRegistryVersionMock = vi.hoisted(() => vi.fn()); -vi.mock("../../channels/plugins/index.js", () => ({ - normalizeChannelId: (...args: unknown[]) => normalizeChannelIdMock(...args), - getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), -})); +type TargetNormalizationModule = typeof import("./target-normalization.js"); -vi.mock("../../plugins/runtime.js", () => ({ - getActivePluginRegistryVersion: (...args: unknown[]) => - getActivePluginRegistryVersionMock(...args), -})); - -import { - buildTargetResolverSignature, - normalizeChannelTargetInput, - normalizeTargetForProvider, -} from "./target-normalization.js"; +let buildTargetResolverSignature: TargetNormalizationModule["buildTargetResolverSignature"]; +let normalizeChannelTargetInput: TargetNormalizationModule["normalizeChannelTargetInput"]; +let normalizeTargetForProvider: TargetNormalizationModule["normalizeTargetForProvider"]; describe("normalizeChannelTargetInput", () => { + beforeEach(async () => { + vi.resetModules(); + normalizeChannelIdMock.mockReset(); + getChannelPluginMock.mockReset(); + getActivePluginRegistryVersionMock.mockReset(); + vi.doMock("../../channels/plugins/index.js", () => ({ + normalizeChannelId: (...args: unknown[]) => normalizeChannelIdMock(...args), + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), + })); + vi.doMock("../../plugins/runtime.js", () => ({ + getActivePluginRegistryVersion: (...args: unknown[]) => + getActivePluginRegistryVersionMock(...args), + })); + ({ buildTargetResolverSignature, normalizeChannelTargetInput, normalizeTargetForProvider } = + await import("./target-normalization.js")); + }); + it("trims raw target input", () => { expect(normalizeChannelTargetInput(" channel:C1 ")).toBe("channel:C1"); }); }); describe("normalizeTargetForProvider", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); normalizeChannelIdMock.mockReset(); getChannelPluginMock.mockReset(); getActivePluginRegistryVersionMock.mockReset(); + vi.doMock("../../channels/plugins/index.js", () => ({ + normalizeChannelId: (...args: unknown[]) => normalizeChannelIdMock(...args), + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), + })); + vi.doMock("../../plugins/runtime.js", () => ({ + getActivePluginRegistryVersion: (...args: unknown[]) => + getActivePluginRegistryVersionMock(...args), + })); + ({ buildTargetResolverSignature, normalizeChannelTargetInput, normalizeTargetForProvider } = + await import("./target-normalization.js")); }); it("returns undefined for missing or blank raw input", () => { @@ -87,8 +105,21 @@ describe("normalizeTargetForProvider", () => { }); describe("buildTargetResolverSignature", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + normalizeChannelIdMock.mockReset(); getChannelPluginMock.mockReset(); + getActivePluginRegistryVersionMock.mockReset(); + vi.doMock("../../channels/plugins/index.js", () => ({ + normalizeChannelId: (...args: unknown[]) => normalizeChannelIdMock(...args), + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), + })); + vi.doMock("../../plugins/runtime.js", () => ({ + getActivePluginRegistryVersion: (...args: unknown[]) => + getActivePluginRegistryVersionMock(...args), + })); + ({ buildTargetResolverSignature, normalizeChannelTargetInput, normalizeTargetForProvider } = + await import("./target-normalization.js")); }); it("builds stable signatures from resolver hint and looksLikeId source", () => { diff --git a/src/infra/outbound/target-resolver.test.ts b/src/infra/outbound/target-resolver.test.ts index 643a5c3ed25..0e877a60c6a 100644 --- a/src/infra/outbound/target-resolver.test.ts +++ b/src/infra/outbound/target-resolver.test.ts @@ -1,28 +1,41 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelDirectoryEntry } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { resetDirectoryCache, resolveMessagingTarget } from "./target-resolver.js"; +type TargetResolverModule = typeof import("./target-resolver.js"); + +let resetDirectoryCache: TargetResolverModule["resetDirectoryCache"]; +let resolveMessagingTarget: TargetResolverModule["resolveMessagingTarget"]; const mocks = vi.hoisted(() => ({ listGroups: vi.fn(), listGroupsLive: vi.fn(), resolveTarget: vi.fn(), getChannelPlugin: vi.fn(), + getActivePluginRegistryVersion: vi.fn(() => 1), })); -vi.mock("../../channels/plugins/index.js", () => ({ - getChannelPlugin: (...args: unknown[]) => mocks.getChannelPlugin(...args), - normalizeChannelId: (value: string) => value, -})); +beforeEach(async () => { + vi.resetModules(); + mocks.listGroups.mockReset(); + mocks.listGroupsLive.mockReset(); + mocks.resolveTarget.mockReset(); + mocks.getChannelPlugin.mockReset(); + mocks.getActivePluginRegistryVersion.mockReset(); + mocks.getActivePluginRegistryVersion.mockReturnValue(1); + vi.doMock("../../channels/plugins/index.js", () => ({ + getChannelPlugin: (...args: unknown[]) => mocks.getChannelPlugin(...args), + normalizeChannelId: (value: string) => value, + })); + vi.doMock("../../plugins/runtime.js", () => ({ + getActivePluginRegistryVersion: () => mocks.getActivePluginRegistryVersion(), + })); + ({ resetDirectoryCache, resolveMessagingTarget } = await import("./target-resolver.js")); +}); describe("resolveMessagingTarget (directory fallback)", () => { const cfg = {} as OpenClawConfig; beforeEach(() => { - mocks.listGroups.mockClear(); - mocks.listGroupsLive.mockClear(); - mocks.resolveTarget.mockClear(); - mocks.getChannelPlugin.mockClear(); resetDirectoryCache(); mocks.getChannelPlugin.mockReturnValue({ directory: { diff --git a/src/infra/outbound/targets.channel-resolution.test.ts b/src/infra/outbound/targets.channel-resolution.test.ts index e676a425bba..f7e38e0bfef 100644 --- a/src/infra/outbound/targets.channel-resolution.test.ts +++ b/src/infra/outbound/targets.channel-resolution.test.ts @@ -48,7 +48,8 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({ import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { resolveOutboundTarget } from "./targets.js"; + +let resolveOutboundTarget: typeof import("./targets.js").resolveOutboundTarget; describe("resolveOutboundTarget channel resolution", () => { let registrySeq = 0; @@ -60,7 +61,9 @@ describe("resolveOutboundTarget channel resolution", () => { mode: "explicit", }); - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resolveOutboundTarget } = await import("./targets.js")); registrySeq += 1; setActivePluginRegistry(createTestRegistry([]), `targets-test-${registrySeq}`); mocks.getChannelPlugin.mockReset(); 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/pairing-token.test.ts b/src/infra/pairing-token.test.ts index 1ef0c8e20d7..9788e448e49 100644 --- a/src/infra/pairing-token.test.ts +++ b/src/infra/pairing-token.test.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const randomBytesMock = vi.hoisted(() => vi.fn()); @@ -11,7 +11,17 @@ vi.mock("node:crypto", async () => { }; }); -import { generatePairingToken, PAIRING_TOKEN_BYTES, verifyPairingToken } from "./pairing-token.js"; +type PairingTokenModule = typeof import("./pairing-token.js"); + +let generatePairingToken: PairingTokenModule["generatePairingToken"]; +let PAIRING_TOKEN_BYTES: PairingTokenModule["PAIRING_TOKEN_BYTES"]; +let verifyPairingToken: PairingTokenModule["verifyPairingToken"]; + +beforeEach(async () => { + vi.resetModules(); + ({ generatePairingToken, PAIRING_TOKEN_BYTES, verifyPairingToken } = + await import("./pairing-token.js")); +}); describe("generatePairingToken", () => { it("uses the configured byte count and returns a base64url token", () => { diff --git a/src/infra/ports.test.ts b/src/infra/ports.test.ts index 090ccb128b9..4c3d3597f40 100644 --- a/src/infra/ports.test.ts +++ b/src/infra/ports.test.ts @@ -7,11 +7,20 @@ const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); -import { inspectPortUsage } from "./ports-inspect.js"; -import { ensurePortAvailable, handlePortError, PortInUseError } from "./ports.js"; + +let inspectPortUsage: typeof import("./ports-inspect.js").inspectPortUsage; +let ensurePortAvailable: typeof import("./ports.js").ensurePortAvailable; +let handlePortError: typeof import("./ports.js").handlePortError; +let PortInUseError: typeof import("./ports.js").PortInUseError; const describeUnix = process.platform === "win32" ? describe.skip : describe; +beforeEach(async () => { + vi.resetModules(); + ({ inspectPortUsage } = await import("./ports-inspect.js")); + ({ ensurePortAvailable, handlePortError, PortInUseError } = await import("./ports.js")); +}); + describe("ports helpers", () => { it("ensurePortAvailable rejects when port busy", async () => { const server = net.createServer(); diff --git a/src/infra/provider-usage.auth.plugin.test.ts b/src/infra/provider-usage.auth.plugin.test.ts index 6782e89489b..64339a919d2 100644 --- a/src/infra/provider-usage.auth.plugin.test.ts +++ b/src/infra/provider-usage.auth.plugin.test.ts @@ -7,12 +7,14 @@ vi.mock("../plugins/provider-runtime.js", () => ({ resolveProviderUsageAuthWithPluginMock(...args), })); -import { resolveProviderAuths } from "./provider-usage.auth.js"; +let resolveProviderAuths: typeof import("./provider-usage.auth.js").resolveProviderAuths; describe("resolveProviderAuths plugin seam", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); resolveProviderUsageAuthWithPluginMock.mockReset(); resolveProviderUsageAuthWithPluginMock.mockResolvedValue(null); + ({ resolveProviderAuths } = await import("./provider-usage.auth.js")); }); it("prefers plugin-owned usage auth when available", async () => { diff --git a/src/infra/provider-usage.load.plugin.test.ts b/src/infra/provider-usage.load.plugin.test.ts index 55cff6cad72..6d4d7d7b602 100644 --- a/src/infra/provider-usage.load.plugin.test.ts +++ b/src/infra/provider-usage.load.plugin.test.ts @@ -12,14 +12,16 @@ vi.mock("../plugins/provider-runtime.js", () => ({ resolveProviderUsageSnapshotWithPluginMock(...args), })); -import { loadProviderUsageSummary } from "./provider-usage.load.js"; +let loadProviderUsageSummary: typeof import("./provider-usage.load.js").loadProviderUsageSummary; const usageNow = Date.UTC(2026, 0, 7, 0, 0, 0); describe("provider-usage.load plugin seam", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); resolveProviderUsageSnapshotWithPluginMock.mockReset(); resolveProviderUsageSnapshotWithPluginMock.mockResolvedValue(null); + ({ loadProviderUsageSummary } = await import("./provider-usage.load.js")); }); it("prefers plugin-owned usage snapshots", async () => { diff --git a/src/infra/provider-usage.load.test.ts b/src/infra/provider-usage.load.test.ts index 1a91b87a56b..c388b5702e6 100644 --- a/src/infra/provider-usage.load.test.ts +++ b/src/infra/provider-usage.load.test.ts @@ -2,23 +2,13 @@ import { describe, expect, it, vi } from "vitest"; import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; import { loadProviderUsageSummary } from "./provider-usage.load.js"; import { ignoredErrors } from "./provider-usage.shared.js"; +import { + loadUsageWithAuth, + type ProviderUsageAuth, + usageNow, +} from "./provider-usage.test-support.js"; -const usageNow = Date.UTC(2026, 0, 7, 0, 0, 0); - -type ProviderAuth = NonNullable< - NonNullable[0]>["auth"] ->[number]; - -async function loadUsageWithAuth( - auth: ProviderAuth[], - mockFetch: ReturnType, -) { - return await loadProviderUsageSummary({ - now: usageNow, - auth, - fetch: mockFetch as unknown as typeof fetch, - }); -} +type ProviderAuth = ProviderUsageAuth; describe("provider-usage.load", () => { it("loads snapshots for copilot gemini codex and xiaomi", async () => { @@ -53,6 +43,7 @@ describe("provider-usage.load", () => { }); const summary = await loadUsageWithAuth( + loadProviderUsageSummary, [ { provider: "github-copilot", token: "copilot-token" }, { provider: "google-gemini-cli", token: "gemini-token" }, @@ -85,13 +76,14 @@ describe("provider-usage.load", () => { it("returns empty provider list when auth resolves to none", async () => { const mockFetch = createProviderUsageFetch(async () => makeResponse(404, "not found")); - const summary = await loadUsageWithAuth([], mockFetch); + const summary = await loadUsageWithAuth(loadProviderUsageSummary, [], mockFetch); expect(summary).toEqual({ updatedAt: usageNow, providers: [] }); }); it("returns unsupported provider snapshots for unknown provider ids", async () => { const mockFetch = createProviderUsageFetch(async () => makeResponse(404, "not found")); const summary = await loadUsageWithAuth( + loadProviderUsageSummary, [{ provider: "unsupported-provider", token: "token-u" }] as unknown as ProviderAuth[], mockFetch, ); @@ -109,6 +101,7 @@ describe("provider-usage.load", () => { ignoredErrors.add("HTTP 500"); try { const summary = await loadUsageWithAuth( + loadProviderUsageSummary, [{ provider: "anthropic", token: "token-a" }], mockFetch, ); diff --git a/src/infra/provider-usage.test-support.ts b/src/infra/provider-usage.test-support.ts new file mode 100644 index 00000000000..2d2609a29d6 --- /dev/null +++ b/src/infra/provider-usage.test-support.ts @@ -0,0 +1,27 @@ +import { createProviderUsageFetch } from "../test-utils/provider-usage-fetch.js"; +import type { ProviderAuth } from "./provider-usage.auth.js"; +import type { UsageSummary } from "./provider-usage.types.js"; + +export const usageNow = Date.UTC(2026, 0, 7, 0, 0, 0); + +type ProviderUsageLoader = (params: { + now: number; + auth?: ProviderAuth[]; + fetch?: typeof fetch; +}) => Promise; + +export type ProviderUsageAuth = NonNullable< + NonNullable[0]>["auth"] +>[number]; + +export async function loadUsageWithAuth( + loadProviderUsageSummary: T, + auth: ProviderUsageAuth[], + mockFetch: ReturnType, +) { + return await loadProviderUsageSummary({ + now: usageNow, + auth, + fetch: mockFetch as unknown as typeof fetch, + }); +} diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index 2e45a2ee9dc..fdd2326a9a0 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -11,23 +11,9 @@ import { loadProviderUsageSummary, type UsageSummary, } from "./provider-usage.js"; +import { loadUsageWithAuth, usageNow } from "./provider-usage.test-support.js"; const minimaxRemainsEndpoint = "api.minimaxi.com/v1/api/openplatform/coding_plan/remains"; -const usageNow = Date.UTC(2026, 0, 7, 0, 0, 0); -type ProviderAuth = NonNullable< - NonNullable[0]>["auth"] ->[number]; - -async function loadUsageWithAuth( - auth: ProviderAuth[], - mockFetch: ReturnType, -) { - return await loadProviderUsageSummary({ - now: usageNow, - auth, - fetch: mockFetch as unknown as typeof fetch, - }); -} function expectSingleAnthropicProvider(summary: UsageSummary) { expect(summary.providers).toHaveLength(1); @@ -55,7 +41,11 @@ async function expectMinimaxUsage( ) { const mockFetch = createMinimaxOnlyFetch(payload); - const summary = await loadUsageWithAuth([{ provider: "minimax", token: "token-1b" }], mockFetch); + const summary = await loadUsageWithAuth( + loadProviderUsageSummary, + [{ provider: "minimax", token: "token-1b" }], + mockFetch, + ); const minimax = summary.providers.find((p) => p.provider === "minimax"); expect(minimax?.windows[0]?.usedPercent).toBe(expected.usedPercent); @@ -166,6 +156,7 @@ describe("provider usage loading", () => { }); const summary = await loadUsageWithAuth( + loadProviderUsageSummary, [ { provider: "anthropic", token: "token-1" }, { provider: "minimax", token: "token-1b" }, @@ -344,6 +335,7 @@ describe("provider usage loading", () => { }); const summary = await loadUsageWithAuth( + loadProviderUsageSummary, [{ provider: "anthropic", token: "sk-ant-oauth-1" }], mockFetch, ); diff --git a/src/infra/push-apns.relay.test.ts b/src/infra/push-apns.relay.test.ts index 4e8e8054311..0079597a8cd 100644 --- a/src/infra/push-apns.relay.test.ts +++ b/src/infra/push-apns.relay.test.ts @@ -27,6 +27,21 @@ afterEach(() => { vi.unstubAllGlobals(); }); +function createRelayPushParams() { + return { + relayConfig: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + sendGrant: "send-grant-123", + relayHandle: "relay-handle-123", + payload: { aps: { "content-available": 1 } }, + pushType: "background" as const, + priority: "5" as const, + gatewayIdentity: relayGatewayIdentity, + }; +} + describe("push-apns.relay", () => { describe("resolveApnsRelayConfigFromEnv", () => { it("returns a missing-config error when no relay base URL is configured", () => { @@ -190,18 +205,7 @@ describe("push-apns.relay", () => { }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - const result = await sendApnsRelayPush({ - relayConfig: { - baseUrl: "https://relay.example.com", - timeoutMs: 1000, - }, - sendGrant: "send-grant-123", - relayHandle: "relay-handle-123", - payload: { aps: { "content-available": 1 } }, - pushType: "background", - priority: "5", - gatewayIdentity: relayGatewayIdentity, - }); + const result = await sendApnsRelayPush(createRelayPushParams()); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" }); @@ -221,20 +225,7 @@ describe("push-apns.relay", () => { }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - await expect( - sendApnsRelayPush({ - relayConfig: { - baseUrl: "https://relay.example.com", - timeoutMs: 1000, - }, - sendGrant: "send-grant-123", - relayHandle: "relay-handle-123", - payload: { aps: { "content-available": 1 } }, - pushType: "background", - priority: "5", - gatewayIdentity: relayGatewayIdentity, - }), - ).resolves.toEqual({ + await expect(sendApnsRelayPush(createRelayPushParams())).resolves.toEqual({ ok: true, status: 202, apnsId: undefined, @@ -258,20 +249,7 @@ describe("push-apns.relay", () => { }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - await expect( - sendApnsRelayPush({ - relayConfig: { - baseUrl: "https://relay.example.com", - timeoutMs: 1000, - }, - sendGrant: "send-grant-123", - relayHandle: "relay-handle-123", - payload: { aps: { "content-available": 1 } }, - pushType: "background", - priority: "5", - gatewayIdentity: relayGatewayIdentity, - }), - ).resolves.toEqual({ + await expect(sendApnsRelayPush(createRelayPushParams())).resolves.toEqual({ ok: false, status: 410, apnsId: "relay-apns-id", diff --git a/src/infra/restart-stale-pids.test.ts b/src/infra/restart-stale-pids.test.ts index b7589d26e15..4ff0823e4c3 100644 --- a/src/infra/restart-stale-pids.test.ts +++ b/src/infra/restart-stale-pids.test.ts @@ -32,11 +32,9 @@ vi.mock("../logging/subsystem.js", () => ({ })); import { resolveLsofCommandSync } from "./ports-lsof.js"; -import { - __testing, - cleanStaleGatewayProcessesSync, - findGatewayPidsOnPortSync, -} from "./restart-stale-pids.js"; +let __testing: typeof import("./restart-stale-pids.js").__testing; +let cleanStaleGatewayProcessesSync: typeof import("./restart-stale-pids.js").cleanStaleGatewayProcessesSync; +let findGatewayPidsOnPortSync: typeof import("./restart-stale-pids.js").findGatewayPidsOnPortSync; function lsofOutput(entries: Array<{ pid: number; cmd: string }>): string { return entries.map(({ pid, cmd }) => `p${pid}\nc${cmd}`).join("\n") + "\n"; @@ -89,6 +87,12 @@ function installInitialBusyPoll( describe.skipIf(isWindows)("restart-stale-pids", () => { beforeEach(() => { + vi.resetModules(); + }); + + beforeEach(async () => { + ({ __testing, cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } = + await import("./restart-stale-pids.js")); mockSpawnSync.mockReset(); mockResolveGatewayPort.mockReset(); mockRestartWarn.mockReset(); diff --git a/src/infra/restart.test.ts b/src/infra/restart.test.ts index e21225be37b..fe6e760041b 100644 --- a/src/infra/restart.test.ts +++ b/src/infra/restart.test.ts @@ -16,13 +16,14 @@ vi.mock("../config/paths.js", () => ({ resolveGatewayPort: (...args: unknown[]) => resolveGatewayPortMock(...args), })); -import { - __testing, - cleanStaleGatewayProcessesSync, - findGatewayPidsOnPortSync, -} from "./restart-stale-pids.js"; +let __testing: typeof import("./restart-stale-pids.js").__testing; +let cleanStaleGatewayProcessesSync: typeof import("./restart-stale-pids.js").cleanStaleGatewayProcessesSync; +let findGatewayPidsOnPortSync: typeof import("./restart-stale-pids.js").findGatewayPidsOnPortSync; -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); + ({ __testing, cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } = + await import("./restart-stale-pids.js")); spawnSyncMock.mockReset(); resolveLsofCommandSyncMock.mockReset(); resolveGatewayPortMock.mockReset(); 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/infra/secure-random.test.ts b/src/infra/secure-random.test.ts index 2a595900c7b..1c9f8d949bc 100644 --- a/src/infra/secure-random.test.ts +++ b/src/infra/secure-random.test.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const cryptoMocks = vi.hoisted(() => ({ randomBytes: vi.fn((bytes: number) => Buffer.alloc(bytes, 0xab)), @@ -11,7 +11,13 @@ vi.mock("node:crypto", () => ({ randomUUID: cryptoMocks.randomUUID, })); -import { generateSecureToken, generateSecureUuid } from "./secure-random.js"; +let generateSecureToken: typeof import("./secure-random.js").generateSecureToken; +let generateSecureUuid: typeof import("./secure-random.js").generateSecureUuid; + +beforeEach(async () => { + vi.resetModules(); + ({ generateSecureToken, generateSecureUuid } = await import("./secure-random.js")); +}); describe("secure-random", () => { it("delegates UUID generation to crypto.randomUUID", () => { diff --git a/src/infra/session-maintenance-warning.test.ts b/src/infra/session-maintenance-warning.test.ts index f4c2e0757a1..4395a46df89 100644 --- a/src/infra/session-maintenance-warning.test.ts +++ b/src/infra/session-maintenance-warning.test.ts @@ -15,28 +15,9 @@ const mocks = vi.hoisted(() => ({ enqueueSystemEvent: vi.fn(), })); -vi.mock("../agents/agent-scope.js", () => ({ - resolveSessionAgentId: mocks.resolveSessionAgentId, -})); +type SessionMaintenanceWarningModule = typeof import("./session-maintenance-warning.js"); -vi.mock("../utils/message-channel.js", () => ({ - normalizeMessageChannel: mocks.normalizeMessageChannel, - isDeliverableMessageChannel: mocks.isDeliverableMessageChannel, -})); - -vi.mock("./outbound/targets.js", () => ({ - resolveSessionDeliveryTarget: mocks.resolveSessionDeliveryTarget, -})); - -vi.mock("./outbound/deliver.js", () => ({ - deliverOutboundPayloads: mocks.deliverOutboundPayloads, -})); - -vi.mock("./system-events.js", () => ({ - enqueueSystemEvent: mocks.enqueueSystemEvent, -})); - -const { deliverSessionMaintenanceWarning } = await import("./session-maintenance-warning.js"); +let deliverSessionMaintenanceWarning: SessionMaintenanceWarningModule["deliverSessionMaintenanceWarning"]; function createParams( overrides: Partial[0]> = {}, @@ -62,17 +43,35 @@ describe("deliverSessionMaintenanceWarning", () => { let prevVitest: string | undefined; let prevNodeEnv: string | undefined; - beforeEach(() => { + beforeEach(async () => { prevVitest = process.env.VITEST; prevNodeEnv = process.env.NODE_ENV; delete process.env.VITEST; process.env.NODE_ENV = "development"; + vi.resetModules(); mocks.resolveSessionAgentId.mockClear(); mocks.resolveSessionDeliveryTarget.mockClear(); mocks.normalizeMessageChannel.mockClear(); mocks.isDeliverableMessageChannel.mockClear(); mocks.deliverOutboundPayloads.mockClear(); mocks.enqueueSystemEvent.mockClear(); + vi.doMock("../agents/agent-scope.js", () => ({ + resolveSessionAgentId: mocks.resolveSessionAgentId, + })); + vi.doMock("../utils/message-channel.js", () => ({ + normalizeMessageChannel: mocks.normalizeMessageChannel, + isDeliverableMessageChannel: mocks.isDeliverableMessageChannel, + })); + vi.doMock("./outbound/targets.js", () => ({ + resolveSessionDeliveryTarget: mocks.resolveSessionDeliveryTarget, + })); + vi.doMock("./outbound/deliver.js", () => ({ + deliverOutboundPayloads: mocks.deliverOutboundPayloads, + })); + vi.doMock("./system-events.js", () => ({ + enqueueSystemEvent: mocks.enqueueSystemEvent, + })); + ({ deliverSessionMaintenanceWarning } = await import("./session-maintenance-warning.js")); }); afterEach(() => { diff --git a/src/infra/transport-ready.test.ts b/src/infra/transport-ready.test.ts index a4703ba512c..e55dcb7dd7b 100644 --- a/src/infra/transport-ready.test.ts +++ b/src/infra/transport-ready.test.ts @@ -1,43 +1,45 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { waitForTransportReady } from "./transport-ready.js"; let injectedSleepError: Error | null = null; - -// Perf: `sleepWithAbort` uses `node:timers/promises` which isn't controlled by fake timers. -// Route sleeps through global `setTimeout` so tests can advance time deterministically. -vi.mock("./backoff.js", () => ({ - sleepWithAbort: async (ms: number, signal?: AbortSignal) => { - if (injectedSleepError) { - throw injectedSleepError; - } - if (signal?.aborted) { - throw new Error("aborted"); - } - if (ms <= 0) { - return; - } - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, ms); - const onAbort = () => { - clearTimeout(timer); - signal?.removeEventListener("abort", onAbort); - reject(new Error("aborted")); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - }); - }, -})); +type TransportReadyModule = typeof import("./transport-ready.js"); +let waitForTransportReady: TransportReadyModule["waitForTransportReady"]; function createRuntime() { return { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; } describe("waitForTransportReady", () => { - beforeEach(() => { + beforeEach(async () => { vi.useFakeTimers(); + vi.resetModules(); + // Perf: `sleepWithAbort` uses `node:timers/promises` which isn't controlled by fake timers. + // Route sleeps through global `setTimeout` so tests can advance time deterministically. + vi.doMock("./backoff.js", () => ({ + sleepWithAbort: async (ms: number, signal?: AbortSignal) => { + if (injectedSleepError) { + throw injectedSleepError; + } + if (signal?.aborted) { + throw new Error("aborted"); + } + if (ms <= 0) { + return; + } + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + reject(new Error("aborted")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); + }, + })); + ({ waitForTransportReady } = await import("./transport-ready.js")); }); afterEach(() => { diff --git a/src/infra/windows-task-restart.test.ts b/src/infra/windows-task-restart.test.ts index 1a25a7a7415..5da5625f9b8 100644 --- a/src/infra/windows-task-restart.test.ts +++ b/src/infra/windows-task-restart.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureFullEnv } from "../test-utils/env.js"; const spawnMock = vi.hoisted(() => vi.fn()); @@ -14,7 +14,9 @@ vi.mock("./tmp-openclaw-dir.js", () => ({ resolvePreferredOpenClawTmpDir: () => resolvePreferredOpenClawTmpDirMock(), })); -import { relaunchGatewayScheduledTask } from "./windows-task-restart.js"; +type WindowsTaskRestartModule = typeof import("./windows-task-restart.js"); + +let relaunchGatewayScheduledTask: WindowsTaskRestartModule["relaunchGatewayScheduledTask"]; const envSnapshot = captureFullEnv(); const createdScriptPaths = new Set(); @@ -51,6 +53,11 @@ afterEach(() => { }); describe("relaunchGatewayScheduledTask", () => { + beforeEach(async () => { + vi.resetModules(); + ({ relaunchGatewayScheduledTask } = await import("./windows-task-restart.js")); + }); + it("writes a detached schtasks relaunch helper", () => { const unref = vi.fn(); let seenCommandArg = ""; diff --git a/src/infra/wsl.test.ts b/src/infra/wsl.test.ts index d026cf4bbb1..bc1aa23dad0 100644 --- a/src/infra/wsl.test.ts +++ b/src/infra/wsl.test.ts @@ -14,7 +14,11 @@ vi.mock("node:fs/promises", () => ({ }, })); -const { isWSLEnv, isWSLSync, isWSL2Sync, isWSL, resetWSLStateForTests } = await import("./wsl.js"); +let isWSLEnv: typeof import("./wsl.js").isWSLEnv; +let isWSLSync: typeof import("./wsl.js").isWSLSync; +let isWSL2Sync: typeof import("./wsl.js").isWSL2Sync; +let isWSL: typeof import("./wsl.js").isWSL; +let resetWSLStateForTests: typeof import("./wsl.js").resetWSLStateForTests; const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); @@ -29,13 +33,18 @@ describe("wsl detection", () => { let envSnapshot: ReturnType; beforeEach(() => { + vi.resetModules(); envSnapshot = captureEnv(["WSL_INTEROP", "WSL_DISTRO_NAME", "WSLENV"]); readFileSyncMock.mockReset(); readFileMock.mockReset(); - resetWSLStateForTests(); setPlatform("linux"); }); + beforeEach(async () => { + ({ isWSLEnv, isWSLSync, isWSL2Sync, isWSL, resetWSLStateForTests } = await import("./wsl.js")); + resetWSLStateForTests(); + }); + afterEach(() => { envSnapshot.restore(); resetWSLStateForTests(); 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-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index 7058cef6bb1..b9fb809f2a0 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -2,51 +2,35 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveApiKeyForProvider } from "../agents/model-auth.js"; import type { MsgContext } from "../auto-reply/templating.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; -import { fetchRemoteMedia } from "../media/fetch.js"; -import { runExec } from "../process/exec.js"; import { withEnvAsync } from "../test-utils/env.js"; -import { clearMediaUnderstandingBinaryCacheForTests } from "./runner.js"; import { createSafeAudioFixtureBuffer } from "./runner.test-utils.js"; +type ResolveApiKeyForProvider = typeof import("../agents/model-auth.js").resolveApiKeyForProvider; + const resolveApiKeyForProviderMock = vi.hoisted(() => - vi.fn(async () => ({ + vi.fn(async () => ({ apiKey: "test-key", // pragma: allowlist secret source: "test", mode: "api-key", })), ); const hasAvailableAuthForProviderMock = vi.hoisted(() => - vi.fn(async (...args: Parameters) => { + vi.fn(async (...args: Parameters) => { const resolved = await resolveApiKeyForProviderMock(...args); return Boolean(resolved?.apiKey); }), ); - -vi.mock("../agents/model-auth.js", () => ({ - resolveApiKeyForProvider: resolveApiKeyForProviderMock, - hasAvailableAuthForProvider: hasAvailableAuthForProviderMock, - requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => { - if (auth?.apiKey) { - return auth.apiKey; - } - throw new Error(`No API key resolved for provider "${provider}" (auth mode: ${auth?.mode}).`); - }, -})); - -vi.mock("../media/fetch.js", () => ({ - fetchRemoteMedia: vi.fn(), -})); - -vi.mock("../process/exec.js", () => ({ - runExec: vi.fn(), -})); +const fetchRemoteMediaMock = vi.hoisted(() => vi.fn()); +const runExecMock = vi.hoisted(() => vi.fn()); let applyMediaUnderstanding: typeof import("./apply.js").applyMediaUnderstanding; -const mockedRunExec = vi.mocked(runExec); +let clearMediaUnderstandingBinaryCacheForTests: typeof import("./runner.js").clearMediaUnderstandingBinaryCacheForTests; +const mockedResolveApiKey = resolveApiKeyForProviderMock; +const mockedFetchRemoteMedia = fetchRemoteMediaMock; +const mockedRunExec = runExecMock; const TEMP_MEDIA_PREFIX = "openclaw-media-"; let suiteTempMediaRootDir = ""; @@ -241,14 +225,32 @@ function expectFileNotApplied(params: { } describe("applyMediaUnderstanding", () => { - const mockedResolveApiKey = vi.mocked(resolveApiKeyForProvider); - const mockedFetchRemoteMedia = vi.mocked(fetchRemoteMedia); - 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}).`, + ); + }, + })); + vi.doMock("../media/fetch.js", () => ({ + fetchRemoteMedia: fetchRemoteMediaMock, + })); + vi.doMock("../process/exec.js", () => ({ + runExec: runExecMock, + })); + ({ applyMediaUnderstanding } = await import("./apply.js")); + ({ clearMediaUnderstandingBinaryCacheForTests } = await import("./runner.js")); + const baseDir = resolvePreferredOpenClawTmpDir(); await fs.mkdir(baseDir, { recursive: true }); suiteTempMediaRootDir = await fs.mkdtemp(path.join(baseDir, TEMP_MEDIA_PREFIX)); - ({ applyMediaUnderstanding } = await import("./apply.js")); }); beforeEach(() => { diff --git a/src/media-understanding/providers/image.test.ts b/src/media-understanding/providers/image.test.ts index d52c6590eef..9044d8ba83d 100644 --- a/src/media-understanding/providers/image.test.ts +++ b/src/media-understanding/providers/image.test.ts @@ -16,49 +16,43 @@ const resolveApiKeyForProviderMock = vi.fn(async () => ({ const requireApiKeyMock = vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""); const setRuntimeApiKeyMock = vi.fn(); const discoverModelsMock = vi.fn(); -let imageImportSeq = 0; +type ImageModule = typeof import("./image.js"); -vi.mock("@mariozechner/pi-ai", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - complete: completeMock, - }; -}); - -vi.mock("../../agents/minimax-vlm.js", () => ({ - isMinimaxVlmProvider: (provider: string) => - provider === "minimax" || provider === "minimax-portal", - isMinimaxVlmModel: (provider: string, modelId: string) => - (provider === "minimax" || provider === "minimax-portal") && modelId === "MiniMax-VL-01", - minimaxUnderstandImage: minimaxUnderstandImageMock, -})); - -vi.mock("../../agents/models-config.js", () => ({ - ensureOpenClawModelsJson: ensureOpenClawModelsJsonMock, -})); - -vi.mock("../../agents/model-auth.js", () => ({ - getApiKeyForModel: getApiKeyForModelMock, - resolveApiKeyForProvider: resolveApiKeyForProviderMock, - requireApiKey: requireApiKeyMock, -})); - -vi.mock("../../agents/pi-model-discovery-runtime.js", () => ({ - discoverAuthStorage: () => ({ - setRuntimeApiKey: setRuntimeApiKeyMock, - }), - discoverModels: discoverModelsMock, -})); - -async function importImageModule() { - imageImportSeq += 1; - return await import(/* @vite-ignore */ `./image.js?case=${imageImportSeq}`); -} +let describeImageWithModel: ImageModule["describeImageWithModel"]; describe("describeImageWithModel", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); vi.clearAllMocks(); + vi.doMock("@mariozechner/pi-ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + complete: completeMock, + }; + }); + vi.doMock("../../agents/minimax-vlm.js", () => ({ + isMinimaxVlmProvider: (provider: string) => + provider === "minimax" || provider === "minimax-portal", + isMinimaxVlmModel: (provider: string, modelId: string) => + (provider === "minimax" || provider === "minimax-portal") && modelId === "MiniMax-VL-01", + minimaxUnderstandImage: minimaxUnderstandImageMock, + })); + vi.doMock("../../agents/models-config.js", () => ({ + ensureOpenClawModelsJson: ensureOpenClawModelsJsonMock, + })); + vi.doMock("../../agents/model-auth.js", () => ({ + getApiKeyForModel: getApiKeyForModelMock, + resolveApiKeyForProvider: resolveApiKeyForProviderMock, + requireApiKey: requireApiKeyMock, + })); + vi.doMock("../../agents/pi-model-discovery-runtime.js", () => ({ + discoverAuthStorage: () => ({ + setRuntimeApiKey: setRuntimeApiKeyMock, + }), + discoverModels: discoverModelsMock, + })); + ({ describeImageWithModel } = await import("./image.js")); minimaxUnderstandImageMock.mockResolvedValue("portal ok"); discoverModelsMock.mockReturnValue({ find: vi.fn(() => ({ @@ -71,8 +65,6 @@ describe("describeImageWithModel", () => { }); it("routes minimax-portal image models through the MiniMax VLM endpoint", async () => { - const { describeImageWithModel } = await importImageModule(); - const result = await describeImageWithModel({ cfg: {}, agentDir: "/tmp/openclaw-agent", @@ -121,8 +113,6 @@ describe("describeImageWithModel", () => { content: [{ type: "text", text: "generic ok" }], }); - const { describeImageWithModel } = await importImageModule(); - const result = await describeImageWithModel({ cfg: {}, agentDir: "/tmp/openclaw-agent", @@ -165,8 +155,6 @@ describe("describeImageWithModel", () => { content: [{ type: "text", text: "flash ok" }], }); - const { describeImageWithModel } = await importImageModule(); - const result = await describeImageWithModel({ cfg: {}, agentDir: "/tmp/openclaw-agent", @@ -215,8 +203,6 @@ describe("describeImageWithModel", () => { content: [{ type: "text", text: "flash lite ok" }], }); - const { describeImageWithModel } = await importImageModule(); - const result = await describeImageWithModel({ cfg: {}, agentDir: "/tmp/openclaw-agent", diff --git a/src/media/fetch.telegram-network.test.ts b/src/media/fetch.telegram-network.test.ts index d7a4d8e217d..faf16314d98 100644 --- a/src/media/fetch.telegram-network.test.ts +++ b/src/media/fetch.telegram-network.test.ts @@ -1,9 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - resolveTelegramTransport, - shouldRetryTelegramIpv4Fallback, -} from "../../extensions/telegram/src/fetch.js"; -import { fetchRemoteMedia } from "./fetch.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const undiciMocks = vi.hoisted(() => { const createDispatcherCtor = | string>() => @@ -26,9 +21,20 @@ vi.mock("undici", () => ({ fetch: undiciMocks.fetch, })); +let resolveTelegramTransport: typeof import("../../extensions/telegram/src/fetch.js").resolveTelegramTransport; +let shouldRetryTelegramTransportFallback: typeof import("../../extensions/telegram/src/fetch.js").shouldRetryTelegramTransportFallback; +let fetchRemoteMedia: typeof import("./fetch.js").fetchRemoteMedia; + describe("fetchRemoteMedia telegram network policy", () => { type LookupFn = NonNullable[0]["lookupFn"]>; + beforeEach(async () => { + vi.resetModules(); + ({ resolveTelegramTransport, shouldRetryTelegramTransportFallback } = + await import("../../extensions/telegram/src/fetch.js")); + ({ fetchRemoteMedia } = await import("./fetch.js")); + }); + function createTelegramFetchFailedError(code: string): Error { return Object.assign(new TypeError("fetch failed"), { cause: { code }, @@ -64,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: { @@ -114,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: { @@ -161,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: { @@ -208,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: { @@ -226,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: { @@ -244,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.test.ts b/src/media/fetch.test.ts index 4498ca4b550..ea7354135d4 100644 --- a/src/media/fetch.test.ts +++ b/src/media/fetch.test.ts @@ -31,6 +31,29 @@ function makeLookupFn() { >; } +async function expectRedactedTelegramFetchError(params: { + telegramFileUrl: string; + telegramToken: string; + redactedTelegramToken: string; + fetchImpl: Parameters[0]["fetchImpl"]; +}) { + const error = await fetchRemoteMedia({ + url: params.telegramFileUrl, + fetchImpl: params.fetchImpl, + lookupFn: makeLookupFn(), + maxBytes: 1024, + ssrfPolicy: { + allowedHostnames: ["api.telegram.org"], + allowRfc2544BenchmarkRange: true, + }, + }).catch((err: unknown) => err as Error); + + expect(error).toBeInstanceOf(Error); + const errorText = error instanceof Error ? String(error) : ""; + expect(errorText).not.toContain(params.telegramToken); + expect(errorText).toContain(`bot${params.redactedTelegramToken}`); +} + describe("fetchRemoteMedia", () => { const telegramToken = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcd"; const redactedTelegramToken = `${telegramToken.slice(0, 6)}…${telegramToken.slice(-4)}`; @@ -100,41 +123,23 @@ describe("fetchRemoteMedia", () => { throw new Error(`dial failed for ${telegramFileUrl}`); }); - const error = await fetchRemoteMedia({ - url: telegramFileUrl, + await expectRedactedTelegramFetchError({ + telegramFileUrl, + telegramToken, + redactedTelegramToken, fetchImpl, - lookupFn: makeLookupFn(), - maxBytes: 1024, - ssrfPolicy: { - allowedHostnames: ["api.telegram.org"], - allowRfc2544BenchmarkRange: true, - }, - }).catch((err: unknown) => err as Error); - - expect(error).toBeInstanceOf(Error); - const errorText = error instanceof Error ? String(error) : ""; - expect(errorText).not.toContain(telegramToken); - expect(errorText).toContain(`bot${redactedTelegramToken}`); + }); }); it("redacts Telegram bot tokens from HTTP error messages", async () => { const fetchImpl = vi.fn(async () => new Response("unauthorized", { status: 401 })); - const error = await fetchRemoteMedia({ - url: telegramFileUrl, + await expectRedactedTelegramFetchError({ + telegramFileUrl, + telegramToken, + redactedTelegramToken, fetchImpl, - lookupFn: makeLookupFn(), - maxBytes: 1024, - ssrfPolicy: { - allowedHostnames: ["api.telegram.org"], - allowRfc2544BenchmarkRange: true, - }, - }).catch((err: unknown) => err as Error); - - expect(error).toBeInstanceOf(Error); - const errorText = error instanceof Error ? String(error) : ""; - expect(errorText).not.toContain(telegramToken); - expect(errorText).toContain(`bot${redactedTelegramToken}`); + }); }); it("blocks private IP literals before fetching", async () => { 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/media/input-files.fetch-guard.test.ts b/src/media/input-files.fetch-guard.test.ts index 377bbf78fa9..6bd9fbb4b81 100644 --- a/src/media/input-files.fetch-guard.test.ts +++ b/src/media/input-files.fetch-guard.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const fetchWithSsrFGuardMock = vi.fn(); const convertHeicToJpegMock = vi.fn(); @@ -24,15 +24,13 @@ let fetchWithGuard: typeof import("./input-files.js").fetchWithGuard; let extractImageContentFromSource: typeof import("./input-files.js").extractImageContentFromSource; let extractFileContentFromSource: typeof import("./input-files.js").extractFileContentFromSource; -beforeAll(async () => { +beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); ({ fetchWithGuard, extractImageContentFromSource, extractFileContentFromSource } = await import("./input-files.js")); }); -beforeEach(() => { - vi.clearAllMocks(); -}); - describe("HEIC input image normalization", () => { it("converts base64 HEIC images to JPEG before returning them", async () => { const normalized = Buffer.from("jpeg-normalized"); diff --git a/src/media/store.outside-workspace.test.ts b/src/media/store.outside-workspace.test.ts index 6483a856cd9..97c8c9df52b 100644 --- a/src/media/store.outside-workspace.test.ts +++ b/src/media/store.outside-workspace.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; const mocks = vi.hoisted(() => ({ @@ -15,13 +15,22 @@ vi.mock("../infra/fs-safe.js", async (importOriginal) => { }; }); -const { saveMediaSource } = await import("./store.js"); -const { SafeOpenError } = await import("../infra/fs-safe.js"); +type StoreModule = typeof import("./store.js"); +type FsSafeModule = typeof import("../infra/fs-safe.js"); + +let saveMediaSource: StoreModule["saveMediaSource"]; +let SafeOpenError: FsSafeModule["SafeOpenError"]; describe("media store outside-workspace mapping", () => { let tempHome: TempHomeEnv; let home = ""; + beforeEach(async () => { + vi.resetModules(); + ({ saveMediaSource } = await import("./store.js")); + ({ SafeOpenError } = await import("../infra/fs-safe.js")); + }); + beforeAll(async () => { tempHome = await createTempHomeEnv("openclaw-media-store-test-home-"); home = tempHome.home; diff --git a/src/memory/batch-http.test.ts b/src/memory/batch-http.test.ts index d70cdf292a2..275e3725eb9 100644 --- a/src/memory/batch-http.test.ts +++ b/src/memory/batch-http.test.ts @@ -1,7 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { retryAsync } from "../infra/retry.js"; -import { postJsonWithRetry } from "./batch-http.js"; -import { postJson } from "./post-json.js"; vi.mock("../infra/retry.js", () => ({ retryAsync: vi.fn(async (run: () => Promise) => await run()), @@ -12,11 +9,18 @@ vi.mock("./post-json.js", () => ({ })); describe("postJsonWithRetry", () => { - const retryAsyncMock = vi.mocked(retryAsync); - const postJsonMock = vi.mocked(postJson); + let retryAsyncMock: ReturnType>; + let postJsonMock: ReturnType>; + let postJsonWithRetry: typeof import("./batch-http.js").postJsonWithRetry; - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); vi.clearAllMocks(); + ({ postJsonWithRetry } = await import("./batch-http.js")); + const retryModule = await import("../infra/retry.js"); + const postJsonModule = await import("./post-json.js"); + retryAsyncMock = vi.mocked(retryModule.retryAsync); + postJsonMock = vi.mocked(postJsonModule.postJson); }); it("posts JSON and returns parsed response payload", async () => { diff --git a/src/memory/embedding-manager.test-harness.ts b/src/memory/embedding-manager.test-harness.ts index 6835c9cce27..c0e973fade1 100644 --- a/src/memory/embedding-manager.test-harness.ts +++ b/src/memory/embedding-manager.test-harness.ts @@ -1,14 +1,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, beforeEach, expect } from "vitest"; +import { afterAll, beforeAll, beforeEach, expect, vi, type Mock } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { getEmbedBatchMock, resetEmbeddingMocks } from "./embedding.test-mocks.js"; -import { - getMemorySearchManager, - type MemoryIndexManager, - type MemorySearchManager, -} from "./index.js"; +import type { MemoryIndexManager, MemorySearchManager } from "./index.js"; + +type EmbeddingTestMocksModule = typeof import("./embedding.test-mocks.js"); +type MemoryIndexModule = typeof import("./index.js"); export function installEmbeddingManagerFixture(opts: { fixturePrefix: string; @@ -21,7 +19,6 @@ export function installEmbeddingManagerFixture(opts: { }) => OpenClawConfig; resetIndexEachTest?: boolean; }) { - const embedBatch = getEmbedBatchMock(); const resetIndexEachTest = opts.resetIndexEachTest ?? true; let fixtureRoot: string | undefined; @@ -29,6 +26,9 @@ export function installEmbeddingManagerFixture(opts: { let memoryDir: string | undefined; let managerLarge: MemoryIndexManager | undefined; let managerSmall: MemoryIndexManager | undefined; + let embedBatch: Mock<(texts: string[]) => Promise> | undefined; + let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"]; + let resetEmbeddingMocks: EmbeddingTestMocksModule["resetEmbeddingMocks"]; const resetManager = (manager: MemoryIndexManager) => { (manager as unknown as { resetIndex: () => void }).resetIndex(); @@ -56,6 +56,12 @@ export function installEmbeddingManagerFixture(opts: { }; beforeAll(async () => { + vi.resetModules(); + await import("./embedding.test-mocks.js"); + const embeddingMocks = await import("./embedding.test-mocks.js"); + embedBatch = embeddingMocks.getEmbedBatchMock(); + resetEmbeddingMocks = embeddingMocks.resetEmbeddingMocks; + ({ getMemorySearchManager } = await import("./index.js")); fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), opts.fixturePrefix)); workspaceDir = path.join(fixtureRoot, "workspace"); memoryDir = path.join(workspaceDir, "memory"); @@ -116,7 +122,9 @@ export function installEmbeddingManagerFixture(opts: { }); return { - embedBatch, + get embedBatch() { + return requireValue(embedBatch, "embedBatch"); + }, getFixtureRoot: () => requireValue(fixtureRoot, "fixtureRoot"), getWorkspaceDir: () => requireValue(workspaceDir, "workspaceDir"), getMemoryDir: () => requireValue(memoryDir, "memoryDir"), diff --git a/src/memory/embeddings-remote-fetch.test.ts b/src/memory/embeddings-remote-fetch.test.ts index bcef98fafda..eeaa39e9277 100644 --- a/src/memory/embeddings-remote-fetch.test.ts +++ b/src/memory/embeddings-remote-fetch.test.ts @@ -1,15 +1,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fetchRemoteEmbeddingVectors } from "./embeddings-remote-fetch.js"; import { postJson } from "./post-json.js"; vi.mock("./post-json.js", () => ({ postJson: vi.fn(), })); +type EmbeddingsRemoteFetchModule = typeof import("./embeddings-remote-fetch.js"); + +let fetchRemoteEmbeddingVectors: EmbeddingsRemoteFetchModule["fetchRemoteEmbeddingVectors"]; + describe("fetchRemoteEmbeddingVectors", () => { const postJsonMock = vi.mocked(postJson); - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ fetchRemoteEmbeddingVectors } = await import("./embeddings-remote-fetch.js")); vi.clearAllMocks(); }); diff --git a/src/memory/embeddings-voyage.test.ts b/src/memory/embeddings-voyage.test.ts index ccc164bd064..9dac8c04d75 100644 --- a/src/memory/embeddings-voyage.test.ts +++ b/src/memory/embeddings-voyage.test.ts @@ -1,7 +1,5 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import * as authModule from "../agents/model-auth.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type FetchMock, withFetchPreconnect } from "../test-utils/fetch-mock.js"; -import { createVoyageEmbeddingProvider, normalizeVoyageModel } from "./embeddings-voyage.js"; import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; vi.mock("../agents/model-auth.js", async () => { @@ -20,6 +18,17 @@ const createFetchMock = () => { return withFetchPreconnect(fetchMock); }; +let authModule: typeof import("../agents/model-auth.js"); +let createVoyageEmbeddingProvider: typeof import("./embeddings-voyage.js").createVoyageEmbeddingProvider; +let normalizeVoyageModel: typeof import("./embeddings-voyage.js").normalizeVoyageModel; + +beforeEach(async () => { + vi.resetModules(); + authModule = await import("../agents/model-auth.js"); + ({ createVoyageEmbeddingProvider, normalizeVoyageModel } = + await import("./embeddings-voyage.js")); +}); + function mockVoyageApiKey() { vi.mocked(authModule.resolveApiKeyForProvider).mockResolvedValue({ apiKey: "voyage-key-123", diff --git a/src/memory/embeddings.test.ts b/src/memory/embeddings.test.ts index f15624ee1cb..8cf984522e2 100644 --- a/src/memory/embeddings.test.ts +++ b/src/memory/embeddings.test.ts @@ -1,7 +1,5 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import * as authModule from "../agents/model-auth.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_GEMINI_EMBEDDING_MODEL } from "./embeddings-gemini.js"; -import { createEmbeddingProvider, DEFAULT_LOCAL_MODEL } from "./embeddings.js"; import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; vi.mock("../agents/model-auth.js", async () => { @@ -33,12 +31,26 @@ function readFirstFetchRequest(fetchMock: { mock: { calls: unknown[][] } }) { return { url, init: init as RequestInit | undefined }; } +type EmbeddingsModule = typeof import("./embeddings.js"); +type AuthModule = typeof import("../agents/model-auth.js"); +type ResolvedProviderAuth = Awaited>; + +let authModule: AuthModule; +let createEmbeddingProvider: EmbeddingsModule["createEmbeddingProvider"]; +let DEFAULT_LOCAL_MODEL: EmbeddingsModule["DEFAULT_LOCAL_MODEL"]; + +beforeEach(async () => { + vi.resetModules(); + authModule = await import("../agents/model-auth.js"); + ({ createEmbeddingProvider, DEFAULT_LOCAL_MODEL } = await import("./embeddings.js")); +}); + afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); }); -function requireProvider(result: Awaited>) { +function requireProvider(result: Awaited>) { if (!result.provider) { throw new Error("Expected embedding provider"); } @@ -71,7 +83,7 @@ function createLocalProvider(options?: { fallback?: "none" | "openai" }) { } function expectAutoSelectedProvider( - result: Awaited>, + result: Awaited>, expectedId: "openai" | "gemini" | "mistral", ) { expect(result.requestedProvider).toBe("auto"); @@ -291,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, @@ -359,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); + } }); }); @@ -650,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/index.test.ts b/src/memory/index.test.ts index dcb0b061073..1072eab2cc4 100644 --- a/src/memory/index.test.ts +++ b/src/memory/index.test.ts @@ -3,8 +3,12 @@ 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 { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; import "./test-runtime-mocks.js"; +import type { MemoryIndexManager } from "./index.js"; + +type MemoryIndexModule = typeof import("./index.js"); + +let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"]; let embedBatchCalls = 0; let embedBatchInputCalls = 0; @@ -151,6 +155,9 @@ describe("memory index", () => { }); beforeEach(async () => { + vi.resetModules(); + await import("./test-runtime-mocks.js"); + ({ getMemorySearchManager } = await import("./index.js")); // Perf: most suites don't need atomic swap behavior for full reindexes. // Keep atomic reindex tests on the safe path. vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "1"); diff --git a/src/memory/manager.async-search.test.ts b/src/memory/manager.async-search.test.ts index 22ecd91b267..7250314cd55 100644 --- a/src/memory/manager.async-search.test.ts +++ b/src/memory/manager.async-search.test.ts @@ -87,7 +87,11 @@ 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(); + }); let closed = false; const closePromise = manager.close().then(() => { diff --git a/src/memory/manager.atomic-reindex.test.ts b/src/memory/manager.atomic-reindex.test.ts index d7d610312f5..b4dd35f9f37 100644 --- a/src/memory/manager.atomic-reindex.test.ts +++ b/src/memory/manager.atomic-reindex.test.ts @@ -3,25 +3,33 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { getEmbedBatchMock, resetEmbeddingMocks } from "./embedding.test-mocks.js"; import type { MemoryIndexManager } from "./index.js"; -import { getRequiredMemoryIndexManager } from "./test-manager-helpers.js"; let shouldFail = false; +type EmbeddingTestMocksModule = typeof import("./embedding.test-mocks.js"); +type TestManagerHelpersModule = typeof import("./test-manager-helpers.js"); + describe("memory manager atomic reindex", () => { let fixtureRoot = ""; let caseId = 0; let workspaceDir: string; let indexPath: string; let manager: MemoryIndexManager | null = null; - const embedBatch = getEmbedBatchMock(); + let embedBatch: ReturnType; + let resetEmbeddingMocks: EmbeddingTestMocksModule["resetEmbeddingMocks"]; + let getRequiredMemoryIndexManager: TestManagerHelpersModule["getRequiredMemoryIndexManager"]; beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-atomic-")); }); beforeEach(async () => { + vi.resetModules(); + const embeddingMocks = await import("./embedding.test-mocks.js"); + embedBatch = embeddingMocks.getEmbedBatchMock(); + resetEmbeddingMocks = embeddingMocks.resetEmbeddingMocks; + ({ getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js")); vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "0"); resetEmbeddingMocks(); shouldFail = false; diff --git a/src/memory/manager.batch.test.ts b/src/memory/manager.batch.test.ts index 453f1a6c815..38be2020f35 100644 --- a/src/memory/manager.batch.test.ts +++ b/src/memory/manager.batch.test.ts @@ -4,21 +4,15 @@ import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { useFastShortTimeouts } from "../../test/helpers/fast-short-timeouts.js"; import type { OpenClawConfig } from "../config/config.js"; -import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js"; import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; -import "./test-runtime-mocks.js"; + +type MemoryIndexManager = import("./index.js").MemoryIndexManager; +type MemoryIndexModule = typeof import("./index.js"); const embedBatch = vi.fn(async (_texts: string[]) => [] as number[][]); const embedQuery = vi.fn(async () => [0.5, 0.5, 0.5]); - -vi.mock("./embeddings.js", () => ({ - createEmbeddingProvider: async () => - createOpenAIEmbeddingProviderMock({ - embedQuery, - embedBatch, - }), -})); +let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"]; describe("memory indexing with OpenAI batches", () => { let fixtureRoot: string; @@ -118,6 +112,17 @@ describe("memory indexing with OpenAI batches", () => { } beforeAll(async () => { + vi.resetModules(); + vi.doMock("./embeddings.js", () => ({ + createEmbeddingProvider: async () => + createOpenAIEmbeddingProviderMock({ + embedQuery, + embedBatch, + }), + })); + await import("./test-runtime-mocks.js"); + ({ getMemorySearchManager } = await import("./index.js")); + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-batch-")); workspaceDir = path.join(fixtureRoot, "workspace"); memoryDir = path.join(workspaceDir, "memory"); diff --git a/src/memory/manager.embedding-batches.test.ts b/src/memory/manager.embedding-batches.test.ts index e2af1ed97f2..d7b1071deed 100644 --- a/src/memory/manager.embedding-batches.test.ts +++ b/src/memory/manager.embedding-batches.test.ts @@ -25,7 +25,6 @@ const fx = installEmbeddingManagerFixture({ }, }), }); -const { embedBatch } = fx; describe("memory embedding batches", () => { async function expectSyncWithFastTimeouts(manager: { @@ -55,13 +54,13 @@ describe("memory embedding batches", () => { }); const status = managerLarge.status(); - const totalTexts = embedBatch.mock.calls.reduce( + const totalTexts = fx.embedBatch.mock.calls.reduce( (sum: number, call: unknown[]) => sum + ((call[0] as string[] | undefined)?.length ?? 0), 0, ); expect(totalTexts).toBe(status.chunks); - expect(embedBatch.mock.calls.length).toBeGreaterThan(1); - const inputs: string[] = embedBatch.mock.calls.flatMap( + expect(fx.embedBatch.mock.calls.length).toBeGreaterThan(1); + const inputs: string[] = fx.embedBatch.mock.calls.flatMap( (call: unknown[]) => (call[0] as string[] | undefined) ?? [], ); expect(inputs.every((text) => Buffer.byteLength(text, "utf8") <= 8000)).toBe(true); @@ -80,7 +79,7 @@ describe("memory embedding batches", () => { await fs.writeFile(path.join(memoryDir, "2026-01-04.md"), content); await managerSmall.sync({ reason: "test" }); - expect(embedBatch.mock.calls.length).toBe(1); + expect(fx.embedBatch.mock.calls.length).toBe(1); }); it("retries embeddings on transient rate limit and 5xx errors", async () => { @@ -95,7 +94,7 @@ describe("memory embedding batches", () => { "openai embeddings failed: 502 Bad Gateway (cloudflare)", ]; let calls = 0; - embedBatch.mockImplementation(async (texts: string[]) => { + fx.embedBatch.mockImplementation(async (texts: string[]) => { calls += 1; const transient = transientErrors[calls - 1]; if (transient) { @@ -117,7 +116,7 @@ describe("memory embedding batches", () => { await fs.writeFile(path.join(memoryDir, "2026-01-08.md"), content); let calls = 0; - embedBatch.mockImplementation(async (texts: string[]) => { + fx.embedBatch.mockImplementation(async (texts: string[]) => { calls += 1; if (calls === 1) { throw new Error("AWS Bedrock embeddings failed: Too many tokens per day"); @@ -136,7 +135,9 @@ describe("memory embedding batches", () => { await fs.writeFile(path.join(memoryDir, "2026-01-07.md"), "\n\n\n"); await managerSmall.sync({ reason: "test" }); - const inputs = embedBatch.mock.calls.flatMap((call: unknown[]) => (call[0] as string[]) ?? []); + const inputs = fx.embedBatch.mock.calls.flatMap( + (call: unknown[]) => (call[0] as string[]) ?? [], + ); expect(inputs).not.toContain(""); }); }); diff --git a/src/memory/manager.get-concurrency.test.ts b/src/memory/manager.get-concurrency.test.ts index 515a9d8226d..236f6780b84 100644 --- a/src/memory/manager.get-concurrency.test.ts +++ b/src/memory/manager.get-concurrency.test.ts @@ -3,12 +3,11 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; -import { - closeAllMemoryIndexManagers, - MemoryIndexManager as RawMemoryIndexManager, -} from "./manager.js"; import "./test-runtime-mocks.js"; +import type { MemoryIndexManager } from "./index.js"; + +type MemoryIndexModule = typeof import("./index.js"); +type ManagerModule = typeof import("./manager.js"); const hoisted = vi.hoisted(() => ({ providerCreateCalls: 0, @@ -34,10 +33,19 @@ vi.mock("./embeddings.js", () => ({ }, })); +let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"]; +let closeAllMemoryIndexManagers: ManagerModule["closeAllMemoryIndexManagers"]; +let RawMemoryIndexManager: ManagerModule["MemoryIndexManager"]; + describe("memory manager cache hydration", () => { let workspaceDir = ""; beforeEach(async () => { + vi.resetModules(); + await import("./test-runtime-mocks.js"); + ({ getMemorySearchManager } = await import("./index.js")); + ({ closeAllMemoryIndexManagers, MemoryIndexManager: RawMemoryIndexManager } = + await import("./manager.js")); workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-concurrent-")); await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Hello memory."); diff --git a/src/memory/manager.mistral-provider.test.ts b/src/memory/manager.mistral-provider.test.ts index 3345b01933c..be10e3c232b 100644 --- a/src/memory/manager.mistral-provider.test.ts +++ b/src/memory/manager.mistral-provider.test.ts @@ -11,7 +11,7 @@ import type { OllamaEmbeddingClient, OpenAiEmbeddingClient, } from "./embeddings.js"; -import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; +import type { MemoryIndexManager } from "./index.js"; const { createEmbeddingProviderMock } = vi.hoisted(() => ({ createEmbeddingProviderMock: vi.fn(), @@ -25,6 +25,10 @@ vi.mock("./sqlite-vec.js", () => ({ loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }), })); +type MemoryIndexModule = typeof import("./index.js"); + +let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"]; + function createProvider(id: string): EmbeddingProvider { return { id, @@ -64,6 +68,8 @@ describe("memory manager mistral provider wiring", () => { let manager: MemoryIndexManager | null = null; beforeEach(async () => { + vi.resetModules(); + ({ getMemorySearchManager } = await import("./index.js")); createEmbeddingProviderMock.mockReset(); workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-mistral-")); indexPath = path.join(workspaceDir, "index.sqlite"); diff --git a/src/memory/manager.vector-dedupe.test.ts b/src/memory/manager.vector-dedupe.test.ts index fcd21a88431..64242ec3f0e 100644 --- a/src/memory/manager.vector-dedupe.test.ts +++ b/src/memory/manager.vector-dedupe.test.ts @@ -4,8 +4,6 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { MemoryIndexManager } from "./index.js"; -import { buildFileEntry } from "./internal.js"; -import { createMemoryManagerOrThrow } from "./test-manager.js"; vi.mock("./embeddings.js", () => { return { @@ -21,6 +19,12 @@ vi.mock("./embeddings.js", () => { }; }); +type MemoryInternalModule = typeof import("./internal.js"); +type TestManagerModule = typeof import("./test-manager.js"); + +let buildFileEntry: MemoryInternalModule["buildFileEntry"]; +let createMemoryManagerOrThrow: TestManagerModule["createMemoryManagerOrThrow"]; + describe("memory vector dedupe", () => { let workspaceDir: string; let indexPath: string; @@ -40,6 +44,9 @@ describe("memory vector dedupe", () => { } beforeEach(async () => { + vi.resetModules(); + ({ buildFileEntry } = await import("./internal.js")); + ({ createMemoryManagerOrThrow } = await import("./test-manager.js")); workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-")); indexPath = path.join(workspaceDir, "index.sqlite"); await seedMemoryWorkspace(workspaceDir); diff --git a/src/memory/manager.watcher-config.test.ts b/src/memory/manager.watcher-config.test.ts index b10cf84c71f..36d1b830e4a 100644 --- a/src/memory/manager.watcher-config.test.ts +++ b/src/memory/manager.watcher-config.test.ts @@ -1,10 +1,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { MemorySearchConfig } from "../config/types.tools.js"; -import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; +import type { MemoryIndexManager } from "./index.js"; const { watchMock } = vi.hoisted(() => ({ watchMock: vi.fn(() => ({ @@ -34,11 +34,20 @@ vi.mock("./embeddings.js", () => ({ }), })); +type MemoryIndexModule = typeof import("./index.js"); + +let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"]; + describe("memory watcher config", () => { let manager: MemoryIndexManager | null = null; let workspaceDir = ""; let extraDir = ""; + beforeEach(async () => { + vi.resetModules(); + ({ getMemorySearchManager } = await import("./index.js")); + }); + afterEach(async () => { watchMock.mockClear(); if (manager) { diff --git a/src/memory/post-json.test.ts b/src/memory/post-json.test.ts index 7e1aaf27cb6..1fd4210c111 100644 --- a/src/memory/post-json.test.ts +++ b/src/memory/post-json.test.ts @@ -1,16 +1,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { postJson } from "./post-json.js"; -import { withRemoteHttpResponse } from "./remote-http.js"; vi.mock("./remote-http.js", () => ({ withRemoteHttpResponse: vi.fn(), })); -describe("postJson", () => { - const remoteHttpMock = vi.mocked(withRemoteHttpResponse); +let postJson: typeof import("./post-json.js").postJson; +let withRemoteHttpResponse: typeof import("./remote-http.js").withRemoteHttpResponse; - beforeEach(() => { +describe("postJson", () => { + let remoteHttpMock: ReturnType>; + + beforeEach(async () => { + vi.resetModules(); vi.clearAllMocks(); + ({ postJson } = await import("./post-json.js")); + ({ withRemoteHttpResponse } = await import("./remote-http.js")); + remoteHttpMock = vi.mocked(withRemoteHttpResponse); }); it("parses JSON payload on successful response", async () => { diff --git a/src/memory/test-manager-helpers.ts b/src/memory/test-manager-helpers.ts index 4bbcf2d608e..cfe3f09e49f 100644 --- a/src/memory/test-manager-helpers.ts +++ b/src/memory/test-manager-helpers.ts @@ -1,10 +1,12 @@ import type { OpenClawConfig } from "../config/config.js"; -import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; +import type { MemoryIndexManager } from "./index.js"; export async function getRequiredMemoryIndexManager(params: { cfg: OpenClawConfig; agentId?: string; }): Promise { + await import("./embedding.test-mocks.js"); + const { getMemorySearchManager } = await import("./index.js"); const result = await getMemorySearchManager({ cfg: params.cfg, agentId: params.agentId ?? "main", diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index e72a9399623..6622f6c010f 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SecretInput } from "../config/types.secrets.js"; -import { encodePairingSetupCode, resolvePairingSetupFromConfig } from "./setup-code.js"; vi.mock("../infra/device-bootstrap.js", () => ({ issueDeviceBootstrapToken: vi.fn(async () => ({ @@ -9,6 +8,9 @@ vi.mock("../infra/device-bootstrap.js", () => ({ })), })); +let encodePairingSetupCode: typeof import("./setup-code.js").encodePairingSetupCode; +let resolvePairingSetupFromConfig: typeof import("./setup-code.js").resolvePairingSetupFromConfig; + describe("pairing setup code", () => { type ResolvedSetup = Awaited>; const defaultEnvSecretProviderConfig = { @@ -68,10 +70,17 @@ describe("pairing setup code", () => { } beforeEach(() => { + vi.resetModules(); vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", ""); vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", ""); vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", ""); vi.stubEnv("CLAWDBOT_GATEWAY_PASSWORD", ""); + vi.stubEnv("OPENCLAW_GATEWAY_PORT", ""); + vi.stubEnv("CLAWDBOT_GATEWAY_PORT", ""); + }); + + beforeEach(async () => { + ({ encodePairingSetupCode, resolvePairingSetupFromConfig } = await import("./setup-code.js")); }); afterEach(() => { 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/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index 03490dc8432..20ab0596a12 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -16,14 +16,22 @@ export * from "../agents/provider-id.js"; export * from "../agents/schema/typebox.js"; export * from "../agents/sglang-defaults.js"; export * from "../agents/tools/common.js"; -export * from "../agents/tools/discord-actions-shared.js"; -export * from "../agents/tools/discord-actions.js"; -export * from "../agents/tools/telegram-actions.js"; export * from "../agents/tools/web-guarded-fetch.js"; export * from "../agents/tools/web-shared.js"; -export * from "../agents/tools/discord-actions-moderation-shared.js"; export * from "../agents/tools/web-fetch-utils.js"; export * from "../agents/vllm-defaults.js"; // Intentional public runtime surface: channel plugins use ingress agent helpers directly. export * from "../agents/agent-command.js"; export * from "../tts/tts.js"; +// Legacy channel action runtime re-exports. New bundled plugin code should use +// local extension-owned modules instead of adding more public SDK surface here. +export { + handleDiscordAction, + readDiscordParentIdParam, + isDiscordModerationAction, + readDiscordModerationCommand, +} from "../../extensions/discord/runtime-api.js"; +export { + handleTelegramAction, + readTelegramButtons, +} from "../../extensions/telegram/runtime-api.js"; 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 447489b1a0f..b953d4d974a 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -4,6 +4,37 @@ 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", + "nostr", + "nextcloud-talk", + "nostr", + "signal", + "slack", + "synology-chat", + "telegram", + "tlon", + "twitch", + "whatsapp", + "zalo", + "zalouser", +]); type GuardedSource = { path: string; @@ -11,33 +42,29 @@ type GuardedSource = { }; const SAME_CHANNEL_SDK_GUARDS: GuardedSource[] = [ - { - path: "extensions/discord/src/plugin-shared.ts", - forbiddenPatterns: [/openclaw\/plugin-sdk\/discord/, /plugin-sdk-internal\/discord/], - }, { 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/], }, ]; @@ -89,6 +116,22 @@ const SETUP_BARREL_GUARDS: GuardedSource[] = [ }, ]; +const LOCAL_EXTENSION_API_BARREL_GUARDS = [ + "device-pair", + "diagnostics-otel", + "diffs", + "llm-task", + "line", + "mattermost", + "memory-lancedb", + "nextcloud-talk", + "synology-chat", + "talk-voice", + "thread-ownership", + "tlon", + "voice-call", +] as const; + function readSource(path: string): string { return readFileSync(resolve(ROOT_DIR, "..", path), "utf8"); } @@ -138,6 +181,48 @@ function collectExtensionSourceFiles(): string[] { } if ( fullPath.includes(".test.") || + 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") ) { @@ -149,6 +234,67 @@ function collectExtensionSourceFiles(): string[] { return files; } +function collectExtensionFiles(extensionId: string): string[] { + const extensionDir = resolve(ROOT_DIR, "..", "extensions", extensionId); + const files: string[] = []; + const stack = [extensionDir]; + 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(".test-") || + fullPath.includes(".spec.") || + fullPath.includes(".fixture.") || + fullPath.includes(".snap") || + fullPath.endsWith("/runtime-api.ts") + ) { + continue; + } + files.push(fullPath); + } + } + 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) { @@ -181,4 +327,56 @@ 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"))); + } + }); + + it("keeps internalized extension helper seams behind local api barrels", () => { + for (const extensionId of LOCAL_EXTENSION_API_BARREL_GUARDS) { + for (const file of collectExtensionFiles(extensionId)) { + const normalized = file.replaceAll("\\", "/"); + if ( + normalized.endsWith("/api.ts") || + normalized.includes(".test.") || + normalized.includes(".spec.") || + normalized.includes(".fixture.") || + normalized.includes(".snap") + ) { + continue; + } + const text = readFileSync(file, "utf8"); + expect( + text, + `${normalized} should import ${extensionId} helpers via the local api barrel`, + ).not.toMatch(new RegExp(`["']openclaw/plugin-sdk/${extensionId}["']`, "u")); + } + } + }); }); diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index fad81c36d59..1460acba87d 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -34,6 +34,8 @@ export type * from "../channels/plugins/types.js"; export * from "../channels/plugins/config-writes.js"; export * from "../channels/plugins/directory-config.js"; export * from "../channels/plugins/media-payload.js"; +export * from "../channels/plugins/message-tool-legacy.js"; +export * from "../channels/plugins/message-tool-schema.js"; export * from "../channels/plugins/normalize/signal.js"; export * from "../channels/plugins/normalize/whatsapp.js"; export * from "../channels/plugins/outbound/direct-text-media.js"; 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/copilot-proxy.ts b/src/plugin-sdk/copilot-proxy.ts index 80a83010c1d..d4a4dec92bf 100644 --- a/src/plugin-sdk/copilot-proxy.ts +++ b/src/plugin-sdk/copilot-proxy.ts @@ -1,7 +1,7 @@ // Narrow plugin-sdk surface for the bundled copilot-proxy plugin. // Keep this list additive and scoped to symbols used under extensions/copilot-proxy. -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { definePluginEntry } from "./core.js"; export type { OpenClawPluginApi, ProviderAuthContext, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 1cfea088601..56f0bdafa26 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -5,6 +5,7 @@ import type { OpenClawPluginApi, OpenClawPluginCommandDefinition, OpenClawPluginConfigSchema, + OpenClawPluginDefinition, PluginInteractiveTelegramHandlerContext, } from "../plugins/types.js"; @@ -42,6 +43,7 @@ export type { ProviderAuthMethod, ProviderAuthResult, OpenClawPluginCommandDefinition, + OpenClawPluginDefinition, PluginInteractiveTelegramHandlerContext, } from "../plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; @@ -57,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, @@ -88,11 +102,53 @@ type DefineChannelPluginEntryOptions OpenClawPluginConfigSchema; + configSchema?: DefinePluginEntryOptions["configSchema"]; setRuntime?: (runtime: PluginRuntime) => void; registerFull?: (api: OpenClawPluginApi) => void; }; +type DefinePluginEntryOptions = { + id: string; + name: string; + description: string; + kind?: OpenClawPluginDefinition["kind"]; + configSchema?: OpenClawPluginConfigSchema | (() => OpenClawPluginConfigSchema); + register: (api: OpenClawPluginApi) => void; +}; + +type DefinedPluginEntry = { + id: string; + name: string; + description: string; + configSchema: OpenClawPluginConfigSchema; + register: NonNullable; +} & Pick; + +function resolvePluginConfigSchema( + configSchema: DefinePluginEntryOptions["configSchema"] = emptyPluginConfigSchema, +): OpenClawPluginConfigSchema { + return typeof configSchema === "function" ? configSchema() : configSchema; +} + +// Shared generic plugin-entry boilerplate for bundled and third-party plugins. +export function definePluginEntry({ + id, + name, + description, + kind, + configSchema = emptyPluginConfigSchema, + register, +}: DefinePluginEntryOptions): DefinedPluginEntry { + return { + id, + name, + description, + ...(kind ? { kind } : {}), + configSchema: resolvePluginConfigSchema(configSchema), + register, + }; +} + // Shared channel-plugin entry boilerplate for bundled and third-party channels. export function defineChannelPluginEntry({ id, @@ -103,11 +159,11 @@ export function defineChannelPluginEntry({ setRuntime, registerFull, }: DefineChannelPluginEntryOptions) { - return { + return definePluginEntry({ id, name, description, - configSchema: configSchema(), + configSchema, register(api: OpenClawPluginApi) { setRuntime?.(api.runtime); api.registerChannel({ plugin }); @@ -116,7 +172,7 @@ export function defineChannelPluginEntry({ } registerFull?.(api); }, - }; + }); } // Shared setup-entry shape so bundled channels do not duplicate `{ plugin }`. diff --git a/src/plugin-sdk/device-pair.ts b/src/plugin-sdk/device-pair.ts index 5828ad0535f..a87e1eea8f1 100644 --- a/src/plugin-sdk/device-pair.ts +++ b/src/plugin-sdk/device-pair.ts @@ -1,6 +1,7 @@ // Narrow plugin-sdk surface for the bundled device-pair plugin. // Keep this list additive and scoped to symbols used under extensions/device-pair. +export { definePluginEntry } from "./core.js"; export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js"; export { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; 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/image-generation.ts b/src/plugin-sdk/image-generation.ts index d9afa8b3a3d..25fde2e9d2b 100644 --- a/src/plugin-sdk/image-generation.ts +++ b/src/plugin-sdk/image-generation.ts @@ -3,8 +3,10 @@ export type { GeneratedImageAsset, ImageGenerationProvider, + ImageGenerationResolution, ImageGenerationRequest, ImageGenerationResult, + ImageGenerationSourceImage, } from "../image-generation/types.js"; export { buildGoogleImageGenerationProvider } from "../image-generation/providers/google.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 178c0e20b22..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,13 +15,15 @@ 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", () => { const forbidden = [ "chunkMarkdownText", "chunkText", - "resolveTextChunkLimit", "hasControlCommand", "isControlCommandMessage", "shouldComputeCommandAuthorized", @@ -25,9 +31,7 @@ describe("plugin-sdk exports", () => { "buildMentionRegexes", "matchesMentionPatterns", "resolveStateDir", - "loadConfig", "writeConfigFile", - "runCommandWithTimeout", "enqueueSystemEvent", "fetchRemoteMedia", "saveMediaBuffer", @@ -66,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"); } @@ -83,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( @@ -117,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 1f9198d4e7f..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, @@ -46,5 +63,7 @@ export type { RuntimeEnv } from "../runtime.js"; export type { HookEntry } from "../hooks/types.js"; export type { ReplyPayload } from "../auto-reply/types.js"; export type { WizardPrompter } from "../wizard/prompts.js"; +export type { ContextEngineFactory } from "../context-engine/registry.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { registerContextEngine } from "../context-engine/registry.js"; 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/llm-task.ts b/src/plugin-sdk/llm-task.ts index c69e82f36f7..b93a3197d26 100644 --- a/src/plugin-sdk/llm-task.ts +++ b/src/plugin-sdk/llm-task.ts @@ -1,6 +1,7 @@ // Narrow plugin-sdk surface for the bundled llm-task plugin. // Keep this list additive and scoped to symbols used under extensions/llm-task. +export { definePluginEntry } from "./core.js"; export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; export { formatThinkingLevels, diff --git a/src/plugin-sdk/lobster.ts b/src/plugin-sdk/lobster.ts index 436acdf4d45..968fcf2cae1 100644 --- a/src/plugin-sdk/lobster.ts +++ b/src/plugin-sdk/lobster.ts @@ -1,6 +1,7 @@ // Narrow plugin-sdk surface for the bundled lobster plugin. // Keep this list additive and scoped to symbols used under extensions/lobster. +export { definePluginEntry } from "./core.js"; export { applyWindowsSpawnProgramPolicy, materializeWindowsSpawnProgram, 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/memory-lancedb.ts b/src/plugin-sdk/memory-lancedb.ts index 840ed95982c..23d3e2619c8 100644 --- a/src/plugin-sdk/memory-lancedb.ts +++ b/src/plugin-sdk/memory-lancedb.ts @@ -1,4 +1,5 @@ // Narrow plugin-sdk surface for the bundled memory-lancedb plugin. // Keep this list additive and scoped to symbols used under extensions/memory-lancedb. +export { definePluginEntry } from "./core.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; diff --git a/src/plugin-sdk/minimax-portal-auth.ts b/src/plugin-sdk/minimax-portal-auth.ts index 07aefa0aafa..a8dad415488 100644 --- a/src/plugin-sdk/minimax-portal-auth.ts +++ b/src/plugin-sdk/minimax-portal-auth.ts @@ -1,7 +1,7 @@ // Narrow plugin-sdk surface for MiniMax OAuth helpers used by the bundled minimax plugin. // Keep this list additive and scoped to MiniMax OAuth support code. -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { definePluginEntry } from "./core.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export type { OpenClawPluginApi, 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/open-prose.ts b/src/plugin-sdk/open-prose.ts index 1973404f2a8..049370ed986 100644 --- a/src/plugin-sdk/open-prose.ts +++ b/src/plugin-sdk/open-prose.ts @@ -1,4 +1,5 @@ // Narrow plugin-sdk surface for the bundled open-prose plugin. // Keep this list additive and scoped to symbols used under extensions/open-prose. +export { definePluginEntry } from "./core.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; diff --git a/src/plugin-sdk/outbound-media.test.ts b/src/plugin-sdk/outbound-media.test.ts index bc56f2e6ea4..84b0db6def9 100644 --- a/src/plugin-sdk/outbound-media.test.ts +++ b/src/plugin-sdk/outbound-media.test.ts @@ -1,5 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; -import { loadOutboundMediaFromUrl } from "./outbound-media.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const loadWebMediaMock = vi.hoisted(() => vi.fn()); @@ -7,7 +6,17 @@ vi.mock("../../extensions/whatsapp/src/media.js", () => ({ loadWebMedia: loadWebMediaMock, })); +type OutboundMediaModule = typeof import("./outbound-media.js"); + +let loadOutboundMediaFromUrl: OutboundMediaModule["loadOutboundMediaFromUrl"]; + describe("loadOutboundMediaFromUrl", () => { + beforeEach(async () => { + vi.resetModules(); + ({ loadOutboundMediaFromUrl } = await import("./outbound-media.js")); + loadWebMediaMock.mockReset(); + }); + it("forwards maxBytes and mediaLocalRoots to loadWebMedia", async () => { loadWebMediaMock.mockResolvedValueOnce({ buffer: Buffer.from("x"), diff --git a/src/plugin-sdk/phone-control.ts b/src/plugin-sdk/phone-control.ts index 394ff9c88ee..c116eba1076 100644 --- a/src/plugin-sdk/phone-control.ts +++ b/src/plugin-sdk/phone-control.ts @@ -1,6 +1,7 @@ // Narrow plugin-sdk surface for the bundled phone-control plugin. // Keep this list additive and scoped to symbols used under extensions/phone-control. +export { definePluginEntry } from "./core.js"; export type { OpenClawPluginApi, OpenClawPluginCommandDefinition, diff --git a/src/plugin-sdk/plugin-runtime.ts b/src/plugin-sdk/plugin-runtime.ts index ecc80f8f224..7286beae159 100644 --- a/src/plugin-sdk/plugin-runtime.ts +++ b/src/plugin-sdk/plugin-runtime.ts @@ -2,5 +2,7 @@ export * from "../plugins/commands.js"; export * from "../plugins/hook-runner-global.js"; +export * from "../plugins/http-path.js"; +export * from "../plugins/http-registry.js"; export * from "../plugins/interactive.js"; export * from "../plugins/types.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 c8764cf0001..9f6f3442ad4 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/qwen-portal-auth.ts b/src/plugin-sdk/qwen-portal-auth.ts index f6cde98b90f..adc61259a09 100644 --- a/src/plugin-sdk/qwen-portal-auth.ts +++ b/src/plugin-sdk/qwen-portal-auth.ts @@ -1,7 +1,7 @@ // Narrow plugin-sdk surface for the bundled qwen-portal-auth plugin. // Keep this list additive and scoped to symbols used under extensions/qwen-portal-auth. -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { definePluginEntry } from "./core.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export type { OpenClawPluginApi, 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 8e6793543af..bb3dcfe7c59 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,6 +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 "../../extensions/slack/runtime-api.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 156f7d9b81f..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"; @@ -20,6 +21,7 @@ import * as setupSdk from "openclaw/plugin-sdk/setup"; import * as signalSdk from "openclaw/plugin-sdk/signal"; import * as slackSdk from "openclaw/plugin-sdk/slack"; import * as telegramSdk from "openclaw/plugin-sdk/telegram"; +import * as testingSdk from "openclaw/plugin-sdk/testing"; import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; import { describe, expect, expectTypeOf, it } from "vitest"; import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; @@ -40,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", () => { @@ -49,6 +64,7 @@ describe("plugin-sdk subpath exports", () => { it("keeps core focused on generic shared exports", () => { expect(typeof coreSdk.emptyPluginConfigSchema).toBe("function"); + expect(typeof coreSdk.definePluginEntry).toBe("function"); expect(typeof coreSdk.defineChannelPluginEntry).toBe("function"); expect(typeof coreSdk.defineSetupPluginEntry).toBe("function"); expect("runPassiveAccountLifecycle" in asExports(coreSdk)).toBe(false); @@ -84,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"); @@ -113,6 +135,11 @@ describe("plugin-sdk subpath exports", () => { expectTypeOf().toMatchTypeOf(); }); + it("exports the public testing seam", () => { + expect(typeof testingSdk.removeAckReactionAfterReply).toBe("function"); + expect(typeof testingSdk.shouldAckReaction).toBe("function"); + }); + it("keeps core shared types aligned with the channel prelude", () => { expectTypeOf().toMatchTypeOf(); expectTypeOf().toMatchTypeOf(); @@ -155,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"); @@ -170,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"); }); @@ -195,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"); }); @@ -240,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/talk-voice.ts b/src/plugin-sdk/talk-voice.ts index 3ee313ec42f..e89f210af62 100644 --- a/src/plugin-sdk/talk-voice.ts +++ b/src/plugin-sdk/talk-voice.ts @@ -1,4 +1,5 @@ // Narrow plugin-sdk surface for the bundled talk-voice plugin. // Keep this list additive and scoped to symbols used under extensions/talk-voice. +export { definePluginEntry } from "./core.js"; export type { OpenClawPluginApi } from "../plugins/types.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/test-utils.ts b/src/plugin-sdk/test-utils.ts index 5d825813d0e..26cddc72854 100644 --- a/src/plugin-sdk/test-utils.ts +++ b/src/plugin-sdk/test-utils.ts @@ -1,9 +1,4 @@ -// Narrow plugin-sdk surface for the bundled test-utils plugin. -// Keep this list additive and scoped to symbols used under extensions/test-utils. +// Deprecated compatibility alias. +// Prefer openclaw/plugin-sdk/testing for public test helpers. -export { removeAckReactionAfterReply, shouldAckReaction } from "../channels/ack-reactions.js"; -export type { ChannelAccountSnapshot, ChannelGatewayContext } from "../channels/plugins/types.js"; -export type { OpenClawConfig } from "../config/config.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { RuntimeEnv } from "../runtime.js"; -export type { MockFn } from "../test-utils/vitest-mock-fn.js"; +export * from "./testing.js"; diff --git a/src/plugin-sdk/testing.ts b/src/plugin-sdk/testing.ts new file mode 100644 index 00000000000..e8a7e89f646 --- /dev/null +++ b/src/plugin-sdk/testing.ts @@ -0,0 +1,9 @@ +// Narrow public testing surface for plugin authors. +// Keep this list additive and limited to helpers we are willing to support. + +export { removeAckReactionAfterReply, shouldAckReaction } from "../channels/ack-reactions.js"; +export type { ChannelAccountSnapshot, ChannelGatewayContext } from "../channels/plugins/types.js"; +export type { OpenClawConfig } from "../config/config.js"; +export type { PluginRuntime } from "../plugins/runtime/types.js"; +export type { RuntimeEnv } from "../runtime.js"; +export type { MockFn } from "../test-utils/vitest-mock-fn.js"; diff --git a/src/plugin-sdk/thread-ownership.ts b/src/plugin-sdk/thread-ownership.ts index 48d72fa5d35..ea8ad079a8c 100644 --- a/src/plugin-sdk/thread-ownership.ts +++ b/src/plugin-sdk/thread-ownership.ts @@ -1,5 +1,6 @@ // Narrow plugin-sdk surface for the bundled thread-ownership plugin. // Keep this list additive and scoped to symbols used under extensions/thread-ownership. +export { definePluginEntry } from "./core.js"; export type { OpenClawConfig } from "../config/config.js"; export type { OpenClawPluginApi } from "../plugins/types.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/voice-call.ts b/src/plugin-sdk/voice-call.ts index 7dea0885862..b3f1a889f78 100644 --- a/src/plugin-sdk/voice-call.ts +++ b/src/plugin-sdk/voice-call.ts @@ -1,6 +1,7 @@ // Narrow plugin-sdk surface for the bundled voice-call plugin. // Keep this list additive and scoped to symbols used under extensions/voice-call. +export { definePluginEntry } from "./core.js"; export { TtsAutoSchema, TtsConfigSchema, 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/build-smoke-entry.ts b/src/plugins/build-smoke-entry.ts new file mode 100644 index 00000000000..c4604dedfeb --- /dev/null +++ b/src/plugins/build-smoke-entry.ts @@ -0,0 +1,7 @@ +export { + clearPluginCommands, + executePluginCommand, + getPluginCommandSpecs, + matchPluginCommand, +} from "./commands.js"; +export { loadOpenClawPlugins } from "./loader.js"; diff --git a/src/plugins/bundle-mcp.test-support.ts b/src/plugins/bundle-mcp.test-support.ts new file mode 100644 index 00000000000..8b6723e7e13 --- /dev/null +++ b/src/plugins/bundle-mcp.test-support.ts @@ -0,0 +1,54 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; + +export function createBundleMcpTempHarness() { + const tempDirs: string[] = []; + + return { + async createTempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + }, + async cleanup() { + clearPluginManifestRegistryCache(); + await Promise.all( + tempDirs + .splice(0, tempDirs.length) + .map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }, + }; +} + +export async function createBundleProbePlugin(homeDir: string) { + const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "bundle-probe"); + const serverPath = path.join(pluginRoot, "servers", "probe.mjs"); + await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); + await fs.mkdir(path.dirname(serverPath), { recursive: true }); + await fs.writeFile(serverPath, "export {};\n", "utf-8"); + await fs.writeFile( + path.join(pluginRoot, ".claude-plugin", "plugin.json"), + `${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile( + path.join(pluginRoot, ".mcp.json"), + `${JSON.stringify( + { + mcpServers: { + bundleProbe: { + command: "node", + args: ["./servers/probe.mjs"], + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + return { pluginRoot, serverPath }; +} diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index ce4c460baf0..b9d5ca18cf3 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -1,69 +1,34 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { captureEnv } from "../test-utils/env.js"; import { isRecord } from "../utils.js"; import { loadEnabledBundleMcpConfig } from "./bundle-mcp.js"; -import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; - -const tempDirs: string[] = []; +import { createBundleMcpTempHarness, createBundleProbePlugin } from "./bundle-mcp.test-support.js"; function getServerArgs(value: unknown): unknown[] | undefined { return isRecord(value) && Array.isArray(value.args) ? value.args : undefined; } -async function createTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} +const tempHarness = createBundleMcpTempHarness(); afterEach(async () => { - clearPluginManifestRegistryCache(); - await Promise.all( - tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); + await tempHarness.cleanup(); }); describe("loadEnabledBundleMcpConfig", () => { it("loads enabled Claude bundle MCP config and absolutizes relative args", async () => { const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); try { - const homeDir = await createTempDir("openclaw-bundle-mcp-home-"); - const workspaceDir = await createTempDir("openclaw-bundle-mcp-workspace-"); + const homeDir = await tempHarness.createTempDir("openclaw-bundle-mcp-home-"); + const workspaceDir = await tempHarness.createTempDir("openclaw-bundle-mcp-workspace-"); process.env.HOME = homeDir; process.env.USERPROFILE = homeDir; delete process.env.OPENCLAW_HOME; delete process.env.OPENCLAW_STATE_DIR; - const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "bundle-probe"); - const serverPath = path.join(pluginRoot, "servers", "probe.mjs"); - await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); - await fs.mkdir(path.dirname(serverPath), { recursive: true }); - await fs.writeFile(serverPath, "export {};\n", "utf-8"); - await fs.writeFile( - path.join(pluginRoot, ".claude-plugin", "plugin.json"), - `${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`, - "utf-8", - ); - await fs.writeFile( - path.join(pluginRoot, ".mcp.json"), - `${JSON.stringify( - { - mcpServers: { - bundleProbe: { - command: "node", - args: ["./servers/probe.mjs"], - }, - }, - }, - null, - 2, - )}\n`, - "utf-8", - ); + const { pluginRoot, serverPath } = await createBundleProbePlugin(homeDir); const config: OpenClawConfig = { plugins: { @@ -100,8 +65,8 @@ describe("loadEnabledBundleMcpConfig", () => { it("merges inline bundle MCP servers and skips disabled bundles", async () => { const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); try { - const homeDir = await createTempDir("openclaw-bundle-inline-home-"); - const workspaceDir = await createTempDir("openclaw-bundle-inline-workspace-"); + const homeDir = await tempHarness.createTempDir("openclaw-bundle-inline-home-"); + const workspaceDir = await tempHarness.createTempDir("openclaw-bundle-inline-workspace-"); process.env.HOME = homeDir; process.env.USERPROFILE = homeDir; delete process.env.OPENCLAW_HOME; @@ -170,8 +135,10 @@ describe("loadEnabledBundleMcpConfig", () => { it("resolves inline Claude MCP paths from the plugin root and expands CLAUDE_PLUGIN_ROOT", async () => { const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); try { - const homeDir = await createTempDir("openclaw-bundle-inline-placeholder-home-"); - const workspaceDir = await createTempDir("openclaw-bundle-inline-placeholder-workspace-"); + const homeDir = await tempHarness.createTempDir("openclaw-bundle-inline-placeholder-home-"); + const workspaceDir = await tempHarness.createTempDir( + "openclaw-bundle-inline-placeholder-workspace-", + ); process.env.HOME = homeDir; process.env.USERPROFILE = homeDir; delete process.env.OPENCLAW_HOME; 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 a75d7304270..58a0f6ab4fd 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; } >; @@ -124,11 +129,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, }; } @@ -238,6 +275,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" }; @@ -262,7 +300,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") { @@ -295,8 +333,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 4842bef5e76..355ceb43962 100644 --- a/src/plugins/contracts/auth.contract.test.ts +++ b/src/plugins/contracts/auth.contract.test.ts @@ -4,43 +4,61 @@ import { replaceRuntimeAuthProfileStoreSnapshots, } from "../../agents/auth-profiles/store.js"; import { createNonExitingRuntime } from "../../runtime.js"; +import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; import type { WizardMultiSelectParams, WizardPrompter, WizardProgress, WizardSelectParams, } from "../../wizard/prompts.js"; -import { registerProviders, requireProvider } from "./testkit.js"; +import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; type LoginOpenAICodexOAuth = - (typeof import("../../plugins/provider-openai-codex-oauth.js"))["loginOpenAICodexOAuth"]; + (typeof import("openclaw/plugin-sdk/provider-auth"))["loginOpenAICodexOAuth"]; type LoginQwenPortalOAuth = (typeof import("../../../extensions/qwen-portal-auth/oauth.js"))["loginQwenPortalOAuth"]; type GithubCopilotLoginCommand = - (typeof import("../../providers/github-copilot-auth.js"))["githubCopilotLoginCommand"]; + (typeof import("openclaw/plugin-sdk/provider-auth"))["githubCopilotLoginCommand"]; type CreateVpsAwareHandlers = - (typeof import("../../plugins/provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"]; + (typeof import("../provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"]; const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn()); const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn()); const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn()); -vi.mock("../../plugins/provider-openai-codex-oauth.js", () => ({ - loginOpenAICodexOAuth: loginOpenAICodexOAuthMock, -})); +vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loginOpenAICodexOAuth: loginOpenAICodexOAuthMock, + githubCopilotLoginCommand: githubCopilotLoginCommandMock, + }; +}); vi.mock("../../../extensions/qwen-portal-auth/oauth.js", () => ({ loginQwenPortalOAuth: loginQwenPortalOAuthMock, })); -vi.mock("../../providers/github-copilot-auth.js", () => ({ - githubCopilotLoginCommand: githubCopilotLoginCommandMock, -})); - const openAIPlugin = (await import("../../../extensions/openai/index.js")).default; const qwenPortalPlugin = (await import("../../../extensions/qwen-portal-auth/index.js")).default; const githubCopilotPlugin = (await import("../../../extensions/github-copilot/index.js")).default; +function registerProviders(...plugins: Array<{ register(api: OpenClawPluginApi): void }>) { + const captured = createCapturedPluginRegistration(); + for (const plugin of plugins) { + plugin.register(captured.api); + } + return captured.providers; +} + +function requireProvider(providers: ProviderPlugin[], providerId: string) { + const provider = providers.find((entry) => entry.id === providerId); + if (!provider) { + throw new Error(`provider ${providerId} missing`); + } + return provider; +} + function buildPrompter(): WizardPrompter { const progress: WizardProgress = { update() {}, diff --git a/src/plugins/contracts/catalog.contract.test.ts b/src/plugins/contracts/catalog.contract.test.ts index dcfe0c86f6a..a87e632ac45 100644 --- a/src/plugins/contracts/catalog.contract.test.ts +++ b/src/plugins/contracts/catalog.contract.test.ts @@ -1,35 +1,61 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, it, vi } from "vitest"; import { - providerContractPluginIds, + expectAugmentedCodexCatalog, + expectCodexBuiltInSuppression, + expectCodexMissingAuthHint, +} from "../provider-runtime.test-support.js"; +import { + resolveProviderContractPluginIdsForProvider, resolveProviderContractProvidersForPluginIds, uniqueProviderContractProviders, } from "./registry.js"; -const resolvePluginProvidersMock = vi.fn(); -const resolveOwningPluginIdsForProviderMock = vi.fn(); -const resolveNonBundledProviderPluginIdsMock = vi.fn(); +type ResolvePluginProviders = typeof import("../providers.js").resolvePluginProviders; +type ResolveOwningPluginIdsForProvider = + typeof import("../providers.js").resolveOwningPluginIdsForProvider; +type ResolveNonBundledProviderPluginIds = + typeof import("../providers.js").resolveNonBundledProviderPluginIds; + +const resolvePluginProvidersMock = vi.hoisted(() => + vi.fn((_) => uniqueProviderContractProviders), +); +const resolveOwningPluginIdsForProviderMock = vi.hoisted(() => + vi.fn((params) => + resolveProviderContractPluginIdsForProvider(params.provider), + ), +); +const resolveNonBundledProviderPluginIdsMock = vi.hoisted(() => + vi.fn((_) => [] as string[]), +); vi.mock("../providers.js", () => ({ - resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args), - resolveOwningPluginIdsForProvider: (...args: unknown[]) => - resolveOwningPluginIdsForProviderMock(...args), - resolveNonBundledProviderPluginIds: (...args: unknown[]) => - resolveNonBundledProviderPluginIdsMock(...args), + resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), + resolveOwningPluginIdsForProvider: (params: unknown) => + resolveOwningPluginIdsForProviderMock(params as never), + resolveNonBundledProviderPluginIds: (params: unknown) => + resolveNonBundledProviderPluginIdsMock(params as never), })); -const { - augmentModelCatalogWithProviderPlugins, - buildProviderMissingAuthMessageWithPlugin, - resetProviderRuntimeHookCacheForTest, - resolveProviderBuiltInModelSuppression, -} = await import("../provider-runtime.js"); +let augmentModelCatalogWithProviderPlugins: typeof import("../provider-runtime.js").augmentModelCatalogWithProviderPlugins; +let buildProviderMissingAuthMessageWithPlugin: typeof import("../provider-runtime.js").buildProviderMissingAuthMessageWithPlugin; +let resetProviderRuntimeHookCacheForTest: typeof import("../provider-runtime.js").resetProviderRuntimeHookCacheForTest; +let resolveProviderBuiltInModelSuppression: typeof import("../provider-runtime.js").resolveProviderBuiltInModelSuppression; describe("provider catalog contract", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ + augmentModelCatalogWithProviderPlugins, + buildProviderMissingAuthMessageWithPlugin, + resetProviderRuntimeHookCacheForTest, + resolveProviderBuiltInModelSuppression, + } = await import("../provider-runtime.js")); resetProviderRuntimeHookCacheForTest(); resolveOwningPluginIdsForProviderMock.mockReset(); - resolveOwningPluginIdsForProviderMock.mockReturnValue(providerContractPluginIds); + resolveOwningPluginIdsForProviderMock.mockImplementation((params) => + resolveProviderContractPluginIdsForProvider(params.provider), + ); resolveNonBundledProviderPluginIdsMock.mockReset(); resolveNonBundledProviderPluginIdsMock.mockReturnValue([]); @@ -45,57 +71,14 @@ describe("provider catalog contract", () => { }); it("keeps codex-only missing-auth hints wired through the provider runtime", () => { - expect( - buildProviderMissingAuthMessageWithPlugin({ - provider: "openai", - env: process.env, - context: { - env: process.env, - provider: "openai", - listProfileIds: (providerId) => (providerId === "openai-codex" ? ["p1"] : []), - }, - }), - ).toContain("openai-codex/gpt-5.4"); + expectCodexMissingAuthHint(buildProviderMissingAuthMessageWithPlugin); }); it("keeps built-in model suppression wired through the provider runtime", () => { - expect( - resolveProviderBuiltInModelSuppression({ - env: process.env, - context: { - env: process.env, - provider: "azure-openai-responses", - modelId: "gpt-5.3-codex-spark", - }, - }), - ).toMatchObject({ - suppress: true, - errorMessage: expect.stringContaining("openai-codex/gpt-5.3-codex-spark"), - }); + expectCodexBuiltInSuppression(resolveProviderBuiltInModelSuppression); }); it("keeps bundled model augmentation wired through the provider runtime", async () => { - await expect( - augmentModelCatalogWithProviderPlugins({ - env: process.env, - context: { - env: process.env, - entries: [ - { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, - { provider: "openai", id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, - { provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, - ], - }, - }), - ).resolves.toEqual([ - { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, - { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, - { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, - { - provider: "openai-codex", - id: "gpt-5.3-codex-spark", - name: "gpt-5.3-codex-spark", - }, - ]); + await expectAugmentedCodexCatalog(augmentModelCatalogWithProviderPlugins); }); }); diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index 0a334a619a1..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 { @@ -75,6 +90,74 @@ function createModelConfig(id: string, name = id): ModelDefinitionConfig { maxTokens: 8_192, }; } + +function setQwenPortalOauthSnapshot() { + replaceRuntimeAuthProfileStoreSnapshots([ + { + store: { + version: 1, + profiles: { + "qwen-portal:default": { + type: "oauth", + provider: "qwen-portal", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + }, + ]); +} + +function setGithubCopilotProfileSnapshot() { + replaceRuntimeAuthProfileStoreSnapshots([ + { + store: { + version: 1, + profiles: { + "github-copilot:github": { + type: "token", + provider: "github-copilot", + token: "profile-token", + }, + }, + }, + }, + ]); +} + +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", + })), + }); +} + describe("provider discovery contract", () => { afterEach(() => { resolveCopilotApiTokenMock.mockReset(); @@ -85,30 +168,11 @@ describe("provider discovery contract", () => { }); it("keeps qwen portal oauth marker fallback provider-owned", async () => { - const provider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal"); - replaceRuntimeAuthProfileStoreSnapshots([ - { - store: { - version: 1, - profiles: { - "qwen-portal:default": { - type: "oauth", - provider: "qwen-portal", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60_000, - }, - }, - }, - }, - ]); + setQwenPortalOauthSnapshot(); await expect( - runProviderCatalog({ - provider, - config: {}, - env: {} as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), + runCatalog({ + provider: qwenPortalProvider, }), ).resolves.toEqual({ provider: { @@ -124,28 +188,11 @@ describe("provider discovery contract", () => { }); it("keeps qwen portal env api keys higher priority than oauth markers", async () => { - const provider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal"); - replaceRuntimeAuthProfileStoreSnapshots([ - { - store: { - version: 1, - profiles: { - "qwen-portal:default": { - type: "oauth", - provider: "qwen-portal", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60_000, - }, - }, - }, - }, - ]); + setQwenPortalOauthSnapshot(); await expect( - runProviderCatalog({ - provider, - config: {}, + runCatalog({ + provider: qwenPortalProvider, env: { QWEN_PORTAL_API_KEY: "env-key" } as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: "env-key" }), }), @@ -157,41 +204,15 @@ describe("provider discovery contract", () => { }); it("keeps GitHub Copilot catalog disabled without env tokens or profiles", async () => { - const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot"); - - await expect( - runProviderCatalog({ - provider, - config: {}, - env: {} as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), - }), - ).resolves.toBeNull(); + await expect(runCatalog({ provider: githubCopilotProvider })).resolves.toBeNull(); }); it("keeps GitHub Copilot profile-only catalog fallback provider-owned", async () => { - const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot"); - replaceRuntimeAuthProfileStoreSnapshots([ - { - store: { - version: 1, - profiles: { - "github-copilot:github": { - type: "token", - provider: "github-copilot", - token: "profile-token", - }, - }, - }, - }, - ]); + setGithubCopilotProfileSnapshot(); await expect( - runProviderCatalog({ - provider, - config: {}, - env: {} as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), + runCatalog({ + provider: githubCopilotProvider, }), ).resolves.toEqual({ provider: { @@ -202,7 +223,6 @@ describe("provider discovery contract", () => { }); it("keeps GitHub Copilot env-token base URL resolution provider-owned", async () => { - const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot"); resolveCopilotApiTokenMock.mockResolvedValueOnce({ token: "copilot-api-token", baseUrl: "https://copilot-proxy.example.com", @@ -210,9 +230,8 @@ describe("provider discovery contract", () => { }); await expect( - runProviderCatalog({ - provider, - config: {}, + runCatalog({ + 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/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index dde3ef19c19..c550f1d96b2 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -1,10 +1,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { withBundledPluginAllowlistCompat } from "../bundled-compat.js"; +import { loadPluginManifestRegistry } from "../manifest-registry.js"; import { __testing as providerTesting } from "../providers.js"; import { resolvePluginWebSearchProviders } from "../web-search-providers.js"; import { providerContractCompatPluginIds, webSearchProviderContractRegistry } from "./registry.js"; import { uniqueSortedStrings } from "./testkit.js"; +function resolveBundledManifestProviderPluginIds() { + return uniqueSortedStrings( + loadPluginManifestRegistry({}) + .plugins.filter((plugin) => plugin.origin === "bundled" && plugin.providers.length > 0) + .map((plugin) => plugin.id), + ); +} + describe("plugin loader contract", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -12,6 +21,7 @@ describe("plugin loader contract", () => { it("keeps bundled provider compatibility wired to the provider registry", () => { const providerPluginIds = uniqueSortedStrings(providerContractCompatPluginIds); + const manifestProviderPluginIds = resolveBundledManifestProviderPluginIds(); const compatPluginIds = providerTesting.resolveBundledProviderCompatPluginIds({ config: { plugins: { @@ -29,18 +39,22 @@ describe("plugin loader contract", () => { pluginIds: compatPluginIds, }); + expect(providerPluginIds).toEqual(manifestProviderPluginIds); + expect(uniqueSortedStrings(compatPluginIds)).toEqual(manifestProviderPluginIds); expect(uniqueSortedStrings(compatPluginIds)).toEqual(expect.arrayContaining(providerPluginIds)); expect(compatConfig?.plugins?.allow).toEqual(expect.arrayContaining(providerPluginIds)); }); it("keeps vitest bundled provider enablement wired to the provider registry", () => { const providerPluginIds = uniqueSortedStrings(providerContractCompatPluginIds); + const manifestProviderPluginIds = resolveBundledManifestProviderPluginIds(); const compatConfig = providerTesting.withBundledProviderVitestCompat({ config: undefined, pluginIds: providerPluginIds, env: { VITEST: "1" } as NodeJS.ProcessEnv, }); + expect(providerPluginIds).toEqual(manifestProviderPluginIds); expect(compatConfig?.plugins).toMatchObject({ enabled: true, allow: expect.arrayContaining(providerPluginIds), diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index adedfe57d0c..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(), ]; @@ -177,6 +139,19 @@ export function requireProviderContractProvider(providerId: string): ProviderPlu return provider; } +export function resolveProviderContractPluginIdsForProvider( + providerId: string, +): string[] | undefined { + const pluginIds = [ + ...new Set( + providerContractRegistry + .filter((entry) => entry.provider.id === providerId) + .map((entry) => entry.pluginId), + ), + ]; + return pluginIds.length > 0 ? pluginIds : undefined; +} + export function resolveProviderContractProvidersForPluginIds( pluginIds: readonly string[], ): ProviderPlugin[] { @@ -221,7 +196,6 @@ export const imageGenerationProviderContractRegistry: ImageGenerationProviderCon const bundledPluginRegistrationList = [ ...new Map( [ - ...bundledProviderPlugins, ...bundledSpeechPlugins, ...bundledMediaUnderstandingPlugins, ...bundledImageGenerationPlugins, @@ -230,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/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index 15adc59e130..4009d31886a 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -330,6 +330,38 @@ describe("provider runtime contract", () => { }); }); + it("owns openai gpt-5.4 mini forward-compat resolution", () => { + const provider = requireProviderContractProvider("openai"); + const model = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-5.4-mini", + modelRegistry: { + find: (_provider: string, id: string) => + id === "gpt-5-mini" + ? createModel({ + id, + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + input: ["text", "image"], + reasoning: true, + contextWindow: 400_000, + maxTokens: 128_000, + }) + : null, + } as never, + }); + + expect(model).toMatchObject({ + id: "gpt-5.4-mini", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + contextWindow: 400_000, + maxTokens: 128_000, + }); + }); + it("owns direct openai transport normalization", () => { const provider = requireProviderContractProvider("openai"); expect( diff --git a/src/plugins/contracts/shape.contract.test.ts b/src/plugins/contracts/shape.contract.test.ts new file mode 100644 index 00000000000..ffc7c92360a --- /dev/null +++ b/src/plugins/contracts/shape.contract.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { createPluginRegistry, type PluginRecord } from "../registry.js"; +import type { PluginRuntime } from "../runtime/types.js"; +import { buildAllPluginInspectReports } from "../status.js"; +import type { OpenClawPluginApi } from "../types.js"; + +function createPluginRecord(id: string, name: string): PluginRecord { + return { + id, + name, + source: `/virtual/${id}/index.ts`, + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }; +} + +function registerTestPlugin(params: { + registry: ReturnType; + config: OpenClawConfig; + record: PluginRecord; + register(api: OpenClawPluginApi): void; +}) { + params.registry.registry.plugins.push(params.record); + params.register( + params.registry.createApi(params.record, { + config: params.config, + }), + ); +} + +describe("plugin shape compatibility matrix", () => { + it("keeps legacy hook-only, plain capability, and hybrid capability shapes explicit", () => { + const config = {} as OpenClawConfig; + const registry = createPluginRegistry({ + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + runtime: {} as PluginRuntime, + }); + + registerTestPlugin({ + registry, + config, + record: createPluginRecord("lca-legacy", "LCA Legacy"), + register(api) { + api.on("before_agent_start", () => ({ + prependContext: "legacy", + })); + }, + }); + + registerTestPlugin({ + registry, + config, + record: createPluginRecord("plain-provider", "Plain Provider"), + register(api) { + api.registerProvider({ + id: "plain-provider", + label: "Plain Provider", + auth: [], + }); + }, + }); + + registerTestPlugin({ + registry, + config, + record: createPluginRecord("hybrid-company", "Hybrid Company"), + register(api) { + api.registerProvider({ + id: "hybrid-company", + label: "Hybrid Company", + auth: [], + }); + api.registerWebSearchProvider({ + id: "hybrid-search", + label: "Hybrid Search", + hint: "Search the web", + envVars: ["HYBRID_SEARCH_KEY"], + placeholder: "hsk_...", + signupUrl: "https://example.com/signup", + getCredentialValue: () => "hsk-test", + setCredentialValue(searchConfigTarget, value) { + searchConfigTarget.apiKey = value; + }, + createTool: () => ({ + description: "Hybrid search", + parameters: {}, + execute: async () => ({}), + }), + }); + }, + }); + + registerTestPlugin({ + registry, + config, + record: createPluginRecord("channel-demo", "Channel Demo"), + register(api) { + api.registerChannel({ + plugin: { + id: "channel-demo", + meta: { + id: "channel-demo", + label: "Channel Demo", + selectionLabel: "Channel Demo", + docsPath: "/channels/channel-demo", + blurb: "channel demo", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, + }); + + const inspect = buildAllPluginInspectReports({ + config, + report: { + workspaceDir: "/virtual-workspace", + ...registry.registry, + }, + }); + + expect( + inspect.map((entry) => ({ + id: entry.plugin.id, + shape: entry.shape, + capabilityMode: entry.capabilityMode, + })), + ).toEqual([ + { + id: "lca-legacy", + shape: "hook-only", + capabilityMode: "none", + }, + { + id: "plain-provider", + shape: "plain-capability", + capabilityMode: "plain", + }, + { + id: "hybrid-company", + shape: "hybrid-capability", + capabilityMode: "hybrid", + }, + { + id: "channel-demo", + shape: "plain-capability", + capabilityMode: "plain", + }, + ]); + + expect(inspect[0]?.usesLegacyBeforeAgentStart).toBe(true); + expect(inspect[1]?.capabilities.map((entry) => entry.kind)).toEqual(["text-inference"]); + expect(inspect[2]?.capabilities.map((entry) => entry.kind)).toEqual([ + "text-inference", + "web-search", + ]); + expect(inspect[3]?.capabilities.map((entry) => entry.kind)).toEqual(["channel"]); + }); +}); 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 0a673572d59..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"); @@ -102,6 +104,8 @@ const { const { registerSessionBindingAdapter, unregisterSessionBindingAdapter } = await import("../infra/outbound/session-binding-service.js"); +type PluginBindingRequest = Awaited>; + function createAdapter(channel: string, accountId: string): SessionBindingAdapter { return { channel, @@ -119,10 +123,43 @@ function createAdapter(channel: string, accountId: string): SessionBindingAdapte }; } +async function resolveRequestedBinding(request: PluginBindingRequest) { + expect(["pending", "bound"]).toContain(request.status); + if (request.status === "pending") { + const approved = await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }); + expect(approved.status).toBe("approved"); + if (approved.status !== "approved") { + throw new Error("expected approved bind result"); + } + return approved.binding; + } + if (request.status === "bound") { + return request.binding; + } + 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" }); @@ -344,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", @@ -485,25 +738,7 @@ describe("plugin conversation binding approvals", () => { binding: { summary: "Bind this conversation to Codex thread abc." }, }); - expect(["pending", "bound"]).toContain(request.status); - const binding = - request.status === "pending" - ? await resolvePluginConversationBindingApproval({ - approvalId: request.approvalId, - decision: "allow-once", - senderId: "user-1", - }).then((approved) => { - expect(approved.status).toBe("approved"); - if (approved.status !== "approved") { - throw new Error("expected approved bind result"); - } - return approved.binding; - }) - : request.status === "bound" - ? request.binding - : (() => { - throw new Error("expected pending or bound bind result"); - })(); + const binding = await resolveRequestedBinding(request); expect(binding).toEqual( expect.objectContaining({ @@ -546,25 +781,7 @@ describe("plugin conversation binding approvals", () => { }, }); - expect(["pending", "bound"]).toContain(request.status); - const binding = - request.status === "pending" - ? await resolvePluginConversationBindingApproval({ - approvalId: request.approvalId, - decision: "allow-once", - senderId: "user-1", - }).then((approved) => { - expect(approved.status).toBe("approved"); - if (approved.status !== "approved") { - throw new Error("expected approved bind result"); - } - return approved.binding; - }) - : request.status === "bound" - ? request.binding - : (() => { - throw new Error("expected pending or bound bind result"); - })(); + const binding = await resolveRequestedBinding(request); expect(binding).toEqual( expect.objectContaining({ 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/interactive.test.ts b/src/plugins/interactive.test.ts index 14980ec4545..2b595e856f8 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -1,10 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; import * as conversationBinding from "./conversation-binding.js"; +import type { + DiscordInteractiveDispatchContext, + SlackInteractiveDispatchContext, + TelegramInteractiveDispatchContext, +} from "./interactive-dispatch-adapters.js"; import { clearPluginInteractiveHandlers, dispatchPluginInteractiveHandler, registerPluginInteractiveHandler, } from "./interactive.js"; +import type { + PluginInteractiveDiscordHandlerContext, + PluginInteractiveSlackHandlerContext, + PluginInteractiveTelegramHandlerContext, +} from "./types.js"; let requestPluginConversationBindingMock: MockInstance< typeof conversationBinding.requestPluginConversationBinding @@ -16,6 +26,53 @@ let getCurrentPluginConversationBindingMock: MockInstance< typeof conversationBinding.getCurrentPluginConversationBinding >; +type InteractiveDispatchParams = + | { + channel: "telegram"; + data: string; + callbackId: string; + ctx: TelegramInteractiveDispatchContext; + respond: PluginInteractiveTelegramHandlerContext["respond"]; + } + | { + channel: "discord"; + data: string; + interactionId: string; + ctx: DiscordInteractiveDispatchContext; + respond: PluginInteractiveDiscordHandlerContext["respond"]; + } + | { + channel: "slack"; + data: string; + interactionId: string; + ctx: SlackInteractiveDispatchContext; + respond: PluginInteractiveSlackHandlerContext["respond"]; + }; + +async function expectDedupedInteractiveDispatch(params: { + baseParams: InteractiveDispatchParams; + handler: ReturnType; + expectedCall: unknown; +}) { + const dispatch = async (baseParams: InteractiveDispatchParams) => { + if (baseParams.channel === "telegram") { + return await dispatchPluginInteractiveHandler(baseParams); + } + if (baseParams.channel === "discord") { + return await dispatchPluginInteractiveHandler(baseParams); + } + return await dispatchPluginInteractiveHandler(baseParams); + }; + + const first = await dispatch(params.baseParams); + const duplicate = await dispatch(params.baseParams); + + expect(first).toEqual({ matched: true, handled: true, duplicate: false }); + expect(duplicate).toEqual({ matched: true, handled: true, duplicate: true }); + expect(params.handler).toHaveBeenCalledTimes(1); + expect(params.handler).toHaveBeenCalledWith(expect.objectContaining(params.expectedCall)); +} + describe("plugin interactive handlers", () => { beforeEach(() => { clearPluginInteractiveHandlers(); @@ -99,14 +156,10 @@ describe("plugin interactive handlers", () => { }, }; - const first = await dispatchPluginInteractiveHandler(baseParams); - const duplicate = await dispatchPluginInteractiveHandler(baseParams); - - expect(first).toEqual({ matched: true, handled: true, duplicate: false }); - expect(duplicate).toEqual({ matched: true, handled: true, duplicate: true }); - expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ + await expectDedupedInteractiveDispatch({ + baseParams, + handler, + expectedCall: { channel: "telegram", conversationId: "-10099:topic:77", callback: expect.objectContaining({ @@ -115,8 +168,8 @@ describe("plugin interactive handlers", () => { chatId: "-10099", messageId: 55, }), - }), - ); + }, + }); }); it("rejects duplicate namespace registrations", () => { @@ -176,14 +229,10 @@ describe("plugin interactive handlers", () => { }, }; - const first = await dispatchPluginInteractiveHandler(baseParams); - const duplicate = await dispatchPluginInteractiveHandler(baseParams); - - expect(first).toEqual({ matched: true, handled: true, duplicate: false }); - expect(duplicate).toEqual({ matched: true, handled: true, duplicate: true }); - expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ + await expectDedupedInteractiveDispatch({ + baseParams, + handler, + expectedCall: { channel: "discord", conversationId: "channel-1", interaction: expect.objectContaining({ @@ -192,8 +241,8 @@ describe("plugin interactive handlers", () => { messageId: "message-1", values: ["allow"], }), - }), - ); + }, + }); }); it("routes Slack interactions by namespace and dedupes interaction ids", async () => { @@ -241,14 +290,10 @@ describe("plugin interactive handlers", () => { }, }; - const first = await dispatchPluginInteractiveHandler(baseParams); - const duplicate = await dispatchPluginInteractiveHandler(baseParams); - - expect(first).toEqual({ matched: true, handled: true, duplicate: false }); - expect(duplicate).toEqual({ matched: true, handled: true, duplicate: true }); - expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ + await expectDedupedInteractiveDispatch({ + baseParams, + handler, + expectedCall: { channel: "slack", conversationId: "C123", threadId: "1710000000.000100", @@ -258,8 +303,8 @@ describe("plugin interactive handlers", () => { actionId: "codex", messageTs: "1710000000.000200", }), - }), - ); + }, + }); }); it("wires Telegram conversation binding helpers with topic context", async () => { diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index a1e25c0ea3e..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,146 +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", () => { - 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"); @@ -529,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(() => { @@ -615,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", () => { @@ -697,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 () => { @@ -925,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", () => { @@ -1347,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", @@ -1372,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", @@ -1428,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", () => { @@ -1776,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", () => { @@ -2018,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 () => { @@ -2559,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, @@ -2865,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", () => { @@ -2929,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", () => { @@ -3294,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", () => { @@ -3353,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({}); @@ -3393,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-api-key-auth.runtime.ts b/src/plugins/provider-api-key-auth.runtime.ts index ad37b986b91..40404f512af 100644 --- a/src/plugins/provider-api-key-auth.runtime.ts +++ b/src/plugins/provider-api-key-auth.runtime.ts @@ -6,7 +6,7 @@ import { } from "./provider-auth-input.js"; import { applyPrimaryModel } from "./provider-model-primary.js"; -export { +export const providerApiKeyAuthRuntime = { applyAuthProfileConfig, applyPrimaryModel, buildApiKeyCredential, diff --git a/src/plugins/provider-api-key-auth.ts b/src/plugins/provider-api-key-auth.ts index aa3805aea8f..183c8c4f5f0 100644 --- a/src/plugins/provider-api-key-auth.ts +++ b/src/plugins/provider-api-key-auth.ts @@ -1,6 +1,7 @@ import { upsertAuthProfile } from "../agents/auth-profiles/profiles.js"; import type { OpenClawConfig } from "../config/config.js"; import type { SecretInput } from "../config/types.secrets.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import type { ProviderAuthMethod, @@ -29,14 +30,10 @@ type ProviderApiKeyAuthMethodOptions = { applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig; }; -let providerApiKeyAuthRuntimePromise: - | Promise - | undefined; - -function loadProviderApiKeyAuthRuntime() { - providerApiKeyAuthRuntimePromise ??= import("./provider-api-key-auth.runtime.js"); - return providerApiKeyAuthRuntimePromise; -} +const loadProviderApiKeyAuthRuntime = createLazyRuntimeSurface( + () => import("./provider-api-key-auth.runtime.js"), + ({ providerApiKeyAuthRuntime }) => providerApiKeyAuthRuntime, +); function resolveStringOption(opts: Record | undefined, optionKey: string) { return normalizeOptionalSecretInput(opts?.[optionKey]); diff --git a/src/plugins/provider-catalog-metadata.ts b/src/plugins/provider-catalog-metadata.ts index 5714861b219..1347fe00629 100644 --- a/src/plugins/provider-catalog-metadata.ts +++ b/src/plugins/provider-catalog-metadata.ts @@ -38,6 +38,16 @@ export function augmentBundledProviderCatalog( providerId: OPENAI_PROVIDER_ID, templateIds: ["gpt-5.2-pro", "gpt-5.2"], }); + const openAiGpt54MiniTemplate = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_PROVIDER_ID, + templateIds: ["gpt-5-mini"], + }); + const openAiGpt54NanoTemplate = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_PROVIDER_ID, + templateIds: ["gpt-5-nano", "gpt-5-mini"], + }); const openAiCodexGpt54Template = findCatalogTemplate({ entries: context.entries, providerId: OPENAI_CODEX_PROVIDER_ID, @@ -64,6 +74,20 @@ export function augmentBundledProviderCatalog( name: "gpt-5.4-pro", } : undefined, + openAiGpt54MiniTemplate + ? { + ...openAiGpt54MiniTemplate, + id: "gpt-5.4-mini", + name: "gpt-5.4-mini", + } + : undefined, + openAiGpt54NanoTemplate + ? { + ...openAiGpt54NanoTemplate, + id: "gpt-5.4-nano", + name: "gpt-5.4-nano", + } + : undefined, openAiCodexGpt54Template ? { ...openAiCodexGpt54Template, 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/provider-runtime.test-support.ts b/src/plugins/provider-runtime.test-support.ts new file mode 100644 index 00000000000..9e9fb0bb877 --- /dev/null +++ b/src/plugins/provider-runtime.test-support.ts @@ -0,0 +1,91 @@ +import { expect } from "vitest"; + +export const openaiCodexCatalogEntries = [ + { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, + { provider: "openai", id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, + { provider: "openai", id: "gpt-5-mini", name: "GPT-5 mini" }, + { provider: "openai", id: "gpt-5-nano", name: "GPT-5 nano" }, + { provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, +]; + +export const expectedAugmentedOpenaiCodexCatalogEntries = [ + { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, + { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, + { provider: "openai", id: "gpt-5.4-mini", name: "gpt-5.4-mini" }, + { provider: "openai", id: "gpt-5.4-nano", name: "gpt-5.4-nano" }, + { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, + { + provider: "openai-codex", + id: "gpt-5.3-codex-spark", + name: "gpt-5.3-codex-spark", + }, +]; + +export function expectCodexMissingAuthHint( + buildProviderMissingAuthMessageWithPlugin: (params: { + provider: string; + env: NodeJS.ProcessEnv; + context: { + env: NodeJS.ProcessEnv; + provider: string; + listProfileIds: (providerId: string) => string[]; + }; + }) => string | undefined, +) { + expect( + buildProviderMissingAuthMessageWithPlugin({ + provider: "openai", + env: process.env, + context: { + env: process.env, + provider: "openai", + listProfileIds: (providerId) => (providerId === "openai-codex" ? ["p1"] : []), + }, + }), + ).toContain("openai-codex/gpt-5.4"); +} + +export function expectCodexBuiltInSuppression( + resolveProviderBuiltInModelSuppression: (params: { + env: NodeJS.ProcessEnv; + context: { + env: NodeJS.ProcessEnv; + provider: string; + modelId: string; + }; + }) => unknown, +) { + expect( + resolveProviderBuiltInModelSuppression({ + env: process.env, + context: { + env: process.env, + provider: "azure-openai-responses", + modelId: "gpt-5.3-codex-spark", + }, + }), + ).toMatchObject({ + suppress: true, + errorMessage: expect.stringContaining("openai-codex/gpt-5.3-codex-spark"), + }); +} + +export async function expectAugmentedCodexCatalog( + augmentModelCatalogWithProviderPlugins: (params: { + env: NodeJS.ProcessEnv; + context: { + env: NodeJS.ProcessEnv; + entries: typeof openaiCodexCatalogEntries; + }; + }) => Promise, +) { + await expect( + augmentModelCatalogWithProviderPlugins({ + env: process.env, + context: { + env: process.env, + entries: openaiCodexCatalogEntries, + }, + }), + ).resolves.toEqual(expectedAugmentedOpenaiCodexCatalogEntries); +} diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 07ee1794562..2c1cc1e2d57 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -1,4 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + expectAugmentedCodexCatalog, + expectCodexBuiltInSuppression, + expectCodexMissingAuthHint, +} from "./provider-runtime.test-support.js"; import type { ProviderPlugin, ProviderRuntimeModel } from "./types.js"; type ResolvePluginProviders = typeof import("./providers.js").resolvePluginProviders; @@ -23,30 +28,28 @@ vi.mock("./providers.js", () => ({ resolveOwningPluginIdsForProviderMock(params as never), })); -import { - augmentModelCatalogWithProviderPlugins, - buildProviderAuthDoctorHintWithPlugin, - buildProviderMissingAuthMessageWithPlugin, - formatProviderAuthProfileApiKeyWithPlugin, - prepareProviderExtraParams, - resolveProviderCacheTtlEligibility, - resolveProviderBinaryThinking, - resolveProviderBuiltInModelSuppression, - resolveProviderDefaultThinkingLevel, - resolveProviderModernModelRef, - resolveProviderUsageSnapshotWithPlugin, - resolveProviderCapabilitiesWithPlugin, - resolveProviderUsageAuthWithPlugin, - resolveProviderXHighThinking, - normalizeProviderResolvedModelWithPlugin, - prepareProviderDynamicModel, - prepareProviderRuntimeAuth, - resetProviderRuntimeHookCacheForTest, - refreshProviderOAuthCredentialWithPlugin, - resolveProviderRuntimePlugin, - runProviderDynamicModel, - wrapProviderStreamFn, -} from "./provider-runtime.js"; +let augmentModelCatalogWithProviderPlugins: typeof import("./provider-runtime.js").augmentModelCatalogWithProviderPlugins; +let buildProviderAuthDoctorHintWithPlugin: typeof import("./provider-runtime.js").buildProviderAuthDoctorHintWithPlugin; +let buildProviderMissingAuthMessageWithPlugin: typeof import("./provider-runtime.js").buildProviderMissingAuthMessageWithPlugin; +let formatProviderAuthProfileApiKeyWithPlugin: typeof import("./provider-runtime.js").formatProviderAuthProfileApiKeyWithPlugin; +let prepareProviderExtraParams: typeof import("./provider-runtime.js").prepareProviderExtraParams; +let resolveProviderCacheTtlEligibility: typeof import("./provider-runtime.js").resolveProviderCacheTtlEligibility; +let resolveProviderBinaryThinking: typeof import("./provider-runtime.js").resolveProviderBinaryThinking; +let resolveProviderBuiltInModelSuppression: typeof import("./provider-runtime.js").resolveProviderBuiltInModelSuppression; +let resolveProviderDefaultThinkingLevel: typeof import("./provider-runtime.js").resolveProviderDefaultThinkingLevel; +let resolveProviderModernModelRef: typeof import("./provider-runtime.js").resolveProviderModernModelRef; +let resolveProviderUsageSnapshotWithPlugin: typeof import("./provider-runtime.js").resolveProviderUsageSnapshotWithPlugin; +let resolveProviderCapabilitiesWithPlugin: typeof import("./provider-runtime.js").resolveProviderCapabilitiesWithPlugin; +let resolveProviderUsageAuthWithPlugin: typeof import("./provider-runtime.js").resolveProviderUsageAuthWithPlugin; +let resolveProviderXHighThinking: typeof import("./provider-runtime.js").resolveProviderXHighThinking; +let normalizeProviderResolvedModelWithPlugin: typeof import("./provider-runtime.js").normalizeProviderResolvedModelWithPlugin; +let prepareProviderDynamicModel: typeof import("./provider-runtime.js").prepareProviderDynamicModel; +let prepareProviderRuntimeAuth: typeof import("./provider-runtime.js").prepareProviderRuntimeAuth; +let resetProviderRuntimeHookCacheForTest: typeof import("./provider-runtime.js").resetProviderRuntimeHookCacheForTest; +let refreshProviderOAuthCredentialWithPlugin: typeof import("./provider-runtime.js").refreshProviderOAuthCredentialWithPlugin; +let resolveProviderRuntimePlugin: typeof import("./provider-runtime.js").resolveProviderRuntimePlugin; +let runProviderDynamicModel: typeof import("./provider-runtime.js").runProviderDynamicModel; +let wrapProviderStreamFn: typeof import("./provider-runtime.js").wrapProviderStreamFn; const MODEL: ProviderRuntimeModel = { id: "demo-model", @@ -62,7 +65,32 @@ const MODEL: ProviderRuntimeModel = { }; describe("provider-runtime", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ + augmentModelCatalogWithProviderPlugins, + buildProviderAuthDoctorHintWithPlugin, + buildProviderMissingAuthMessageWithPlugin, + formatProviderAuthProfileApiKeyWithPlugin, + prepareProviderExtraParams, + resolveProviderCacheTtlEligibility, + resolveProviderBinaryThinking, + resolveProviderBuiltInModelSuppression, + resolveProviderDefaultThinkingLevel, + resolveProviderModernModelRef, + resolveProviderUsageSnapshotWithPlugin, + resolveProviderCapabilitiesWithPlugin, + resolveProviderUsageAuthWithPlugin, + resolveProviderXHighThinking, + normalizeProviderResolvedModelWithPlugin, + prepareProviderDynamicModel, + prepareProviderRuntimeAuth, + resetProviderRuntimeHookCacheForTest, + refreshProviderOAuthCredentialWithPlugin, + resolveProviderRuntimePlugin, + runProviderDynamicModel, + wrapProviderStreamFn, + } = await import("./provider-runtime.js")); resetProviderRuntimeHookCacheForTest(); resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue([]); @@ -410,54 +438,9 @@ describe("provider-runtime", () => { }), ).toBe(true); - expect( - buildProviderMissingAuthMessageWithPlugin({ - provider: "openai", - env: process.env, - context: { - env: process.env, - provider: "openai", - listProfileIds: (providerId) => (providerId === "openai-codex" ? ["p1"] : []), - }, - }), - ).toContain("openai-codex/gpt-5.4"); - - expect( - resolveProviderBuiltInModelSuppression({ - env: process.env, - context: { - env: process.env, - provider: "azure-openai-responses", - modelId: "gpt-5.3-codex-spark", - }, - }), - ).toMatchObject({ - suppress: true, - errorMessage: expect.stringContaining("openai-codex/gpt-5.3-codex-spark"), - }); - - await expect( - augmentModelCatalogWithProviderPlugins({ - env: process.env, - context: { - env: process.env, - entries: [ - { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, - { provider: "openai", id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, - { provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, - ], - }, - }), - ).resolves.toEqual([ - { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, - { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, - { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, - { - provider: "openai-codex", - id: "gpt-5.3-codex-spark", - name: "gpt-5.3-codex-spark", - }, - ]); + expectCodexMissingAuthHint(buildProviderMissingAuthMessageWithPlugin); + expectCodexBuiltInSuppression(resolveProviderBuiltInModelSuppression); + await expectAugmentedCodexCatalog(augmentModelCatalogWithProviderPlugins); expect(prepareDynamicModel).toHaveBeenCalledTimes(1); expect(refreshOAuth).toHaveBeenCalledTimes(1); @@ -488,6 +471,8 @@ describe("provider-runtime", () => { entries: [ { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, { provider: "openai", id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, + { provider: "openai", id: "gpt-5-mini", name: "GPT-5 mini" }, + { provider: "openai", id: "gpt-5-nano", name: "GPT-5 nano" }, { provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, ], }, @@ -495,6 +480,8 @@ describe("provider-runtime", () => { ).resolves.toEqual([ { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, + { provider: "openai", id: "gpt-5.4-mini", name: "gpt-5.4-mini" }, + { provider: "openai", id: "gpt-5.4-nano", name: "gpt-5.4-nano" }, { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, { provider: "openai-codex", diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index bfc976a7abf..ff804babb43 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveOwningPluginIdsForProvider, resolvePluginProviders } from "./providers.js"; const loadOpenClawPluginsMock = vi.fn(); const loadPluginManifestRegistryMock = vi.fn(); @@ -12,8 +11,12 @@ vi.mock("./manifest-registry.js", () => ({ loadPluginManifestRegistry: (...args: unknown[]) => loadPluginManifestRegistryMock(...args), })); +let resolveOwningPluginIdsForProvider: typeof import("./providers.js").resolveOwningPluginIdsForProvider; +let resolvePluginProviders: typeof import("./providers.js").resolvePluginProviders; + describe("resolvePluginProviders", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); loadOpenClawPluginsMock.mockReset(); loadOpenClawPluginsMock.mockReturnValue({ providers: [{ pluginId: "google", provider: { id: "demo-provider" } }], @@ -29,6 +32,8 @@ describe("resolvePluginProviders", () => { ], diagnostics: [], }); + ({ resolveOwningPluginIdsForProvider, resolvePluginProviders } = + await import("./providers.js")); }); it("forwards an explicit env to plugin loading", () => { @@ -65,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, @@ -84,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-typing.test.ts b/src/plugins/runtime/runtime-discord-typing.test.ts index 1eb5b6fd315..6f6ec1a1dec 100644 --- a/src/plugins/runtime/runtime-discord-typing.test.ts +++ b/src/plugins/runtime/runtime-discord-typing.test.ts @@ -1,5 +1,9 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, it, vi } from "vitest"; import { createDiscordTypingLease } from "./runtime-discord-typing.js"; +import { + expectBackgroundTypingPulseFailuresAreSwallowed, + expectIndependentTypingLeases, +} from "./typing-lease.test-support.js"; describe("createDiscordTypingLease", () => { afterEach(() => { @@ -7,51 +11,30 @@ describe("createDiscordTypingLease", () => { }); it("pulses immediately and keeps leases independent", async () => { - vi.useFakeTimers(); - const pulse = vi.fn(async () => undefined); - - const leaseA = await createDiscordTypingLease({ - channelId: "123", - intervalMs: 2_000, - pulse, + await expectIndependentTypingLeases({ + createLease: createDiscordTypingLease, + buildParams: (pulse) => ({ + channelId: "123", + intervalMs: 2_000, + pulse, + }), }); - const leaseB = await createDiscordTypingLease({ - channelId: "123", - intervalMs: 2_000, - pulse, - }); - - expect(pulse).toHaveBeenCalledTimes(2); - - await vi.advanceTimersByTimeAsync(2_000); - expect(pulse).toHaveBeenCalledTimes(4); - - leaseA.stop(); - await vi.advanceTimersByTimeAsync(2_000); - expect(pulse).toHaveBeenCalledTimes(5); - - await leaseB.refresh(); - expect(pulse).toHaveBeenCalledTimes(6); - - leaseB.stop(); }); it("swallows background pulse failures", async () => { - vi.useFakeTimers(); const pulse = vi .fn<(params: { channelId: string; accountId?: string; cfg?: unknown }) => Promise>() .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error("boom")); - const lease = await createDiscordTypingLease({ - channelId: "123", - intervalMs: 2_000, + await expectBackgroundTypingPulseFailuresAreSwallowed({ + createLease: createDiscordTypingLease, pulse, + buildParams: (pulse) => ({ + channelId: "123", + intervalMs: 2_000, + pulse, + }), }); - - await expect(vi.advanceTimersByTimeAsync(2_000)).resolves.toBe(vi); - expect(pulse).toHaveBeenCalledTimes(2); - - lease.stop(); }); }); diff --git a/src/plugins/runtime/runtime-discord.ts b/src/plugins/runtime/runtime-discord.ts index 033c1631828..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,124 +8,72 @@ import { setThreadBindingIdleTimeoutBySessionKey, setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, -} from "../../../extensions/discord/src/monitor/thread-bindings.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, +); -let runtimeDiscordOpsPromise: Promise | null = null; +const bindDiscordRuntimeMethod = createLazyRuntimeMethodBinder(loadRuntimeDiscordOps); -function loadRuntimeDiscordOps() { - runtimeDiscordOpsPromise ??= import("./runtime-discord-ops.runtime.js").then( - ({ runtimeDiscordOps }) => runtimeDiscordOps, - ); - return runtimeDiscordOpsPromise; -} - -const auditChannelPermissionsLazy: PluginRuntimeChannel["discord"]["auditChannelPermissions"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.auditChannelPermissions(...args); - }; - -const listDirectoryGroupsLiveLazy: PluginRuntimeChannel["discord"]["listDirectoryGroupsLive"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.listDirectoryGroupsLive(...args); - }; - -const listDirectoryPeersLiveLazy: PluginRuntimeChannel["discord"]["listDirectoryPeersLive"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.listDirectoryPeersLive(...args); - }; - -const probeDiscordLazy: PluginRuntimeChannel["discord"]["probeDiscord"] = async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.probeDiscord(...args); -}; - -const resolveChannelAllowlistLazy: PluginRuntimeChannel["discord"]["resolveChannelAllowlist"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.resolveChannelAllowlist(...args); - }; - -const resolveUserAllowlistLazy: PluginRuntimeChannel["discord"]["resolveUserAllowlist"] = async ( - ...args -) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.resolveUserAllowlist(...args); -}; - -const sendComponentMessageLazy: PluginRuntimeChannel["discord"]["sendComponentMessage"] = async ( - ...args -) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.sendComponentMessage(...args); -}; - -const sendMessageDiscordLazy: PluginRuntimeChannel["discord"]["sendMessageDiscord"] = async ( - ...args -) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.sendMessageDiscord(...args); -}; - -const sendPollDiscordLazy: PluginRuntimeChannel["discord"]["sendPollDiscord"] = async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.sendPollDiscord(...args); -}; - -const monitorDiscordProviderLazy: PluginRuntimeChannel["discord"]["monitorDiscordProvider"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.monitorDiscordProvider(...args); - }; - -const sendTypingDiscordLazy: PluginRuntimeChannel["discord"]["typing"]["pulse"] = async ( - ...args -) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.typing.pulse(...args); -}; - -const editMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["editMessage"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.conversationActions.editMessage(...args); - }; - -const deleteMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["deleteMessage"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.conversationActions.deleteMessage(...args); - }; - -const pinMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["pinMessage"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.conversationActions.pinMessage(...args); - }; - -const unpinMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["unpinMessage"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.conversationActions.unpinMessage(...args); - }; - -const createThreadDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["createThread"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.conversationActions.createThread(...args); - }; - -const editChannelDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["editChannel"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.conversationActions.editChannel(...args); - }; +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 = 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..8c06f2dda34 100644 --- a/src/plugins/runtime/runtime-slack-ops.runtime.ts +++ b/src/plugins/runtime/runtime-slack-ops.runtime.ts @@ -1,13 +1,13 @@ 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"; -import { handleSlackAction as handleSlackActionImpl } from "../../agents/tools/slack-actions.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 "../../../extensions/slack/runtime-api.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeSlackOps = Pick< diff --git a/src/plugins/runtime/runtime-slack.ts b/src/plugins/runtime/runtime-slack.ts index 23d34a7e5f4..9f1cab0f094 100644 --- a/src/plugins/runtime/runtime-slack.ts +++ b/src/plugins/runtime/runtime-slack.ts @@ -1,65 +1,38 @@ +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, +); -let runtimeSlackOpsPromise: Promise | null = null; +const bindSlackRuntimeMethod = createLazyRuntimeMethodBinder(loadRuntimeSlackOps); -function loadRuntimeSlackOps() { - runtimeSlackOpsPromise ??= import("./runtime-slack-ops.runtime.js").then( - ({ runtimeSlackOps }) => runtimeSlackOps, - ); - return runtimeSlackOpsPromise; -} - -const listDirectoryGroupsLiveLazy: PluginRuntimeChannel["slack"]["listDirectoryGroupsLive"] = - async (...args) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.listDirectoryGroupsLive(...args); - }; - -const listDirectoryPeersLiveLazy: PluginRuntimeChannel["slack"]["listDirectoryPeersLive"] = async ( - ...args -) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.listDirectoryPeersLive(...args); -}; - -const probeSlackLazy: PluginRuntimeChannel["slack"]["probeSlack"] = async (...args) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.probeSlack(...args); -}; - -const resolveChannelAllowlistLazy: PluginRuntimeChannel["slack"]["resolveChannelAllowlist"] = - async (...args) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.resolveChannelAllowlist(...args); - }; - -const resolveUserAllowlistLazy: PluginRuntimeChannel["slack"]["resolveUserAllowlist"] = async ( - ...args -) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.resolveUserAllowlist(...args); -}; - -const sendMessageSlackLazy: PluginRuntimeChannel["slack"]["sendMessageSlack"] = async (...args) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.sendMessageSlack(...args); -}; - -const monitorSlackProviderLazy: PluginRuntimeChannel["slack"]["monitorSlackProvider"] = async ( - ...args -) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.monitorSlackProvider(...args); -}; - -const handleSlackActionLazy: PluginRuntimeChannel["slack"]["handleSlackAction"] = async ( - ...args -) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.handleSlackAction(...args); -}; +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-typing.test.ts b/src/plugins/runtime/runtime-telegram-typing.test.ts index 3394aa1cf50..0ec97971eb8 100644 --- a/src/plugins/runtime/runtime-telegram-typing.test.ts +++ b/src/plugins/runtime/runtime-telegram-typing.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createTelegramTypingLease } from "./runtime-telegram-typing.js"; +import { + expectBackgroundTypingPulseFailuresAreSwallowed, + expectIndependentTypingLeases, +} from "./typing-lease.test-support.js"; describe("createTelegramTypingLease", () => { afterEach(() => { @@ -7,37 +11,17 @@ describe("createTelegramTypingLease", () => { }); it("pulses immediately and keeps leases independent", async () => { - vi.useFakeTimers(); - const pulse = vi.fn(async () => undefined); - - const leaseA = await createTelegramTypingLease({ - to: "telegram:123", - intervalMs: 2_000, - pulse, + await expectIndependentTypingLeases({ + createLease: createTelegramTypingLease, + buildParams: (pulse) => ({ + to: "telegram:123", + intervalMs: 2_000, + pulse, + }), }); - const leaseB = await createTelegramTypingLease({ - to: "telegram:123", - intervalMs: 2_000, - pulse, - }); - - expect(pulse).toHaveBeenCalledTimes(2); - - await vi.advanceTimersByTimeAsync(2_000); - expect(pulse).toHaveBeenCalledTimes(4); - - leaseA.stop(); - await vi.advanceTimersByTimeAsync(2_000); - expect(pulse).toHaveBeenCalledTimes(5); - - await leaseB.refresh(); - expect(pulse).toHaveBeenCalledTimes(6); - - leaseB.stop(); }); it("swallows background pulse failures", async () => { - vi.useFakeTimers(); const pulse = vi .fn< (params: { @@ -50,16 +34,15 @@ describe("createTelegramTypingLease", () => { .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error("boom")); - const lease = await createTelegramTypingLease({ - to: "telegram:123", - intervalMs: 2_000, + await expectBackgroundTypingPulseFailuresAreSwallowed({ + createLease: createTelegramTypingLease, pulse, + buildParams: (pulse) => ({ + to: "telegram:123", + intervalMs: 2_000, + pulse, + }), }); - - await expect(vi.advanceTimersByTimeAsync(2_000)).resolves.toBe(vi); - expect(pulse).toHaveBeenCalledTimes(2); - - lease.stop(); }); it("falls back to the default interval for non-finite values", async () => { diff --git a/src/plugins/runtime/runtime-telegram.ts b/src/plugins/runtime/runtime-telegram.ts index d0d71d08c4e..74b4de7e48e 100644 --- a/src/plugins/runtime/runtime-telegram.ts +++ b/src/plugins/runtime/runtime-telegram.ts @@ -1,98 +1,60 @@ -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"; +} 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, +); -let runtimeTelegramOpsPromise: Promise | null = null; +const bindTelegramRuntimeMethod = createLazyRuntimeMethodBinder(loadRuntimeTelegramOps); -function loadRuntimeTelegramOps() { - runtimeTelegramOpsPromise ??= import("./runtime-telegram-ops.runtime.js").then( - ({ runtimeTelegramOps }) => runtimeTelegramOps, - ); - return runtimeTelegramOpsPromise; -} - -const auditGroupMembershipLazy: PluginRuntimeChannel["telegram"]["auditGroupMembership"] = async ( - ...args -) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.auditGroupMembership(...args); -}; - -const probeTelegramLazy: PluginRuntimeChannel["telegram"]["probeTelegram"] = async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.probeTelegram(...args); -}; - -const sendMessageTelegramLazy: PluginRuntimeChannel["telegram"]["sendMessageTelegram"] = async ( - ...args -) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.sendMessageTelegram(...args); -}; - -const sendPollTelegramLazy: PluginRuntimeChannel["telegram"]["sendPollTelegram"] = async ( - ...args -) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.sendPollTelegram(...args); -}; - -const monitorTelegramProviderLazy: PluginRuntimeChannel["telegram"]["monitorTelegramProvider"] = - async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.monitorTelegramProvider(...args); - }; - -const sendTypingTelegramLazy: PluginRuntimeChannel["telegram"]["typing"]["pulse"] = async ( - ...args -) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.typing.pulse(...args); -}; - -const editMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["editMessage"] = - async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.conversationActions.editMessage(...args); - }; - -const editMessageReplyMarkupTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["editReplyMarkup"] = - async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.conversationActions.editReplyMarkup(...args); - }; - -const deleteMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["deleteMessage"] = - async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.conversationActions.deleteMessage(...args); - }; - -const renameForumTopicTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["renameTopic"] = - async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.conversationActions.renameTopic(...args); - }; - -const pinMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["pinMessage"] = - async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.conversationActions.pinMessage(...args); - }; - -const unpinMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["unpinMessage"] = - async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.conversationActions.unpinMessage(...args); - }; +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 = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.editReplyMarkup, +); +const deleteMessageTelegramLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.deleteMessage, +); +const renameForumTopicTelegramLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.renameTopic, +); +const pinMessageTelegramLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.pinMessage, +); +const unpinMessageTelegramLazy = bindTelegramRuntimeMethod( + (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.unpinMessage, +); export function createRuntimeTelegram(): PluginRuntimeChannel["telegram"] { return { 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 10f8e9e6a94..72bb3fd6af0 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -1,37 +1,40 @@ -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"; +} 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, +); -const sendMessageWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendMessageWhatsApp"] = async ( - ...args -) => { - const runtimeWhatsAppOutbound = await loadWebOutbound(); - return runtimeWhatsAppOutbound.sendMessageWhatsApp(...args); -}; +const loadWebLogin = createLazyRuntimeSurface( + () => import("./runtime-whatsapp-login.runtime.js"), + ({ runtimeWhatsAppLogin }) => runtimeWhatsAppLogin, +); -const sendPollWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendPollWhatsApp"] = async ( - ...args -) => { - const runtimeWhatsAppOutbound = await loadWebOutbound(); - return runtimeWhatsAppOutbound.sendPollWhatsApp(...args); -}; +const bindWhatsAppOutboundMethod = createLazyRuntimeMethodBinder(loadWebOutbound); +const bindWhatsAppLoginMethod = createLazyRuntimeMethodBinder(loadWebLogin); -const loginWebLazy: PluginRuntime["channel"]["whatsapp"]["loginWeb"] = async (...args) => { - const runtimeWhatsAppLogin = await loadWebLogin(); - return runtimeWhatsAppLogin.loginWeb(...args); -}; +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 @@ -61,31 +64,15 @@ 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 webOutboundPromise: Promise | null = null; -let webLoginPromise: Promise | null = null; let whatsappActionsPromise: Promise< - typeof import("../../agents/tools/whatsapp-actions.js") + typeof import("../../../extensions/whatsapp/runtime-api.js") > | null = null; -function loadWebOutbound() { - webOutboundPromise ??= import("./runtime-whatsapp-outbound.runtime.js").then( - ({ runtimeWhatsAppOutbound }) => runtimeWhatsAppOutbound, - ); - return webOutboundPromise; -} - -function loadWebLogin() { - webLoginPromise ??= import("./runtime-whatsapp-login.runtime.js").then( - ({ runtimeWhatsAppLogin }) => runtimeWhatsAppLogin, - ); - return webLoginPromise; -} - function loadWebLoginQr() { - webLoginQrPromise ??= import("../../../extensions/whatsapp/src/login-qr.js"); + webLoginQrPromise ??= import("../../../extensions/whatsapp/login-qr-api.js"); return webLoginQrPromise; } @@ -95,7 +82,7 @@ function loadWebChannel() { } function loadWhatsAppActions() { - whatsappActionsPromise ??= import("../../agents/tools/whatsapp-actions.js"); + whatsappActionsPromise ??= import("../../../extensions/whatsapp/runtime-api.js"); return whatsappActionsPromise; } diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 1b0c21044a8..6b0a0e3a8f6 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -1,3 +1,10 @@ +/** + * Runtime helpers for native channel plugins. + * + * This surface exposes core and channel-specific helpers used by bundled + * plugins. Prefer hooks unless you need tight in-process coupling with the + * OpenClaw messaging/runtime stack. + */ type ReadChannelAllowFromStore = typeof import("../../pairing/pairing-store.js").readChannelAllowFromStore; type UpsertChannelPairingRequest = @@ -87,29 +94,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 +128,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; - handleSlackAction: typeof import("../../agents/tools/slack-actions.js").handleSlackAction; + 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("../../../extensions/slack/runtime-api.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 +173,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,37 +187,37 @@ 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; + handleWhatsAppAction: typeof import("../../../extensions/whatsapp/runtime-api.js").handleWhatsAppAction; createLoginTool: typeof import("./runtime-whatsapp-login-tool.js").createRuntimeWhatsAppLoginTool; }; line: { 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/runtime/typing-lease.test-support.ts b/src/plugins/runtime/typing-lease.test-support.ts new file mode 100644 index 00000000000..f32511d760d --- /dev/null +++ b/src/plugins/runtime/typing-lease.test-support.ts @@ -0,0 +1,47 @@ +import { expect, vi } from "vitest"; + +export async function expectIndependentTypingLeases< + TParams extends { intervalMs?: number; pulse: (...args: never[]) => Promise }, + TLease extends { refresh: () => Promise; stop: () => void }, +>(params: { + createLease: (params: TParams) => Promise; + buildParams: (pulse: TParams["pulse"]) => TParams; +}) { + vi.useFakeTimers(); + const pulse = vi.fn(async () => undefined) as TParams["pulse"]; + + const leaseA = await params.createLease(params.buildParams(pulse)); + const leaseB = await params.createLease(params.buildParams(pulse)); + + expect(pulse).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(2_000); + expect(pulse).toHaveBeenCalledTimes(4); + + leaseA.stop(); + await vi.advanceTimersByTimeAsync(2_000); + expect(pulse).toHaveBeenCalledTimes(5); + + await leaseB.refresh(); + expect(pulse).toHaveBeenCalledTimes(6); + + leaseB.stop(); +} + +export async function expectBackgroundTypingPulseFailuresAreSwallowed< + TParams extends { intervalMs?: number; pulse: (...args: never[]) => Promise }, + TLease extends { stop: () => void }, +>(params: { + createLease: (params: TParams) => Promise; + buildParams: (pulse: TParams["pulse"]) => TParams; + pulse: TParams["pulse"]; +}) { + vi.useFakeTimers(); + + const lease = await params.createLease(params.buildParams(params.pulse)); + + await expect(vi.advanceTimersByTimeAsync(2_000)).resolves.toBe(vi); + expect(params.pulse).toHaveBeenCalledTimes(2); + + lease.stop(); +} diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index c93ce5ef37b..d16db23da4b 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { buildPluginStatusReport } from "./status.js"; 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(), @@ -22,7 +24,8 @@ vi.mock("../agents/workspace.js", () => ({ })); describe("buildPluginStatusReport", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); loadConfigMock.mockReset(); loadOpenClawPluginsMock.mockReset(); loadConfigMock.mockReturnValue({}); @@ -31,13 +34,22 @@ describe("buildPluginStatusReport", () => { diagnostics: [], channels: [], providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], tools: [], hooks: [], + typedHooks: [], + channelSetups: [], + httpRoutes: [], gatewayHandlers: {}, cliRegistrars: [], services: [], commands: [], }); + ({ buildAllPluginInspectReports, buildPluginInspectReport, buildPluginStatusReport } = + await import("./status.js")); }); it("forwards an explicit env to plugin loading", () => { @@ -57,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..5588d6f5874 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, + })) + .toSorted((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].toSorted(), + })) + .toSorted((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/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 80c41858733..c18f5008c31 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resolvePluginTools } from "./tools.js"; type MockRegistryToolEntry = { pluginId: string; @@ -14,6 +13,8 @@ vi.mock("./loader.js", () => ({ loadOpenClawPlugins: (params: unknown) => loadOpenClawPluginsMock(params), })); +let resolvePluginTools: typeof import("./tools.js").resolvePluginTools; + function makeTool(name: string) { return { name, @@ -90,8 +91,10 @@ function resolveOptionalDemoTools(toolAllowlist?: string[]) { } describe("resolvePluginTools optional tools", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); loadOpenClawPluginsMock.mockClear(); + ({ resolvePluginTools } = await import("./tools.js")); }); it("skips optional tools without explicit allowlist", () => { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 6deb59669f1..b5bd28cc110 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. */ @@ -1234,6 +1266,12 @@ export type OpenClawPluginApi = { registrationMode: PluginRegistrationMode; config: OpenClawConfig; pluginConfig?: Record; + /** + * In-process runtime helpers for trusted native plugins. + * + * This surface is broader than hooks. Prefer hooks for third-party + * automation/integration unless you need native registry integration. + */ runtime: PluginRuntime; logger: PluginLogger; registerTool: ( @@ -1246,16 +1284,25 @@ export type OpenClawPluginApi = { opts?: OpenClawPluginHookOptions, ) => void; registerHttpRoute: (params: OpenClawPluginHttpRouteParams) => void; + /** Register a native messaging channel plugin (channel capability). */ registerChannel: (registration: OpenClawPluginChannelRegistration | ChannelPlugin) => void; registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void; registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void; registerService: (service: OpenClawPluginService) => void; + /** Register a native model/provider plugin (text inference capability). */ registerProvider: (provider: ProviderPlugin) => void; + /** Register a speech synthesis provider (speech capability). */ registerSpeechProvider: (provider: SpeechProviderPlugin) => void; + /** Register a media understanding provider (media understanding capability). */ registerMediaUnderstandingProvider: (provider: MediaUnderstandingProviderPlugin) => void; + /** Register an image generation provider (image generation capability). */ registerImageGenerationProvider: (provider: ImageGenerationProviderPlugin) => void; + /** Register a web search provider (web search capability). */ 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/voice-call.plugin.test.ts b/src/plugins/voice-call.plugin.test.ts index 0ca6106d1a9..6a018c27b42 100644 --- a/src/plugins/voice-call.plugin.test.ts +++ b/src/plugins/voice-call.plugin.test.ts @@ -45,7 +45,7 @@ type RegisterCliContext = { function setup(config: Record): Registered { const methods = new Map(); const tools: unknown[] = []; - plugin.register({ + void plugin.register({ id: "voice-call", name: "Voice Call", description: "test", diff --git a/src/plugins/wired-hooks-compaction.test.ts b/src/plugins/wired-hooks-compaction.test.ts index 694f4a1f4b4..1fc258d4cef 100644 --- a/src/plugins/wired-hooks-compaction.test.ts +++ b/src/plugins/wired-hooks-compaction.test.ts @@ -1,9 +1,8 @@ /** * Test: before_compaction & after_compaction hook wiring */ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { makeZeroUsageSnapshot } from "../agents/usage.js"; -import { emitAgentEvent } from "../infra/agent-events.js"; const hookMocks = vi.hoisted(() => ({ runner: { @@ -11,13 +10,6 @@ const hookMocks = vi.hoisted(() => ({ runBeforeCompaction: vi.fn(async () => {}), runAfterCompaction: vi.fn(async () => {}), }, -})); - -vi.mock("../plugins/hook-runner-global.js", () => ({ - getGlobalHookRunner: () => hookMocks.runner, -})); - -vi.mock("../infra/agent-events.js", () => ({ emitAgentEvent: vi.fn(), })); @@ -25,29 +17,42 @@ describe("compaction hook wiring", () => { let handleAutoCompactionStart: typeof import("../agents/pi-embedded-subscribe.handlers.compaction.js").handleAutoCompactionStart; let handleAutoCompactionEnd: typeof import("../agents/pi-embedded-subscribe.handlers.compaction.js").handleAutoCompactionEnd; - beforeAll(async () => { - ({ handleAutoCompactionStart, handleAutoCompactionEnd } = - await import("../agents/pi-embedded-subscribe.handlers.compaction.js")); - }); - - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); hookMocks.runner.hasHooks.mockClear(); hookMocks.runner.hasHooks.mockReturnValue(false); hookMocks.runner.runBeforeCompaction.mockClear(); hookMocks.runner.runBeforeCompaction.mockResolvedValue(undefined); hookMocks.runner.runAfterCompaction.mockClear(); hookMocks.runner.runAfterCompaction.mockResolvedValue(undefined); - vi.mocked(emitAgentEvent).mockClear(); + hookMocks.emitAgentEvent.mockClear(); + vi.doMock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => hookMocks.runner, + })); + vi.doMock("../infra/agent-events.js", () => ({ + emitAgentEvent: hookMocks.emitAgentEvent, + })); + ({ handleAutoCompactionStart, handleAutoCompactionEnd } = + await import("../agents/pi-embedded-subscribe.handlers.compaction.js")); }); 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(), @@ -94,7 +99,7 @@ describe("compaction hook wiring", () => { const hookCtx = beforeCalls[0]?.[1] as { sessionKey?: string } | undefined; expect(hookCtx?.sessionKey).toBe("agent:main:web-abc123"); expect(ctx.ensureCompactionPromise).toHaveBeenCalledTimes(1); - expect(emitAgentEvent).toHaveBeenCalledWith({ + expect(hookMocks.emitAgentEvent).toHaveBeenCalledWith({ runId: "r1", stream: "compaction", data: { phase: "start" }, @@ -111,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, }); @@ -126,16 +133,19 @@ 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(emitAgentEvent).toHaveBeenCalledWith({ + expect(hookMocks.emitAgentEvent).toHaveBeenCalledWith({ runId: "r2", stream: "compaction", data: { phase: "end", willRetry: false, completed: true }, @@ -166,7 +176,7 @@ describe("compaction hook wiring", () => { expect(ctx.noteCompactionRetry).toHaveBeenCalledTimes(1); expect(ctx.resetForCompactionRetry).toHaveBeenCalledTimes(1); expect(ctx.maybeResolveCompactionWait).not.toHaveBeenCalled(); - expect(emitAgentEvent).toHaveBeenCalledWith({ + expect(hookMocks.emitAgentEvent).toHaveBeenCalledWith({ runId: "r3", stream: "compaction", data: { phase: "end", willRetry: true, completed: true }, diff --git a/src/process/command-queue.test.ts b/src/process/command-queue.test.ts index b6e6f17cd85..a35512d4f0d 100644 --- a/src/process/command-queue.test.ts +++ b/src/process/command-queue.test.ts @@ -17,19 +17,19 @@ vi.mock("../logging/diagnostic.js", () => ({ diagnosticLogger: diagnosticMocks.diag, })); -import { - clearCommandLane, - CommandLaneClearedError, - enqueueCommand, - enqueueCommandInLane, - GatewayDrainingError, - getActiveTaskCount, - getQueueSize, - markGatewayDraining, - resetAllLanes, - setCommandLaneConcurrency, - waitForActiveTasks, -} from "./command-queue.js"; +type CommandQueueModule = typeof import("./command-queue.js"); + +let clearCommandLane: CommandQueueModule["clearCommandLane"]; +let CommandLaneClearedError: CommandQueueModule["CommandLaneClearedError"]; +let enqueueCommand: CommandQueueModule["enqueueCommand"]; +let enqueueCommandInLane: CommandQueueModule["enqueueCommandInLane"]; +let GatewayDrainingError: CommandQueueModule["GatewayDrainingError"]; +let getActiveTaskCount: CommandQueueModule["getActiveTaskCount"]; +let getQueueSize: CommandQueueModule["getQueueSize"]; +let markGatewayDraining: CommandQueueModule["markGatewayDraining"]; +let resetAllLanes: CommandQueueModule["resetAllLanes"]; +let setCommandLaneConcurrency: CommandQueueModule["setCommandLaneConcurrency"]; +let waitForActiveTasks: CommandQueueModule["waitForActiveTasks"]; function createDeferred(): { promise: Promise; resolve: () => void } { let resolve!: () => void; @@ -54,7 +54,21 @@ function enqueueBlockedMainTask( } describe("command queue", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ + clearCommandLane, + CommandLaneClearedError, + enqueueCommand, + enqueueCommandInLane, + GatewayDrainingError, + getActiveTaskCount, + getQueueSize, + markGatewayDraining, + resetAllLanes, + setCommandLaneConcurrency, + waitForActiveTasks, + } = await import("./command-queue.js")); resetAllLanes(); diagnosticMocks.logLaneEnqueue.mockClear(); diagnosticMocks.logLaneDequeue.mockClear(); diff --git a/src/process/exec.no-output-timer.test.ts b/src/process/exec.no-output-timer.test.ts index 9c851f1e1a2..dfd7348877a 100644 --- a/src/process/exec.no-output-timer.test.ts +++ b/src/process/exec.no-output-timer.test.ts @@ -1,6 +1,6 @@ import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const spawnMock = vi.hoisted(() => vi.fn()); @@ -12,7 +12,9 @@ vi.mock("node:child_process", async () => { }; }); -import { runCommandWithTimeout } from "./exec.js"; +type ExecModule = typeof import("./exec.js"); + +let runCommandWithTimeout: ExecModule["runCommandWithTimeout"]; function createFakeSpawnedChild() { const child = new EventEmitter() as EventEmitter & ChildProcess; @@ -39,6 +41,11 @@ function createFakeSpawnedChild() { } describe("runCommandWithTimeout no-output timer", () => { + beforeEach(async () => { + vi.resetModules(); + ({ runCommandWithTimeout } = await import("./exec.js")); + }); + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); diff --git a/src/process/exec.windows.test.ts b/src/process/exec.windows.test.ts index 85600755dac..b2357858565 100644 --- a/src/process/exec.windows.test.ts +++ b/src/process/exec.windows.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "node:events"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const spawnMock = vi.hoisted(() => vi.fn()); const execFileMock = vi.hoisted(() => vi.fn()); @@ -13,7 +13,8 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); -import { runCommandWithTimeout, runExec } from "./exec.js"; +let runCommandWithTimeout: typeof import("./exec.js").runCommandWithTimeout; +let runExec: typeof import("./exec.js").runExec; type MockChild = EventEmitter & { stdout: EventEmitter; @@ -64,6 +65,11 @@ function expectCmdWrappedInvocation(params: { } describe("windows command wrapper behavior", () => { + beforeEach(async () => { + vi.resetModules(); + ({ runCommandWithTimeout, runExec } = await import("./exec.js")); + }); + afterEach(() => { spawnMock.mockReset(); execFileMock.mockReset(); diff --git a/src/process/kill-tree.test.ts b/src/process/kill-tree.test.ts index a506442aed4..7260938b438 100644 --- a/src/process/kill-tree.test.ts +++ b/src/process/kill-tree.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { killProcessTree } from "./kill-tree.js"; const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), @@ -9,6 +8,8 @@ vi.mock("node:child_process", () => ({ spawn: (...args: unknown[]) => spawnMock(...args), })); +let killProcessTree: typeof import("./kill-tree.js").killProcessTree; + async function withPlatform(platform: NodeJS.Platform, run: () => Promise | T): Promise { const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); Object.defineProperty(process, "platform", { value: platform, configurable: true }); @@ -24,7 +25,9 @@ async function withPlatform(platform: NodeJS.Platform, run: () => Promise describe("killProcessTree", () => { let killSpy: ReturnType; - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ killProcessTree } = await import("./kill-tree.js")); spawnMock.mockClear(); killSpy = vi.spyOn(process, "kill"); vi.useFakeTimers(); diff --git a/src/process/supervisor/adapters/child.test.ts b/src/process/supervisor/adapters/child.test.ts index 8494a701c7e..2d3040f8811 100644 --- a/src/process/supervisor/adapters/child.test.ts +++ b/src/process/supervisor/adapters/child.test.ts @@ -1,7 +1,7 @@ import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; const { spawnWithFallbackMock, killProcessTreeMock } = vi.hoisted(() => ({ spawnWithFallbackMock: vi.fn(), @@ -51,11 +51,9 @@ async function createAdapterHarness(params?: { describe("createChildAdapter", () => { const originalServiceMarker = process.env.OPENCLAW_SERVICE_MARKER; - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); ({ createChildAdapter } = await import("./child.js")); - }); - - beforeEach(() => { spawnWithFallbackMock.mockClear(); killProcessTreeMock.mockClear(); delete process.env.OPENCLAW_SERVICE_MARKER; diff --git a/src/process/supervisor/adapters/pty.test.ts b/src/process/supervisor/adapters/pty.test.ts index 32ca418b533..83e650c073a 100644 --- a/src/process/supervisor/adapters/pty.test.ts +++ b/src/process/supervisor/adapters/pty.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { spawnMock, ptyKillMock, killProcessTreeMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), @@ -39,11 +39,9 @@ function expectSpawnEnv() { describe("createPtyAdapter", () => { let createPtyAdapter: typeof import("./pty.js").createPtyAdapter; - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); ({ createPtyAdapter } = await import("./pty.js")); - }); - - beforeEach(() => { spawnMock.mockClear(); ptyKillMock.mockClear(); killProcessTreeMock.mockClear(); diff --git a/src/process/supervisor/supervisor.pty-command.test.ts b/src/process/supervisor/supervisor.pty-command.test.ts index daee348944d..eb3427d462f 100644 --- a/src/process/supervisor/supervisor.pty-command.test.ts +++ b/src/process/supervisor/supervisor.pty-command.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const { createPtyAdapterMock } = vi.hoisted(() => ({ createPtyAdapterMock: vi.fn(), @@ -35,11 +35,9 @@ function createStubPtyAdapter() { describe("process supervisor PTY command contract", () => { let createProcessSupervisor: typeof import("./supervisor.js").createProcessSupervisor; - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); ({ createProcessSupervisor } = await import("./supervisor.js")); - }); - - beforeEach(() => { createPtyAdapterMock.mockClear(); }); diff --git a/src/secrets/target-registry-query.ts b/src/secrets/target-registry-query.ts index fcfdc694f85..230b68f0180 100644 --- a/src/secrets/target-registry-query.ts +++ b/src/secrets/target-registry-query.ts @@ -239,6 +239,24 @@ export function resolvePlanTargetAgainstRegistry(candidate: { return null; } +export function resolveConfigSecretTargetByPath(pathSegments: string[]): ResolvedPlanTarget | null { + for (const entry of OPENCLAW_COMPILED_SECRET_TARGETS) { + if (!entry.includeInPlan) { + continue; + } + const matched = matchPathTokens(pathSegments, entry.pathTokens); + if (!matched) { + continue; + } + const resolved = toResolvedPlanTarget(entry, pathSegments, matched.captures); + if (!resolved) { + continue; + } + return resolved; + } + return null; +} + export function discoverConfigSecretTargets( config: OpenClawConfig, ): DiscoveredConfigSecretTarget[] { diff --git a/src/secrets/target-registry.test.ts b/src/secrets/target-registry.test.ts index cc536fd2eb3..78e9e5f1cfe 100644 --- a/src/secrets/target-registry.test.ts +++ b/src/secrets/target-registry.test.ts @@ -3,7 +3,10 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { buildSecretRefCredentialMatrix } from "./credential-matrix.js"; -import { discoverConfigSecretTargetsByIds } from "./target-registry.js"; +import { + discoverConfigSecretTargetsByIds, + resolveConfigSecretTargetByPath, +} from "./target-registry.js"; describe("secret target registry", () => { it("stays in sync with docs/reference/secretref-user-supplied-credentials-matrix.json", () => { @@ -96,4 +99,15 @@ describe("secret target registry", () => { expect(targets[0]?.entry.id).toBe("talk.apiKey"); expect(targets[0]?.path).toBe("talk.apiKey"); }); + + it("resolves config targets by exact path including sibling ref metadata", () => { + const target = resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]); + expect(target).not.toBeNull(); + expect(target?.entry.id).toBe("channels.googlechat.serviceAccount"); + expect(target?.refPathSegments).toEqual(["channels", "googlechat", "serviceAccountRef"]); + }); + + it("returns null when no config target path matches", () => { + expect(resolveConfigSecretTargetByPath(["gateway", "auth", "mode"])).toBeNull(); + }); }); diff --git a/src/security/audit-channel.collect.runtime.ts b/src/security/audit-channel.collect.runtime.ts index 6a33ff6a93a..bed24a7f73e 100644 --- a/src/security/audit-channel.collect.runtime.ts +++ b/src/security/audit-channel.collect.runtime.ts @@ -1 +1,10 @@ -export { collectChannelSecurityFindings } from "./audit-channel.js"; +import { collectChannelSecurityFindings as collectChannelSecurityFindingsImpl } from "./audit-channel.js"; + +type CollectChannelSecurityFindings = + typeof import("./audit-channel.js").collectChannelSecurityFindings; + +export function collectChannelSecurityFindings( + ...args: Parameters +): ReturnType { + return collectChannelSecurityFindingsImpl(...args); +} diff --git a/src/security/audit-channel.runtime.ts b/src/security/audit-channel.runtime.ts index 867f0a91162..de2d666cb87 100644 --- a/src/security/audit-channel.runtime.ts +++ b/src/security/audit-channel.runtime.ts @@ -1,9 +1,17 @@ -export { readChannelAllowFromStore } from "../pairing/pairing-store.js"; -export { - isDiscordMutableAllowEntry, - isZalouserMutableGroupEntry, -} from "./mutable-allowlist-detectors.js"; -export { +import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; +import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, } from "../plugin-sdk/telegram.js"; +import { + isDiscordMutableAllowEntry, + isZalouserMutableGroupEntry, +} from "./mutable-allowlist-detectors.js"; + +export const auditChannelRuntime = { + readChannelAllowFromStore, + isDiscordMutableAllowEntry, + isZalouserMutableGroupEntry, + isNumericTelegramUserId, + normalizeTelegramAllowFromEntry, +}; diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index 44b83c28cc3..dd920e77818 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -11,18 +11,15 @@ import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../con import type { OpenClawConfig } from "../config/config.js"; import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js"; import { resolveDmAllowState } from "./dm-policy-shared.js"; -let auditChannelRuntimeModulePromise: - | Promise - | undefined; - -function loadAuditChannelRuntimeModule() { - auditChannelRuntimeModulePromise ??= import("./audit-channel.runtime.js"); - return auditChannelRuntimeModulePromise; -} +const loadAuditChannelRuntimeModule = createLazyRuntimeSurface( + () => import("./audit-channel.runtime.js"), + ({ auditChannelRuntime }) => auditChannelRuntime, +); function normalizeAllowFromList(list: Array | undefined | null): string[] { return normalizeStringEntries(Array.isArray(list) ? list : undefined); diff --git a/src/security/audit.runtime.ts b/src/security/audit.runtime.ts index 349d2f26fe5..f36d23de14d 100644 --- a/src/security/audit.runtime.ts +++ b/src/security/audit.runtime.ts @@ -1 +1,9 @@ -export { runSecurityAudit } from "./audit.js"; +import { runSecurityAudit as runSecurityAuditImpl } from "./audit.js"; + +type RunSecurityAudit = typeof import("./audit.js").runSecurityAudit; + +export function runSecurityAudit( + ...args: Parameters +): ReturnType { + return runSecurityAuditImpl(...args); +} 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/security/windows-acl.test.ts b/src/security/windows-acl.test.ts index f9cb67fa4e5..6f073e34a10 100644 --- a/src/security/windows-acl.test.ts +++ b/src/security/windows-acl.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { WindowsAclEntry, WindowsAclSummary } from "./windows-acl.js"; const MOCK_USERNAME = "MockUser"; @@ -8,15 +8,26 @@ vi.mock("node:os", () => ({ userInfo: () => ({ username: MOCK_USERNAME }), })); -const { - createIcaclsResetCommand, - formatIcaclsResetCommand, - formatWindowsAclSummary, - inspectWindowsAcl, - parseIcaclsOutput, - resolveWindowsUserPrincipal, - summarizeWindowsAcl, -} = await import("./windows-acl.js"); +let createIcaclsResetCommand: typeof import("./windows-acl.js").createIcaclsResetCommand; +let formatIcaclsResetCommand: typeof import("./windows-acl.js").formatIcaclsResetCommand; +let formatWindowsAclSummary: typeof import("./windows-acl.js").formatWindowsAclSummary; +let inspectWindowsAcl: typeof import("./windows-acl.js").inspectWindowsAcl; +let parseIcaclsOutput: typeof import("./windows-acl.js").parseIcaclsOutput; +let resolveWindowsUserPrincipal: typeof import("./windows-acl.js").resolveWindowsUserPrincipal; +let summarizeWindowsAcl: typeof import("./windows-acl.js").summarizeWindowsAcl; + +beforeEach(async () => { + vi.resetModules(); + ({ + createIcaclsResetCommand, + formatIcaclsResetCommand, + formatWindowsAclSummary, + inspectWindowsAcl, + parseIcaclsOutput, + resolveWindowsUserPrincipal, + summarizeWindowsAcl, + } = await import("./windows-acl.js")); +}); function aclEntry(params: { principal: string; diff --git a/src/shared/lazy-runtime.ts b/src/shared/lazy-runtime.ts new file mode 100644 index 00000000000..23f6a6039de --- /dev/null +++ b/src/shared/lazy-runtime.ts @@ -0,0 +1,44 @@ +export function createLazyRuntimeSurface( + importer: () => Promise, + select: (module: TModule) => TSurface, +): () => Promise { + let cached: Promise | null = null; + return () => { + cached ??= importer().then(select); + return cached; + }; +} + +/** 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, +): (...args: TArgs) => Promise> { + const invoke = async (...args: TArgs): Promise> => { + const method = select(await load()); + return await method(...args); + }; + return invoke; +} + +export function createLazyRuntimeMethodBinder(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/edge-tts-validation.test.ts b/src/tts/edge-tts-validation.test.ts index 08697a2c9bd..51e4dbce39f 100644 --- a/src/tts/edge-tts-validation.test.ts +++ b/src/tts/edge-tts-validation.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; let mockTtsPromise = vi.fn<(text: string, filePath: string) => Promise>(); @@ -13,7 +13,9 @@ vi.mock("node-edge-tts", () => ({ }, })); -const { edgeTTS } = await import("./tts-core.js"); +type TtsCoreModule = typeof import("./tts-core.js"); + +let edgeTTS: TtsCoreModule["edgeTTS"]; const baseEdgeConfig = { enabled: true, @@ -27,6 +29,11 @@ const baseEdgeConfig = { describe("edgeTTS – empty audio validation", () => { let tempDir: string; + beforeEach(async () => { + vi.resetModules(); + ({ edgeTTS } = await import("./tts-core.js")); + }); + afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); diff --git a/src/tts/provider-registry.ts b/src/tts/provider-registry.ts index ee60764aa4d..d1462880a99 100644 --- a/src/tts/provider-registry.ts +++ b/src/tts/provider-registry.ts @@ -7,11 +7,11 @@ import { buildElevenLabsSpeechProvider } from "./providers/elevenlabs.js"; import { buildMicrosoftSpeechProvider } from "./providers/microsoft.js"; import { buildOpenAISpeechProvider } from "./providers/openai.js"; -const BUILTIN_SPEECH_PROVIDERS: readonly SpeechProviderPlugin[] = [ - buildOpenAISpeechProvider(), - buildElevenLabsSpeechProvider(), - buildMicrosoftSpeechProvider(), -]; +const BUILTIN_SPEECH_PROVIDER_BUILDERS = [ + buildOpenAISpeechProvider, + buildElevenLabsSpeechProvider, + buildMicrosoftSpeechProvider, +] as const satisfies readonly (() => SpeechProviderPlugin)[]; function trimToUndefined(value: string | undefined): string | undefined { const trimmed = value?.trim().toLowerCase(); @@ -58,8 +58,8 @@ function buildProviderMaps(cfg?: OpenClawConfig): { } }; - for (const provider of BUILTIN_SPEECH_PROVIDERS) { - register(provider); + for (const buildProvider of BUILTIN_SPEECH_PROVIDER_BUILDERS) { + register(buildProvider()); } for (const provider of resolveSpeechProviderPluginEntries(cfg)) { register(provider); diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index 16b91b6f330..ade83c0b30a 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -362,20 +362,43 @@ describe("tts", () => { }); describe("summarizeText", () => { + let summarizeTextForTest: typeof summarizeText; + let resolveTtsConfigForTest: typeof resolveTtsConfig; + let completeSimpleForTest: typeof completeSimple; + let getApiKeyForModelForTest: typeof getApiKeyForModel; + let resolveModelAsyncForTest: typeof resolveModelAsync; + let ensureCustomApiRegisteredForTest: typeof ensureCustomApiRegistered; + const baseCfg: OpenClawConfig = { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } }, messages: { tts: {} }, }; - const baseConfig = resolveTtsConfig(baseCfg); + + beforeEach(async () => { + vi.resetModules(); + ({ completeSimple: completeSimpleForTest } = await import("@mariozechner/pi-ai")); + ({ getApiKeyForModel: getApiKeyForModelForTest } = await import("../agents/model-auth.js")); + ({ resolveModelAsync: resolveModelAsyncForTest } = + await import("../agents/pi-embedded-runner/model.js")); + ({ ensureCustomApiRegistered: ensureCustomApiRegisteredForTest } = + await import("../agents/custom-api-registry.js")); + const ttsModule = await import("./tts.js"); + summarizeTextForTest = ttsModule._test.summarizeText; + resolveTtsConfigForTest = ttsModule.resolveTtsConfig; + vi.mocked(completeSimpleForTest).mockResolvedValue( + mockAssistantMessage([{ type: "text", text: "Summary" }]), + ); + }); it("summarizes text and returns result with metrics", async () => { const mockSummary = "This is a summarized version of the text."; - vi.mocked(completeSimple).mockResolvedValue( + const baseConfig = resolveTtsConfigForTest(baseCfg); + vi.mocked(completeSimpleForTest).mockResolvedValue( mockAssistantMessage([{ type: "text", text: mockSummary }]), ); const longText = "A".repeat(2000); - const result = await summarizeText({ + const result = await summarizeTextForTest({ text: longText, targetLength: 1500, cfg: baseCfg, @@ -387,11 +410,12 @@ describe("tts", () => { expect(result.inputLength).toBe(2000); expect(result.outputLength).toBe(mockSummary.length); expect(result.latencyMs).toBeGreaterThanOrEqual(0); - expect(completeSimple).toHaveBeenCalledTimes(1); + expect(completeSimpleForTest).toHaveBeenCalledTimes(1); }); it("calls the summary model with the expected parameters", async () => { - await summarizeText({ + const baseConfig = resolveTtsConfigForTest(baseCfg); + await summarizeTextForTest({ text: "Long text to summarize", targetLength: 500, cfg: baseCfg, @@ -399,11 +423,11 @@ describe("tts", () => { timeoutMs: 30_000, }); - const callArgs = vi.mocked(completeSimple).mock.calls[0]; + const callArgs = vi.mocked(completeSimpleForTest).mock.calls[0]; expect(callArgs?.[1]?.messages?.[0]?.role).toBe("user"); expect(callArgs?.[2]?.maxTokens).toBe(250); expect(callArgs?.[2]?.temperature).toBe(0.3); - expect(getApiKeyForModel).toHaveBeenCalledTimes(1); + expect(getApiKeyForModelForTest).toHaveBeenCalledTimes(1); }); it("uses summaryModel override when configured", async () => { @@ -411,8 +435,8 @@ describe("tts", () => { agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, messages: { tts: { summaryModel: "openai/gpt-4.1-mini" } }, }; - const config = resolveTtsConfig(cfg); - await summarizeText({ + const config = resolveTtsConfigForTest(cfg); + await summarizeTextForTest({ text: "Long text to summarize", targetLength: 500, cfg, @@ -420,11 +444,17 @@ describe("tts", () => { timeoutMs: 30_000, }); - expect(resolveModelAsync).toHaveBeenCalledWith("openai", "gpt-4.1-mini", undefined, cfg); + expect(resolveModelAsyncForTest).toHaveBeenCalledWith( + "openai", + "gpt-4.1-mini", + undefined, + cfg, + ); }); it("registers the Ollama api before direct summarization", async () => { - vi.mocked(resolveModelAsync).mockResolvedValue({ + const baseConfig = resolveTtsConfigForTest(baseCfg); + vi.mocked(resolveModelAsyncForTest).mockResolvedValue({ ...createResolvedModel("ollama", "qwen3:8b", "ollama"), model: { ...createResolvedModel("ollama", "qwen3:8b", "ollama").model, @@ -432,7 +462,7 @@ describe("tts", () => { }, } as never); - await summarizeText({ + await summarizeTextForTest({ text: "Long text to summarize", targetLength: 500, cfg: baseCfg, @@ -440,10 +470,11 @@ describe("tts", () => { timeoutMs: 30_000, }); - expect(ensureCustomApiRegistered).toHaveBeenCalledWith("ollama", expect.any(Function)); + expect(ensureCustomApiRegisteredForTest).toHaveBeenCalledWith("ollama", expect.any(Function)); }); it("validates targetLength bounds", async () => { + const baseConfig = resolveTtsConfigForTest(baseCfg); const cases = [ { targetLength: 99, shouldThrow: true }, { targetLength: 100, shouldThrow: false }, @@ -451,7 +482,7 @@ describe("tts", () => { { targetLength: 10001, shouldThrow: true }, ] as const; for (const testCase of cases) { - const call = summarizeText({ + const call = summarizeTextForTest({ text: "text", targetLength: testCase.targetLength, cfg: baseCfg, @@ -469,6 +500,7 @@ describe("tts", () => { }); it("throws when summary output is missing or empty", async () => { + const baseConfig = resolveTtsConfigForTest(baseCfg); const cases = [ { name: "no summary blocks", message: mockAssistantMessage([]) }, { @@ -477,9 +509,9 @@ describe("tts", () => { }, ] as const; for (const testCase of cases) { - vi.mocked(completeSimple).mockResolvedValue(testCase.message); + vi.mocked(completeSimpleForTest).mockResolvedValue(testCase.message); await expect( - summarizeText({ + summarizeTextForTest({ text: "text", targetLength: 500, cfg: baseCfg, @@ -564,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); + }); + } }); }); @@ -646,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); + } }); }); @@ -737,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/src/utils/message-channel.ts b/src/utils/message-channel.ts index ed580960ad4..f80633e450d 100644 --- a/src/utils/message-channel.ts +++ b/src/utils/message-channel.ts @@ -12,10 +12,23 @@ import { normalizeGatewayClientMode, normalizeGatewayClientName, } from "../gateway/protocol/client-info.js"; -import { getActivePluginRegistry } from "../plugins/runtime.js"; export const INTERNAL_MESSAGE_CHANNEL = "webchat" as const; export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL; +const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState"); + +type PluginRegistryStateLike = { + registry?: { + channels?: Array<{ + plugin: { + id: string; + meta: { + aliases?: string[]; + }; + }; + }>; + } | null; +}; const MARKDOWN_CAPABLE_CHANNELS = new Set([ "slack", @@ -64,8 +77,13 @@ export function normalizeMessageChannel(raw?: string | null): string | undefined if (builtIn) { return builtIn; } - const registry = getActivePluginRegistry(); - const pluginMatch = registry?.channels.find((entry) => { + const channels = + ( + globalThis as typeof globalThis & { + [REGISTRY_STATE]?: PluginRegistryStateLike; + } + )[REGISTRY_STATE]?.registry?.channels ?? []; + const pluginMatch = channels.find((entry) => { if (entry.plugin.id.toLowerCase() === normalized) { return true; } @@ -77,19 +95,23 @@ export function normalizeMessageChannel(raw?: string | null): string | undefined } const listPluginChannelIds = (): string[] => { - const registry = getActivePluginRegistry(); - if (!registry) { - return []; - } - return registry.channels.map((entry) => entry.plugin.id); + const channels = + ( + globalThis as typeof globalThis & { + [REGISTRY_STATE]?: PluginRegistryStateLike; + } + )[REGISTRY_STATE]?.registry?.channels ?? []; + return channels.map((entry) => entry.plugin.id); }; const listPluginChannelAliases = (): string[] => { - const registry = getActivePluginRegistry(); - if (!registry) { - return []; - } - return registry.channels.flatMap((entry) => entry.plugin.meta.aliases ?? []); + const channels = + ( + globalThis as typeof globalThis & { + [REGISTRY_STATE]?: PluginRegistryStateLike; + } + )[REGISTRY_STATE]?.registry?.channels ?? []; + return channels.flatMap((entry) => entry.plugin.meta.aliases ?? []); }; export const listDeliverableMessageChannels = (): ChannelId[] => diff --git a/src/whatsapp/resolve-outbound-target.test.ts b/src/whatsapp/resolve-outbound-target.test.ts index 5c4495053b2..4d7d16b4393 100644 --- a/src/whatsapp/resolve-outbound-target.test.ts +++ b/src/whatsapp/resolve-outbound-target.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import * as normalize from "./normalize.js"; -import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js"; vi.mock("./normalize.js"); vi.mock("../infra/outbound/target-errors.js", () => ({ missingTargetError: (platform: string, format: string) => new Error(`${platform}: ${format}`), })); +let resolveWhatsAppOutboundTarget: typeof import("./resolve-outbound-target.js").resolveWhatsAppOutboundTarget; + type ResolveParams = Parameters[0]; const PRIMARY_TARGET = "+11234567890"; const SECONDARY_TARGET = "+19876543210"; @@ -62,8 +63,10 @@ function expectDeniedForTarget(params: { } describe("resolveWhatsAppOutboundTarget", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); vi.resetAllMocks(); + ({ resolveWhatsAppOutboundTarget } = await import("./resolve-outbound-target.js")); }); describe("empty/missing to parameter", () => { diff --git a/test/helpers/extensions/chunk-test-helpers.ts b/test/helpers/extensions/chunk-test-helpers.ts new file mode 100644 index 00000000000..c6589284fd3 --- /dev/null +++ b/test/helpers/extensions/chunk-test-helpers.ts @@ -0,0 +1 @@ +export { countLines, hasBalancedFences } from "../../../src/test-utils/chunk-test-helpers.js"; diff --git a/extensions/test-utils/directory.ts b/test/helpers/extensions/directory.ts similarity index 100% rename from extensions/test-utils/directory.ts rename to test/helpers/extensions/directory.ts diff --git a/extensions/discord/src/monitor/provider.test-support.ts b/test/helpers/extensions/discord-provider.test-support.ts similarity index 76% rename from extensions/discord/src/monitor/provider.test-support.ts rename to test/helpers/extensions/discord-provider.test-support.ts index 23ffb7da2f2..2c8ad988d04 100644 --- a/extensions/discord/src/monitor/provider.test-support.ts +++ b/test/helpers/extensions/discord-provider.test-support.ts @@ -1,7 +1,7 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/discord"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { Mock } from "vitest"; import { expect, vi } from "vitest"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; export type NativeCommandSpecMock = { name: string; @@ -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", () => ({ @@ -303,84 +304,97 @@ vi.mock("@buape/carbon/voice", () => ({ VoicePlugin: class VoicePlugin {}, })); -vi.mock("../../../../src/acp/control-plane/manager.js", () => ({ - getAcpSessionManager: () => ({ - getSessionStatus: getAcpSessionStatusMock, - }), -})); +vi.mock("openclaw/plugin-sdk/acp-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/acp-runtime", + ); + return { + ...actual, + getAcpSessionManager: () => ({ + getSessionStatus: getAcpSessionStatusMock, + }), + isAcpRuntimeError: (error: unknown): error is { code: string } => + error instanceof Error && "code" in error, + }; +}); -vi.mock("../../../../src/auto-reply/chunk.js", () => ({ - resolveTextChunkLimit: () => 2000, -})); +vi.mock("openclaw/plugin-sdk/reply-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/reply-runtime", + ); + return { + ...actual, + resolveTextChunkLimit: () => 2000, + listNativeCommandSpecsForConfig: listNativeCommandSpecsForConfigMock, + listSkillCommandsForAgents: listSkillCommandsForAgentsMock, + }; +}); -vi.mock("../../../../src/auto-reply/commands-registry.js", () => ({ - listNativeCommandSpecsForConfig: listNativeCommandSpecsForConfigMock, -})); +vi.mock("openclaw/plugin-sdk/config-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/config-runtime", + ); + return { + ...actual, + isNativeCommandsExplicitlyDisabled: () => false, + loadConfig: () => ({}), + resolveNativeCommandsEnabled: resolveNativeCommandsEnabledMock, + resolveNativeSkillsEnabled: resolveNativeSkillsEnabledMock, + }; +}); -vi.mock("../../../../src/auto-reply/skill-commands.js", () => ({ - listSkillCommandsForAgents: listSkillCommandsForAgentsMock, -})); +vi.mock("openclaw/plugin-sdk/runtime-env", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/runtime-env", + ); + return { + ...actual, + danger: (value: string) => value, + isVerbose: isVerboseMock, + logVerbose: vi.fn(), + shouldLogVerbose: shouldLogVerboseMock, + warn: (value: string) => value, + createSubsystemLogger: () => { + const logger = { + child: vi.fn(() => logger), + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }; + return logger; + }, + createNonExitingRuntime: () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }), + }; +}); -vi.mock("../../../../src/config/commands.js", () => ({ - isNativeCommandsExplicitlyDisabled: () => false, - resolveNativeCommandsEnabled: resolveNativeCommandsEnabledMock, - resolveNativeSkillsEnabled: resolveNativeSkillsEnabledMock, -})); +vi.mock("openclaw/plugin-sdk/infra-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/infra-runtime", + ); + return { + ...actual, + formatErrorMessage: (error: unknown) => String(error), + }; +}); -vi.mock("../../../../src/config/config.js", () => ({ - loadConfig: () => ({}), -})); - -vi.mock("../../../../src/globals.js", () => ({ - danger: (value: string) => value, - isVerbose: isVerboseMock, - logVerbose: vi.fn(), - shouldLogVerbose: shouldLogVerboseMock, - warn: (value: string) => value, -})); - -vi.mock("../../../../src/infra/errors.js", () => ({ - formatErrorMessage: (error: unknown) => String(error), -})); - -vi.mock("../../../../src/infra/retry-policy.js", () => ({ - createDiscordRetryRunner: () => async (run: () => Promise) => run(), -})); - -vi.mock("../../../../src/logging/subsystem.js", () => ({ - createSubsystemLogger: () => { - const logger = { - child: vi.fn(() => logger), - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }; - return logger; - }, -})); - -vi.mock("../../../../src/runtime.js", () => ({ - createNonExitingRuntime: () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }), -})); - -vi.mock("../accounts.js", () => ({ +vi.mock("../../../extensions/discord/src/accounts.js", () => ({ resolveDiscordAccount: resolveDiscordAccountMock, })); -vi.mock("../probe.js", () => ({ +vi.mock("../../../extensions/discord/src/probe.js", () => ({ fetchDiscordApplicationId: async () => "app-1", })); -vi.mock("../token.js", () => ({ +vi.mock("../../../extensions/discord/src/token.js", () => ({ normalizeDiscordToken: (value?: string) => value, })); -vi.mock("../voice/command.js", () => ({ +vi.mock("../../../extensions/discord/src/voice/command.js", () => ({ createDiscordVoiceCommand: () => ({ name: "voice-command" }), })); -vi.mock("./agent-components.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/agent-components.js", () => ({ createAgentComponentButton: () => ({ id: "btn" }), createAgentSelectMenu: () => ({ id: "menu" }), createDiscordComponentButton: () => ({ id: "btn2" }), @@ -392,15 +406,15 @@ vi.mock("./agent-components.js", () => ({ createDiscordComponentUserSelect: () => ({ id: "user" }), })); -vi.mock("./auto-presence.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/auto-presence.js", () => ({ createDiscordAutoPresenceController: createDiscordAutoPresenceControllerMock, })); -vi.mock("./commands.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/commands.js", () => ({ resolveDiscordSlashCommandConfig: () => ({ ephemeral: false }), })); -vi.mock("./exec-approvals.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/exec-approvals.js", () => ({ createExecApprovalButton: () => ({ id: "exec-approval" }), DiscordExecApprovalHandler: class DiscordExecApprovalHandler { async start() { @@ -412,11 +426,11 @@ vi.mock("./exec-approvals.js", () => ({ }, })); -vi.mock("./gateway-plugin.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/gateway-plugin.js", () => ({ createDiscordGatewayPlugin: () => ({ id: "gateway-plugin" }), })); -vi.mock("./listeners.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/listeners.js", () => ({ DiscordMessageListener: class DiscordMessageListener {}, DiscordPresenceListener: class DiscordPresenceListener {}, DiscordReactionListener: class DiscordReactionListener {}, @@ -425,34 +439,34 @@ vi.mock("./listeners.js", () => ({ registerDiscordListener: vi.fn(), })); -vi.mock("./message-handler.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/message-handler.js", () => ({ createDiscordMessageHandler: createDiscordMessageHandlerMock, })); -vi.mock("./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("./presence.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/presence.js", () => ({ resolveDiscordPresenceUpdate: () => undefined, })); -vi.mock("./provider.allowlist.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/provider.allowlist.js", () => ({ resolveDiscordAllowlistConfig: resolveDiscordAllowlistConfigMock, })); -vi.mock("./provider.lifecycle.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/provider.lifecycle.js", () => ({ runDiscordGatewayLifecycle: monitorLifecycleMock, })); -vi.mock("./rest-fetch.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/rest-fetch.js", () => ({ resolveDiscordRestFetch: () => async () => undefined, })); -vi.mock("./thread-bindings.js", () => ({ +vi.mock("../../../extensions/discord/src/monitor/thread-bindings.js", () => ({ createNoopThreadBindingManager: createNoopThreadBindingManagerMock, createThreadBindingManager: createThreadBindingManagerMock, reconcileAcpThreadBindingsOnStartup: reconcileAcpThreadBindingsOnStartupMock, diff --git a/test/helpers/extensions/env.ts b/test/helpers/extensions/env.ts new file mode 100644 index 00000000000..bc48bfd3d10 --- /dev/null +++ b/test/helpers/extensions/env.ts @@ -0,0 +1 @@ +export { captureEnv, withEnv, withEnvAsync } from "../../../src/test-utils/env.js"; diff --git a/test/helpers/extensions/fetch-mock.ts b/test/helpers/extensions/fetch-mock.ts new file mode 100644 index 00000000000..e1774b46463 --- /dev/null +++ b/test/helpers/extensions/fetch-mock.ts @@ -0,0 +1 @@ +export { withFetchPreconnect, type FetchMock } from "../../../src/test-utils/fetch-mock.js"; diff --git a/test/helpers/extensions/frozen-time.ts b/test/helpers/extensions/frozen-time.ts new file mode 100644 index 00000000000..69f188f09ca --- /dev/null +++ b/test/helpers/extensions/frozen-time.ts @@ -0,0 +1 @@ +export { useFrozenTime, useRealTime } from "../../../src/test-utils/frozen-time.js"; diff --git a/test/helpers/extensions/mock-http-response.ts b/test/helpers/extensions/mock-http-response.ts new file mode 100644 index 00000000000..3bbed0372a8 --- /dev/null +++ b/test/helpers/extensions/mock-http-response.ts @@ -0,0 +1 @@ +export { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js"; diff --git a/extensions/test-utils/plugin-api.ts b/test/helpers/extensions/plugin-api.ts similarity index 95% rename from extensions/test-utils/plugin-api.ts rename to test/helpers/extensions/plugin-api.ts index bb94c326ee8..ee1e97178a8 100644 --- a/extensions/test-utils/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/test-utils/plugin-command.ts b/test/helpers/extensions/plugin-command.ts similarity index 100% rename from extensions/test-utils/plugin-command.ts rename to test/helpers/extensions/plugin-command.ts diff --git a/test/helpers/extensions/plugin-registration.ts b/test/helpers/extensions/plugin-registration.ts new file mode 100644 index 00000000000..bd20510800e --- /dev/null +++ b/test/helpers/extensions/plugin-registration.ts @@ -0,0 +1 @@ +export { registerSingleProviderPlugin } from "../../../src/test-utils/plugin-registration.js"; diff --git a/extensions/test-utils/plugin-runtime-mock.ts b/test/helpers/extensions/plugin-runtime-mock.ts similarity index 99% rename from extensions/test-utils/plugin-runtime-mock.ts rename to test/helpers/extensions/plugin-runtime-mock.ts index fbc9bcdc7fd..d71eeb2d584 100644 --- a/extensions/test-utils/plugin-runtime-mock.ts +++ b/test/helpers/extensions/plugin-runtime-mock.ts @@ -1,6 +1,6 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "openclaw/plugin-sdk/agent-runtime"; -import type { PluginRuntime } from "openclaw/plugin-sdk/test-utils"; -import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk/test-utils"; +import type { PluginRuntime } from "openclaw/plugin-sdk/testing"; +import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; type DeepPartial = { diff --git a/test/helpers/extensions/provider-usage-fetch.ts b/test/helpers/extensions/provider-usage-fetch.ts new file mode 100644 index 00000000000..fe54174732e --- /dev/null +++ b/test/helpers/extensions/provider-usage-fetch.ts @@ -0,0 +1,4 @@ +export { + createProviderUsageFetch, + makeResponse, +} from "../../../src/test-utils/provider-usage-fetch.js"; diff --git a/extensions/test-utils/runtime-env.ts b/test/helpers/extensions/runtime-env.ts similarity index 77% rename from extensions/test-utils/runtime-env.ts rename to test/helpers/extensions/runtime-env.ts index a5e52665b0e..b197619e43e 100644 --- a/extensions/test-utils/runtime-env.ts +++ b/test/helpers/extensions/runtime-env.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk/test-utils"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; export function createRuntimeEnv(): RuntimeEnv { diff --git a/extensions/test-utils/send-config.ts b/test/helpers/extensions/send-config.ts similarity index 100% rename from extensions/test-utils/send-config.ts rename to test/helpers/extensions/send-config.ts diff --git a/test/helpers/extensions/setup-wizard.ts b/test/helpers/extensions/setup-wizard.ts new file mode 100644 index 00000000000..109394ee886 --- /dev/null +++ b/test/helpers/extensions/setup-wizard.ts @@ -0,0 +1,28 @@ +import { vi } from "vitest"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; + +export type { WizardPrompter } from "../../../src/wizard/prompts.js"; + +export async function selectFirstWizardOption(params: { + options: Array<{ value: T }>; +}): Promise { + const first = params.options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; +} + +export function createTestWizardPrompter(overrides: Partial = {}): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: selectFirstWizardOption as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} diff --git a/extensions/test-utils/start-account-context.ts b/test/helpers/extensions/start-account-context.ts similarity index 95% rename from extensions/test-utils/start-account-context.ts rename to test/helpers/extensions/start-account-context.ts index a878b3dbfd9..56a66a9ca56 100644 --- a/extensions/test-utils/start-account-context.ts +++ b/test/helpers/extensions/start-account-context.ts @@ -2,7 +2,7 @@ import type { ChannelAccountSnapshot, ChannelGatewayContext, OpenClawConfig, -} from "openclaw/plugin-sdk/test-utils"; +} from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; import { createRuntimeEnv } from "./runtime-env.js"; diff --git a/extensions/test-utils/start-account-lifecycle.ts b/test/helpers/extensions/start-account-lifecycle.ts similarity index 98% rename from extensions/test-utils/start-account-lifecycle.ts rename to test/helpers/extensions/start-account-lifecycle.ts index 6ce1c734736..ea76fe857d5 100644 --- a/extensions/test-utils/start-account-lifecycle.ts +++ b/test/helpers/extensions/start-account-lifecycle.ts @@ -1,4 +1,4 @@ -import type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/test-utils"; +import type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/testing"; import { expect, vi } from "vitest"; import { createStartAccountContext } from "./start-account-context.js"; diff --git a/extensions/test-utils/status-issues.ts b/test/helpers/extensions/status-issues.ts similarity index 100% rename from extensions/test-utils/status-issues.ts rename to test/helpers/extensions/status-issues.ts diff --git a/test/helpers/extensions/subagent-hooks.ts b/test/helpers/extensions/subagent-hooks.ts new file mode 100644 index 00000000000..2cd80fc5a35 --- /dev/null +++ b/test/helpers/extensions/subagent-hooks.ts @@ -0,0 +1,25 @@ +export function registerHookHandlersForTest(params: { + config: Record; + register: (api: TApi) => void; +}) { + const handlers = new Map unknown>(); + const api = { + config: params.config, + on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => { + handlers.set(hookName, handler); + }, + } as TApi; + params.register(api); + return handlers; +} + +export function getRequiredHookHandler( + handlers: Map unknown>, + hookName: string, +): (event: unknown, ctx: unknown) => unknown { + const handler = handlers.get(hookName); + if (!handler) { + throw new Error(`expected ${hookName} hook handler`); + } + return handler; +} diff --git a/test/helpers/extensions/telegram-plugin-command.ts b/test/helpers/extensions/telegram-plugin-command.ts new file mode 100644 index 00000000000..dec0046de1f --- /dev/null +++ b/test/helpers/extensions/telegram-plugin-command.ts @@ -0,0 +1,22 @@ +import { vi } from "vitest"; + +export const pluginCommandMocks = { + getPluginCommandSpecs: vi.fn(() => []), + matchPluginCommand: vi.fn(() => null), + executePluginCommand: vi.fn(async () => ({ text: "ok" })), +}; + +vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({ + getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, + matchPluginCommand: pluginCommandMocks.matchPluginCommand, + executePluginCommand: pluginCommandMocks.executePluginCommand, +})); + +export function resetPluginCommandMocks() { + pluginCommandMocks.getPluginCommandSpecs.mockClear(); + pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([]); + pluginCommandMocks.matchPluginCommand.mockClear(); + pluginCommandMocks.matchPluginCommand.mockReturnValue(null); + pluginCommandMocks.executePluginCommand.mockClear(); + pluginCommandMocks.executePluginCommand.mockResolvedValue({ text: "ok" }); +} diff --git a/test/helpers/extensions/temp-dir.ts b/test/helpers/extensions/temp-dir.ts new file mode 100644 index 00000000000..08ec26218ec --- /dev/null +++ b/test/helpers/extensions/temp-dir.ts @@ -0,0 +1 @@ +export { withTempDir } from "../../../src/test-utils/temp-dir.js"; diff --git a/test/helpers/extensions/typed-cases.ts b/test/helpers/extensions/typed-cases.ts new file mode 100644 index 00000000000..45be30b08c3 --- /dev/null +++ b/test/helpers/extensions/typed-cases.ts @@ -0,0 +1 @@ +export { typedCases } from "../../../src/test-utils/typed-cases.js"; diff --git a/tsdown.config.ts b/tsdown.config.ts index 966e12afc10..48e69927f98 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -171,6 +171,7 @@ function buildCoreDistEntries(): Record { "line/accounts": "src/line/accounts.ts", "line/send": "src/line/send.ts", "line/template-messages": "src/line/template-messages.ts", + "plugins/build-smoke-entry": "src/plugins/build-smoke-entry.ts", "plugins/runtime/index": "src/plugins/runtime/index.ts", "llm-slug-generator": "src/hooks/llm-slug-generator.ts", }; diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index fd02f7673e9..b037d32c64c 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -89,6 +89,49 @@ function createStorageMock(): Storage { }; } +function setTestWindowUrl(urlString: string) { + const current = new URL(urlString); + const history = { + replaceState: vi.fn((_state: unknown, _title: string, nextUrl: string | URL) => { + const next = new URL(String(nextUrl), current.toString()); + current.href = next.toString(); + current.protocol = next.protocol; + current.host = next.host; + current.pathname = next.pathname; + current.search = next.search; + current.hash = next.hash; + }), + }; + const locationLike = { + get href() { + return current.toString(); + }, + get protocol() { + return current.protocol; + }, + get host() { + return current.host; + }, + get pathname() { + return current.pathname; + }, + get search() { + return current.search; + }, + get hash() { + return current.hash; + }, + }; + vi.stubGlobal("window", { + location: locationLike, + history, + setInterval, + clearInterval, + } as unknown as Window & typeof globalThis); + vi.stubGlobal("location", locationLike as Location); + return { history, location: locationLike }; +} + const createHost = (tab: Tab): SettingsHost => ({ settings: { gatewayUrl: "", @@ -233,15 +276,44 @@ describe("setTabFromRoute", () => { describe("applySettingsFromUrl", () => { beforeEach(() => { vi.stubGlobal("localStorage", createStorageMock()); + vi.stubGlobal("sessionStorage", createStorageMock()); vi.stubGlobal("navigator", { language: "en-US" } as Navigator); + setTestWindowUrl("https://control.example/ui/overview"); }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllGlobals(); - window.history.replaceState({}, "", "/chat"); + }); + + it("hydrates query token params and strips them from the URL", () => { + setTestWindowUrl("https://control.example/ui/overview?token=abc123"); + const host = createHost("overview"); + host.settings.gatewayUrl = "wss://control.example/openclaw"; + + applySettingsFromUrl(host); + + expect(host.settings.token).toBe("abc123"); + expect(window.location.search).toBe(""); + }); + + it("keeps query token params pending when a gatewayUrl confirmation is required", () => { + setTestWindowUrl( + "https://control.example/ui/overview?gatewayUrl=wss://other-gateway.example/openclaw&token=abc123", + ); + const host = createHost("overview"); + host.settings.gatewayUrl = "wss://control.example/openclaw"; + + applySettingsFromUrl(host); + + expect(host.settings.token).toBe(""); + expect(host.pendingGatewayUrl).toBe("wss://other-gateway.example/openclaw"); + expect(host.pendingGatewayToken).toBe("abc123"); + expect(window.location.search).toBe(""); }); it("resets stale persisted session selection to main when a token is supplied without a session", () => { + setTestWindowUrl("https://control.example/chat#token=test-token"); const host = createHost("chat"); host.settings = { ...host.settings, @@ -252,8 +324,6 @@ describe("applySettingsFromUrl", () => { }; host.sessionKey = "agent:test_old:main"; - window.history.replaceState({}, "", "/chat#token=test-token"); - applySettingsFromUrl(host); expect(host.sessionKey).toBe("main"); @@ -262,6 +332,9 @@ describe("applySettingsFromUrl", () => { }); it("preserves an explicit session from the URL when token and session are both supplied", () => { + setTestWindowUrl( + "https://control.example/chat?session=agent%3Atest_new%3Amain#token=test-token", + ); const host = createHost("chat"); host.settings = { ...host.settings, @@ -272,8 +345,6 @@ describe("applySettingsFromUrl", () => { }; host.sessionKey = "agent:test_old:main"; - window.history.replaceState({}, "", "/chat?session=agent%3Atest_new%3Amain#token=test-token"); - applySettingsFromUrl(host); expect(host.sessionKey).toBe("agent:test_new:main"); @@ -282,6 +353,9 @@ describe("applySettingsFromUrl", () => { }); it("does not reset the current gateway session when a different gateway is pending confirmation", () => { + setTestWindowUrl( + "https://control.example/chat?gatewayUrl=ws%3A%2F%2Fgateway-b.example%3A18789#token=test-token", + ); const host = createHost("chat"); host.settings = { ...host.settings, @@ -292,12 +366,6 @@ describe("applySettingsFromUrl", () => { }; host.sessionKey = "agent:test_old:main"; - window.history.replaceState( - {}, - "", - "/chat?gatewayUrl=ws%3A%2F%2Fgateway-b.example%3A18789#token=test-token", - ); - applySettingsFromUrl(host); expect(host.sessionKey).toBe("agent:test_old:main"); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 2a9c2685589..bd924915b76 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -97,7 +97,7 @@ export function applySettingsFromUrl(host: SettingsHost) { const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl"); const nextGatewayUrl = gatewayUrlRaw?.trim() ?? ""; const gatewayUrlChanged = Boolean(nextGatewayUrl && nextGatewayUrl !== host.settings.gatewayUrl); - const tokenRaw = hashParams.get("token"); + const tokenRaw = hashParams.get("token") ?? params.get("token"); const passwordRaw = params.get("password") ?? hashParams.get("password"); const sessionRaw = params.get("session") ?? hashParams.get("session"); const shouldResetSessionForToken = Boolean( diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 5251eda790c..3407288c03d 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -315,11 +315,11 @@ describe("control UI routing", () => { expect(container.scrollTop).toBe(maxScroll); }); - it("strips query token params without importing them", async () => { + it("hydrates token from query params and strips them", async () => { const app = mountApp("/ui/overview?token=abc123"); await app.updateComplete; - expect(app.settings.token).toBe(""); + expect(app.settings.token).toBe("abc123"); expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( undefined, ); @@ -405,6 +405,28 @@ describe("control UI routing", () => { expect(window.location.hash).toBe(""); }); + it("keeps a query token pending until the gateway URL change is confirmed", async () => { + const app = mountApp( + "/ui/overview?gatewayUrl=wss://other-gateway.example/openclaw&token=abc123", + ); + await app.updateComplete; + + expect(app.settings.gatewayUrl).not.toBe("wss://other-gateway.example/openclaw"); + expect(app.settings.token).toBe(""); + + const confirmButton = Array.from(app.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Confirm", + ); + expect(confirmButton).not.toBeUndefined(); + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await app.updateComplete; + + expect(app.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw"); + expect(app.settings.token).toBe("abc123"); + expect(window.location.search).toBe(""); + expect(window.location.hash).toBe(""); + }); + it("restores the token after a same-tab refresh", async () => { const first = mountApp("/ui/overview#token=abc123"); await first.updateComplete;