diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index adf5045728a..1d4a0bbb53a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,7 +11,7 @@ Describe the problem and fix in 2–5 bullets: - [ ] Bug fix - [ ] Feature -- [ ] Refactor +- [ ] Refactor required for the fix - [ ] Docs - [ ] Security hardening - [ ] Chore/infra diff --git a/AGENTS.md b/AGENTS.md index daaa0b1ebd5..6df75f20ad2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,8 @@ - Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`). - Tests: colocated `*.test.ts`. - Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. -- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them. +- Nomenclature: use "plugin" / "plugins" in docs, UI, changelogs, and contributor guidance. `extensions/*` remains the internal directory/package path to avoid repo-wide churn from a rename. +- Plugins: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them. - Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias). - Import boundaries: extension production code should treat `openclaw/plugin-sdk/*` plus local `api.ts` / `runtime-api.ts` barrels as the public surface. Do not import core `src/**`, `src/plugin-sdk-internal/**`, or another extension's `src/**` directly. - Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e33a2d82a6..c2e2f7521ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Models/Anthropic Vertex: add core `anthropic-vertex` provider support for Claude via Google Vertex AI, including GCP auth/discovery and main run-path routing. (#43356) Thanks @sallyom and @yossiovadia. - 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. @@ -52,6 +53,8 @@ Docs: https://docs.openclaw.ai - Docs/plugins: add the community DingTalk plugin listing to the docs catalog. (#29913) Thanks @sliverp. - Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp. - Plugins/context engines: pass the embedded runner `modelId` into context-engine `assemble()` so plugins can adapt context formatting per model. (#47437) thanks @jscianna. +- Plugins/context engines: add transcript maintenance rewrites for context engines, preserve active-branch transcript metadata during rewrites, and harden overflow-recovery truncation to rewrite sessions under the normal session write lock. (#51191) Thanks @jalehman. +- Telegram/apiRoot: add per-account custom Bot API endpoint support across send, probe, setup, doctor repair, and inbound media download paths so proxied or self-hosted Telegram deployments work end to end. (#48842) Thanks @Cypherm. ### Fixes @@ -152,6 +155,9 @@ Docs: https://docs.openclaw.ai - Telegram: stabilize pairing/session/forum routing and reply formatting tests (#50155) Thanks @joshavant. - Hardening: refresh stale device pairing requests and pending metadata (#50695) Thanks @smaeljaish771 and @joshavant. - Gateway: harden OpenResponses file-context escaping (#50782) Thanks @YLChen-007 and @joshavant. +- LINE: harden Express webhook parsing to verified raw body (#51202) Thanks @gladiator9797 and @joshavant. +- Exec: harden host env override handling across gateway and node (#51207) Thanks @gladiator9797 and @joshavant. +- xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek ### Fixes @@ -182,6 +188,13 @@ Docs: https://docs.openclaw.ai - Tests/CLI: reduce command-secret gateway test import pressure while keeping the real protocol payload validator in place, so the isolated lane no longer carries the heavier runtime-web and message-channel graphs. (#50663) Thanks @huntharo. - Gateway/plugins: share plugin interactive callback routing and plugin bind approval state across duplicate module graphs so Telegram Codex picker buttons and plugin bind approvals no longer fall through to normal inbound message routing. (#50722) Thanks @huntharo. - Agents/compaction: add an opt-in post-compaction session JSONL truncation step that drops summarized transcript entries while preserving the retained branch tail and live session metadata. (#41021) thanks @thirumaleshp. +- Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys. +- Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras. +- Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman. +- Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan. +- Agents/embedded transport errors: distinguish common network failures like connection refused, DNS lookup failure, and interrupted sockets from true timeouts in embedded-run user messaging and lifecycle diagnostics. (#51419) Thanks @scoootscooob. +- Discord/startup logging: report client initialization while the gateway is still connecting instead of claiming Discord is logged in before readiness is reached. (#51425) Thanks @scoootscooob. +- Gateway/probe: honor caller `--timeout` for active local loopback probes in `gateway status`, keep inactive remote-mode loopback probes fast, and clamp probe timers to JS-safe bounds so slow local/container gateways stop reporting false timeouts. (#47533) Thanks @MonkeyLeeT. ### Breaking @@ -195,6 +208,7 @@ Docs: https://docs.openclaw.ai - Exec/env sandbox: block build-tool JVM injection (`MAVEN_OPTS`, `SBT_OPTS`, `GRADLE_OPTS`, `ANT_OPTS`), glibc tunable exploitation (`GLIBC_TUNABLES`), and .NET dependency resolution hijack (`DOTNET_ADDITIONAL_DEPS`) from the host exec environment, and restrict Gradle init script redirect (`GRADLE_USER_HOME`) as an override-only block so user-configured Gradle homes still propagate. (#49702) - Plugins/Matrix: add a new Matrix plugin backed by the official `matrix-js-sdk`. If you are upgrading from the previous public Matrix plugin, follow the migration guide: https://docs.openclaw.ai/install/migrating-matrix Thanks @gumadeiras. - Discord/commands: switch native command deployment to Carbon reconcile by default so Discord restarts stop churning slash commands through OpenClaw’s local deploy path. (#46597) Thanks @huntharo and @thewilloftheshadow. +- Plugins/Matrix: durably dedupe inbound room events across gateway restarts so previously handled Matrix messages are not replayed as new, while preserving clean-restart backlog delivery for unseen events. (#50922) thanks @gumadeiras ## 2026.3.13 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8914ffc1f31..1968040e3e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,8 +83,9 @@ Welcome to the lobster tank! 🦞 1. **Bugs & small fixes** β†’ Open a PR! 2. **New features / architecture** β†’ Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first -3. **Test/CI-only PRs for known `main` failures** β†’ Don't open a PR, the Maintainer team is already tracking it and such PRs will be closed automatically. If you've spotted a _new_ regression not yet shown in main CI, report it as an issue first. -4. **Questions** β†’ Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) +3. **Refactor-only PRs** β†’ Don't open a PR. We are not accepting refactor-only changes unless a maintainer explicitly asks for them as part of a concrete fix. +4. **Test/CI-only PRs for known `main` failures** β†’ Don't open a PR. The Maintainer team is already tracking those failures, and PRs that only tweak tests or CI to chase them will be closed unless they are required to validate a new fix. +5. **Questions** β†’ Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) ## Before You PR @@ -97,7 +98,9 @@ Welcome to the lobster tank! 🦞 - For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins` - If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review - If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs. +- Do not submit refactor-only PRs unless a maintainer explicitly requested that refactor for an active fix or deliverable. - Do not submit test or CI-config fixes for failures already red on `main` CI. If a failure is already visible in the [main branch CI runs](https://github.com/openclaw/openclaw/actions), it's a known issue the Maintainer team is tracking, and a PR that only addresses those failures will be closed automatically. If you spot a _new_ regression not yet shown in main CI, report it as an issue first. +- Do not submit test-only PRs that just try to make known `main` CI failures pass. Test changes are acceptable when they are required to validate a new fix or cover new behavior in the same PR. - Ensure CI checks pass - Keep PRs focused (one thing per PR; do not mix unrelated concerns) - Describe what & why diff --git a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift index d5d27a212f5..a3d92efa3f1 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift @@ -1,5 +1,10 @@ import Foundation +struct HostEnvOverrideDiagnostics: Equatable { + var blockedKeys: [String] + var invalidKeys: [String] +} + enum HostEnvSanitizer { /// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs. /// Parity is validated by src/infra/host-env-security.policy-parity.test.ts. @@ -41,6 +46,67 @@ enum HostEnvSanitizer { return filtered.isEmpty ? nil : filtered } + private static func isPortableHead(_ scalar: UnicodeScalar) -> Bool { + let value = scalar.value + return value == 95 || (65...90).contains(value) || (97...122).contains(value) + } + + private static func isPortableTail(_ scalar: UnicodeScalar) -> Bool { + let value = scalar.value + return self.isPortableHead(scalar) || (48...57).contains(value) + } + + private static func normalizeOverrideKey(_ rawKey: String) -> String? { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return nil } + guard let first = key.unicodeScalars.first, self.isPortableHead(first) else { + return nil + } + for scalar in key.unicodeScalars.dropFirst() { + if self.isPortableTail(scalar) || scalar == "(" || scalar == ")" { + continue + } + return nil + } + return key + } + + private static func sortedUnique(_ values: [String]) -> [String] { + Array(Set(values)).sorted() + } + + static func inspectOverrides( + overrides: [String: String]?, + blockPathOverrides: Bool = true) -> HostEnvOverrideDiagnostics + { + guard let overrides else { + return HostEnvOverrideDiagnostics(blockedKeys: [], invalidKeys: []) + } + + var blocked: [String] = [] + var invalid: [String] = [] + for (rawKey, _) in overrides { + let candidate = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard let normalized = self.normalizeOverrideKey(rawKey) else { + invalid.append(candidate.isEmpty ? rawKey : candidate) + continue + } + let upper = normalized.uppercased() + if blockPathOverrides, upper == "PATH" { + blocked.append(upper) + continue + } + if self.isBlockedOverride(upper) || self.isBlocked(upper) { + blocked.append(upper) + continue + } + } + + return HostEnvOverrideDiagnostics( + blockedKeys: self.sortedUnique(blocked), + invalidKeys: self.sortedUnique(invalid)) + } + static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] { var merged: [String: String] = [:] for (rawKey, value) in ProcessInfo.processInfo.environment { @@ -57,8 +123,7 @@ enum HostEnvSanitizer { guard let effectiveOverrides else { return merged } for (rawKey, value) in effectiveOverrides { - let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { continue } + guard let key = self.normalizeOverrideKey(rawKey) else { continue } let upper = key.uppercased() // PATH is part of the security boundary (command resolution + safe-bin checks). Never // allow request-scoped PATH overrides from agents/gateways. diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index 40db384b226..e45261cda2e 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -63,7 +63,23 @@ enum HostEnvSecurityPolicy { "OPENSSL_ENGINES", "PYTHONSTARTUP", "WGETRC", - "CURL_HOME" + "CURL_HOME", + "CLASSPATH", + "CGO_CFLAGS", + "CGO_LDFLAGS", + "GOFLAGS", + "CORECLR_PROFILER_PATH", + "PHPRC", + "PHP_INI_SCAN_DIR", + "DENO_DIR", + "BUN_CONFIG_REGISTRY", + "LUA_PATH", + "LUA_CPATH", + "GEM_HOME", + "GEM_PATH", + "BUNDLE_GEMFILE", + "COMPOSER_HOME", + "XDG_CONFIG_HOME" ] static let blockedOverridePrefixes: [String] = [ diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift index c24f5d0f1b8..956abf94ad6 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift @@ -465,6 +465,23 @@ actor MacNodeRuntime { ? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines) : self.mainSessionKey let runId = UUID().uuidString + let envOverrideDiagnostics = HostEnvSanitizer.inspectOverrides( + overrides: params.env, + blockPathOverrides: true) + if !envOverrideDiagnostics.blockedKeys.isEmpty || !envOverrideDiagnostics.invalidKeys.isEmpty { + var details: [String] = [] + if !envOverrideDiagnostics.blockedKeys.isEmpty { + details.append("blocked override keys: \(envOverrideDiagnostics.blockedKeys.joined(separator: ", "))") + } + if !envOverrideDiagnostics.invalidKeys.isEmpty { + details.append( + "invalid non-portable override keys: \(envOverrideDiagnostics.invalidKeys.joined(separator: ", "))") + } + return Self.errorResponse( + req, + code: .invalidRequest, + message: "SYSTEM_RUN_DENIED: environment override rejected (\(details.joined(separator: "; ")))") + } let evaluation = await ExecApprovalEvaluator.evaluate( command: command, rawCommand: params.rawCommand, diff --git a/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift b/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift index 1e9da910b2a..55a15419576 100644 --- a/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift @@ -33,4 +33,24 @@ struct HostEnvSanitizerTests { let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"]) #expect(env["OPENCLAW_TOKEN"] == "secret") } + + @Test func `inspect overrides rejects blocked and invalid keys`() { + let diagnostics = HostEnvSanitizer.inspectOverrides(overrides: [ + "CLASSPATH": "/tmp/evil-classpath", + "BAD-KEY": "x", + "ProgramFiles(x86)": "C:\\Program Files (x86)", + ]) + + #expect(diagnostics.blockedKeys == ["CLASSPATH"]) + #expect(diagnostics.invalidKeys == ["BAD-KEY"]) + } + + @Test func `sanitize accepts Windows-style override key names`() { + let env = HostEnvSanitizer.sanitize(overrides: [ + "ProgramFiles(x86)": "D:\\SDKs", + "CommonProgramFiles(x86)": "D:\\Common", + ]) + #expect(env["ProgramFiles(x86)"] == "D:\\SDKs") + #expect(env["CommonProgramFiles(x86)"] == "D:\\Common") + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift index 20b4184f5c9..38c4211f014 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift @@ -21,6 +21,32 @@ struct MacNodeRuntimeTests { #expect(response.ok == false) } + @Test func `handle invoke rejects blocked system run env override before execution`() async throws { + let runtime = MacNodeRuntime() + let params = OpenClawSystemRunParams( + command: ["/bin/sh", "-lc", "echo ok"], + env: ["CLASSPATH": "/tmp/evil-classpath"]) + let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) + let response = await runtime.handleInvoke( + BridgeInvokeRequest(id: "req-2c", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json)) + #expect(response.ok == false) + #expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true) + #expect(response.error?.message.contains("CLASSPATH") == true) + } + + @Test func `handle invoke rejects invalid system run env override key before execution`() async throws { + let runtime = MacNodeRuntime() + let params = OpenClawSystemRunParams( + command: ["/bin/sh", "-lc", "echo ok"], + env: ["BAD-KEY": "x"]) + let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) + let response = await runtime.handleInvoke( + BridgeInvokeRequest(id: "req-2d", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json)) + #expect(response.ok == false) + #expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true) + #expect(response.error?.message.contains("BAD-KEY") == true) + } + @Test func `handle invoke rejects empty system which`() async throws { let runtime = MacNodeRuntime() let params = OpenClawSystemWhichParams(bins: []) diff --git a/docs/channels/line.md b/docs/channels/line.md index a965dc6e991..079025e10ac 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -51,6 +51,7 @@ If you need a custom path, set `channels.line.webhookPath` or Security note: - LINE signature verification is body-dependent (HMAC over the raw body), so OpenClaw applies strict pre-auth body limits and timeout before verification. +- OpenClaw processes webhook events from the verified raw request bytes. Upstream middleware-transformed `req.body` values are ignored for signature-integrity safety. ## Configure diff --git a/docs/docs.json b/docs/docs.json index 65e4ed25c1b..be9fa476ea7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -64,6 +64,18 @@ "source": "/platforms/raspberry-pi", "destination": "/install/raspberry-pi" }, + { + "source": "/plugins/building-extensions", + "destination": "/plugins/building-plugins" + }, + { + "source": "/plugins/agent-tools", + "destination": "/plugins/building-plugins#registering-agent-tools" + }, + { + "source": "/tools/capability-cookbook", + "destination": "/plugins/architecture" + }, { "source": "/brave-search", "destination": "/tools/brave-search" @@ -948,6 +960,7 @@ "channels/telegram", "channels/tlon", "channels/twitch", + "plugins/voice-call", "channels/whatsapp", "channels/zalo", "channels/zalouser" @@ -1014,79 +1027,35 @@ ] }, { - "tab": "Tools", + "tab": "Tools & Plugins", "groups": [ { "group": "Overview", "pages": ["tools/index"] }, { - "group": "Built-in tools", + "group": "Plugins", "pages": [ - "tools/apply-patch", - "tools/brave-search", - "tools/btw", - "tools/diffs", - "tools/elevated", - "tools/exec", - "tools/exec-approvals", - "tools/firecrawl", - "tools/tavily", - "tools/llm-task", - "tools/lobster", - "tools/loop-detection", - "tools/pdf", - "tools/perplexity-search", - "tools/reactions", - "tools/thinking", - "tools/web" - ] - }, - { - "group": "Browser", - "pages": [ - "tools/browser", - "tools/browser-login", - "tools/browser-linux-troubleshooting", - "tools/browser-wsl2-windows-remote-cdp-troubleshooting" - ] - }, - { - "group": "Agent coordination", - "pages": [ - "tools/agent-send", - "tools/subagents", - "tools/acp-agents", - "tools/multi-agent-sandbox-tools" + "tools/plugin", + "plugins/building-plugins", + "plugins/community", + "plugins/bundles", + "plugins/manifest", + "plugins/sdk-migration", + "plugins/architecture" ] }, { "group": "Skills", "pages": [ - "tools/creating-skills", - "tools/slash-commands", "tools/skills", + "tools/creating-skills", "tools/skills-config", + "tools/slash-commands", "tools/clawhub", - "tools/plugin", "prose" ] }, - { - "group": "Extensions", - "pages": [ - "plugins/building-extensions", - "plugins/sdk-migration", - "plugins/architecture", - "plugins/community", - "plugins/bundles", - "plugins/voice-call", - "plugins/zalouser", - "plugins/manifest", - "plugins/agent-tools", - "tools/capability-cookbook" - ] - }, { "group": "Automation", "pages": [ @@ -1102,18 +1071,48 @@ ] }, { - "group": "Media and devices", + "group": "Tools", "pages": [ - "nodes/index", - "nodes/troubleshooting", - "nodes/media-understanding", - "nodes/images", - "nodes/audio", - "nodes/camera", - "nodes/talk", - "nodes/voicewake", - "nodes/location-command", - "tools/tts" + "tools/apply-patch", + { + "group": "Browser", + "pages": [ + "tools/browser", + "tools/browser-login", + "tools/browser-linux-troubleshooting", + "tools/browser-wsl2-windows-remote-cdp-troubleshooting" + ] + }, + "tools/btw", + "tools/diffs", + "tools/elevated", + "tools/exec", + "tools/exec-approvals", + "tools/llm-task", + "tools/lobster", + "tools/loop-detection", + "tools/pdf", + "tools/reactions", + "tools/thinking", + { + "group": "Web and search", + "pages": [ + "tools/web", + "tools/brave-search", + "tools/firecrawl", + "tools/perplexity-search", + "tools/tavily" + ] + } + ] + }, + { + "group": "Agent coordination", + "pages": [ + "tools/agent-send", + "tools/subagents", + "tools/acp-agents", + "tools/multi-agent-sandbox-tools" ] } ] @@ -1283,6 +1282,21 @@ "security/CONTRIBUTING-THREAT-MODEL" ] }, + { + "group": "Nodes and devices", + "pages": [ + "nodes/index", + "nodes/troubleshooting", + "nodes/media-understanding", + "nodes/images", + "nodes/audio", + "nodes/camera", + "nodes/talk", + "nodes/voicewake", + "nodes/location-command", + "tools/tts" + ] + }, { "group": "Web interfaces", "pages": ["web/index", "web/control-ui", "web/dashboard", "web/webchat", "web/tui"] diff --git a/docs/plugins/agent-tools.md b/docs/plugins/agent-tools.md index 8740fd51fa4..930bdfbe629 100644 --- a/docs/plugins/agent-tools.md +++ b/docs/plugins/agent-tools.md @@ -1,99 +1,10 @@ --- -summary: "Write agent tools in a plugin (schemas, optional tools, allowlists)" +summary: "Redirects to Building Plugins (registering tools section)" read_when: - - You want to add a new agent tool in a plugin - - You need to make a tool opt-in via allowlists -title: "Plugin Agent Tools" + - Legacy link to agent-tools +title: "Registering Tools" --- -# Plugin agent tools +# Registering Tools in Plugins -OpenClaw plugins can register **agent tools** (JSON‑schema functions) that are exposed -to the LLM during agent runs. Tools can be **required** (always available) or -**optional** (opt‑in). - -Agent tools are configured under `tools` in the main config, or per‑agent under -`agents.list[].tools`. The allowlist/denylist policy controls which tools the agent -can call. - -## Basic tool - -```ts -import { Type } from "@sinclair/typebox"; - -export default function (api) { - api.registerTool({ - name: "my_tool", - description: "Do a thing", - parameters: Type.Object({ - input: Type.String(), - }), - async execute(_id, params) { - return { content: [{ type: "text", text: params.input }] }; - }, - }); -} -``` - -## Optional tool (opt-in) - -Optional tools are **never** auto‑enabled. Users must add them to an agent -allowlist. - -```ts -export default function (api) { - api.registerTool( - { - name: "workflow_tool", - description: "Run a local workflow", - parameters: { - type: "object", - properties: { - pipeline: { type: "string" }, - }, - required: ["pipeline"], - }, - async execute(_id, params) { - return { content: [{ type: "text", text: params.pipeline }] }; - }, - }, - { optional: true }, - ); -} -``` - -Enable optional tools in `agents.list[].tools.allow` (or global `tools.allow`): - -```json5 -{ - agents: { - list: [ - { - id: "main", - tools: { - allow: [ - "workflow_tool", // specific tool name - "workflow", // plugin id (enables all tools from that plugin) - "group:plugins", // all plugin tools - ], - }, - }, - ], - }, -} -``` - -Other config knobs that affect tool availability: - -- Allowlists that only name plugin tools are treated as plugin opt-ins; core tools remain - enabled unless you also include core tools or groups in the allowlist. -- `tools.profile` / `agents.list[].tools.profile` (base allowlist) -- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny) -- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed) - -## Rules + tips - -- Tool names must **not** clash with core tool names; conflicting tools are skipped. -- Plugin ids used in allowlists must not clash with core tool names. -- Prefer `optional: true` for tools that trigger side effects or require extra - binaries/credentials. +This page has moved. See [Building Plugins: Registering agent tools](/plugins/building-plugins#registering-agent-tools). diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 4ffdeb29125..49aa6344ca9 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -1,17 +1,23 @@ --- -summary: "Plugin architecture internals: capability model, ownership, contracts, load pipeline, runtime helpers" +summary: "Plugin internals: capability model, ownership, contracts, load pipeline, and runtime helpers" read_when: - Building or debugging native OpenClaw plugins - Understanding the plugin capability model or ownership boundaries - Working on the plugin load pipeline or registry - Implementing provider runtime hooks or channel plugins -title: "Plugin Architecture" +title: "Plugin Internals" +sidebarTitle: "Internals" --- -# Plugin Architecture +# Plugin Internals -This page covers the internal architecture of the OpenClaw plugin system. For -user-facing setup, discovery, and configuration, see [Plugins](/tools/plugin). + + This page is for **plugin developers and contributors**. If you just want to + install and use plugins, see [Plugins](/tools/plugin). If you want to build + a plugin, see [Building Plugins](/plugins/building-plugins). + + +This page covers the internal architecture of the OpenClaw plugin system. ## Public capability model @@ -927,25 +933,31 @@ authoring plugins: - `openclaw/plugin-sdk/core` for the generic shared plugin-facing contract. - Stable channel primitives such as `openclaw/plugin-sdk/channel-setup`, `openclaw/plugin-sdk/channel-pairing`, + `openclaw/plugin-sdk/channel-contract`, + `openclaw/plugin-sdk/channel-feedback`, + `openclaw/plugin-sdk/channel-inbound`, + `openclaw/plugin-sdk/channel-lifecycle`, `openclaw/plugin-sdk/channel-reply-pipeline`, + `openclaw/plugin-sdk/command-auth`, `openclaw/plugin-sdk/secret-input`, and `openclaw/plugin-sdk/webhook-ingress` for shared setup/auth/reply/webhook - wiring. + wiring. `channel-inbound` is the shared home for debounce, mention matching, + envelope formatting, and inbound envelope context helpers. - Domain subpaths such as `openclaw/plugin-sdk/channel-config-helpers`, + `openclaw/plugin-sdk/allow-from`, `openclaw/plugin-sdk/channel-config-schema`, `openclaw/plugin-sdk/channel-policy`, - `openclaw/plugin-sdk/channel-runtime`, `openclaw/plugin-sdk/config-runtime`, + `openclaw/plugin-sdk/infra-runtime`, `openclaw/plugin-sdk/agent-runtime`, `openclaw/plugin-sdk/lazy-runtime`, `openclaw/plugin-sdk/reply-history`, `openclaw/plugin-sdk/routing`, + `openclaw/plugin-sdk/status-helpers`, `openclaw/plugin-sdk/runtime-store`, and `openclaw/plugin-sdk/directory-runtime` for shared runtime/config helpers. -- Narrow channel-core subpaths such as `openclaw/plugin-sdk/discord-core`, - `openclaw/plugin-sdk/telegram-core`, and `openclaw/plugin-sdk/whatsapp-core` - for channel-specific primitives that should stay smaller than the full - channel helper barrels. +- `openclaw/plugin-sdk/channel-runtime` remains only as a compatibility shim. + New code should import the narrower primitives instead. - 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`, @@ -956,26 +968,25 @@ authoring plugins: `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. -- `openclaw/plugin-sdk/imessage` for iMessage channel plugin types and shared channel-facing helpers. Built-in iMessage implementation internals stay private to the bundled extension. -- `openclaw/plugin-sdk/whatsapp` for WhatsApp channel plugin types and shared channel-facing helpers. Built-in WhatsApp implementation internals stay private to the bundled extension. -- `openclaw/plugin-sdk/bluebubbles` remains public because it carries a small - focused helper surface that is shared intentionally. +- No bundled channel-branded public subpaths remain. Channel-specific helper and + runtime seams live under `extensions//api.js` and `extensions//runtime-api.js`; + the public SDK contract is the generic shared primitives instead. Compatibility note: - Avoid the root `openclaw/plugin-sdk` barrel for new code. - Prefer the narrow stable primitives first. The newer setup/pairing/reply/ - secret-input/webhook subpaths are the intended contract for new bundled and - external plugin work. + feedback/contract/inbound/threading/command/secret-input/webhook/infra/ + allowlist/status/message-tool subpaths are the intended contract for new + bundled and external plugin work. + Target parsing/matching belongs on `openclaw/plugin-sdk/channel-targets`. + Message action gates and reaction message-id helpers belong on + `openclaw/plugin-sdk/channel-actions`. - Bundled extension-specific helper barrels are not stable by default. If a helper is only needed by a bundled extension, keep it behind the extension's local `api.js` or `runtime-api.js` seam instead of promoting it into `openclaw/plugin-sdk/`. -- Channel-branded bundled bars such as `feishu`, `googlechat`, `irc`, `line`, - `nostr`, `twitch`, and `zalo` stay private unless they are explicitly added +- Channel-branded bundled bars stay private unless they are explicitly added back to the public contract. - Capability-specific subpaths such as `image-generation`, `media-understanding`, and `speech` exist because bundled/native plugins use @@ -988,7 +999,7 @@ Plugins should own channel-specific `describeMessageTool(...)` schema contributions. Keep provider-specific fields in the plugin, not in shared core. For shared portable schema fragments, reuse the generic helpers exported through -`openclaw/plugin-sdk/channel-runtime`: +`openclaw/plugin-sdk/channel-actions`: - `createMessageToolButtonsSchema()` for button-grid style payloads - `createMessageToolCardSchema()` for structured card payloads diff --git a/docs/plugins/building-extensions.md b/docs/plugins/building-extensions.md index 7b4548194cd..f0db0f3173f 100644 --- a/docs/plugins/building-extensions.md +++ b/docs/plugins/building-extensions.md @@ -1,224 +1,10 @@ --- -title: "Building Extensions" -summary: "Step-by-step guide for creating OpenClaw channel and provider extensions" +title: "Building Plugins" +summary: "Redirects to the current Building Plugins guide" read_when: - - You want to create a new OpenClaw plugin or extension - - You need to understand the plugin SDK import patterns - - You are adding a new channel or provider to OpenClaw + - Legacy link to building-extensions --- -# Building Extensions +# Building Plugins -Extensions add channels, model providers, tools, or other capabilities to OpenClaw. -This guide walks through creating one from scratch. - -## Prerequisites - -- OpenClaw repository cloned and dependencies installed (`pnpm install`) -- Familiarity with TypeScript (ESM) - -## Extension structure - -Every extension lives under `extensions//` and follows this layout: - -``` -extensions/my-channel/ -β”œβ”€β”€ package.json # npm metadata + openclaw config -β”œβ”€β”€ index.ts # Entry point (defineChannelPluginEntry) -β”œβ”€β”€ setup-entry.ts # Setup wizard (optional) -β”œβ”€β”€ api.ts # Public contract barrel (optional) -β”œβ”€β”€ runtime-api.ts # Internal runtime barrel (optional) -└── src/ - β”œβ”€β”€ channel.ts # Channel adapter implementation - β”œβ”€β”€ runtime.ts # Runtime wiring - └── *.test.ts # Colocated tests -``` - -## Create an extension - - - - Create `extensions/my-channel/package.json`: - - ```json - { - "name": "@openclaw/my-channel", - "version": "2026.1.1", - "description": "OpenClaw My Channel plugin", - "type": "module", - "dependencies": {}, - "openclaw": { - "extensions": ["./index.ts"], - "setupEntry": "./setup-entry.ts", - "channel": { - "id": "my-channel", - "label": "My Channel", - "selectionLabel": "My Channel (plugin)", - "docsPath": "/channels/my-channel", - "docsLabel": "my-channel", - "blurb": "Short description of the channel.", - "order": 80 - }, - "install": { - "npmSpec": "@openclaw/my-channel", - "localPath": "extensions/my-channel" - } - } - } - ``` - - The `openclaw` field tells the plugin system what your extension provides. - For provider plugins, use `providers` instead of `channel`. - - - - - Create `extensions/my-channel/index.ts`: - - ```typescript - import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; - - export default defineChannelPluginEntry({ - id: "my-channel", - name: "My Channel", - description: "Connects OpenClaw to My Channel", - plugin: { - // Channel adapter implementation - }, - }); - ``` - - For provider plugins, use `definePluginEntry` instead. - - - - - Always import from specific `openclaw/plugin-sdk/` paths rather than - the monolithic root. The old `openclaw/plugin-sdk/compat` barrel is deprecated - (see [SDK Migration](/plugins/sdk-migration)). - - ```typescript - // Correct: focused subpaths - import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; - import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; - import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; - import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; - import { createOptionalChannelSetupSurface } from "openclaw/plugin-sdk/channel-setup"; - import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; - import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; - - // Wrong: monolithic root (lint will reject this) - import { ... } from "openclaw/plugin-sdk"; - ``` - - - | Subpath | Purpose | - | --- | --- | - | `plugin-sdk/core` | Plugin entry definitions, base types | - | `plugin-sdk/channel-setup` | Optional setup adapters/wizards | - | `plugin-sdk/channel-pairing` | DM pairing primitives | - | `plugin-sdk/channel-reply-pipeline` | Prefix + typing reply wiring | - | `plugin-sdk/channel-config-schema` | Config schema builders | - | `plugin-sdk/channel-policy` | Group/DM policy helpers | - | `plugin-sdk/secret-input` | Secret input parsing/helpers | - | `plugin-sdk/webhook-ingress` | Webhook request/target helpers | - | `plugin-sdk/runtime-store` | Persistent plugin storage | - | `plugin-sdk/allow-from` | Allowlist resolution | - | `plugin-sdk/reply-payload` | Message reply types | - | `plugin-sdk/provider-oauth` | OAuth login + PKCE helpers | - | `plugin-sdk/provider-onboard` | Provider onboarding config patches | - | `plugin-sdk/testing` | Test utilities | - - - Use the narrowest primitive that matches the job. Reach for `channel-runtime` - or other larger helper barrels only when a dedicated subpath does not exist yet. - - - - - Within your extension, create barrel files for internal code sharing instead - of importing through the plugin SDK: - - ```typescript - // api.ts β€” public contract for this extension - export { MyChannelConfig } from "./src/config.js"; - export { MyChannelRuntime } from "./src/runtime.js"; - - // runtime-api.ts β€” internal-only exports (not for production consumers) - export { internalHelper } from "./src/helpers.js"; - ``` - - - Never import your own extension back through its published SDK contract - path from production files. Route internal imports through `./api.ts` or - `./runtime-api.ts` instead. The SDK contract is for external consumers only. - - - - - - Create `openclaw.plugin.json` in your extension root: - - ```json - { - "id": "my-channel", - "kind": "channel", - "channels": ["my-channel"], - "name": "My Channel Plugin", - "description": "Connects OpenClaw to My Channel" - } - ``` - - See [Plugin manifest](/plugins/manifest) for the full schema. - - - - - OpenClaw runs contract tests against all registered plugins. After adding your - extension, run: - - ```bash - pnpm test:contracts:channels # channel plugins - pnpm test:contracts:plugins # provider plugins - ``` - - Contract tests verify your plugin conforms to the expected interface (setup - wizard, session binding, message handling, group policy, etc.). - - For unit tests, import test helpers from the public testing surface: - - ```typescript - import { createTestRuntime } from "openclaw/plugin-sdk/testing"; - ``` - - - - -## Lint enforcement - -Three scripts enforce SDK boundaries: - -1. **No monolithic root imports** β€” `openclaw/plugin-sdk` root is rejected -2. **No direct src/ imports** β€” extensions cannot import `../../src/` directly -3. **No self-imports** β€” extensions cannot import their own `plugin-sdk/` subpath - -Run `pnpm check` to verify all boundaries before committing. - -## Pre-submission checklist - -**package.json** has correct `openclaw` metadata -Entry point uses `defineChannelPluginEntry` or `definePluginEntry` -All imports use focused `plugin-sdk/` paths -Internal imports use local barrels, not SDK self-imports -`openclaw.plugin.json` manifest is present and valid -Contract tests pass (`pnpm test:contracts`) -Unit tests colocated as `*.test.ts` -`pnpm check` passes (lint + format) -Doc page created under `docs/channels/` or `docs/plugins/` - -## Related - -- [Plugin SDK Migration](/plugins/sdk-migration) β€” migrating from compat to focused subpaths -- [Plugin Architecture](/plugins/architecture) β€” internals and capability model -- [Plugin Manifest](/plugins/manifest) β€” full manifest schema -- [Community Plugins](/plugins/community) β€” existing community extensions +This page has moved to [Building Plugins](/plugins/building-plugins). diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md new file mode 100644 index 00000000000..121b673f5c6 --- /dev/null +++ b/docs/plugins/building-plugins.md @@ -0,0 +1,369 @@ +--- +title: "Building Plugins" +sidebarTitle: "Building Plugins" +summary: "Step-by-step guide for creating OpenClaw plugins with any combination of capabilities" +read_when: + - You want to create a new OpenClaw plugin + - You need to understand the plugin SDK import patterns + - You are adding a new channel, provider, tool, or other capability to OpenClaw +--- + +# Building Plugins + +Plugins extend OpenClaw with new capabilities: channels, model providers, speech, +image generation, web search, agent tools, or any combination. A single plugin +can register multiple capabilities. + +OpenClaw encourages **external plugin development**. You do not need to add your +plugin to the OpenClaw repository. Publish your plugin on npm, and users install +it with `openclaw plugins install `. OpenClaw also maintains a set of +core plugins in-repo, but the plugin system is designed for independent ownership +and distribution. + +## Prerequisites + +- Node >= 22 and a package manager (npm or pnpm) +- Familiarity with TypeScript (ESM) +- For in-repo plugins: OpenClaw repository cloned and `pnpm install` done + +## Plugin capabilities + +A plugin can register one or more capabilities. The capability you register +determines what your plugin provides to OpenClaw: + +| Capability | Registration method | What it adds | +| ------------------- | --------------------------------------------- | ------------------------------ | +| Text inference | `api.registerProvider(...)` | Model provider (LLM) | +| Channel / messaging | `api.registerChannel(...)` | Chat channel (e.g. Slack, IRC) | +| Speech | `api.registerSpeechProvider(...)` | Text-to-speech / STT | +| Media understanding | `api.registerMediaUnderstandingProvider(...)` | Image/audio/video analysis | +| Image generation | `api.registerImageGenerationProvider(...)` | Image generation | +| Web search | `api.registerWebSearchProvider(...)` | Web search provider | +| Agent tools | `api.registerTool(...)` | Tools callable by the agent | + +A plugin that registers zero capabilities but provides hooks or services is a +**hook-only** plugin. That pattern is still supported. + +## Plugin structure + +Plugins follow this layout (whether in-repo or standalone): + +``` +my-plugin/ +β”œβ”€β”€ package.json # npm metadata + openclaw config +β”œβ”€β”€ openclaw.plugin.json # Plugin manifest +β”œβ”€β”€ index.ts # Entry point +β”œβ”€β”€ setup-entry.ts # Setup wizard (optional) +β”œβ”€β”€ api.ts # Public exports (optional) +β”œβ”€β”€ runtime-api.ts # Internal exports (optional) +└── src/ + β”œβ”€β”€ provider.ts # Capability implementation + β”œβ”€β”€ runtime.ts # Runtime wiring + └── *.test.ts # Colocated tests +``` + +## Create a plugin + + + + Create `package.json` with the `openclaw` metadata block. The structure + depends on what capabilities your plugin provides. + + **Channel plugin example:** + + ```json + { + "name": "@myorg/openclaw-my-channel", + "version": "1.0.0", + "type": "module", + "openclaw": { + "extensions": ["./index.ts"], + "channel": { + "id": "my-channel", + "label": "My Channel", + "blurb": "Short description of the channel." + } + } + } + ``` + + **Provider plugin example:** + + ```json + { + "name": "@myorg/openclaw-my-provider", + "version": "1.0.0", + "type": "module", + "openclaw": { + "extensions": ["./index.ts"], + "providers": ["my-provider"] + } + } + ``` + + The `openclaw` field tells the plugin system what your plugin provides. + A plugin can declare both `channel` and `providers` if it provides multiple + capabilities. + + + + + The entry point registers your capabilities with the plugin API. + + **Channel plugin:** + + ```typescript + import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; + + export default defineChannelPluginEntry({ + id: "my-channel", + name: "My Channel", + description: "Connects OpenClaw to My Channel", + plugin: { + // Channel adapter implementation + }, + }); + ``` + + **Provider plugin:** + + ```typescript + import { definePluginEntry } from "openclaw/plugin-sdk/core"; + + export default definePluginEntry({ + id: "my-provider", + name: "My Provider", + register(api) { + api.registerProvider({ + // Provider implementation + }); + }, + }); + ``` + + **Multi-capability plugin** (provider + tool): + + ```typescript + import { definePluginEntry } from "openclaw/plugin-sdk/core"; + + export default definePluginEntry({ + id: "my-plugin", + name: "My Plugin", + register(api) { + api.registerProvider({ /* ... */ }); + api.registerTool({ /* ... */ }); + api.registerImageGenerationProvider({ /* ... */ }); + }, + }); + ``` + + Use `defineChannelPluginEntry` for channel plugins and `definePluginEntry` + for everything else. A single plugin can register as many capabilities as needed. + + + + + Always import from specific `openclaw/plugin-sdk/\` paths. The old + monolithic import is deprecated (see [SDK Migration](/plugins/sdk-migration)). + + If older plugin code still imports `openclaw/extension-api`, treat that as a + temporary compatibility bridge only. New code should use injected runtime + helpers such as `api.runtime.agent.*` instead of importing host-side agent + helpers directly. + + ```typescript + // Correct: focused subpaths + import { definePluginEntry } from "openclaw/plugin-sdk/core"; + import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; + import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; + + // Wrong: monolithic root (lint will reject this) + import { ... } from "openclaw/plugin-sdk"; + + // Deprecated: legacy host bridge + import { runEmbeddedPiAgent } from "openclaw/extension-api"; + ``` + + + | Subpath | Purpose | + | --- | --- | + | `plugin-sdk/core` | Plugin entry definitions and base types | + | `plugin-sdk/channel-setup` | Setup wizard adapters | + | `plugin-sdk/channel-pairing` | DM pairing primitives | + | `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | + | `plugin-sdk/channel-config-schema` | Config schema builders | + | `plugin-sdk/channel-policy` | Group/DM policy helpers | + | `plugin-sdk/secret-input` | Secret input parsing/helpers | + | `plugin-sdk/webhook-ingress` | Webhook request/target helpers | + | `plugin-sdk/runtime-store` | Persistent plugin storage | + | `plugin-sdk/allow-from` | Allowlist resolution | + | `plugin-sdk/reply-payload` | Message reply types | + | `plugin-sdk/provider-oauth` | OAuth login + PKCE helpers | + | `plugin-sdk/provider-onboard` | Provider onboarding config patches | + | `plugin-sdk/testing` | Test utilities | + + + Use the narrowest subpath that matches the job. + + + + + Within your plugin, create local module files for internal code sharing + instead of re-importing through the plugin SDK: + + ```typescript + // api.ts β€” public exports for this plugin + export { MyConfig } from "./src/config.js"; + export { MyRuntime } from "./src/runtime.js"; + + // runtime-api.ts β€” internal-only exports + export { internalHelper } from "./src/helpers.js"; + ``` + + + Never import your own plugin back through its published SDK path from + production files. Route internal imports through local files like `./api.ts` + or `./runtime-api.ts`. The SDK path is for external consumers only. + + + + + + Create `openclaw.plugin.json` in your plugin root: + + ```json + { + "id": "my-plugin", + "kind": "provider", + "name": "My Plugin", + "description": "Adds My Provider to OpenClaw" + } + ``` + + For channel plugins, set `"kind": "channel"` and add `"channels": ["my-channel"]`. + + See [Plugin Manifest](/plugins/manifest) for the full schema. + + + + + **External plugins:** run your own test suite against the plugin SDK contracts. + + **In-repo plugins:** OpenClaw runs contract tests against all registered plugins: + + ```bash + pnpm test:contracts:channels # channel plugins + pnpm test:contracts:plugins # provider plugins + ``` + + For unit tests, import test helpers from the testing surface: + + ```typescript + import { createTestRuntime } from "openclaw/plugin-sdk/testing"; + ``` + + + + + **External plugins:** publish to npm, then install: + + ```bash + npm publish + openclaw plugins install @myorg/openclaw-my-plugin + ``` + + **In-repo plugins:** place the plugin under `extensions/` and it is + automatically discovered during build. + + Users can browse and install community plugins with: + + ```bash + openclaw plugins search + openclaw plugins install + ``` + + + + +## Registering agent tools + +Plugins can register **agent tools** β€” typed functions the LLM can call. Tools +can be required (always available) or optional (users opt in via allowlists). + +```typescript +import { Type } from "@sinclair/typebox"; + +export default definePluginEntry({ + id: "my-plugin", + name: "My Plugin", + register(api) { + // Required tool (always available) + api.registerTool({ + name: "my_tool", + description: "Do a thing", + parameters: Type.Object({ input: Type.String() }), + async execute(_id, params) { + return { content: [{ type: "text", text: params.input }] }; + }, + }); + + // Optional tool (user must add to allowlist) + api.registerTool( + { + name: "workflow_tool", + description: "Run a workflow", + parameters: Type.Object({ pipeline: Type.String() }), + async execute(_id, params) { + return { content: [{ type: "text", text: params.pipeline }] }; + }, + }, + { optional: true }, + ); + }, +}); +``` + +Enable optional tools in config: + +```json5 +{ + tools: { allow: ["workflow_tool"] }, +} +``` + +Tips: + +- Tool names must not clash with core tool names (conflicts are skipped) +- Use `optional: true` for tools that trigger side effects or require extra binaries +- Users can enable all tools from a plugin by adding the plugin id to `tools.allow` + +## Lint enforcement (in-repo plugins) + +Three scripts enforce SDK boundaries for plugins in the OpenClaw repository: + +1. **No monolithic root imports** β€” `openclaw/plugin-sdk` root is rejected +2. **No direct src/ imports** β€” plugins cannot import `../../src/` directly +3. **No self-imports** β€” plugins cannot import their own `plugin-sdk/\` subpath + +Run `pnpm check` to verify all boundaries before committing. + +External plugins are not subject to these lint rules, but following the same +patterns is strongly recommended. + +## Pre-submission checklist + +**package.json** has correct `openclaw` metadata +Entry point uses `defineChannelPluginEntry` or `definePluginEntry` +All imports use focused `plugin-sdk/\` paths +Internal imports use local modules, not SDK self-imports +`openclaw.plugin.json` manifest is present and valid +Tests pass +`pnpm check` passes (in-repo plugins) + +## Related + +- [Plugin SDK Migration](/plugins/sdk-migration) β€” migrating from deprecated compat surfaces +- [Plugin Architecture](/plugins/architecture) β€” internals and capability model +- [Plugin Manifest](/plugins/manifest) β€” full manifest schema +- [Plugin Agent Tools](/plugins/building-plugins#registering-agent-tools) β€” adding agent tools in a plugin +- [Community Plugins](/plugins/community) β€” listing and quality bar diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index 82a5605e099..b60b110e6b7 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -1,307 +1,181 @@ --- -summary: "Unified bundle format guide for Codex, Claude, and Cursor bundles in OpenClaw" +summary: "Install and use Codex, Claude, and Cursor bundles as OpenClaw plugins" read_when: - - You want to install or debug a Codex, Claude, or Cursor-compatible bundle + - You want to install a Codex, Claude, or Cursor-compatible bundle - You need to understand how OpenClaw maps bundle content into native features - - You are documenting bundle compatibility or current support limits + - You are debugging bundle detection or missing capabilities title: "Plugin Bundles" --- -# Plugin bundles +# Plugin Bundles -OpenClaw supports one shared class of external plugin package: **bundle -plugins**. +OpenClaw can install plugins from three external ecosystems: **Codex**, **Claude**, +and **Cursor**. These are called **bundles** β€” content and metadata packs that +OpenClaw maps into native features like skills, hooks, and MCP tools. -Today that means three closely related ecosystems: + + Bundles are **not** the same as native OpenClaw plugins. Native plugins run + in-process and can register any capability. Bundles are content packs with + selective feature mapping and a narrower trust boundary. + -- Codex bundles -- Claude bundles -- Cursor bundles +## Why bundles exist -OpenClaw shows all of them as `Format: bundle` in `openclaw plugins list`. -Verbose output and `openclaw plugins inspect ` also show the subtype -(`codex`, `claude`, or `cursor`). +Many useful plugins are published in Codex, Claude, or Cursor format. Instead +of requiring authors to rewrite them as native OpenClaw plugins, OpenClaw +detects these formats and maps their supported content into the native feature +set. This means you can install a Claude command pack or a Codex skill bundle +and use it immediately. -Related: +## Install a bundle -- Plugin system overview: [Plugins](/tools/plugin) -- CLI install/list flows: [plugins](/cli/plugins) -- Native manifest schema: [Plugin manifest](/plugins/manifest) + + + ```bash + # Local directory + openclaw plugins install ./my-bundle -## What a bundle is + # Archive + openclaw plugins install ./my-bundle.tgz -A bundle is a **content/metadata pack**, not a native in-process OpenClaw -plugin. + # Claude marketplace + openclaw plugins marketplace list + openclaw plugins install @ + ``` -Today, OpenClaw does **not** execute bundle runtime code in-process. Instead, -it detects known bundle files, reads the metadata, and maps supported bundle -content into native OpenClaw surfaces such as skills, hook packs, MCP config, -and embedded Pi settings. + -That is the main trust boundary: + + ```bash + openclaw plugins list + openclaw plugins inspect + ``` -- native OpenClaw plugin: runtime module executes in-process -- bundle: metadata/content pack, with selective feature mapping + Bundles show as `Format: bundle` with a subtype of `codex`, `claude`, or `cursor`. -## Shared bundle model + -Codex, Claude, and Cursor bundles are similar enough that OpenClaw treats them -as one normalized model. + + ```bash + openclaw gateway restart + ``` -Shared idea: + Mapped features (skills, hooks, MCP tools) are available in the next session. -- a small manifest file, or a default directory layout -- one or more content roots such as `skills/` or `commands/` -- optional tool/runtime metadata such as MCP, hooks, agents, or LSP -- install as a directory or archive, then enable in the normal plugin list + + -Common OpenClaw behavior: +## What OpenClaw maps from bundles -- detect the bundle subtype -- normalize it into one internal bundle record -- map supported parts into native OpenClaw features -- report unsupported parts as detected-but-not-wired capabilities - -In practice, most users do not need to think about the vendor-specific format -first. The more useful question is: which bundle surfaces does OpenClaw map -today? - -## Detection order - -OpenClaw prefers native OpenClaw plugin/package layouts before bundle handling. - -Practical effect: - -- `openclaw.plugin.json` wins over bundle detection -- package installs with valid `package.json` + `openclaw.extensions` use the - native install path -- if a directory contains both native and bundle metadata, OpenClaw treats it - as native first - -That avoids partially installing a dual-format package as a bundle and then -loading it later as a native plugin. - -## What works today - -OpenClaw normalizes bundle metadata into one internal bundle record, then maps -supported surfaces into existing native behavior. +Not every bundle feature runs in OpenClaw today. Here is what works and what +is detected but not yet wired. ### Supported now -#### Skill content - -- bundle skill roots load as normal OpenClaw skill roots -- Claude `commands` roots are treated as additional skill roots -- Cursor `.cursor/commands` roots are treated as additional skill roots - -This means Claude markdown command files work through the normal OpenClaw skill -loader. Cursor command markdown works through the same path. - -#### Hook packs - -- bundle hook roots work **only** when they use the normal OpenClaw hook-pack - layout. Today this is primarily the Codex-compatible case: - - `HOOK.md` - - `handler.ts` or `handler.js` - -#### MCP for Pi - -- enabled bundles can contribute MCP server config -- OpenClaw merges bundle MCP config into the effective embedded Pi settings as - `mcpServers` -- OpenClaw also exposes supported bundle MCP tools during embedded Pi agent - turns by launching supported stdio MCP servers as subprocesses -- project-local Pi settings still apply after bundle defaults, so workspace - settings can override bundle MCP entries when needed - -#### Embedded Pi settings - -- Claude `settings.json` is imported as default embedded Pi settings when the - bundle is enabled -- OpenClaw sanitizes shell override keys before applying them - -Sanitized keys: - -- `shellPath` -- `shellCommandPrefix` +| Feature | How it maps | Applies to | +| ------------- | ---------------------------------------------------------------------------------------------------- | -------------- | +| Skill content | Bundle skill roots load as normal OpenClaw skills | All formats | +| Commands | `commands/` and `.cursor/commands/` treated as skill roots | Claude, Cursor | +| Hook packs | OpenClaw-style `HOOK.md` + `handler.ts` layouts | Codex | +| MCP tools | Bundle MCP config merged into embedded Pi settings; supported stdio servers launched as subprocesses | All formats | +| Settings | Claude `settings.json` imported as embedded Pi defaults | Claude | ### Detected but not executed -These surfaces are detected, shown in bundle capabilities, and may appear in -diagnostics/info output, but OpenClaw does not run them yet: +These are recognized and shown in diagnostics, but OpenClaw does not run them: -- Claude `agents` -- Claude `hooks.json` automation -- Claude `lspServers` -- Claude `outputStyles` -- Cursor `.cursor/agents` -- Cursor `.cursor/hooks.json` -- Cursor `.cursor/rules` +- Claude `agents`, `hooks.json` automation, `lspServers`, `outputStyles` +- Cursor `.cursor/agents`, `.cursor/hooks.json`, `.cursor/rules` - Codex inline/app metadata beyond capability reporting -## Capability reporting +## Bundle formats -`openclaw plugins inspect ` shows bundle capabilities from the normalized -bundle record. + + + Markers: `.codex-plugin/plugin.json` -Supported capabilities are loaded quietly. Unsupported capabilities produce a -warning such as: + Optional content: `skills/`, `hooks/`, `.mcp.json`, `.app.json` -```text -bundle capability detected but not wired into OpenClaw yet: agents -``` + Codex bundles fit OpenClaw best when they use skill roots and OpenClaw-style + hook-pack directories (`HOOK.md` + `handler.ts`). -Current exceptions: + -- Claude `commands` is considered supported because it maps to skills -- Claude `settings` is considered supported because it maps to embedded Pi settings -- Cursor `commands` is considered supported because it maps to skills -- bundle MCP is considered supported because it maps into embedded Pi settings - and exposes supported stdio tools to embedded Pi -- Codex `hooks` is considered supported only for OpenClaw hook-pack layouts + + Two detection modes: -## Format differences + - **Manifest-based:** `.claude-plugin/plugin.json` + - **Manifestless:** default Claude layout (`skills/`, `commands/`, `agents/`, `hooks/`, `.mcp.json`, `settings.json`) -The formats are close, but not byte-for-byte identical. These are the practical -differences that matter in OpenClaw. + Claude-specific behavior: -### Codex + - `commands/` is treated as skill content + - `settings.json` is imported into embedded Pi settings (shell override keys are sanitized) + - `.mcp.json` exposes supported stdio tools to embedded Pi + - `hooks/hooks.json` is detected but not executed + - Custom component paths in the manifest are additive (they extend defaults, not replace them) -Typical markers: + -- `.codex-plugin/plugin.json` -- optional `skills/` -- optional `hooks/` -- optional `.mcp.json` -- optional `.app.json` + + Markers: `.cursor-plugin/plugin.json` -Codex bundles fit OpenClaw best when they use skill roots and OpenClaw-style -hook-pack directories. + Optional content: `skills/`, `.cursor/commands/`, `.cursor/agents/`, `.cursor/rules/`, `.cursor/hooks.json`, `.mcp.json` -### Claude + - `.cursor/commands/` is treated as skill content + - `.cursor/rules/`, `.cursor/agents/`, and `.cursor/hooks.json` are detect-only -OpenClaw supports both: + + -- manifest-based Claude bundles: `.claude-plugin/plugin.json` -- manifestless Claude bundles that use the default Claude layout +## Detection precedence -Default Claude layout markers OpenClaw recognizes: +OpenClaw checks for native plugin format first: -- `skills/` -- `commands/` -- `agents/` -- `hooks/hooks.json` -- `.mcp.json` -- `.lsp.json` -- `settings.json` +1. `openclaw.plugin.json` or valid `package.json` with `openclaw.extensions` β€” treated as **native plugin** +2. Bundle markers (`.codex-plugin/`, `.claude-plugin/`, or default Claude/Cursor layout) β€” treated as **bundle** -Claude-specific notes: +If a directory contains both, OpenClaw uses the native path. This prevents +dual-format packages from being partially installed as bundles. -- `commands/` is treated like skill content -- `settings.json` is imported into embedded Pi settings -- `.mcp.json` and manifest `mcpServers` can expose supported stdio tools to - embedded Pi -- `hooks/hooks.json` is detected, but not executed as Claude automation +## Security -### Cursor +Bundles have a narrower trust boundary than native plugins: -Typical markers: +- OpenClaw does **not** load arbitrary bundle runtime modules in-process +- Skills and hook-pack paths must stay inside the plugin root (boundary-checked) +- Settings files are read with the same boundary checks +- Supported stdio MCP servers may be launched as subprocesses -- `.cursor-plugin/plugin.json` -- optional `skills/` -- optional `.cursor/commands/` -- optional `.cursor/agents/` -- optional `.cursor/rules/` -- optional `.cursor/hooks.json` -- optional `.mcp.json` - -Cursor-specific notes: - -- `.cursor/commands/` is treated like skill content -- `.cursor/rules/`, `.cursor/agents/`, and `.cursor/hooks.json` are - detect-only today - -## Claude custom paths - -Claude bundle manifests can declare custom component paths. OpenClaw treats -those paths as **additive**, not replacing defaults. - -Currently recognized custom path keys: - -- `skills` -- `commands` -- `agents` -- `hooks` -- `mcpServers` -- `lspServers` -- `outputStyles` - -Examples: - -- default `commands/` plus manifest `commands: "extra-commands"` => - OpenClaw scans both -- default `skills/` plus manifest `skills: ["team-skills"]` => - OpenClaw scans both - -## Security model - -Bundle support is intentionally narrower than native plugin support. - -Current behavior: - -- bundle discovery reads files inside the plugin root with boundary checks -- skills and hook-pack paths must stay inside the plugin root -- bundle settings files are read with the same boundary checks -- supported stdio bundle MCP servers may be launched as subprocesses for - embedded Pi tool calls -- OpenClaw does not load arbitrary bundle runtime modules in-process - -This makes bundle support safer by default than native plugin modules, but you -should still treat third-party bundles as trusted content for the features they -do expose. - -## Install examples - -```bash -openclaw plugins install ./my-codex-bundle -openclaw plugins install ./my-claude-bundle -openclaw plugins install ./my-cursor-bundle -openclaw plugins install ./my-bundle.tgz -openclaw plugins marketplace list -openclaw plugins install @ -openclaw plugins inspect my-bundle -``` - -If the directory is a native OpenClaw plugin/package, the native install path -still wins. - -For Claude marketplace names, OpenClaw reads the local Claude known-marketplace -registry at `~/.claude/plugins/known_marketplaces.json`. Marketplace entries -can resolve to bundle-compatible directories/archives or to native plugin -sources; after resolution, the normal install rules still apply. +This makes bundles safer by default, but you should still treat third-party +bundles as trusted content for the features they do expose. ## Troubleshooting -### Bundle is detected but capabilities do not run + + + Run `openclaw plugins inspect `. If a capability is listed but marked as + not wired, that is a product limit β€” not a broken install. + -Check `openclaw plugins inspect `. + + Make sure the bundle is enabled and the markdown files are inside a detected + `commands/` or `skills/` root. + -If the capability is listed but OpenClaw says it is not wired yet, that is a -real product limit, not a broken install. + + Only embedded Pi settings from `settings.json` are supported. OpenClaw does + not treat bundle settings as raw config patches. + -### Claude command files do not appear + + `hooks/hooks.json` is detect-only. If you need runnable hooks, use the + OpenClaw hook-pack layout or ship a native plugin. + + -Make sure the bundle is enabled and the markdown files are inside a detected -`commands` root or `skills` root. +## Related -### Claude settings do not apply - -Current support is limited to embedded Pi settings from `settings.json`. -OpenClaw does not treat bundle settings as raw OpenClaw config patches. - -### Claude hooks do not execute - -`hooks/hooks.json` is only detected today. - -If you need runnable bundle hooks today, use the normal OpenClaw hook-pack -layout through a supported Codex hook root or ship a native OpenClaw plugin. +- [Install and Configure Plugins](/tools/plugin) +- [Building Plugins](/plugins/building-plugins) β€” create a native plugin +- [Plugin Manifest](/plugins/manifest) β€” native manifest schema diff --git a/docs/plugins/community.md b/docs/plugins/community.md index 12df6c3eee0..d6cbcd76301 100644 --- a/docs/plugins/community.md +++ b/docs/plugins/community.md @@ -1,60 +1,128 @@ --- -summary: "Community plugins: quality bar, hosting requirements, and PR submission path" +summary: "Community-maintained OpenClaw plugins: browse, install, and submit your own" read_when: - - You want to publish a third-party OpenClaw plugin - - You want to propose a plugin for docs listing -title: "Community plugins" + - You want to find third-party OpenClaw plugins + - You want to publish or list your own plugin +title: "Community Plugins" --- -# Community plugins +# Community Plugins -This page tracks high-quality **community-maintained plugins** for OpenClaw. +Community plugins are third-party packages that extend OpenClaw with new +channels, tools, providers, or other capabilities. They are built and maintained +by the community, published on npm, and installable with a single command. -We accept PRs that add community plugins here when they meet the quality bar. - -## Required for listing - -- Plugin package is published on npmjs (installable via `openclaw plugins install `). -- Source code is hosted on GitHub (public repository). -- Repository includes setup/use docs and an issue tracker. -- Plugin has a clear maintenance signal (active maintainer, recent updates, or responsive issue handling). - -## How to submit - -Open a PR that adds your plugin to this page with: - -- Plugin name -- npm package name -- GitHub repository URL -- One-line description -- Install command - -## Review bar - -We prefer plugins that are useful, documented, and safe to operate. -Low-effort wrappers, unclear ownership, or unmaintained packages may be declined. - -## Candidate format - -Use this format when adding entries: - -- **Plugin Name** β€” short description - npm: `@scope/package` - repo: `https://github.com/org/repo` - install: `openclaw plugins install @scope/package` +```bash +openclaw plugins install +``` ## Listed plugins -- **openclaw-dingtalk** β€” The OpenClaw DingTalk channel plugin enables the integration of enterprise robots using the Stream mode. It supports text, images and file messages via any DingTalk client. - npm: `@largezhou/ddingtalk` - repo: `https://github.com/largezhou/openclaw-dingtalk` - install: `openclaw plugins install @largezhou/ddingtalk` -- **QQbot** β€” Connect OpenClaw to QQ via the QQ Bot API. Supports private chats, group mentions, channel messages, and rich media including voice, images, videos, and files. - npm: `@sliverp/qqbot` - repo: `https://github.com/sliverp/qqbot` - install: `openclaw plugins install @sliverp/qqbot` +### Codex App Server Bridge -- **WeChat** β€” Connect OpenClaw to WeChat personal accounts via WeChatPadPro (iPad protocol). Supports text, image, and file exchange with keyword-triggered conversations. - npm: `@icesword760/openclaw-wechat` - repo: `https://github.com/icesword0760/openclaw-wechat` - install: `openclaw plugins install @icesword760/openclaw-wechat` +Independent OpenClaw bridge for Codex App Server conversations. Bind a chat to +a Codex thread, talk to it with plain text, and control it with chat-native +commands for resume, planning, review, model selection, compaction, and more. + +- **npm:** `openclaw-codex-app-server` +- **repo:** [github.com/pwrdrvr/openclaw-codex-app-server](https://github.com/pwrdrvr/openclaw-codex-app-server) + +```bash +openclaw plugins install openclaw-codex-app-server +``` + +### DingTalk + +Enterprise robot integration using Stream mode. Supports text, images, and +file messages via any DingTalk client. + +- **npm:** `@largezhou/ddingtalk` +- **repo:** [github.com/largezhou/openclaw-dingtalk](https://github.com/largezhou/openclaw-dingtalk) + +```bash +openclaw plugins install @largezhou/ddingtalk +``` + +### Lossless Claw (LCM) + +Lossless Context Management plugin for OpenClaw. DAG-based conversation +summarization with incremental compaction β€” preserves full context fidelity +while reducing token usage. + +- **npm:** `@martian-engineering/lossless-claw` +- **repo:** [github.com/Martian-Engineering/lossless-claw](https://github.com/Martian-Engineering/lossless-claw) + +```bash +openclaw plugins install @martian-engineering/lossless-claw +``` + +### Opik + +Official plugin that exports agent traces to Opik. Monitor agent behavior, +cost, tokens, errors, and more. + +- **npm:** `@opik/opik-openclaw` +- **repo:** [github.com/comet-ml/opik-openclaw](https://github.com/comet-ml/opik-openclaw) + +```bash +openclaw plugins install @opik/opik-openclaw +``` + +### QQbot + +Connect OpenClaw to QQ via the QQ Bot API. Supports private chats, group +mentions, channel messages, and rich media including voice, images, videos, +and files. + +- **npm:** `@sliverp/qqbot` +- **repo:** [github.com/sliverp/qqbot](https://github.com/sliverp/qqbot) + +```bash +openclaw plugins install @sliverp/qqbot +``` + +## Submit your plugin + +We welcome community plugins that are useful, documented, and safe to operate. + + + + Your plugin must be installable via `openclaw plugins install \`. + See [Building Plugins](/plugins/building-plugins) for the full guide. + + + + + Source code must be in a public repository with setup docs and an issue + tracker. + + + + + Add your plugin to this page with: + + - Plugin name + - npm package name + - GitHub repository URL + - One-line description + - Install command + + + + +## Quality bar + +| Requirement | Why | +| -------------------- | --------------------------------------------- | +| Published on npm | Users need `openclaw plugins install` to work | +| Public GitHub repo | Source review, issue tracking, transparency | +| Setup and usage docs | Users need to know how to configure it | +| Active maintenance | Recent updates or responsive issue handling | + +Low-effort wrappers, unclear ownership, or unmaintained packages may be declined. + +## Related + +- [Install and Configure Plugins](/tools/plugin) β€” how to install any plugin +- [Building Plugins](/plugins/building-plugins) β€” create your own +- [Plugin Manifest](/plugins/manifest) β€” manifest schema diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index c02cc53cd3d..52501f5b9c7 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -1,144 +1,168 @@ --- title: "Plugin SDK Migration" -summary: "Migrate from openclaw/plugin-sdk/compat to focused subpath imports" +sidebarTitle: "SDK Migration" +summary: "Migrate from the legacy backwards-compatibility layer to the modern plugin SDK" read_when: - You see the OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED warning - - You are updating a plugin from the monolithic plugin-sdk import to scoped subpaths + - You see the OPENCLAW_EXTENSION_API_DEPRECATED warning + - You are updating a plugin to the modern plugin architecture - You maintain an external OpenClaw plugin --- # Plugin SDK Migration -OpenClaw is migrating from a single monolithic `openclaw/plugin-sdk/compat` barrel -to **focused subpath imports** (`openclaw/plugin-sdk/\`). This page explains -what changed, why, and how to migrate. +OpenClaw has moved from a broad backwards-compatibility layer to a modern plugin +architecture with focused, documented imports. If your plugin was built before +the new architecture, this guide helps you migrate. -## Why this change +## What is changing -The monolithic compat barrel re-exported everything from a single entry point. -This caused: +The old plugin system provided two wide-open surfaces that let plugins import +anything they needed from a single entry point: -- **Slow startup**: importing one helper pulled in dozens of unrelated modules. -- **Circular dependency risk**: broad re-exports made it easy to create import cycles. -- **Unclear API surface**: no way to tell which exports were stable vs internal. +- **`openclaw/plugin-sdk/compat`** β€” a single import that re-exported dozens of + helpers. It was introduced to keep older hook-based plugins working while the + new plugin architecture was being built. +- **`openclaw/extension-api`** β€” a bridge that gave plugins direct access to + host-side helpers like the embedded agent runner. -Focused subpaths fix all three: each subpath is a small, self-contained module -with a clear purpose. +Both surfaces are now **deprecated**. They still work at runtime, but new +plugins must not use them, and existing plugins should migrate before the next +major release removes them. -## What triggers the warning + + The backwards-compatibility layer will be removed in a future major release. + Plugins that still import from these surfaces will break when that happens. + -If your plugin imports from the compat barrel, you will see: +## Why this changed -``` -[OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED] Warning: openclaw/plugin-sdk/compat is -deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/\ imports. -``` +The old approach caused problems: -The compat barrel still works at runtime. This is a deprecation warning, not an -error. But new plugins **must not** use it, and existing plugins should migrate -before compat is removed. +- **Slow startup** β€” importing one helper loaded dozens of unrelated modules +- **Circular dependencies** β€” broad re-exports made it easy to create import cycles +- **Unclear API surface** β€” no way to tell which exports were stable vs internal + +The modern plugin SDK fixes this: each import path (`openclaw/plugin-sdk/\`) +is a small, self-contained module with a clear purpose and documented contract. ## How to migrate -### Step 1: Find compat imports + + + Search your plugin for imports from either deprecated surface: -Search your extension for imports from the compat path: + ```bash + grep -r "plugin-sdk/compat" my-plugin/ + grep -r "openclaw/extension-api" my-plugin/ + ``` -```bash -grep -r "plugin-sdk/compat" extensions/my-plugin/ -``` + -### Step 2: Replace with focused subpaths + + Each export from the old surface maps to a specific modern import path: -Each export from compat maps to a specific subpath. Replace the import source: + ```typescript + // Before (deprecated backwards-compatibility layer) + import { + createChannelReplyPipeline, + createPluginRuntimeStore, + resolveControlCommandGate, + } from "openclaw/plugin-sdk/compat"; -```typescript -// Before (compat barrel) -import { - createChannelReplyPipeline, - createPluginRuntimeStore, - resolveControlCommandGate, -} from "openclaw/plugin-sdk/compat"; + // After (modern focused imports) + import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; + import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; + import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; + ``` -// After (focused subpaths) -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; -``` + For host-side helpers, use the injected plugin runtime instead of importing + directly: -### Step 3: Verify + ```typescript + // Before (deprecated extension-api bridge) + import { runEmbeddedPiAgent } from "openclaw/extension-api"; + const result = await runEmbeddedPiAgent({ sessionId, prompt }); -Run the build and tests: + // After (injected runtime) + const result = await api.runtime.agent.runEmbeddedPiAgent({ sessionId, prompt }); + ``` -```bash -pnpm build -pnpm test -- extensions/my-plugin/ -``` + The same pattern applies to other legacy bridge helpers: -## Subpath reference + | Old import | Modern equivalent | + | --- | --- | + | `resolveAgentDir` | `api.runtime.agent.resolveAgentDir` | + | `resolveAgentWorkspaceDir` | `api.runtime.agent.resolveAgentWorkspaceDir` | + | `resolveAgentIdentity` | `api.runtime.agent.resolveAgentIdentity` | + | `resolveThinkingDefault` | `api.runtime.agent.resolveThinkingDefault` | + | `resolveAgentTimeoutMs` | `api.runtime.agent.resolveAgentTimeoutMs` | + | `ensureAgentWorkspace` | `api.runtime.agent.ensureAgentWorkspace` | + | session store helpers | `api.runtime.agent.session.*` | -| Subpath | Purpose | Key exports | -| ----------------------------------- | ------------------------------------ | ---------------------------------------------------------------------- | -| `plugin-sdk/core` | Plugin entry definitions, base types | `defineChannelPluginEntry`, `definePluginEntry` | -| `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface` | -| `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` | -| `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` | -| `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter`, `createScopedChannelConfigAdapter` | -| `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types | -| `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` | -| `plugin-sdk/channel-lifecycle` | Account status tracking | `createAccountStatusSink` | -| `plugin-sdk/channel-runtime` | Runtime wiring helpers | Channel runtime utilities | -| `plugin-sdk/channel-send-result` | Send result types | Reply result types | -| `plugin-sdk/runtime-store` | Persistent plugin storage | `createPluginRuntimeStore` | -| `plugin-sdk/allow-from` | Allowlist formatting | `formatAllowFromLowercase`, `formatNormalizedAllowFromEntries` | -| `plugin-sdk/allowlist-resolution` | Allowlist input mapping | `mapAllowlistResolutionInputs` | -| `plugin-sdk/command-auth` | Command gating | `resolveControlCommandGate` | -| `plugin-sdk/secret-input` | Secret input parsing | Secret input helpers | -| `plugin-sdk/webhook-ingress` | Webhook request helpers | Webhook target utilities | -| `plugin-sdk/reply-payload` | Message reply types | Reply payload types | -| `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers | -| `plugin-sdk/keyed-async-queue` | Ordered async queue | `KeyedAsyncQueue` | -| `plugin-sdk/testing` | Test utilities | Test helpers and mocks | + -Use the narrowest subpath that has what you need. If you cannot find an export, + + ```bash + pnpm build + pnpm test -- my-plugin/ + ``` + + + +## Import path reference + + + | Import path | Purpose | Key exports | + | --- | --- | --- | + | `plugin-sdk/core` | Plugin entry definitions, base types | `defineChannelPluginEntry`, `definePluginEntry` | + | `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface` | + | `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` | + | `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` | + | `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter` | + | `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types | + | `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` | + | `plugin-sdk/channel-lifecycle` | Account status tracking | `createAccountStatusSink` | + | `plugin-sdk/channel-runtime` | Runtime wiring helpers | Channel runtime utilities | + | `plugin-sdk/channel-send-result` | Send result types | Reply result types | + | `plugin-sdk/runtime-store` | Persistent plugin storage | `createPluginRuntimeStore` | + | `plugin-sdk/allow-from` | Allowlist formatting | `formatAllowFromLowercase` | + | `plugin-sdk/allowlist-resolution` | Allowlist input mapping | `mapAllowlistResolutionInputs` | + | `plugin-sdk/command-auth` | Command gating | `resolveControlCommandGate` | + | `plugin-sdk/secret-input` | Secret input parsing | Secret input helpers | + | `plugin-sdk/webhook-ingress` | Webhook request helpers | Webhook target utilities | + | `plugin-sdk/reply-payload` | Message reply types | Reply payload types | + | `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers | + | `plugin-sdk/keyed-async-queue` | Ordered async queue | `KeyedAsyncQueue` | + | `plugin-sdk/testing` | Test utilities | Test helpers and mocks | + + +Use the narrowest import that matches the job. If you cannot find an export, check the source at `src/plugin-sdk/` or ask in Discord. -## Compat barrel removal timeline +## Removal timeline -- **Now**: compat barrel emits a deprecation warning at runtime. -- **Next major release**: compat barrel will be removed. Plugins still using it will - fail to import. +| When | What happens | +| ---------------------- | ----------------------------------------------------------------------- | +| **Now** | Deprecated surfaces emit runtime warnings | +| **Next major release** | Deprecated surfaces will be removed; plugins still using them will fail | -Bundled plugins (under `extensions/`) have already been migrated. External plugins -should migrate before the next major release. +All core plugins have already been migrated. External plugins should migrate +before the next major release. -## Suppressing the warning temporarily +## Suppressing the warnings temporarily -If you need to suppress the warning while migrating: +Set these environment variables while you work on migrating: ```bash OPENCLAW_SUPPRESS_PLUGIN_SDK_COMPAT_WARNING=1 openclaw gateway run +OPENCLAW_SUPPRESS_EXTENSION_API_WARNING=1 openclaw gateway run ``` This is a temporary escape hatch, not a permanent solution. -## Internal barrel pattern - -Within your extension, use local barrel files (`api.ts`, `runtime-api.ts`) for -internal code sharing instead of importing through the plugin SDK: - -```typescript -// extensions/my-plugin/api.ts β€” public contract for this extension -export { MyConfig } from "./src/config.js"; -export { MyRuntime } from "./src/runtime.js"; -``` - -Never import your own extension back through `openclaw/plugin-sdk/\` -from production files. That path is for external consumers only. See -[Building Extensions](/plugins/building-extensions#step-4-use-local-barrels-for-internal-imports). - ## Related -- [Building Extensions](/plugins/building-extensions) -- [Plugin Architecture](/plugins/architecture) +- [Building Plugins](/plugins/building-plugins) +- [Plugin Internals](/plugins/architecture) - [Plugin Manifest](/plugins/manifest) diff --git a/docs/providers/xai.md b/docs/providers/xai.md index ec491735e50..271eae0bc57 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -34,8 +34,7 @@ OpenClaw now includes these xAI model families out of the box: - `grok-4`, `grok-4-0709` - `grok-4-fast-reasoning`, `grok-4-fast-non-reasoning` - `grok-4-1-fast-reasoning`, `grok-4-1-fast-non-reasoning` -- `grok-4.20-experimental-beta-0304-reasoning` -- `grok-4.20-experimental-beta-0304-non-reasoning` +- `grok-4.20-reasoning`, `grok-4.20-non-reasoning` - `grok-code-fast-1` The plugin also forward-resolves newer `grok-4*` and `grok-code-fast*` ids when diff --git a/docs/start/hubs.md b/docs/start/hubs.md index 7e530f769b5..754957a96d6 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -164,9 +164,9 @@ Use these hubs to discover every page, including deep dives and reference docs t ## Extensions + plugins - [Plugins overview](/tools/plugin) -- [Building extensions](/plugins/building-extensions) +- [Building plugins](/plugins/building-plugins) - [Plugin manifest](/plugins/manifest) -- [Agent tools](/plugins/agent-tools) +- [Agent tools](/plugins/building-plugins#registering-agent-tools) - [Plugin bundles](/plugins/bundles) - [Community plugins](/plugins/community) - [Capability cookbook](/tools/capability-cookbook) diff --git a/docs/tools/agent-send.md b/docs/tools/agent-send.md index e301feeea12..153a1e9b3c6 100644 --- a/docs/tools/agent-send.md +++ b/docs/tools/agent-send.md @@ -1,53 +1,100 @@ --- -summary: "Direct `openclaw agent` CLI runs (with optional delivery)" +summary: "Run agent turns from the CLI and optionally deliver replies to channels" read_when: - - Adding or modifying the agent CLI entrypoint + - You want to trigger agent runs from scripts or the command line + - You need to deliver agent replies to a chat channel programmatically title: "Agent Send" --- -# `openclaw agent` (direct agent runs) +# Agent Send -`openclaw agent` runs a single agent turn without needing an inbound chat message. -By default it goes **through the Gateway**; add `--local` to force the embedded -runtime on the current machine. +`openclaw agent` runs a single agent turn from the command line without needing +an inbound chat message. Use it for scripted workflows, testing, and +programmatic delivery. + +## Quick start + + + + ```bash + openclaw agent --message "What is the weather today?" + ``` + + This sends the message through the Gateway and prints the reply. + + + + + ```bash + # Target a specific agent + openclaw agent --agent ops --message "Summarize logs" + + # Target a phone number (derives session key) + openclaw agent --to +15555550123 --message "Status update" + + # Reuse an existing session + openclaw agent --session-id abc123 --message "Continue the task" + ``` + + + + + ```bash + # Deliver to WhatsApp (default channel) + openclaw agent --to +15555550123 --message "Report ready" --deliver + + # Deliver to Slack + openclaw agent --agent ops --message "Generate report" \ + --deliver --reply-channel slack --reply-to "#reports" + ``` + + + + +## Flags + +| Flag | Description | +| ----------------------------- | ----------------------------------------------------------- | +| `--message \` | Message to send (required) | +| `--to \` | Derive session key from a target (phone, chat id) | +| `--agent \` | Target a configured agent (uses its `main` session) | +| `--session-id \` | Reuse an existing session by id | +| `--local` | Force local embedded runtime (skip Gateway) | +| `--deliver` | Send the reply to a chat channel | +| `--channel \` | Delivery channel (whatsapp, telegram, discord, slack, etc.) | +| `--reply-to \` | Delivery target override | +| `--reply-channel \` | Delivery channel override | +| `--reply-account \` | Delivery account id override | +| `--thinking \` | Set thinking level (off, minimal, low, medium, high, xhigh) | +| `--verbose \` | Set verbose level | +| `--timeout \` | Override agent timeout | +| `--json` | Output structured JSON | ## Behavior -- Required: `--message ` -- Session selection: - - `--to ` derives the session key (group/channel targets preserve isolation; direct chats collapse to `main`), **or** - - `--session-id ` reuses an existing session by id, **or** - - `--agent ` targets a configured agent directly (uses that agent's `main` session key) -- Runs the same embedded agent runtime as normal inbound replies. -- Thinking/verbose flags persist into the session store. -- Output: - - default: prints reply text (plus `MEDIA:` lines) - - `--json`: prints structured payload + metadata -- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `openclaw message --target`). -- Use `--reply-channel`/`--reply-to`/`--reply-account` to override delivery without changing the session. - -If the Gateway is unreachable, the CLI **falls back** to the embedded local run. +- By default, the CLI goes **through the Gateway**. Add `--local` to force the + embedded runtime on the current machine. +- If the Gateway is unreachable, the CLI **falls back** to the local embedded run. +- Session selection: `--to` derives the session key (group/channel targets + preserve isolation; direct chats collapse to `main`). +- Thinking and verbose flags persist into the session store. +- Output: plain text by default, or `--json` for structured payload + metadata. ## Examples ```bash -openclaw agent --to +15555550123 --message "status update" -openclaw agent --agent ops --message "Summarize logs" -openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium +# Simple turn with JSON output openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json -openclaw agent --to +15555550123 --message "Summon reply" --deliver -openclaw agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports" + +# Turn with thinking level +openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium + +# Deliver to a different channel than the session +openclaw agent --agent ops --message "Alert" --deliver --reply-channel telegram --reply-to "@admin" ``` -## Flags +## Related -- `--local`: run locally (requires model provider API keys in your shell) -- `--deliver`: send the reply to the chosen channel -- `--channel`: delivery channel (`whatsapp|telegram|discord|googlechat|slack|signal|imessage`, default: `whatsapp`) -- `--reply-to`: delivery target override -- `--reply-channel`: delivery channel override -- `--reply-account`: delivery account id override -- `--thinking `: persist thinking level (GPT-5.2 + Codex models only) -- `--verbose `: persist verbose level -- `--timeout `: override agent timeout -- `--json`: output structured JSON +- [Agent CLI reference](/cli/agent) +- [Sub-agents](/tools/subagents) β€” background sub-agent spawning +- [Sessions](/concepts/session) β€” how session keys work diff --git a/docs/tools/capability-cookbook.md b/docs/tools/capability-cookbook.md index f439c362e89..7a5ab50611a 100644 --- a/docs/tools/capability-cookbook.md +++ b/docs/tools/capability-cookbook.md @@ -1,13 +1,20 @@ --- -summary: "Cookbook for adding a new shared capability to OpenClaw" +summary: "Contributor guide for adding a new shared capability to the OpenClaw plugin system" read_when: - 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" +title: "Adding Capabilities (Contributor Guide)" +sidebarTitle: "Adding Capabilities" --- -# Capability Cookbook +# Adding Capabilities + + + This is a **contributor guide** for OpenClaw core developers. If you are + building an external plugin, see [Building Plugins](/plugins/building-plugins) + instead. + Use this when OpenClaw needs a new domain such as image generation, video generation, or some future vendor-backed feature area. diff --git a/docs/tools/creating-skills.md b/docs/tools/creating-skills.md index 964165ad0a2..69024038efc 100644 --- a/docs/tools/creating-skills.md +++ b/docs/tools/creating-skills.md @@ -6,53 +6,112 @@ read_when: - You need a quick starter workflow for SKILL.md-based skills --- -# Creating Custom Skills πŸ›  +# Creating Skills -OpenClaw is designed to be easily extensible. "Skills" are the primary way to add new capabilities to your assistant. +Skills teach the agent how and when to use tools. Each skill is a directory +containing a `SKILL.md` file with YAML frontmatter and markdown instructions. -## What is a Skill? +For how skills are loaded and prioritized, see [Skills](/tools/skills). -A skill is a directory containing a `SKILL.md` file (which provides instructions and tool definitions to the LLM) and optionally some scripts or resources. +## Create your first skill -## Step-by-Step: Your First Skill + + + Skills live in your workspace. Create a new folder: -### 1. Create the Directory + ```bash + mkdir -p ~/.openclaw/workspace/skills/hello-world + ``` -Skills live in your workspace, usually `~/.openclaw/workspace/skills/`. Create a new folder for your skill: + -```bash -mkdir -p ~/.openclaw/workspace/skills/hello-world -``` + + Create `SKILL.md` inside that directory. The frontmatter defines metadata, + and the markdown body contains instructions for the agent. -### 2. Define the `SKILL.md` + ```markdown + --- + name: hello_world + description: A simple skill that says hello. + --- -Create a `SKILL.md` file in that directory. This file uses YAML frontmatter for metadata and Markdown for instructions. + # Hello World Skill -```markdown ---- -name: hello_world -description: A simple skill that says hello. ---- + When the user asks for a greeting, use the `echo` tool to say + "Hello from your custom skill!". + ``` -# Hello World Skill + -When the user asks for a greeting, use the `echo` tool to say "Hello from your custom skill!". -``` + + You can define custom tool schemas in the frontmatter or instruct the agent + to use existing system tools (like `exec` or `browser`). Skills can also + ship inside plugins alongside the tools they document. -### 3. Add Tools (Optional) + -You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `bash` or `browser`). + + Start a new session so OpenClaw picks up the skill: -### 4. Refresh OpenClaw + ```bash + # From chat + /new -Ask your agent to "refresh skills" or restart the gateway. OpenClaw will discover the new directory and index the `SKILL.md`. + # Or restart the gateway + openclaw gateway restart + ``` -## Best Practices + Verify the skill loaded: -- **Be Concise**: Instruct the model on _what_ to do, not how to be an AI. -- **Safety First**: If your skill uses `bash`, ensure the prompts don't allow arbitrary command injection from untrusted user input. -- **Test Locally**: Use `openclaw agent --message "use my new skill"` to test. + ```bash + openclaw skills list + ``` -## Shared Skills + -You can also browse and contribute skills to [ClawHub](https://clawhub.com). + + Send a message that should trigger the skill: + + ```bash + openclaw agent --message "give me a greeting" + ``` + + Or just chat with the agent and ask for a greeting. + + + + +## Skill metadata reference + +The YAML frontmatter supports these fields: + +| Field | Required | Description | +| ----------------------------------- | -------- | ------------------------------------------- | +| `name` | Yes | Unique identifier (snake_case) | +| `description` | Yes | One-line description shown to the agent | +| `metadata.openclaw.os` | No | OS filter (`["darwin"]`, `["linux"]`, etc.) | +| `metadata.openclaw.requires.bins` | No | Required binaries on PATH | +| `metadata.openclaw.requires.config` | No | Required config keys | + +## Best practices + +- **Be concise** β€” instruct the model on _what_ to do, not how to be an AI +- **Safety first** β€” if your skill uses `exec`, ensure prompts don't allow arbitrary command injection from untrusted input +- **Test locally** β€” use `openclaw agent --message "..."` to test before sharing +- **Use ClawHub** β€” browse and contribute skills at [ClawHub](https://clawhub.com) + +## Where skills live + +| Location | Precedence | Scope | +| ------------------------------- | ---------- | --------------------- | +| `\/skills/` | Highest | Per-agent | +| `~/.openclaw/skills/` | Medium | Shared (all agents) | +| Bundled (shipped with OpenClaw) | Lowest | Global | +| `skills.load.extraDirs` | Lowest | Custom shared folders | + +## Related + +- [Skills reference](/tools/skills) β€” loading, precedence, and gating rules +- [Skills config](/tools/skills-config) β€” `skills.*` config schema +- [ClawHub](/tools/clawhub) β€” public skill registry +- [Building Plugins](/plugins/building-plugins) β€” plugins can ship skills diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index c10b955ce2d..96a574f6fc9 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -1,63 +1,114 @@ --- -summary: "Elevated exec mode and /elevated directives" +summary: "Elevated exec mode: run commands on the gateway host from a sandboxed agent" read_when: - Adjusting elevated mode defaults, allowlists, or slash command behavior + - Understanding how sandboxed agents can access the host title: "Elevated Mode" --- -# Elevated Mode (/elevated directives) +# Elevated Mode -## What it does +When an agent runs inside a sandbox, its `exec` commands are confined to the +sandbox environment. **Elevated mode** lets the agent break out and run commands +on the gateway host instead, with configurable approval gates. -- `/elevated on` runs on the gateway host and keeps exec approvals (same as `/elevated ask`). -- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals). -- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`). -- `on`/`ask` do **not** force `exec.security=full`; configured security/ask policy still applies. -- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host). -- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`. -- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state. + + Elevated mode only changes behavior when the agent is **sandboxed**. For + unsandboxed agents, exec already runs on the host. + -## What it controls (and what it does not) +## Directives -- **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow). -- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key. -- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only. -- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned. -- **Host execution**: elevated forces `exec` onto the gateway host; `full` also sets `security=full`. -- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require. -- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status. -- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used. -- **Separate from `/exec`**: `/exec` adjusts per-session defaults for authorized senders and does not require elevated. +Control elevated mode per-session with slash commands: + +| Directive | What it does | +| ---------------- | --------------------------------------------------- | +| `/elevated on` | Run on the gateway host, keep exec approvals | +| `/elevated ask` | Same as `on` (alias) | +| `/elevated full` | Run on the gateway host **and** skip exec approvals | +| `/elevated off` | Return to sandbox-confined execution | + +Also available as `/elev on|off|ask|full`. + +Send `/elevated` with no argument to see the current level. + +## How it works + + + + Elevated must be enabled in config and the sender must be on the allowlist: + + ```json5 + { + tools: { + elevated: { + enabled: true, + allowFrom: { + discord: ["user-id-123"], + whatsapp: ["+15555550123"], + }, + }, + }, + } + ``` + + + + + Send a directive-only message to set the session default: + + ``` + /elevated full + ``` + + Or use it inline (applies to that message only): + + ``` + /elevated on run the deployment script + ``` + + + + + With elevated active, `exec` calls route to the gateway host instead of the + sandbox. In `full` mode, exec approvals are skipped. In `on`/`ask` mode, + configured approval rules still apply. + + ## Resolution order -1. Inline directive on the message (applies only to that message). -2. Session override (set by sending a directive-only message). -3. Global default (`agents.defaults.elevatedDefault` in config). +1. **Inline directive** on the message (applies only to that message) +2. **Session override** (set by sending a directive-only message) +3. **Global default** (`agents.defaults.elevatedDefault` in config) -## Setting a session default +## Availability and allowlists -- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated full`. -- Confirmation reply is sent (`Elevated mode set to full...` / `Elevated mode disabled.`). -- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state. -- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level. +- **Global gate**: `tools.elevated.enabled` (must be `true`) +- **Sender allowlist**: `tools.elevated.allowFrom` with per-channel lists +- **Per-agent gate**: `agents.list[].tools.elevated.enabled` (can only further restrict) +- **Per-agent allowlist**: `agents.list[].tools.elevated.allowFrom` (sender must match both global + per-agent) +- **Discord fallback**: if `tools.elevated.allowFrom.discord` is omitted, `channels.discord.allowFrom` is used as fallback +- **All gates must pass**; otherwise elevated is treated as unavailable -## Availability + allowlists +Allowlist entry formats: -- Feature gate: `tools.elevated.enabled` (default can be off via config even if the code supports it). -- Sender allowlist: `tools.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`). -- Unprefixed allowlist entries match sender-scoped identity values only (`SenderId`, `SenderE164`, `From`); recipient routing fields are never used for elevated authorization. -- Mutable sender metadata requires explicit prefixes: - - `name:` matches `SenderName` - - `username:` matches `SenderUsername` - - `tag:` matches `SenderTag` - - `id:`, `from:`, `e164:` are available for explicit identity targeting -- Per-agent gate: `agents.list[].tools.elevated.enabled` (optional; can only further restrict). -- Per-agent allowlist: `agents.list[].tools.elevated.allowFrom` (optional; when set, the sender must match **both** global + per-agent allowlists). -- Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `channels.discord.allowFrom` list is used as a fallback (legacy: `channels.discord.dm.allowFrom`). Set `tools.elevated.allowFrom.discord` (even `[]`) to override. Per-agent allowlists do **not** use the fallback. -- All gates must pass; otherwise elevated is treated as unavailable. +| Prefix | Matches | +| ----------------------- | ------------------------------- | +| (none) | Sender ID, E.164, or From field | +| `name:` | Sender display name | +| `username:` | Sender username | +| `tag:` | Sender tag | +| `id:`, `from:`, `e164:` | Explicit identity targeting | -## Logging + status +## What elevated does not control -- Elevated exec calls are logged at info level. -- Session status includes elevated mode (e.g. `elevated=ask`, `elevated=full`). +- **Tool policy**: if `exec` is denied by tool policy, elevated cannot override it +- **Separate from `/exec`**: the `/exec` directive adjusts per-session exec defaults for authorized senders and does not require elevated mode + +## Related + +- [Exec tool](/tools/exec) β€” shell command execution +- [Exec approvals](/tools/exec-approvals) β€” approval and allowlist system +- [Sandboxing](/gateway/sandboxing) β€” sandbox configuration +- [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) diff --git a/docs/tools/index.md b/docs/tools/index.md index 075971d6877..77f334e826c 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -1,96 +1,129 @@ --- -summary: "Agent tool surface for OpenClaw (browser, canvas, nodes, message, cron) replacing legacy `openclaw-*` skills" +summary: "OpenClaw tools and plugins overview: what the agent can do and how to extend it" read_when: - - Adding or modifying agent tools - - Retiring or changing `openclaw-*` skills -title: "Tools" + - You want to understand what tools OpenClaw provides + - You need to configure, allow, or deny tools + - You are deciding between built-in tools, skills, and plugins +title: "Tools and Plugins" --- -# Tools (OpenClaw) +# Tools and Plugins -OpenClaw exposes **first-class agent tools** for browser, canvas, nodes, and cron. -These replace the old `openclaw-*` skills: the tools are typed, no shelling, -and the agent should rely on them directly. +Everything the agent does beyond generating text happens through **tools**. +Tools are how the agent reads files, runs commands, browses the web, sends +messages, and interacts with devices. -## Disabling tools +## Tools, skills, and plugins -You can globally allow/deny tools via `tools.allow` / `tools.deny` in `openclaw.json` -(deny wins). This prevents disallowed tools from being sent to model providers. +OpenClaw has three layers that work together: + + + + A tool is a typed function the agent can invoke (e.g. `exec`, `browser`, + `web_search`, `message`). OpenClaw ships a set of **built-in tools** and + plugins can register additional ones. + + The agent sees tools as structured function definitions sent to the model API. + + + + + A skill is a markdown file (`SKILL.md`) injected into the system prompt. + Skills give the agent context, constraints, and step-by-step guidance for + using tools effectively. Skills live in your workspace, in shared folders, + or ship inside plugins. + + [Skills reference](/tools/skills) | [Creating skills](/tools/creating-skills) + + + + + A plugin is a package that can register any combination of capabilities: + channels, model providers, tools, skills, speech, image generation, and more. + Some plugins are **core** (shipped with OpenClaw), others are **external** + (published on npm by the community). + + [Install and configure plugins](/tools/plugin) | [Build your own](/plugins/building-plugins) + + + + +## Built-in tools + +These tools ship with OpenClaw and are available without installing any plugins: + +| Tool | What it does | Page | +| ---------------------------- | -------------------------------------------------------- | --------------------------------- | +| `exec` / `process` | Run shell commands, manage background processes | [Exec](/tools/exec) | +| `browser` | Control a Chromium browser (navigate, click, screenshot) | [Browser](/tools/browser) | +| `web_search` / `web_fetch` | Search the web, fetch page content | [Web](/tools/web) | +| `read` / `write` / `edit` | File I/O in the workspace | | +| `apply_patch` | Multi-hunk file patches | [Apply Patch](/tools/apply-patch) | +| `message` | Send messages across all channels | [Agent Send](/tools/agent-send) | +| `canvas` | Drive node Canvas (present, eval, snapshot) | | +| `nodes` | Discover and target paired devices | | +| `cron` / `gateway` | Manage scheduled jobs, restart gateway | | +| `image` / `image_generate` | Analyze or generate images | | +| `sessions_*` / `agents_list` | Session management, sub-agents | [Sub-agents](/tools/subagents) | + +### Plugin-provided tools + +Plugins can register additional tools. Some examples: + +- [Lobster](/tools/lobster) β€” typed workflow runtime with resumable approvals +- [LLM Task](/tools/llm-task) β€” JSON-only LLM step for structured output +- [Diffs](/tools/diffs) β€” diff viewer and renderer +- [OpenProse](/prose) β€” markdown-first workflow orchestration + +## Tool configuration + +### Allow and deny lists + +Control which tools the agent can call via `tools.allow` / `tools.deny` in +config. Deny always wins over allow. ```json5 { - tools: { deny: ["browser"] }, + tools: { + allow: ["group:fs", "browser", "web_search"], + deny: ["exec"], + }, } ``` -Notes: +### Tool profiles -- Matching is case-insensitive. -- `*` wildcards are supported (`"*"` means all tools). -- If `tools.allow` only references unknown or unloaded plugin tool names, OpenClaw logs a warning and ignores the allowlist so core tools stay available. - -## Tool profiles (base allowlist) - -`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`. +`tools.profile` sets a base allowlist before `allow`/`deny` is applied. Per-agent override: `agents.list[].tools.profile`. -Profiles: +| Profile | What it includes | +| ----------- | ------------------------------------------- | +| `full` | All tools (default) | +| `coding` | File I/O, runtime, sessions, memory, image | +| `messaging` | Messaging, session list/history/send/status | +| `minimal` | `session_status` only | -- `minimal`: `session_status` only -- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image` -- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status` -- `full`: no restriction (same as unset) +### Tool groups -Example (messaging-only by default, allow Slack + Discord tools too): +Use `group:*` shorthands in allow/deny lists: -```json5 -{ - tools: { - profile: "messaging", - allow: ["slack", "discord"], - }, -} -``` +| Group | Tools | +| ------------------ | ------------------------------------------------------------------------------ | +| `group:runtime` | exec, bash, process | +| `group:fs` | read, write, edit, apply_patch | +| `group:sessions` | sessions_list, sessions_history, sessions_send, sessions_spawn, session_status | +| `group:memory` | memory_search, memory_get | +| `group:web` | web_search, web_fetch | +| `group:ui` | browser, canvas | +| `group:automation` | cron, gateway | +| `group:messaging` | message | +| `group:nodes` | nodes | +| `group:openclaw` | All built-in OpenClaw tools (excludes plugin tools) | -Example (coding profile, but deny exec/process everywhere): +### Provider-specific restrictions -```json5 -{ - tools: { - profile: "coding", - deny: ["group:runtime"], - }, -} -``` - -Example (global coding profile, messaging-only support agent): - -```json5 -{ - tools: { profile: "coding" }, - agents: { - list: [ - { - id: "support", - tools: { profile: "messaging", allow: ["slack"] }, - }, - ], - }, -} -``` - -## Provider-specific tool policy - -Use `tools.byProvider` to **further restrict** tools for specific providers -(or a single `provider/model`) without changing your global defaults. -Per-agent override: `agents.list[].tools.byProvider`. - -This is applied **after** the base tool profile and **before** allow/deny lists, -so it can only narrow the tool set. -Provider keys accept either `provider` (e.g. `google-antigravity`) or -`provider/model` (e.g. `openai/gpt-5.2`). - -Example (keep global coding profile, but minimal tools for Google Antigravity): +Use `tools.byProvider` to restrict tools for specific providers without +changing global defaults: ```json5 { @@ -102,515 +135,3 @@ Example (keep global coding profile, but minimal tools for Google Antigravity): }, } ``` - -Example (provider/model-specific allowlist for a flaky endpoint): - -```json5 -{ - tools: { - allow: ["group:fs", "group:runtime", "sessions_list"], - byProvider: { - "openai/gpt-5.2": { allow: ["group:fs", "sessions_list"] }, - }, - }, -} -``` - -Example (agent-specific override for a single provider): - -```json5 -{ - agents: { - list: [ - { - id: "support", - tools: { - byProvider: { - "google-antigravity": { allow: ["message", "sessions_list"] }, - }, - }, - }, - ], - }, -} -``` - -## Tool groups (shorthands) - -Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools. -Use these in `tools.allow` / `tools.deny`. - -Available groups: - -- `group:runtime`: `exec`, `bash`, `process` -- `group:fs`: `read`, `write`, `edit`, `apply_patch` -- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` -- `group:memory`: `memory_search`, `memory_get` -- `group:web`: `web_search`, `web_fetch` -- `group:ui`: `browser`, `canvas` -- `group:automation`: `cron`, `gateway` -- `group:messaging`: `message` -- `group:nodes`: `nodes` -- `group:openclaw`: all built-in OpenClaw tools (excludes provider plugins) - -Example (allow only file tools + browser): - -```json5 -{ - tools: { - allow: ["group:fs", "browser"], - }, -} -``` - -## Plugins + tools - -Plugins can register **additional tools** (and CLI commands) beyond the core set. -See [Plugins](/tools/plugin) for install + config, and [Skills](/tools/skills) for how -tool usage guidance is injected into prompts. Some plugins ship their own skills -alongside tools (for example, the voice-call plugin). - -Optional plugin tools: - -- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host). -- [LLM Task](/tools/llm-task): JSON-only LLM step for structured workflow output (optional schema validation). -- [Diffs](/tools/diffs): read-only diff viewer and PNG or PDF file renderer for before/after text or unified patches. - -## Tool inventory - -### `apply_patch` - -Apply structured patches across one or more files. Use for multi-hunk edits. -Experimental: enable via `tools.exec.applyPatch.enabled` (OpenAI models only). -`tools.exec.applyPatch.workspaceOnly` defaults to `true` (workspace-contained). Set it to `false` only if you intentionally want `apply_patch` to write/delete outside the workspace directory. - -### `exec` - -Run shell commands in the workspace. - -Core parameters: - -- `command` (required) -- `yieldMs` (auto-background after timeout, default 10000) -- `background` (immediate background) -- `timeout` (seconds; kills the process if exceeded, default 1800) -- `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed) -- `host` (`sandbox | gateway | node`) -- `security` (`deny | allowlist | full`) -- `ask` (`off | on-miss | always`) -- `node` (node id/name for `host=node`) -- Need a real TTY? Set `pty: true`. - -Notes: - -- Returns `status: "running"` with a `sessionId` when backgrounded. -- Use `process` to poll/log/write/kill/clear background sessions. -- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`. -- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`. -- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op). -- `host=node` can target a macOS companion app or a headless node host (`openclaw node run`). -- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals). - -### `process` - -Manage background exec sessions. - -Core actions: - -- `list`, `poll`, `log`, `write`, `kill`, `clear`, `remove` - -Notes: - -- `poll` returns new output and exit status when complete. -- `log` supports line-based `offset`/`limit` (omit `offset` to grab the last N lines). -- `process` is scoped per agent; sessions from other agents are not visible. - -### `loop-detection` (tool-call loop guardrails) - -OpenClaw tracks recent tool-call history and blocks or warns when it detects repetitive no-progress loops. -Enable with `tools.loopDetection.enabled: true` (default is `false`). - -```json5 -{ - tools: { - loopDetection: { - enabled: true, - warningThreshold: 10, - criticalThreshold: 20, - globalCircuitBreakerThreshold: 30, - historySize: 30, - detectors: { - genericRepeat: true, - knownPollNoProgress: true, - pingPong: true, - }, - }, - }, -} -``` - -- `genericRepeat`: repeated same tool + same params call pattern. -- `knownPollNoProgress`: repeating poll-like tools with identical outputs. -- `pingPong`: alternating `A/B/A/B` no-progress patterns. -- Per-agent override: `agents.list[].tools.loopDetection`. - -### `web_search` - -Search the web using Brave, Firecrawl, Gemini, Grok, Kimi, Perplexity, or Tavily. - -Core parameters: - -- `query` (required) -- `count` (1–10; default from `tools.web.search.maxResults`) - -Notes: - -- Requires an API key for the chosen provider (recommended: `openclaw configure --section web`). -- Enable via `tools.web.search.enabled`. -- Responses are cached (default 15 min). -- See [Web tools](/tools/web) for setup. - -### `web_fetch` - -Fetch and extract readable content from a URL (HTML β†’ markdown/text). - -Core parameters: - -- `url` (required) -- `extractMode` (`markdown` | `text`) -- `maxChars` (truncate long pages) - -Notes: - -- Enable via `tools.web.fetch.enabled`. -- `maxChars` is clamped by `tools.web.fetch.maxCharsCap` (default 50000). -- Responses are cached (default 15 min). -- For JS-heavy sites, prefer the browser tool. -- See [Web tools](/tools/web) for setup. -- See [Firecrawl](/tools/firecrawl) for the optional anti-bot fallback. - -### `browser` - -Control the dedicated OpenClaw-managed browser. - -Core actions: - -- `status`, `start`, `stop`, `tabs`, `open`, `focus`, `close` -- `snapshot` (aria/ai) -- `screenshot` (returns image block + `MEDIA:`) -- `act` (UI actions: click/type/press/hover/drag/select/fill/resize/wait/evaluate) -- `navigate`, `console`, `pdf`, `upload`, `dialog` - -Profile management: - -- `profiles` β€” list all browser profiles with status -- `create-profile` β€” create new profile with auto-allocated port (or `cdpUrl`) -- `delete-profile` β€” stop browser, delete user data, remove from config (local only) -- `reset-profile` β€” kill orphan process on profile's port (local only) - -Common parameters: - -- `profile` (optional; defaults to `browser.defaultProfile`) -- `target` (`sandbox` | `host` | `node`) -- `node` (optional; picks a specific node id/name) - Notes: -- Requires `browser.enabled=true` (default is `true`; set `false` to disable). -- All actions accept optional `profile` parameter for multi-instance support. -- Omit `profile` for the safe default: isolated OpenClaw-managed browser (`openclaw`). -- Use `profile="user"` for the real local host browser when existing logins/cookies matter and the user is present to click/approve any attach prompt. -- `profile="user"` is host-only; do not combine it with sandbox/node targets. -- When `profile` is omitted, uses `browser.defaultProfile` (defaults to `openclaw`). -- Profile names: lowercase alphanumeric + hyphens only (max 64 chars). -- Port range: 18800-18899 (~100 profiles max). -- Remote profiles are attach-only (no start/stop/reset). -- If a browser-capable node is connected, the tool may auto-route to it (unless you pin `target`). -- `snapshot` defaults to `ai` when Playwright is installed; use `aria` for the accessibility tree. -- `snapshot` also supports role-snapshot options (`interactive`, `compact`, `depth`, `selector`) which return refs like `e12`. -- `act` requires `ref` from `snapshot` (numeric `12` from AI snapshots, or `e12` from role snapshots); use `evaluate` for rare CSS selector needs. -- Avoid `act` β†’ `wait` by default; use it only in exceptional cases (no reliable UI state to wait on). -- `upload` can optionally pass a `ref` to auto-click after arming. -- `upload` also supports `inputRef` (aria ref) or `element` (CSS selector) to set `` directly. - -### `canvas` - -Drive the node Canvas (present, eval, snapshot, A2UI). - -Core actions: - -- `present`, `hide`, `navigate`, `eval` -- `snapshot` (returns image block + `MEDIA:`) -- `a2ui_push`, `a2ui_reset` - -Notes: - -- Uses gateway `node.invoke` under the hood. -- If no `node` is provided, the tool picks a default (single connected node or local mac node). -- A2UI is v0.8 only (no `createSurface`); the CLI rejects v0.9 JSONL with line errors. -- Quick smoke: `openclaw nodes canvas a2ui push --node --text "Hello from A2UI"`. - -### `nodes` - -Discover and target paired nodes; send notifications; capture camera/screen. - -Core actions: - -- `status`, `describe` -- `pending`, `approve`, `reject` (pairing) -- `notify` (macOS `system.notify`) -- `run` (macOS `system.run`) -- `camera_list`, `camera_snap`, `camera_clip`, `screen_record` -- `location_get`, `notifications_list`, `notifications_action` -- `device_status`, `device_info`, `device_permissions`, `device_health` - -Notes: - -- Camera/screen commands require the node app to be foregrounded. -- Images return image blocks + `MEDIA:`. -- Videos return `FILE:` (mp4). -- Location returns a JSON payload (lat/lon/accuracy/timestamp). -- `run` params: `command` argv array; optional `cwd`, `env` (`KEY=VAL`), `commandTimeoutMs`, `invokeTimeoutMs`, `needsScreenRecording`. - -Example (`run`): - -```json -{ - "action": "run", - "node": "office-mac", - "command": ["echo", "Hello"], - "env": ["FOO=bar"], - "commandTimeoutMs": 12000, - "invokeTimeoutMs": 45000, - "needsScreenRecording": false -} -``` - -### `image` - -Analyze an image with the configured image model. - -Core parameters: - -- `image` (required path or URL) -- `prompt` (optional; defaults to "Describe the image.") -- `model` (optional override) -- `maxBytesMb` (optional size cap) - -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, including `google/gemini-3-pro-image-preview` for the native Nano Banana-style path, 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 `nano-banana-pro` skill workflow. Use `agents.defaults.imageGenerationModel`, not `skills.entries`, for stock image generation. - -Native example: - -```json5 -{ - agents: { - defaults: { - imageGenerationModel: { - primary: "google/gemini-3-pro-image-preview", // native Nano Banana path - fallbacks: ["fal/fal-ai/flux/dev"], - }, - }, - }, -} -``` - -### `pdf` - -Analyze one or more PDF documents. - -For full behavior, limits, config, and examples, see [PDF tool](/tools/pdf). - -### `message` - -Send messages and channel actions across Discord/Google Chat/Slack/Telegram/WhatsApp/Signal/iMessage/Microsoft Teams. - -Core actions: - -- `send` (text + optional media; Microsoft Teams also supports `card` for Adaptive Cards) -- `poll` (WhatsApp/Discord/Microsoft Teams polls) -- `react` / `reactions` / `read` / `edit` / `delete` -- `pin` / `unpin` / `list-pins` -- `permissions` -- `thread-create` / `thread-list` / `thread-reply` -- `search` -- `sticker` -- `member-info` / `role-info` -- `emoji-list` / `emoji-upload` / `sticker-upload` -- `role-add` / `role-remove` -- `channel-info` / `channel-list` -- `voice-status` -- `event-list` / `event-create` -- `timeout` / `kick` / `ban` - -Notes: - -- `send` routes WhatsApp via the Gateway; other channels go direct. -- `poll` uses the Gateway for WhatsApp and Microsoft Teams; Discord polls go direct. -- When a message tool call is bound to an active chat session, sends are constrained to that session’s target to avoid cross-context leaks. - -### `cron` - -Manage Gateway cron jobs and wakeups. - -Core actions: - -- `status`, `list` -- `add`, `update`, `remove`, `run`, `runs` -- `wake` (enqueue system event + optional immediate heartbeat) - -Notes: - -- `add` expects a full cron job object (same schema as `cron.add` RPC). -- `update` uses `{ jobId, patch }` (`id` accepted for compatibility). - -### `gateway` - -Restart or apply updates to the running Gateway process (in-place). - -Core actions: - -- `restart` (authorizes + sends `SIGUSR1` for in-process restart; `openclaw gateway` restart in-place) -- `config.schema.lookup` (inspect one config path at a time without loading the full schema into prompt context) -- `config.get` -- `config.apply` (validate + write config + restart + wake) -- `config.patch` (merge partial update + restart + wake) -- `update.run` (run update + restart + wake) - -Notes: - -- `config.schema.lookup` expects a targeted config path such as `gateway.auth` or `agents.list.*.heartbeat`. -- Paths may include slash-delimited plugin ids when addressing `plugins.entries.`, for example `plugins.entries.pack/one.config`. -- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply. -- `config.schema` remains available to internal Control UI flows and is not exposed through the agent `gateway` tool. -- `restart` is enabled by default; set `commands.restart: false` to disable it. - -### `sessions_list` / `sessions_history` / `sessions_send` / `sessions_spawn` / `session_status` - -List sessions, inspect transcript history, or send to another session. - -Core parameters: - -- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none) -- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?` -- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget) -- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`, `sandbox?`, `streamTo?`, `attachments?`, `attachAs?` -- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override) - -Notes: - -- `main` is the canonical direct-chat key; global/unknown are hidden. -- `messageLimit > 0` fetches last N messages per session (tool messages filtered). -- Session targeting is controlled by `tools.sessions.visibility` (default `tree`: current session + spawned subagent sessions). If you run a shared agent for multiple users, consider setting `tools.sessions.visibility: "self"` to prevent cross-session browsing. -- `sessions_send` waits for final completion when `timeoutSeconds > 0`. -- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered. -- `sessions_spawn` supports `runtime: "subagent" | "acp"` (`subagent` default). For ACP runtime behavior, see [ACP Agents](/tools/acp-agents). -- For ACP runtime, `streamTo: "parent"` routes initial-run progress summaries back to the requester session as system events instead of direct child delivery. -- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat. - - Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`). - - If `thread: true` and `mode` is omitted, mode defaults to `session`. - - `mode: "session"` requires `thread: true`. - - If `runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise timeout defaults to `0` (no timeout). - - Discord thread-bound flows depend on `session.threadBindings.*` and `channels.discord.threadBindings.*`. - - Reply format includes `Status`, `Result`, and compact stats. - - `Result` is the assistant completion text; if missing, the latest `toolResult` is used as fallback. -- Manual completion-mode spawns send directly first, with queue fallback and retry on transient failures (`status: "ok"` means run finished, not that announce delivered). -- `sessions_spawn` supports inline file attachments for subagent runtime only (ACP rejects them). Each attachment has `name`, `content`, and optional `encoding` (`utf8` or `base64`) and `mimeType`. Files are materialized into the child workspace at `.openclaw/attachments//` with a `.manifest.json` metadata file. The tool returns a receipt with `count`, `totalBytes`, per file `sha256`, and `relDir`. Attachment content is automatically redacted from transcript persistence. - - Configure limits via `tools.sessions_spawn.attachments` (`enabled`, `maxTotalBytes`, `maxFiles`, `maxFileBytes`, `retainOnSessionKeep`). - - `attachAs.mountPath` is a reserved hint for future mount implementations. -- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately. -- ACP `streamTo: "parent"` responses may include `streamLogPath` (session-scoped `*.acp-stream.jsonl`) for tailing progress history. -- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5). -- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement. -- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility: "spawned"`, OpenClaw clamps `tools.sessions.visibility` to `tree`. - -### `agents_list` - -List agent ids that the current session may target with `sessions_spawn`. - -Notes: - -- Result is restricted to per-agent allowlists (`agents.list[].subagents.allowAgents`). -- When `["*"]` is configured, the tool includes all configured agents and marks `allowAny: true`. - -## Parameters (common) - -Gateway-backed tools (`canvas`, `nodes`, `cron`): - -- `gatewayUrl` (default `ws://127.0.0.1:18789`) -- `gatewayToken` (if auth enabled) -- `timeoutMs` - -Note: when `gatewayUrl` is set, include `gatewayToken` explicitly. Tools do not inherit config -or environment credentials for overrides, and missing explicit credentials is an error. - -Browser tool: - -- `profile` (optional; defaults to `browser.defaultProfile`) -- `target` (`sandbox` | `host` | `node`) -- `node` (optional; pin a specific node id/name) -- Troubleshooting guides: - - Linux startup/CDP issues: [Browser troubleshooting (Linux)](/tools/browser-linux-troubleshooting) - - WSL2 Gateway + Windows remote Chrome CDP: [WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting) - -## Recommended agent flows - -Browser automation: - -1. `browser` β†’ `status` / `start` -2. `snapshot` (ai or aria) -3. `act` (click/type/press) -4. `screenshot` if you need visual confirmation - -Canvas render: - -1. `canvas` β†’ `present` -2. `a2ui_push` (optional) -3. `snapshot` - -Node targeting: - -1. `nodes` β†’ `status` -2. `describe` on the chosen node -3. `notify` / `run` / `camera_snap` / `screen_record` - -## Safety - -- Avoid direct `system.run`; use `nodes` β†’ `run` only with explicit user consent. -- Respect user consent for camera/screen capture. -- Use `status/describe` to ensure permissions before invoking media commands. - -## How tools are presented to the agent - -Tools are exposed in two parallel channels: - -1. **System prompt text**: a human-readable list + guidance. -2. **Tool schema**: the structured function definitions sent to the model API. - -That means the agent sees both β€œwhat tools exist” and β€œhow to call them.” If a tool -doesn’t appear in the system prompt or the schema, the model cannot call it. diff --git a/docs/tools/lobster.md b/docs/tools/lobster.md index 6e502c09c19..fd8e4c5eb92 100644 --- a/docs/tools/lobster.md +++ b/docs/tools/lobster.md @@ -330,7 +330,7 @@ OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep, ## Learn more - [Plugins](/tools/plugin) -- [Plugin tool authoring](/plugins/agent-tools) +- [Plugin tool authoring](/plugins/building-plugins#registering-agent-tools) ## Case study: community workflows diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 7f1ba0fade4..3ede326f0aa 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1,69 +1,64 @@ --- -summary: "OpenClaw plugins/extensions: discovery, config, and safety" +summary: "Install, configure, and manage OpenClaw plugins" read_when: - - Adding or modifying plugins/extensions - - Documenting plugin install or load rules + - Installing or configuring plugins + - Understanding plugin discovery and load rules - Working with Codex/Claude-compatible plugin bundles title: "Plugins" +sidebarTitle: "Install and Configure" --- -# Plugins (Extensions) +# Plugins + +Plugins extend OpenClaw with new capabilities: channels, model providers, tools, +skills, speech, image generation, and more. Some plugins are **core** (shipped +with OpenClaw), others are **external** (published on npm by the community). ## Quick start -A plugin is either: + + + ```bash + openclaw plugins list + ``` + -- a native **OpenClaw plugin** (`openclaw.plugin.json` + runtime module), or -- a compatible **bundle** (`.codex-plugin/plugin.json` or `.claude-plugin/plugin.json`) + + ```bash + # From npm + openclaw plugins install @openclaw/voice-call -Both show up under `openclaw plugins`, but only native OpenClaw plugins execute -runtime code in-process. + # From a local directory or archive + openclaw plugins install ./my-plugin + openclaw plugins install ./my-plugin.tgz + ``` -1. See what is already loaded: + -```bash -openclaw plugins list -``` + + ```bash + openclaw gateway restart + ``` -2. Install an official plugin (example: Voice Call): + Then configure under `plugins.entries.\.config` in your config file. -```bash -openclaw plugins install @openclaw/voice-call -``` + + -Npm specs are registry-only. See [install rules](/cli/plugins#install) for -details on pinning, prerelease gating, and supported spec formats. +## Plugin types -3. Restart the Gateway, then configure under `plugins.entries..config`. +OpenClaw recognizes two plugin formats: -See [Voice Call](/plugins/voice-call) for a concrete example plugin. -Looking for third-party listings? See [Community plugins](/plugins/community). -Need the bundle compatibility details? See [Plugin bundles](/plugins/bundles). +| Format | How it works | Examples | +| ---------- | ------------------------------------------------------------------ | ------------------------------------------------------ | +| **Native** | `openclaw.plugin.json` + runtime module; executes in-process | Official plugins, community npm packages | +| **Bundle** | Codex/Claude/Cursor-compatible layout; mapped to OpenClaw features | `.codex-plugin/`, `.claude-plugin/`, `.cursor-plugin/` | -For compatible bundles, install from a local directory or archive: +Both show up under `openclaw plugins list`. See [Plugin Bundles](/plugins/bundles) for bundle details. -```bash -openclaw plugins install ./my-bundle -openclaw plugins install ./my-bundle.tgz -``` +## Official plugins -For Claude marketplace installs, list the marketplace first, then install by -marketplace entry name: - -```bash -openclaw plugins marketplace list -openclaw plugins install @ -``` - -OpenClaw resolves known Claude marketplace names from -`~/.claude/plugins/known_marketplaces.json`. You can also pass an explicit -marketplace source with `--marketplace`. - -## Available plugins (official) - -### Installable plugins - -These are published to npm and installed with `openclaw plugins install`: +### Installable (npm) | Plugin | Package | Docs | | --------------- | ---------------------- | ------------------------------------ | @@ -74,51 +69,34 @@ These are published to npm and installed with `openclaw plugins install`: | Zalo | `@openclaw/zalo` | [Zalo](/channels/zalo) | | Zalo Personal | `@openclaw/zalouser` | [Zalo Personal](/plugins/zalouser) | -Microsoft Teams is plugin-only as of 2026.1.15. +### Core (shipped with OpenClaw) -Packaged installs also ship install-on-demand metadata for heavyweight official -plugins. Today that includes WhatsApp and `memory-lancedb`: onboarding, -`openclaw channels add`, `openclaw channels login --channel whatsapp`, and -other channel setup flows prompt to install them when first used instead of -shipping their full runtime trees inside the main npm tarball. + + + `anthropic`, `byteplus`, `cloudflare-ai-gateway`, `github-copilot`, `google`, + `huggingface`, `kilocode`, `kimi-coding`, `minimax`, `mistral`, `modelstudio`, + `moonshot`, `nvidia`, `openai`, `opencode`, `opencode-go`, `openrouter`, + `qianfan`, `qwen-portal-auth`, `synthetic`, `together`, `venice`, + `vercel-ai-gateway`, `volcengine`, `xiaomi`, `zai` + -### Bundled plugins + + - `memory-core` β€” bundled memory search (default via `plugins.slots.memory`) + - `memory-lancedb` β€” install-on-demand long-term memory with auto-recall/capture (set `plugins.slots.memory = "memory-lancedb"`) + -These ship with OpenClaw and are enabled by default unless noted. + + `elevenlabs`, `microsoft` + -**Memory:** + + - `copilot-proxy` β€” VS Code Copilot Proxy bridge (disabled by default) + + -- `memory-core` -- bundled memory search (default via `plugins.slots.memory`) -- `memory-lancedb` -- install-on-demand long-term memory with auto-recall/capture (set `plugins.slots.memory = "memory-lancedb"`) +Looking for third-party plugins? See [Community Plugins](/plugins/community). -**Model providers** (all enabled by default): - -`anthropic`, `byteplus`, `cloudflare-ai-gateway`, `github-copilot`, `google`, `huggingface`, `kilocode`, `kimi-coding`, `minimax`, `mistral`, `modelstudio`, `moonshot`, `nvidia`, `openai`, `opencode`, `opencode-go`, `openrouter`, `qianfan`, `qwen-portal-auth`, `synthetic`, `together`, `venice`, `vercel-ai-gateway`, `volcengine`, `xiaomi`, `zai` - -**Speech providers** (enabled by default): - -`elevenlabs`, `microsoft` - -**Other bundled:** - -- `copilot-proxy` -- VS Code Copilot Proxy bridge (disabled by default) - -## Compatible bundles - -OpenClaw also recognizes compatible external bundle layouts: - -- Codex-style bundles: `.codex-plugin/plugin.json` -- Claude-style bundles: `.claude-plugin/plugin.json` or the default Claude - component layout without a manifest -- Cursor-style bundles: `.cursor-plugin/plugin.json` - -They are shown in the plugin list as `format=bundle`, with a subtype of -`codex`, `claude`, or `cursor` in verbose/inspect output. - -See [Plugin bundles](/plugins/bundles) for the exact detection rules, mapping -behavior, and current support matrix. - -## Config +## Configuration ```json5 { @@ -134,204 +112,140 @@ behavior, and current support matrix. } ``` -Fields: +| Field | Description | +| ---------------- | --------------------------------------------------------- | +| `enabled` | Master toggle (default: `true`) | +| `allow` | Plugin allowlist (optional) | +| `deny` | Plugin denylist (optional; deny wins) | +| `load.paths` | Extra plugin files/directories | +| `slots` | Exclusive slot selectors (e.g. `memory`, `contextEngine`) | +| `entries.\` | Per-plugin toggles + config | -- `enabled`: master toggle (default: true) -- `allow`: allowlist (optional) -- `deny`: denylist (optional; deny wins) -- `load.paths`: extra plugin files/dirs -- `slots`: exclusive slot selectors such as `memory` and `contextEngine` -- `entries.`: per-plugin toggles + config +Config changes **require a gateway restart**. -Config changes **require a gateway restart**. See -[Configuration reference](/configuration) for the full config schema. - -Validation rules (strict): - -- Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**. -- Unknown `channels.` keys are **errors** unless a plugin manifest declares - the channel id. -- Native plugin config is validated using the JSON Schema embedded in - `openclaw.plugin.json` (`configSchema`). -- Compatible bundles currently do not expose native OpenClaw config schemas. -- If a plugin is disabled, its config is preserved and a **warning** is emitted. - -### Disabled vs missing vs invalid - -These states are intentionally different: - -- **disabled**: plugin exists, but enablement rules turned it off -- **missing**: config references a plugin id that discovery did not find -- **invalid**: plugin exists, but its config does not match the declared schema - -OpenClaw preserves config for disabled plugins so toggling them back on is not -destructive. + + - **Disabled**: plugin exists but enablement rules turned it off. Config is preserved. + - **Missing**: config references a plugin id that discovery did not find. + - **Invalid**: plugin exists but its config does not match the declared schema. + ## Discovery and precedence -OpenClaw scans, in order: +OpenClaw scans for plugins in this order (first match wins): -1. Config paths + + + `plugins.load.paths` β€” explicit file or directory paths. + -- `plugins.load.paths` (file or directory) + + `\/.openclaw/extensions/*.ts` and `\/.openclaw/extensions/*/index.ts`. + -2. Workspace extensions + + `~/.openclaw/extensions/*.ts` and `~/.openclaw/extensions/*/index.ts`. + -- `/.openclaw/extensions/*.ts` -- `/.openclaw/extensions/*/index.ts` - -3. Global extensions - -- `~/.openclaw/extensions/*.ts` -- `~/.openclaw/extensions/*/index.ts` - -4. Bundled extensions (shipped with OpenClaw; mixed default-on/default-off) - -- `/dist/extensions/*` in packaged installs -- `/dist-runtime/extensions/*` in local built checkouts -- `/extensions/*` in source/Vitest workflows - -Many bundled provider plugins are enabled by default so model catalogs/runtime -hooks stay available without extra setup. Others still require explicit -enablement via `plugins.entries..enabled` or -`openclaw plugins enable `. - -Bundled plugin runtime dependencies are owned by each plugin package. Packaged -builds stage opted-in bundled dependencies under -`dist/extensions//node_modules` instead of requiring mirrored copies in the -root package. Very large official plugins can ship as metadata-only bundled -entries and install their runtime package on demand. npm artifacts ship the -built `dist/extensions/*` tree; source `extensions/*` directories stay in source -checkouts only. - -Installed plugins are enabled by default, but can be disabled the same way. - -Workspace plugins are **disabled by default** unless you explicitly enable them -or allowlist them. This is intentional: a checked-out repo should not silently -become production gateway code. - -If multiple plugins resolve to the same id, the first match in the order above -wins and lower-precedence copies are ignored. + + Shipped with OpenClaw. Many are enabled by default (model providers, speech). + Others require explicit enablement. + + ### Enablement rules -Enablement is resolved after discovery: - - `plugins.enabled: false` disables all plugins -- `plugins.deny` always wins -- `plugins.entries..enabled: false` disables that plugin -- workspace-origin plugins are disabled by default -- allowlists restrict the active set when `plugins.allow` is non-empty -- allowlists are **id-based**, not source-based -- bundled plugins are disabled by default unless: - - the bundled id is in the built-in default-on set, or - - you explicitly enable it, or - - channel config implicitly enables the bundled channel plugin -- exclusive slots can force-enable the selected plugin for that slot +- `plugins.deny` always wins over allow +- `plugins.entries.\.enabled: false` disables that plugin +- Workspace-origin plugins are **disabled by default** (must be explicitly enabled) +- Bundled plugins follow the built-in default-on set unless overridden +- Exclusive slots can force-enable the selected plugin for that slot ## Plugin slots (exclusive categories) -Some plugin categories are **exclusive** (only one active at a time). Use -`plugins.slots` to select which plugin owns the slot: +Some categories are exclusive (only one active at a time): ```json5 { plugins: { slots: { - memory: "memory-core", // or "none" to disable memory plugins - contextEngine: "legacy", // or a plugin id such as "lossless-claw" + memory: "memory-core", // or "none" to disable + contextEngine: "legacy", // or a plugin id }, }, } ``` -Supported exclusive slots: +| Slot | What it controls | Default | +| --------------- | --------------------- | ------------------- | +| `memory` | Active memory plugin | `memory-core` | +| `contextEngine` | Active context engine | `legacy` (built-in) | -- `memory`: active memory plugin (`"none"` disables memory plugins) -- `contextEngine`: active context engine plugin (`"legacy"` is the built-in default) - -If multiple plugins declare `kind: "memory"` or `kind: "context-engine"`, only -the selected plugin loads for that slot. Others are disabled with diagnostics. -Declare `kind` in your [plugin manifest](/plugins/manifest). - -## Plugin IDs - -Default plugin ids: - -- Package packs: `package.json` `name` -- Standalone file: file base name (`~/.../voice-call.ts` -> `voice-call`) - -If a plugin exports `id`, OpenClaw uses it but warns when it does not match the -configured id. - -## Inspection +## CLI reference ```bash -openclaw plugins inspect openai # deep detail on one plugin -openclaw plugins inspect openai --json # machine-readable -openclaw plugins list # compact inventory -openclaw plugins status # operational summary -openclaw plugins doctor # issue-focused diagnostics -``` +openclaw plugins list # compact inventory +openclaw plugins inspect # deep detail +openclaw plugins inspect --json # machine-readable +openclaw plugins status # operational summary +openclaw plugins doctor # diagnostics -## CLI +openclaw plugins install # install from npm +openclaw plugins install # install from local path +openclaw plugins install -l # link (no copy) for dev +openclaw plugins update # update one plugin +openclaw plugins update --all # update all -```bash -openclaw plugins list -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 -openclaw plugins install ./plugin.zip # install from a local zip -openclaw plugins install -l ./extensions/voice-call # link (no copy) for dev -openclaw plugins install @openclaw/voice-call # install from npm -openclaw plugins install @openclaw/voice-call --pin # store exact resolved name@version -openclaw plugins update -openclaw plugins update --all openclaw plugins enable openclaw plugins disable -openclaw plugins doctor ``` -See [`openclaw plugins` CLI reference](/cli/plugins) for full details on each -command (install rules, inspect output, marketplace installs, uninstall). +See [`openclaw plugins` CLI reference](/cli/plugins) for full details. -Plugins may also register their own top-level commands (example: -`openclaw voicecall`). +## Plugin API overview -## Plugin API (overview) +Plugins export either a function or an object with `register(api)`: -Plugins export either: +```typescript +export default definePluginEntry({ + id: "my-plugin", + name: "My Plugin", + register(api) { + api.registerProvider({ + /* ... */ + }); + api.registerTool({ + /* ... */ + }); + api.registerChannel({ + /* ... */ + }); + }, +}); +``` -- A function: `(api) => { ... }` -- An object: `{ id, name, configSchema, register(api) { ... } }` +Common registration methods: -`register(api)` is where plugins attach behavior. Common registrations include: +| Method | What it registers | +| ------------------------------------ | -------------------- | +| `registerProvider` | Model provider (LLM) | +| `registerChannel` | Chat channel | +| `registerTool` | Agent tool | +| `registerHook` / `on(...)` | Lifecycle hooks | +| `registerSpeechProvider` | Text-to-speech / STT | +| `registerMediaUnderstandingProvider` | Image/audio analysis | +| `registerImageGenerationProvider` | Image generation | +| `registerWebSearchProvider` | Web search | +| `registerHttpRoute` | HTTP endpoint | +| `registerCommand` / `registerCli` | CLI commands | +| `registerContextEngine` | Context engine | +| `registerService` | Background service | -- `registerTool` -- `registerHook` -- `on(...)` for typed lifecycle hooks -- `registerChannel` -- `registerProvider` -- `registerSpeechProvider` -- `registerMediaUnderstandingProvider` -- `registerWebSearchProvider` -- `registerHttpRoute` -- `registerCommand` -- `registerCli` -- `registerContextEngine` -- `registerService` +## Related -See [Plugin manifest](/plugins/manifest) for the manifest file format. - -## Further reading - -- [Plugin architecture and internals](/plugins/architecture) -- capability model, - ownership model, contracts, load pipeline, runtime helpers, and developer API - reference -- [Building extensions](/plugins/building-extensions) -- [Plugin bundles](/plugins/bundles) -- [Plugin manifest](/plugins/manifest) -- [Plugin agent tools](/plugins/agent-tools) -- [Capability Cookbook](/tools/capability-cookbook) -- [Community plugins](/plugins/community) +- [Building Plugins](/plugins/building-plugins) β€” create your own plugin +- [Plugin Bundles](/plugins/bundles) β€” Codex/Claude/Cursor bundle compatibility +- [Plugin Manifest](/plugins/manifest) β€” manifest schema +- [Registering Tools](/plugins/building-plugins#registering-agent-tools) β€” add agent tools in a plugin +- [Plugin Internals](/plugins/architecture) β€” capability model and load pipeline +- [Community Plugins](/plugins/community) β€” third-party listings diff --git a/docs/tools/reactions.md b/docs/tools/reactions.md index 17f9cfbb7f9..56d6b5942e7 100644 --- a/docs/tools/reactions.md +++ b/docs/tools/reactions.md @@ -1,23 +1,64 @@ --- -summary: "Reaction semantics shared across channels" +summary: "Reaction tool semantics across all supported channels" read_when: - Working on reactions in any channel + - Understanding how emoji reactions differ across platforms title: "Reactions" --- -# Reaction tooling +# Reactions -Shared reaction semantics across channels: +The agent can add and remove emoji reactions on messages using the `message` +tool with the `react` action. Reaction behavior varies by channel. + +## How it works + +```json +{ + "action": "react", + "messageId": "msg-123", + "emoji": "thumbsup" +} +``` - `emoji` is required when adding a reaction. -- `emoji=""` removes the bot's reaction(s) when supported. -- `remove: true` removes the specified emoji when supported (requires `emoji`). +- Set `emoji` to an empty string (`""`) to remove the bot's reaction(s). +- Set `remove: true` to remove a specific emoji (requires non-empty `emoji`). -Channel notes: +## Channel behavior -- **Discord/Slack**: empty `emoji` removes all of the bot's reactions on the message; `remove: true` removes just that emoji. -- **Google Chat**: empty `emoji` removes the app's reactions on the message; `remove: true` removes just that emoji. -- **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation. -- **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`). -- **Zalo Personal (`zalouser`)**: requires non-empty `emoji`; `remove: true` removes that specific emoji reaction. -- **Signal**: inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled. + + + - Empty `emoji` removes all of the bot's reactions on the message. + - `remove: true` removes just the specified emoji. + + + + - Empty `emoji` removes the app's reactions on the message. + - `remove: true` removes just the specified emoji. + + + + - Empty `emoji` removes the bot's reactions. + - `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation. + + + + - Empty `emoji` removes the bot reaction. + - `remove: true` maps to empty emoji internally (still requires `emoji` in the tool call). + + + + - Requires non-empty `emoji`. + - `remove: true` removes that specific emoji reaction. + + + + - Inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled. + + + +## Related + +- [Agent Send](/tools/agent-send) β€” the `message` tool that includes `react` +- [Channels](/channels) β€” channel-specific configuration diff --git a/extensions/anthropic-vertex/provider-catalog.ts b/extensions/anthropic-vertex/provider-catalog.ts new file mode 100644 index 00000000000..dfad3ade565 --- /dev/null +++ b/extensions/anthropic-vertex/provider-catalog.ts @@ -0,0 +1,65 @@ +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-models"; +import { resolveAnthropicVertexRegion } from "openclaw/plugin-sdk/provider-models"; +export const ANTHROPIC_VERTEX_DEFAULT_MODEL_ID = "claude-sonnet-4-6"; +const ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW = 1_000_000; +const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; + +function buildAnthropicVertexModel(params: { + id: string; + name: string; + reasoning: boolean; + input: ModelDefinitionConfig["input"]; + cost: ModelDefinitionConfig["cost"]; + maxTokens: number; +}): ModelDefinitionConfig { + return { + id: params.id, + name: params.name, + reasoning: params.reasoning, + input: params.input, + cost: params.cost, + contextWindow: ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW, + maxTokens: params.maxTokens, + }; +} + +function buildAnthropicVertexCatalog(): ModelDefinitionConfig[] { + return [ + buildAnthropicVertexModel({ + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + reasoning: true, + input: ["text", "image"], + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + maxTokens: 128000, + }), + buildAnthropicVertexModel({ + id: ANTHROPIC_VERTEX_DEFAULT_MODEL_ID, + name: "Claude Sonnet 4.6", + reasoning: true, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + maxTokens: 128000, + }), + ]; +} + +export function buildAnthropicVertexProvider(params?: { + env?: NodeJS.ProcessEnv; +}): ModelProviderConfig { + const region = resolveAnthropicVertexRegion(params?.env); + const baseUrl = + region.toLowerCase() === "global" + ? "https://aiplatform.googleapis.com" + : `https://${region}-aiplatform.googleapis.com`; + + return { + baseUrl, + api: "anthropic-messages", + apiKey: GCP_VERTEX_CREDENTIALS_MARKER, + models: buildAnthropicVertexCatalog(), + }; +} diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index 02cda25b5bc..677e1ae9703 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -1,4 +1,3 @@ -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"; @@ -6,6 +5,7 @@ import { editBlueBubblesMessage, setGroupIconBlueBubbles } from "./chat.js"; import { resolveBlueBubblesMessageId } from "./monitor.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { sendBlueBubblesReaction } from "./reactions.js"; +import type { OpenClawConfig } from "./runtime-api.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; vi.mock("./accounts.js", async () => { diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index cb40ca810e3..0b5ee8bbf02 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -1,8 +1,8 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import "./test-mocks.js"; import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; +import "./test-mocks.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import type { PluginRuntime } from "./runtime-api.js"; import { setBlueBubblesRuntime } from "./runtime.js"; import { BLUE_BUBBLES_PRIVATE_API_STATUS, diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 4d4b411a639..5719b12e22b 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -4,15 +4,15 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-pairing"; import { createOpenGroupPolicyRestrictSendersWarningCollector, projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; -import { - createAttachedChannelResultAdapter, - createPairingPrefixStripper, - createTextPairingAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { listBlueBubblesAccountIds, diff --git a/extensions/bluebubbles/src/media-send.test.ts b/extensions/bluebubbles/src/media-send.test.ts index 59fe82cbeae..ad1523c7863 100644 --- a/extensions/bluebubbles/src/media-send.test.ts +++ b/extensions/bluebubbles/src/media-send.test.ts @@ -2,9 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { sendBlueBubblesMedia } from "./media-send.js"; +import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; import { setBlueBubblesRuntime } from "./runtime.js"; const sendBlueBubblesAttachmentMock = vi.hoisted(() => vi.fn()); diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 17467465d82..5ff26e2dc96 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -1,6 +1,5 @@ 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/helpers/extensions/plugin-runtime-mock.js"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; @@ -12,6 +11,7 @@ import { resolveBlueBubblesMessageId, _resetBlueBubblesShortIdState, } from "./monitor.js"; +import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; import { setBlueBubblesRuntime } from "./runtime.js"; // Mock dependencies diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts index 8d98b0c45eb..aacbb437841 100644 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -1,6 +1,5 @@ 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/helpers/extensions/plugin-runtime-mock.js"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; @@ -11,6 +10,7 @@ import { resolveBlueBubblesMessageId, _resetBlueBubblesShortIdState, } from "./monitor.js"; +import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; import { setBlueBubblesRuntime } from "./runtime.js"; // Mock dependencies diff --git a/extensions/bluebubbles/src/monitor.webhook-route.test.ts b/extensions/bluebubbles/src/monitor.webhook-route.test.ts index fc48606b8ed..cb30d9edb01 100644 --- a/extensions/bluebubbles/src/monitor.webhook-route.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-route.test.ts @@ -1,9 +1,9 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { afterEach, describe, expect, it } from "vitest"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import type { WebhookTarget } from "./monitor-shared.js"; import { registerBlueBubblesWebhookTarget } from "./monitor.js"; +import type { OpenClawConfig } from "./runtime-api.js"; function createTarget(): WebhookTarget { return { diff --git a/extensions/bluebubbles/src/runtime-api.ts b/extensions/bluebubbles/src/runtime-api.ts index 23c09660d96..4faebbed877 100644 --- a/extensions/bluebubbles/src/runtime-api.ts +++ b/extensions/bluebubbles/src/runtime-api.ts @@ -1 +1 @@ -export * from "openclaw/plugin-sdk/bluebubbles"; +export * from "../../../src/plugin-sdk/bluebubbles.js"; diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index ecb8b1f68e0..ff9935c84b3 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -1,7 +1,7 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; import { beforeEach, describe, expect, it, vi } from "vitest"; import "./test-mocks.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import type { PluginRuntime } from "./runtime-api.js"; import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js"; import { sendMessageBlueBubbles, resolveChatGuidForTarget, createChatForHandle } from "./send.js"; import { diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index 605c5cecc76..833ac88522e 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -5,7 +5,7 @@ import { type ParsedChatTarget, resolveServicePrefixedAllowTarget, resolveServicePrefixedTarget, -} from "openclaw/plugin-sdk/imessage-core"; +} from "../../imessage/api.js"; export type BlueBubblesService = "imessage" | "sms" | "auto"; diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts index 5f7351b2e9f..9b52971be41 100644 --- a/extensions/bluebubbles/src/test-harness.ts +++ b/extensions/bluebubbles/src/test-harness.ts @@ -62,14 +62,16 @@ export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule { export function installBlueBubblesFetchTestHooks(params: { mockFetch: ReturnType; privateApiStatusMock: { - mockReset: () => unknown; + mockReset?: () => unknown; + mockClear?: () => unknown; mockReturnValue: (value: boolean | null) => unknown; }; }) { beforeEach(() => { vi.stubGlobal("fetch", params.mockFetch); params.mockFetch.mockReset(); - params.privateApiStatusMock.mockReset(); + params.privateApiStatusMock.mockReset?.(); + params.privateApiStatusMock.mockClear?.(); params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown); }); diff --git a/extensions/chutes/index.ts b/extensions/chutes/index.ts index 89a2fc4a6fe..de70c603e23 100644 --- a/extensions/chutes/index.ts +++ b/extensions/chutes/index.ts @@ -5,8 +5,8 @@ import { type ProviderAuthContext, type ProviderAuthResult, } from "openclaw/plugin-sdk/provider-auth"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; import { loginChutes } from "openclaw/plugin-sdk/provider-auth-login"; -import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; import { CHUTES_DEFAULT_MODEL_REF, applyChutesApiKeyConfig, diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts index 7e0a28ec7fd..994245461ed 100644 --- a/extensions/discord/src/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -2,7 +2,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/acco import { hasConfiguredSecretInput, normalizeSecretInputString, -} from "openclaw/plugin-sdk/config-runtime"; +} from "openclaw/plugin-sdk/secret-input"; import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId, diff --git a/extensions/discord/src/actions/handle-action.guild-admin.ts b/extensions/discord/src/actions/handle-action.guild-admin.ts index e63d00f23ec..fcb3cf530b6 100644 --- a/extensions/discord/src/actions/handle-action.guild-admin.ts +++ b/extensions/discord/src/actions/handle-action.guild-admin.ts @@ -5,7 +5,7 @@ import { readStringArrayParam, readStringParam, } from "openclaw/plugin-sdk/agent-runtime"; -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; import { handleDiscordAction } from "./runtime.js"; import { isDiscordModerationAction, diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index 9726b07cdda..e0f91daa668 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -5,8 +5,8 @@ import { readStringParam, } from "openclaw/plugin-sdk/agent-runtime"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; -import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; import { buildDiscordInteractiveComponents } from "../shared-interactive.js"; import { resolveDiscordChannelId } from "../targets.js"; diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index 1c6b9b5c70f..51fb193b58e 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -1,12 +1,12 @@ import { createUnionActionGate, listTokenSourcedAccounts, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-actions"; import type { ChannelMessageActionAdapter, ChannelMessageActionName, ChannelMessageToolDiscovery, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-contract"; import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-runtime"; import { createDiscordActionGate, listEnabledDiscordAccounts } from "./accounts.js"; import { handleDiscordMessageAction } from "./actions/handle-action.js"; diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index b5f2224b1dd..152223f12a9 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -1,13 +1,13 @@ +import { afterEach, 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"; +} from "../../../src/channels/plugins/types.js"; +import type { PluginRuntime } from "../../../src/plugins/runtime/types.js"; import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { ResolvedDiscordAccount } from "./accounts.js"; import { discordPlugin } from "./channel.js"; +import type { OpenClawConfig } from "./runtime-api.js"; import { setDiscordRuntime } from "./runtime.js"; const probeDiscordMock = vi.hoisted(() => vi.fn()); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 0ddb5c9e19f..63f11ede836 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -5,20 +5,29 @@ import { createNestedAllowlistOverrideResolver, } from "openclaw/plugin-sdk/allowlist-config-edit"; import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; -import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { - createAttachedChannelResultAdapter, - createChannelDirectoryAdapter, createPairingPrefixStripper, - createTopLevelChannelReplyToModeResolver, - createRuntimeDirectoryLiveAdapter, createTextPairingAdapter, - normalizeMessageChannel, +} from "openclaw/plugin-sdk/channel-pairing"; +import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; +import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/channel-targets"; +import { createTopLevelChannelReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; +import { + createChannelDirectoryAdapter, + createRuntimeDirectoryLiveAdapter, +} from "openclaw/plugin-sdk/directory-runtime"; +import { + createRuntimeOutboundDelegates, resolveOutboundSendDep, - resolveTargetsWithOptionalToken, -} from "openclaw/plugin-sdk/channel-runtime"; -import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; -import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; +} from "openclaw/plugin-sdk/infra-runtime"; +import { + buildOutboundBaseSessionKey, + normalizeMessageChannel, + normalizeOutboundThreadId, + resolveThreadSessionKeys, + type RoutePeer, +} from "openclaw/plugin-sdk/routing"; import { listDiscordAccountIds, resolveDiscordAccount, diff --git a/extensions/discord/src/config-schema.ts b/extensions/discord/src/config-schema.ts index a6866fc092d..6498c77a9fb 100644 --- a/extensions/discord/src/config-schema.ts +++ b/extensions/discord/src/config-schema.ts @@ -1,3 +1,3 @@ -import { buildChannelConfigSchema, DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core"; +import { buildChannelConfigSchema, DiscordConfigSchema } from "./runtime-api.js"; export const DiscordChannelConfigSchema = buildChannelConfigSchema(DiscordConfigSchema); diff --git a/extensions/discord/src/directory-live.ts b/extensions/discord/src/directory-live.ts index 6bd38204a0a..67a8e908f7c 100644 --- a/extensions/discord/src/directory-live.ts +++ b/extensions/discord/src/directory-live.ts @@ -1,5 +1,7 @@ -import type { DirectoryConfigParams } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/channel-runtime"; +import type { + ChannelDirectoryEntry, + DirectoryConfigParams, +} from "openclaw/plugin-sdk/directory-runtime"; import { resolveDiscordAccount } from "./accounts.js"; import { fetchDiscord } from "./api.js"; import { rememberDiscordDirectoryUser } from "./directory-cache.js"; diff --git a/extensions/discord/src/draft-stream.ts b/extensions/discord/src/draft-stream.ts index a12348334bc..ab49b13fbc3 100644 --- a/extensions/discord/src/draft-stream.ts +++ b/extensions/discord/src/draft-stream.ts @@ -1,6 +1,6 @@ import type { RequestClient } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; -import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-runtime"; +import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-lifecycle"; /** Discord messages cap at 2000 characters. */ const DISCORD_STREAM_MAX_CHARS = 2000; diff --git a/extensions/discord/src/group-policy.ts b/extensions/discord/src/group-policy.ts index a5a8ebac5eb..9394e319818 100644 --- a/extensions/discord/src/group-policy.ts +++ b/extensions/discord/src/group-policy.ts @@ -1,9 +1,9 @@ +import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract"; import { resolveToolsBySender, type GroupToolPolicyBySenderConfig, type GroupToolPolicyConfig, } from "openclaw/plugin-sdk/channel-policy"; -import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime"; import { normalizeAtHashSlug } from "openclaw/plugin-sdk/core"; import type { DiscordConfig } from "./runtime-api.js"; diff --git a/extensions/discord/src/monitor/agent-components-helpers.ts b/extensions/discord/src/monitor/agent-components-helpers.ts index eecbe73c351..b7c247d1f07 100644 --- a/extensions/discord/src/monitor/agent-components-helpers.ts +++ b/extensions/discord/src/monitor/agent-components-helpers.ts @@ -11,7 +11,7 @@ import { import type { APIStringSelectComponent } from "discord-api-types/v10"; import { ChannelType } from "discord-api-types/v10"; import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index 0fa42d0e23c..429b575b140 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -19,8 +19,11 @@ import { import type { APIStringSelectComponent } from "discord-api-types/v10"; import { ButtonStyle, ChannelType } from "discord-api-types/v10"; import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "openclaw/plugin-sdk/channel-inbound"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; @@ -31,6 +34,7 @@ import { parsePluginBindingApprovalCustomId, resolvePluginConversationBindingApproval, } from "openclaw/plugin-sdk/conversation-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { @@ -38,10 +42,6 @@ import { type PluginInteractiveDiscordHandlerContext, } from "openclaw/plugin-sdk/plugin-runtime"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import { - formatInboundEnvelope, - resolveEnvelopeFormatOptions, -} from "openclaw/plugin-sdk/reply-runtime"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime"; diff --git a/extensions/discord/src/monitor/allow-list.ts b/extensions/discord/src/monitor/allow-list.ts index 31d95f2f45b..37508b9a092 100644 --- a/extensions/discord/src/monitor/allow-list.ts +++ b/extensions/discord/src/monitor/allow-list.ts @@ -1,11 +1,11 @@ import type { Guild, User } from "@buape/carbon"; -import type { AllowlistMatch } from "openclaw/plugin-sdk/channel-runtime"; +import type { AllowlistMatch } from "openclaw/plugin-sdk/allow-from"; import { buildChannelKeyCandidates, resolveChannelEntryMatchWithFallback, resolveChannelMatchConfig, type ChannelMatchSource, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-targets"; import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import { formatDiscordUserTag } from "./format.js"; diff --git a/extensions/discord/src/monitor/dm-command-auth.ts b/extensions/discord/src/monitor/dm-command-auth.ts index 1e8f1afbb4b..f668545f733 100644 --- a/extensions/discord/src/monitor/dm-command-auth.ts +++ b/extensions/discord/src/monitor/dm-command-auth.ts @@ -1,4 +1,4 @@ -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth"; import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, diff --git a/extensions/discord/src/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts index 607d5088ad1..c30d0c082e9 100644 --- a/extensions/discord/src/monitor/exec-approvals.ts +++ b/extensions/discord/src/monitor/exec-approvals.ts @@ -10,7 +10,6 @@ import { type TopLevelComponents, } from "@buape/carbon"; import { ButtonStyle, Routes } from "discord-api-types/v10"; -import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime"; @@ -24,7 +23,11 @@ import type { ExecApprovalRequest, ExecApprovalResolved, } from "openclaw/plugin-sdk/infra-runtime"; -import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; +import { + normalizeAccountId, + normalizeMessageChannel, + resolveAgentIdFromSessionKey, +} from "openclaw/plugin-sdk/routing"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { compileSafeRegex, testRegexWithBoundedInput } from "openclaw/plugin-sdk/security-runtime"; import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime"; diff --git a/extensions/discord/src/monitor/inbound-worker.ts b/extensions/discord/src/monitor/inbound-worker.ts index 33986e458a3..c00b7dc1c1d 100644 --- a/extensions/discord/src/monitor/inbound-worker.ts +++ b/extensions/discord/src/monitor/inbound-worker.ts @@ -1,4 +1,4 @@ -import { createRunStateMachine } from "openclaw/plugin-sdk/channel-runtime"; +import { createRunStateMachine } from "openclaw/plugin-sdk/channel-lifecycle"; import { formatDurationSeconds } from "openclaw/plugin-sdk/infra-runtime"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import { danger } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 9094cabb645..55822830cd5 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -1,9 +1,15 @@ 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"; -import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime"; +import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from"; +import { + buildMentionRegexes, + logInboundDrop, + matchesMentionWithExplicit, + resolveMentionGatingWithBypass, +} from "openclaw/plugin-sdk/channel-inbound"; +import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; +import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-auth"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { @@ -18,13 +24,10 @@ import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; import { isPluginOwnedSessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; -import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; -import { shouldHandleTextCommands } from "openclaw/plugin-sdk/reply-runtime"; import { recordPendingHistoryEntryIfEnabled, type HistoryEntry, -} from "openclaw/plugin-sdk/reply-runtime"; -import { buildMentionRegexes, matchesMentionWithExplicit } from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/discord/src/monitor/message-handler.preflight.types.ts b/extensions/discord/src/monitor/message-handler.preflight.types.ts index 368352e1551..575d8ee165b 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.types.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.types.ts @@ -1,7 +1,7 @@ import type { ChannelType, Client, User } from "@buape/carbon"; import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; import type { SessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime"; -import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js"; import type { DiscordChannelInfo } from "./message-utils.js"; diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 42f2011d62a..b381013349e 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -1,31 +1,32 @@ import { ChannelType, type RequestClient } from "@buape/carbon"; import { resolveAckReaction, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { shouldAckReaction as shouldAckReactionGate } from "openclaw/plugin-sdk/channel-runtime"; -import { logTypingFailure, logAckFailure } from "openclaw/plugin-sdk/channel-runtime"; -import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; import { createStatusReactionController, DEFAULT_TIMING, + logAckFailure, + logTypingFailure, + shouldAckReaction as shouldAckReactionGate, type StatusReactionAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-feedback"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "openclaw/plugin-sdk/channel-inbound"; +import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; -import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; -import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; -import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; -import { - formatInboundEnvelope, - resolveEnvelopeFormatOptions, -} from "openclaw/plugin-sdk/reply-runtime"; import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; +import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; diff --git a/extensions/discord/src/monitor/message-handler.ts b/extensions/discord/src/monitor/message-handler.ts index 400f35a2529..e17dcc906af 100644 --- a/extensions/discord/src/monitor/message-handler.ts +++ b/extensions/discord/src/monitor/message-handler.ts @@ -2,7 +2,7 @@ import type { Client } from "@buape/carbon"; import { createChannelInboundDebouncer, shouldDebounceTextInbound, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-inbound"; import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; import { danger } from "openclaw/plugin-sdk/runtime-env"; import { buildDiscordInboundJob } from "./inbound-job.js"; diff --git a/extensions/discord/src/monitor/message-utils.ts b/extensions/discord/src/monitor/message-utils.ts index 4e84f4b3827..e0eb58c9266 100644 --- a/extensions/discord/src/monitor/message-utils.ts +++ b/extensions/discord/src/monitor/message-utils.ts @@ -1,9 +1,9 @@ import type { ChannelType, Client, Message } from "@buape/carbon"; import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10"; -import { buildMediaPayload } from "openclaw/plugin-sdk/channel-runtime"; import type { SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime"; import { fetchRemoteMedia, type FetchLike } from "openclaw/plugin-sdk/media-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; +import { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; const DISCORD_CDN_HOSTNAMES = [ diff --git a/extensions/discord/src/monitor/model-picker.test-utils.ts b/extensions/discord/src/monitor/model-picker.test-utils.ts index 56dcd7480c1..60b1c41e8ba 100644 --- a/extensions/discord/src/monitor/model-picker.test-utils.ts +++ b/extensions/discord/src/monitor/model-picker.test-utils.ts @@ -1,4 +1,4 @@ -import type { ModelsProviderData } from "openclaw/plugin-sdk/reply-runtime"; +import type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth"; export function createModelsProviderData( entries: Record, diff --git a/extensions/discord/src/monitor/model-picker.ts b/extensions/discord/src/monitor/model-picker.ts index ec067ede2dd..47313af5801 100644 --- a/extensions/discord/src/monitor/model-picker.ts +++ b/extensions/discord/src/monitor/model-picker.ts @@ -12,11 +12,8 @@ import { import type { APISelectMenuOption } from "discord-api-types/v10"; import { ButtonStyle } from "discord-api-types/v10"; import { normalizeProviderId } from "openclaw/plugin-sdk/agent-runtime"; +import { buildModelsProviderData, type ModelsProviderData } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { - buildModelsProviderData, - type ModelsProviderData, -} from "openclaw/plugin-sdk/reply-runtime"; export const DISCORD_MODEL_PICKER_CUSTOM_ID_KEY = "mdlpk"; export const DISCORD_CUSTOM_ID_MAX_CHARS = 100; diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index 158336d2435..27e129b0bee 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -117,8 +117,8 @@ vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (import }; }); -vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), diff --git a/extensions/discord/src/monitor/native-command-context.ts b/extensions/discord/src/monitor/native-command-context.ts index 07dc0bf0a76..81b97bede15 100644 --- a/extensions/discord/src/monitor/native-command-context.ts +++ b/extensions/discord/src/monitor/native-command-context.ts @@ -1,4 +1,4 @@ -import type { CommandArgs } from "openclaw/plugin-sdk/reply-runtime"; +import type { CommandArgs } from "openclaw/plugin-sdk/command-auth"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import { type DiscordChannelConfigResolved, type DiscordGuildEntryResolved } from "./allow-list.js"; import { buildDiscordInboundAccessContext } from "./inbound-context.js"; diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts index 5c31e81ed8f..314c31f11bf 100644 --- a/extensions/discord/src/monitor/native-command-ui.ts +++ b/extensions/discord/src/monitor/native-command-ui.ts @@ -11,22 +11,20 @@ import { type StringSelectMenuInteraction, } from "@buape/carbon"; import { ButtonStyle } from "discord-api-types/v10"; -import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime"; -import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import { buildCommandTextFromArgs, findCommandByNativeName, listChatCommands, resolveCommandArgChoices, + resolveStoredModelOverride, serializeCommandArgs, -} from "openclaw/plugin-sdk/reply-runtime"; -import { resolveStoredModelOverride } from "openclaw/plugin-sdk/reply-runtime"; -import type { - ChatCommandDefinition, - CommandArgDefinition, - CommandArgValues, - CommandArgs, -} from "openclaw/plugin-sdk/reply-runtime"; + type ChatCommandDefinition, + type CommandArgDefinition, + type CommandArgValues, + type CommandArgs, +} from "openclaw/plugin-sdk/command-auth"; +import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { chunkItems, withTimeout } from "openclaw/plugin-sdk/text-runtime"; diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 315e87b7e6f..d00fab280f0 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -13,8 +13,24 @@ import { import { ApplicationCommandOptionType } from "discord-api-types/v10"; import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/channel-runtime"; +import { + resolveCommandAuthorizedFromAuthorizers, + resolveNativeCommandSessionTargets, +} from "openclaw/plugin-sdk/command-auth"; +import { + buildCommandTextFromArgs, + findCommandByNativeName, + listChatCommands, + parseCommandArgs, + resolveCommandArgChoices, + resolveCommandArgMenu, + serializeCommandArgs, + type ChatCommandDefinition, + type CommandArgDefinition, + type CommandArgValues, + type CommandArgs, + type NativeCommandSpec, +} from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; @@ -30,22 +46,6 @@ import { resolveTextChunksWithFallback, } from "openclaw/plugin-sdk/reply-payload"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import type { - ChatCommandDefinition, - CommandArgDefinition, - CommandArgValues, - CommandArgs, - NativeCommandSpec, -} from "openclaw/plugin-sdk/reply-runtime"; -import { - buildCommandTextFromArgs, - findCommandByNativeName, - listChatCommands, - parseCommandArgs, - resolveCommandArgChoices, - resolveCommandArgMenu, - serializeCommandArgs, -} from "openclaw/plugin-sdk/reply-runtime"; import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/discord/src/monitor/provider.allowlist.ts b/extensions/discord/src/monitor/provider.allowlist.ts index ac6c89dd9f8..8cd945da823 100644 --- a/extensions/discord/src/monitor/provider.allowlist.ts +++ b/extensions/discord/src/monitor/provider.allowlist.ts @@ -4,7 +4,7 @@ import { canonicalizeAllowlistWithResolvedIds, patchAllowlistUsersInConfigEntries, summarizeMapping, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/allow-from"; import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index b2a9e8a6019..884a0bded57 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -1,6 +1,6 @@ import type { Client } from "@buape/carbon"; import type { GatewayPlugin } from "@buape/carbon/gateway"; -import { createArmableStallWatchdog } from "openclaw/plugin-sdk/channel-runtime"; +import { createArmableStallWatchdog } from "openclaw/plugin-sdk/channel-lifecycle"; import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 8dbb6df29f5..8388438f37d 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -14,10 +14,10 @@ import { Routes } from "discord-api-types/v10"; import { getAcpSessionManager } from "openclaw/plugin-sdk/acp-runtime"; import { isAcpRuntimeError } from "openclaw/plugin-sdk/acp-runtime"; import { - resolveThreadBindingIdleTimeoutMs, - resolveThreadBindingMaxAgeMs, - resolveThreadBindingsEnabled, -} from "openclaw/plugin-sdk/channel-runtime"; + listNativeCommandSpecsForConfig, + listSkillCommandsForAgents, + type NativeCommandSpec, +} from "openclaw/plugin-sdk/command-auth"; import { isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, @@ -32,14 +32,16 @@ import { resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/config-runtime"; +import { + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingMaxAgeMs, + resolveThreadBindingsEnabled, +} from "openclaw/plugin-sdk/conversation-runtime"; import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; import { getPluginCommandSpecs } from "openclaw/plugin-sdk/plugin-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import type { NativeCommandSpec } from "openclaw/plugin-sdk/reply-runtime"; -import { listNativeCommandSpecsForConfig } from "openclaw/plugin-sdk/reply-runtime"; -import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; -import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/reply-runtime"; import { danger, isVerbose, @@ -90,6 +92,7 @@ import { resolveDiscordPresenceUpdate } from "./presence.js"; import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js"; import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js"; import { resolveDiscordRestFetch } from "./rest-fetch.js"; +import { formatDiscordStartupStatusMessage } from "./startup-status.js"; import type { DiscordMonitorStatusSink } from "./status.js"; import { createNoopThreadBindingManager, @@ -970,7 +973,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const botIdentity = botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? ""); - runtime.log?.(`logged in to discord${botIdentity ? ` as ${botIdentity}` : ""}`); + runtime.log?.( + formatDiscordStartupStatusMessage({ + gatewayReady: lifecycleGateway?.isConnected === true, + botIdentity: botIdentity || undefined, + }), + ); if (lifecycleGateway?.isConnected) { opts.setStatus?.(createConnectedChannelStatusPatch()); } diff --git a/extensions/discord/src/monitor/startup-status.test.ts b/extensions/discord/src/monitor/startup-status.test.ts new file mode 100644 index 00000000000..47cc84202d6 --- /dev/null +++ b/extensions/discord/src/monitor/startup-status.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { formatDiscordStartupStatusMessage } from "./startup-status.js"; + +describe("formatDiscordStartupStatusMessage", () => { + it("reports logged-in status only after the gateway is ready", () => { + expect( + formatDiscordStartupStatusMessage({ + gatewayReady: true, + botIdentity: "bot-1 (Molty)", + }), + ).toBe("logged in to discord as bot-1 (Molty)"); + }); + + it("reports client initialization while gateway readiness is still pending", () => { + expect( + formatDiscordStartupStatusMessage({ + gatewayReady: false, + botIdentity: "bot-1 (Molty)", + }), + ).toBe("discord client initialized as bot-1 (Molty); awaiting gateway readiness"); + }); + + it("handles missing identity without awkward punctuation", () => { + expect( + formatDiscordStartupStatusMessage({ + gatewayReady: false, + }), + ).toBe("discord client initialized; awaiting gateway readiness"); + }); +}); diff --git a/extensions/discord/src/monitor/startup-status.ts b/extensions/discord/src/monitor/startup-status.ts new file mode 100644 index 00000000000..94f311912b8 --- /dev/null +++ b/extensions/discord/src/monitor/startup-status.ts @@ -0,0 +1,10 @@ +export function formatDiscordStartupStatusMessage(params: { + gatewayReady: boolean; + botIdentity?: string; +}): string { + const identitySuffix = params.botIdentity ? ` as ${params.botIdentity}` : ""; + if (params.gatewayReady) { + return `logged in to discord${identitySuffix}`; + } + return `discord client initialized${identitySuffix}; awaiting gateway readiness`; +} diff --git a/extensions/discord/src/monitor/thread-bindings.config.ts b/extensions/discord/src/monitor/thread-bindings.config.ts index 701defcfbe1..a6520c5e868 100644 --- a/extensions/discord/src/monitor/thread-bindings.config.ts +++ b/extensions/discord/src/monitor/thread-bindings.config.ts @@ -1,9 +1,9 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveThreadBindingIdleTimeoutMs, resolveThreadBindingMaxAgeMs, resolveThreadBindingsEnabled, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +} from "openclaw/plugin-sdk/conversation-runtime"; import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; export { diff --git a/extensions/discord/src/monitor/thread-bindings.manager.ts b/extensions/discord/src/monitor/thread-bindings.manager.ts index 5c37ac4bbf0..0fa8f09aac0 100644 --- a/extensions/discord/src/monitor/thread-bindings.manager.ts +++ b/extensions/discord/src/monitor/thread-bindings.manager.ts @@ -1,8 +1,8 @@ import { Routes } from "discord-api-types/v10"; -import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime"; import { getRuntimeConfigSnapshot, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { registerSessionBindingAdapter, + resolveThreadBindingConversationIdFromBindingId, unregisterSessionBindingAdapter, type BindingTargetKind, type SessionBindingRecord, diff --git a/extensions/discord/src/monitor/thread-bindings.messages.ts b/extensions/discord/src/monitor/thread-bindings.messages.ts index 043e888b7fc..1e0a1f3cbb2 100644 --- a/extensions/discord/src/monitor/thread-bindings.messages.ts +++ b/extensions/discord/src/monitor/thread-bindings.messages.ts @@ -1,6 +1,6 @@ export { - formatThreadBindingDurationLabel, resolveThreadBindingFarewellText, resolveThreadBindingIntroText, resolveThreadBindingThreadName, -} from "openclaw/plugin-sdk/channel-runtime"; + formatThreadBindingDurationLabel, +} from "openclaw/plugin-sdk/conversation-runtime"; diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index 8b18fffec90..471cf841aa8 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -1,16 +1,15 @@ +import { + attachChannelToResult, + type ChannelOutboundAdapter, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveOutboundSendDep, type OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; import { resolvePayloadMediaUrls, sendPayloadMediaSequenceOrFallback, sendTextMediaPayload, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; -import { - attachChannelToResult, - createAttachedChannelResultAdapter, -} from "openclaw/plugin-sdk/channel-send-result"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; +} from "openclaw/plugin-sdk/reply-payload"; import type { DiscordComponentMessageSpec } from "./components.js"; import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js"; import { normalizeDiscordOutboundTarget } from "./normalize.js"; diff --git a/extensions/discord/src/probe.ts b/extensions/discord/src/probe.ts index f84b4aad10a..cdd662718eb 100644 --- a/extensions/discord/src/probe.ts +++ b/extensions/discord/src/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import { resolveFetch } from "openclaw/plugin-sdk/infra-runtime"; import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime"; import { normalizeDiscordToken } from "./token.js"; diff --git a/extensions/discord/src/runtime-api.ts b/extensions/discord/src/runtime-api.ts index 0d355ab506f..7d9bc355184 100644 --- a/extensions/discord/src/runtime-api.ts +++ b/extensions/discord/src/runtime-api.ts @@ -4,7 +4,7 @@ export { PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "../../../src/plugin-sdk/discord.js"; export { buildChannelConfigSchema, getChatChannelMeta, @@ -19,15 +19,15 @@ export { type DiscordActionConfig, type DiscordConfig, type OpenClawConfig, -} from "openclaw/plugin-sdk/discord-core"; -export { DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core"; +} from "../../../src/plugin-sdk/discord-core.js"; +export { DiscordConfigSchema } from "../../../src/plugin-sdk/discord-core.js"; export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; export { assertMediaNotDataUrl, parseAvailableTags, readReactionParams, withNormalizedTimestamp, -} from "openclaw/plugin-sdk/discord-core"; +} from "../../../src/plugin-sdk/discord-core.js"; export { createHybridChannelConfigAdapter, createScopedChannelConfigAdapter, @@ -44,9 +44,9 @@ export { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; export type { ChannelMessageActionAdapter, ChannelMessageActionName, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-contract"; export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, -} from "openclaw/plugin-sdk/config-runtime"; +} from "openclaw/plugin-sdk/secret-input"; diff --git a/extensions/discord/src/session-key-normalization.ts b/extensions/discord/src/session-key-normalization.ts index 06164d6aba5..f63524428c0 100644 --- a/extensions/discord/src/session-key-normalization.ts +++ b/extensions/discord/src/session-key-normalization.ts @@ -1,4 +1,4 @@ -import { normalizeChatType } from "openclaw/plugin-sdk/channel-runtime"; +import { normalizeChatType } from "openclaw/plugin-sdk/account-resolution"; import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; export function normalizeExplicitDiscordSessionKey( diff --git a/extensions/discord/src/setup-account-state.ts b/extensions/discord/src/setup-account-state.ts index 725e6e4037e..2adbcacb424 100644 --- a/extensions/discord/src/setup-account-state.ts +++ b/extensions/discord/src/setup-account-state.ts @@ -1,9 +1,9 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { hasConfiguredSecretInput, normalizeSecretInputString, - type OpenClawConfig, -} from "openclaw/plugin-sdk/config-runtime"; +} from "openclaw/plugin-sdk/secret-input"; import type { DiscordAccountConfig } from "./runtime-api.js"; import { resolveDiscordToken } from "./token.js"; diff --git a/extensions/discord/src/status-issues.ts b/extensions/discord/src/status-issues.ts index 4fa26fd011b..f095221483e 100644 --- a/extensions/discord/src/status-issues.ts +++ b/extensions/discord/src/status-issues.ts @@ -1,13 +1,13 @@ +import type { + ChannelAccountSnapshot, + ChannelStatusIssue, +} from "openclaw/plugin-sdk/channel-contract"; import { appendMatchMetadata, asString, isRecord, resolveEnabledConfiguredAccountId, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { - ChannelAccountSnapshot, - ChannelStatusIssue, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/status-helpers"; type DiscordIntentSummary = { messageContent?: "enabled" | "limited" | "disabled"; diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index a05db63043a..927ae73b0d3 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/discord"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getRequiredHookHandler, diff --git a/extensions/discord/src/targets.ts b/extensions/discord/src/targets.ts index 3660f75921e..cb04a96d914 100644 --- a/extensions/discord/src/targets.ts +++ b/extensions/discord/src/targets.ts @@ -1,4 +1,3 @@ -import type { DirectoryConfigParams } from "openclaw/plugin-sdk/channel-runtime"; import { buildMessagingTarget, parseMentionPrefixOrAtUserTarget, @@ -6,7 +5,8 @@ import { type MessagingTarget, type MessagingTargetKind, type MessagingTargetParseOptions, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-targets"; +import type { DirectoryConfigParams } from "openclaw/plugin-sdk/directory-runtime"; import { rememberDiscordDirectoryUser } from "./directory-cache.js"; import { listDiscordDirectoryPeersLive } from "./directory-live.js"; diff --git a/extensions/discord/src/token.ts b/extensions/discord/src/token.ts index 2a979ca4b3b..b9614e59794 100644 --- a/extensions/discord/src/token.ts +++ b/extensions/discord/src/token.ts @@ -1,7 +1,7 @@ -import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-runtime"; +import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; export type DiscordTokenSource = "env" | "config" | "none"; diff --git a/extensions/discord/src/voice/command.ts b/extensions/discord/src/voice/command.ts index 3ed7aa2ccdb..0d9bf5124d6 100644 --- a/extensions/discord/src/voice/command.ts +++ b/extensions/discord/src/voice/command.ts @@ -10,7 +10,7 @@ import { ChannelType as DiscordChannelType, type APIApplicationCommandChannelOption, } from "discord-api-types/v10"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; diff --git a/extensions/feishu/runtime-api.ts b/extensions/feishu/runtime-api.ts index ece8df41cca..cde6bbf5569 100644 --- a/extensions/feishu/runtime-api.ts +++ b/extensions/feishu/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Feishu extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/feishu"; +export * from "../../src/plugin-sdk/feishu.js"; diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 97fd5dd068d..4eac10cc0cd 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,21 +1,23 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; +import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-actions"; import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; +import type { + ChannelMessageActionAdapter, + ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-contract"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-pairing"; import { createAllowlistProviderGroupPolicyWarningCollector, projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { createChannelDirectoryAdapter, - createMessageToolCardSchema, - createPairingPrefixStripper, createRuntimeDirectoryLiveAdapter, - createRuntimeOutboundDelegates, - createTextPairingAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { - ChannelMessageActionAdapter, - ChannelMessageToolDiscovery, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/directory-runtime"; +import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/infra-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "../runtime-api.js"; import { diff --git a/extensions/feishu/src/thread-bindings.ts b/extensions/feishu/src/thread-bindings.ts index cfae8fb2058..842374155b3 100644 --- a/extensions/feishu/src/thread-bindings.ts +++ b/extensions/feishu/src/thread-bindings.ts @@ -1,11 +1,9 @@ -import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { registerSessionBindingAdapter, + resolveThreadBindingConversationIdFromBindingId, unregisterSessionBindingAdapter, type BindingTargetKind, type SessionBindingRecord, diff --git a/extensions/firecrawl/src/config.ts b/extensions/firecrawl/src/config.ts index 3f2d6a82f8a..3c2c2f3c25d 100644 --- a/extensions/firecrawl/src/config.ts +++ b/extensions/firecrawl/src/config.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { normalizeSecretInput } from "openclaw/plugin-sdk/provider-auth"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; export const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev"; export const DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS = 30; diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index 77fa7077b5d..412d02dd85f 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -3,7 +3,7 @@ import type { ProviderAuthContext, ProviderFetchUsageSnapshotContext, } from "openclaw/plugin-sdk/plugin-entry"; -import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage"; import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index df946f8ec4a..cd47c0e56c7 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Google Chat extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/googlechat"; +export * from "../../src/plugin-sdk/googlechat.js"; diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index fc4cf489928..e8917d13c04 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -3,19 +3,17 @@ import { createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createTextPairingAdapter } from "openclaw/plugin-sdk/channel-pairing"; import { composeWarningCollectors, createAllowlistProviderGroupPolicyWarningCollector, createConditionalWarningCollector, createAllowlistProviderOpenWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; +import { createTopLevelChannelReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; import { - createAttachedChannelResultAdapter, createChannelDirectoryAdapter, - createTopLevelChannelReplyToModeResolver, - createTextPairingAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; -import { listResolvedDirectoryGroupEntriesFromMapKeys, listResolvedDirectoryUserEntriesFromAllowFrom, } from "openclaw/plugin-sdk/directory-runtime"; diff --git a/extensions/imessage/runtime-api.ts b/extensions/imessage/runtime-api.ts index aa6d55c75e5..22b1e4a21ba 100644 --- a/extensions/imessage/runtime-api.ts +++ b/extensions/imessage/runtime-api.ts @@ -13,7 +13,7 @@ export { IMessageConfigSchema, type ChannelPlugin, type IMessageAccountConfig, -} from "openclaw/plugin-sdk/imessage"; +} from "../../src/plugin-sdk/imessage.js"; export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, diff --git a/extensions/imessage/src/channel.runtime.ts b/extensions/imessage/src/channel.runtime.ts index 32cd39a1d64..5ee80d614d6 100644 --- a/extensions/imessage/src/channel.runtime.ts +++ b/extensions/imessage/src/channel.runtime.ts @@ -1,4 +1,4 @@ -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes } from "../runtime-api.js"; import type { ResolvedIMessageAccount } from "./accounts.js"; import { monitorIMessageProvider } from "./monitor.js"; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index d084ee92a15..5257e32f349 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,12 +1,9 @@ import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; -import { - createAttachedChannelResultAdapter, - resolveOutboundSendDep, -} from "openclaw/plugin-sdk/channel-runtime"; -import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import { type RoutePeer } from "openclaw/plugin-sdk/routing"; +import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, diff --git a/extensions/imessage/src/config-schema.ts b/extensions/imessage/src/config-schema.ts index dc960ccdb0e..230c31ce089 100644 --- a/extensions/imessage/src/config-schema.ts +++ b/extensions/imessage/src/config-schema.ts @@ -1,3 +1,3 @@ -import { buildChannelConfigSchema, IMessageConfigSchema } from "openclaw/plugin-sdk/imessage-core"; +import { buildChannelConfigSchema, IMessageConfigSchema } from "../runtime-api.js"; export const IMessageChannelConfigSchema = buildChannelConfigSchema(IMessageConfigSchema); diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index 531a8324dfd..358ecf26f17 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -1,24 +1,25 @@ -import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; -import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; +import { + buildMentionRegexes, + type EnvelopeFormatOptions, + formatInboundEnvelope, + formatInboundFromLabel, + logInboundDrop, + matchesMentionPatterns, + resolveEnvelopeFormatOptions, +} from "openclaw/plugin-sdk/channel-inbound"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; +import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "openclaw/plugin-sdk/config-runtime"; -import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; -import { - formatInboundEnvelope, - formatInboundFromLabel, - resolveEnvelopeFormatOptions, - type EnvelopeFormatOptions, -} from "openclaw/plugin-sdk/reply-runtime"; import { buildPendingHistoryContextFromMap, recordPendingHistoryEntryIfEnabled, type HistoryEntry, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; -import { buildMentionRegexes, matchesMentionPatterns } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { DM_GROUP_ACCESS_REASON, diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index 651926616c6..f5524a12f85 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -1,12 +1,11 @@ import fs from "node:fs/promises"; import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; -import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; import { createChannelInboundDebouncer, shouldDebounceTextInbound, -} from "openclaw/plugin-sdk/channel-runtime"; -import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-inbound"; +import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; +import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveOpenProviderRuntimeGroupPolicy, @@ -18,6 +17,7 @@ import { readChannelAllowFromStore, upsertChannelPairingRequest, } from "openclaw/plugin-sdk/conversation-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/infra-runtime"; import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime"; import { @@ -26,13 +26,13 @@ import { resolveIMessageRemoteAttachmentRoots, } from "openclaw/plugin-sdk/media-runtime"; import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; -import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; +import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; +import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import { danger, logVerbose, shouldLogVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; diff --git a/extensions/imessage/src/outbound-adapter.ts b/extensions/imessage/src/outbound-adapter.ts index cd961c30bfa..0b023fa2b02 100644 --- a/extensions/imessage/src/outbound-adapter.ts +++ b/extensions/imessage/src/outbound-adapter.ts @@ -1,8 +1,8 @@ +import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/infra-runtime"; import { - createScopedChannelMediaMaxBytesResolver, createDirectTextMediaOutbound, -} from "openclaw/plugin-sdk/channel-runtime"; -import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime"; + createScopedChannelMediaMaxBytesResolver, +} from "openclaw/plugin-sdk/media-runtime"; import { sendMessageIMessage } from "./send.js"; function resolveIMessageSender(deps: OutboundSendDeps | undefined) { diff --git a/extensions/imessage/src/probe.ts b/extensions/imessage/src/probe.ts index 7ae049f02eb..1609ec2f657 100644 --- a/extensions/imessage/src/probe.ts +++ b/extensions/imessage/src/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts index 8c68eb5406e..71281cbcf4d 100644 --- a/extensions/irc/src/accounts.ts +++ b/extensions/irc/src/accounts.ts @@ -1,8 +1,8 @@ import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { parseOptionalDelimitedEntries } from "openclaw/plugin-sdk/core"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]); diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 27571c92d35..69fdc07a79f 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -3,17 +3,17 @@ import { createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createTextPairingAdapter } from "openclaw/plugin-sdk/channel-pairing"; import { composeWarningCollectors, createAllowlistProviderOpenWarningCollector, createConditionalWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { - createAttachedChannelResultAdapter, createChannelDirectoryAdapter, - createTextPairingAdapter, listResolvedDirectoryEntriesFromSources, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/directory-runtime"; import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared"; import { listIrcAccountIds, diff --git a/extensions/irc/src/runtime-api.ts b/extensions/irc/src/runtime-api.ts index 40f35e1ad53..96e4bdbbe90 100644 --- a/extensions/irc/src/runtime-api.ts +++ b/extensions/irc/src/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled IRC extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/irc"; +export * from "../../../src/plugin-sdk/irc.js"; diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index 8e3a347e35a..f2e83e9838f 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -1,5 +1,4 @@ -import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { diff --git a/extensions/line/api.ts b/extensions/line/api.ts index 35d637bcc56..3fd34872f05 100644 --- a/extensions/line/api.ts +++ b/extensions/line/api.ts @@ -1,2 +1,41 @@ +export type { + ChannelPlugin, + OpenClawConfig, + OpenClawPluginApi, + PluginRuntime, +} from "openclaw/plugin-sdk/core"; +export { buildChannelConfigSchema, clearAccountEntryFields } from "openclaw/plugin-sdk/core"; +export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +export type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/testing"; +export type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract"; +export { + buildComputedAccountStatusSnapshot, + buildTokenChannelStatusSummary, +} from "openclaw/plugin-sdk/status-helpers"; +export type { + CardAction, + LineChannelData, + LineConfig, + ListItem, + ResolvedLineAccount, +} from "./runtime-api.js"; +export { + createActionCard, + createImageCard, + createInfoCard, + createListCard, + createReceiptCard, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + LineConfigSchema, + listLineAccountIds, + normalizeAccountId, + processLineMessage, + resolveDefaultLineAccountId, + resolveExactLineGroupConfigKey, + resolveLineAccount, + setSetupChannelEnabled, + splitSetupEntries, +} from "./runtime-api.js"; export * from "./runtime-api.js"; export * from "./setup-api.js"; diff --git a/extensions/line/runtime-api.ts b/extensions/line/runtime-api.ts index b40e5c76e0e..675c11a7467 100644 --- a/extensions/line/runtime-api.ts +++ b/extensions/line/runtime-api.ts @@ -1,12 +1,13 @@ // Private runtime barrel for the bundled LINE extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/line"; -export { resolveExactLineGroupConfigKey } from "openclaw/plugin-sdk/line-core"; +export * from "../../src/plugin-sdk/line.js"; export { + DEFAULT_ACCOUNT_ID, formatDocsLink, + resolveExactLineGroupConfigKey, setSetupChannelEnabled, splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, -} from "openclaw/plugin-sdk/line-core"; +} from "../../src/plugin-sdk/line-core.js"; diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 54cd54ff7bf..fd81a4c8f8a 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -1,12 +1,14 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-pairing"; import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createAttachedChannelResultAdapter, - createEmptyChannelDirectoryAdapter, createEmptyChannelResult, - createPairingPrefixStripper, - createTextPairingAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-send-result"; +import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload"; import { buildComputedAccountStatusSnapshot, diff --git a/extensions/line/src/config-adapter.ts b/extensions/line/src/config-adapter.ts index 3894210f0a6..1b10989b45c 100644 --- a/extensions/line/src/config-adapter.ts +++ b/extensions/line/src/config-adapter.ts @@ -5,7 +5,7 @@ import { resolveLineAccount, type OpenClawConfig, type ResolvedLineAccount, -} from "openclaw/plugin-sdk/line-core"; +} from "../runtime-api.js"; export function normalizeLineAllowFrom(entry: string): string { return entry.replace(/^line:(?:user:)?/i, ""); diff --git a/extensions/line/src/group-policy.ts b/extensions/line/src/group-policy.ts index e6b4fa0ba95..eaf30e04cf7 100644 --- a/extensions/line/src/group-policy.ts +++ b/extensions/line/src/group-policy.ts @@ -1,5 +1,5 @@ import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; -import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "openclaw/plugin-sdk/line-core"; +import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "../runtime-api.js"; type LineGroupContext = { cfg: OpenClawConfig; diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 363b4dcb2a1..7e894d2b87a 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -1,11 +1,11 @@ +import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; import { DEFAULT_ACCOUNT_ID, listLineAccountIds, normalizeAccountId, resolveLineAccount, type LineConfig, -} from "openclaw/plugin-sdk/line-core"; -import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; +} from "../runtime-api.js"; const channel = "line" as const; diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 640ad3812b8..6f46cc92217 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -1,3 +1,4 @@ +import { createAllowFromSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup"; import { DEFAULT_ACCOUNT_ID, formatDocsLink, @@ -6,8 +7,7 @@ import { splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, -} from "openclaw/plugin-sdk/line-core"; -import { createAllowFromSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup"; +} from "../runtime-api.js"; import { isLineConfigured, listLineAccountIds, diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts index 865936cb6ff..751ce70e496 100644 --- a/extensions/matrix/runtime-api.ts +++ b/extensions/matrix/runtime-api.ts @@ -3,4 +3,27 @@ // matrix-js-sdk during plain runtime-api import. export * from "./src/auth-precedence.js"; export * from "./helper-api.js"; -export * from "./thread-bindings-runtime.js"; +export { + assertHttpUrlTargetsPrivateNetwork, + closeDispatcher, + createPinnedDispatcher, + resolvePinnedHostnameWithPolicy, + ssrfPolicyFromAllowPrivateNetwork, + type LookupFn, + type SsrFPolicy, +} from "openclaw/plugin-sdk/ssrf-runtime"; +export { + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +} from "./thread-bindings-runtime.js"; +export { writeJsonFileAtomically } from "../../src/plugin-sdk/json-store.js"; +export type { + ChannelDirectoryEntry, + ChannelMessageActionContext, + OpenClawConfig, + PluginRuntime, + RuntimeLogger, + RuntimeEnv, + WizardPrompter, +} from "../../src/plugin-sdk/matrix.js"; +export { formatZonedTimestamp } from "../../src/plugin-sdk/matrix.js"; diff --git a/extensions/matrix/src/actions.account-propagation.test.ts b/extensions/matrix/src/actions.account-propagation.test.ts index 12dfea963f3..eaa2be533b0 100644 --- a/extensions/matrix/src/actions.account-propagation.test.ts +++ b/extensions/matrix/src/actions.account-propagation.test.ts @@ -1,5 +1,5 @@ -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelMessageActionContext } from "../runtime-api.js"; import type { CoreConfig } from "./types.js"; const mocks = vi.hoisted(() => ({ diff --git a/extensions/matrix/src/actions.test.ts b/extensions/matrix/src/actions.test.ts index 5e657bb4603..6750f7d9fb7 100644 --- a/extensions/matrix/src/actions.test.ts +++ b/extensions/matrix/src/actions.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it } from "vitest"; +import type { PluginRuntime } from "../runtime-api.js"; import { matrixMessageActions } from "./actions.js"; import { setMatrixRuntime } from "./runtime.js"; import type { CoreConfig } from "./types.js"; diff --git a/extensions/matrix/src/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts index 8f79f592db8..2c4c8a254bf 100644 --- a/extensions/matrix/src/channel.directory.test.ts +++ b/extensions/matrix/src/channel.directory.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import { matrixPlugin } from "./channel.js"; import { resolveMatrixAccount } from "./matrix/accounts.js"; import { resolveMatrixConfigForAccount } from "./matrix/client/config.js"; diff --git a/extensions/matrix/src/channel.setup.test.ts b/extensions/matrix/src/channel.setup.test.ts index ecafd4819f6..ba065fba792 100644 --- a/extensions/matrix/src/channel.setup.test.ts +++ b/extensions/matrix/src/channel.setup.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js"; const verificationMocks = vi.hoisted(() => ({ bootstrapMatrixVerification: vi.fn(), diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index ca028d8d99d..bef357c3bdd 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -2,20 +2,22 @@ import { createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-pairing"; import { createAllowlistProviderOpenWarningCollector, projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; import { createChannelDirectoryAdapter, - createPairingPrefixStripper, - createScopedAccountReplyToModeResolver, createRuntimeDirectoryLiveAdapter, - createRuntimeOutboundDelegates, - createTextPairingAdapter, listResolvedDirectoryEntriesFromSources, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/directory-runtime"; import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared"; +import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/infra-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { matrixMessageActions } from "./actions.js"; import { MatrixConfigSchema } from "./config-schema.js"; diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index da10215f435..318db978f6b 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; -import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { formatZonedTimestamp } from "../runtime-api.js"; const bootstrapMatrixVerificationMock = vi.fn(); const getMatrixRoomKeyBackupStatusMock = vi.fn(); diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index e1b8c78c56f..4e6882bc20b 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { LookupFn } from "../runtime-api.js"; +import type { LookupFn } from "../../runtime-api.js"; import type { CoreConfig } from "../types.js"; import { getMatrixScopedEnvVarNames, diff --git a/extensions/matrix/src/matrix/client/storage.test.ts b/extensions/matrix/src/matrix/client/storage.test.ts index 923f686df67..f0749dd5bef 100644 --- a/extensions/matrix/src/matrix/client/storage.test.ts +++ b/extensions/matrix/src/matrix/client/storage.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveMatrixAccountStorageRoot } from "openclaw/plugin-sdk/matrix"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { resolveMatrixAccountStorageRoot } from "../../../runtime-api.js"; import { setMatrixRuntime } from "../../runtime.js"; const createBackupArchiveMock = vi.hoisted(() => diff --git a/extensions/matrix/src/matrix/monitor/auto-join.test.ts b/extensions/matrix/src/matrix/monitor/auto-join.test.ts index 07dc83fe2a6..9aa8914777e 100644 --- a/extensions/matrix/src/matrix/monitor/auto-join.test.ts +++ b/extensions/matrix/src/matrix/monitor/auto-join.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime } from "../../../runtime-api.js"; import { setMatrixRuntime } from "../../runtime.js"; import type { MatrixConfig } from "../../types.js"; import { registerMatrixAutoJoin } from "./auto-join.js"; @@ -48,7 +48,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); const inviteHandler = getInviteHandler(); @@ -67,7 +67,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); expect(getInviteHandler()).toBeNull(); @@ -88,7 +88,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); const inviteHandler = getInviteHandler(); @@ -112,7 +112,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); const inviteHandler = getInviteHandler(); @@ -135,7 +135,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); const inviteHandler = getInviteHandler(); @@ -161,7 +161,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error, - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); const inviteHandler = getInviteHandler(); @@ -187,7 +187,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); const inviteHandler = getInviteHandler(); @@ -210,7 +210,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, + } as unknown as import("../../../runtime-api.js").RuntimeEnv, }); const inviteHandler = getInviteHandler(); diff --git a/extensions/matrix/src/matrix/monitor/config.test.ts b/extensions/matrix/src/matrix/monitor/config.test.ts index f2a146879f7..0b85ef811d5 100644 --- a/extensions/matrix/src/matrix/monitor/config.test.ts +++ b/extensions/matrix/src/matrix/monitor/config.test.ts @@ -1,5 +1,5 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix"; import { describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../../../runtime-api.js"; import type { CoreConfig, MatrixRoomConfig } from "../../types.js"; import { resolveMatrixMonitorConfig } from "./config.js"; diff --git a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts index 45c7484d3ca..8623d8541f2 100644 --- a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "../../../runtime-api.js"; import { setMatrixRuntime } from "../../runtime.js"; import type { MatrixClient } from "../sdk.js"; import type { MatrixRawEvent } from "./types.js"; @@ -53,11 +53,19 @@ function createHandlerHarness() { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn(), + markRunComplete: vi.fn(), }), resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined), dispatchReplyFromConfig: vi .fn() .mockResolvedValue({ queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }), + withReplyDispatcher: vi.fn().mockImplementation(async ({ run, onSettled }) => { + try { + return await run(); + } finally { + await onSettled?.(); + } + }), }, commands: { shouldHandleTextCommands: vi.fn().mockReturnValue(true), diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts index 3aa13a735a0..585ce851b0a 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts @@ -52,16 +52,28 @@ type MatrixHandlerTestHarnessOptions = { resolveEnvelopeFormatOptions?: () => Record; formatAgentEnvelope?: ({ body }: { body: string }) => string; finalizeInboundContext?: (ctx: unknown) => unknown; - createReplyDispatcherWithTyping?: () => { + createReplyDispatcherWithTyping?: (params?: { + onError?: (err: unknown, info: { kind: "tool" | "block" | "final" }) => void; + }) => { dispatcher: Record; replyOptions: Record; markDispatchIdle: () => void; + markRunComplete: () => void; }; resolveHumanDelayConfig?: () => undefined; dispatchReplyFromConfig?: () => Promise<{ queuedFinal: boolean; counts: { final: number; block: number; tool: number }; }>; + withReplyDispatcher?: (params: { + dispatcher: { + markComplete?: () => void; + waitForIdle?: () => Promise; + }; + run: () => Promise; + onSettled?: () => void | Promise; + }) => Promise; + inboundDeduper?: MatrixMonitorHandlerParams["inboundDeduper"]; shouldAckReaction?: () => boolean; enqueueSystemEvent?: (...args: unknown[]) => void; getRoomInfo?: MatrixMonitorHandlerParams["getRoomInfo"]; @@ -138,9 +150,32 @@ export function createMatrixHandlerTestHarness( dispatcher: {}, replyOptions: {}, markDispatchIdle: () => {}, + markRunComplete: () => {}, })), resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined), dispatchReplyFromConfig, + withReplyDispatcher: + options.withReplyDispatcher ?? + (async (params: { + dispatcher: { + markComplete?: () => void; + waitForIdle?: () => Promise; + }; + run: () => Promise; + onSettled?: () => void | Promise; + }) => { + const { dispatcher, run, onSettled } = params; + try { + return await run(); + } finally { + dispatcher.markComplete?.(); + try { + await dispatcher.waitForIdle?.(); + } finally { + await onSettled?.(); + } + } + }), }, reactions: { shouldAckReaction: options.shouldAckReaction ?? (() => false), @@ -179,6 +214,7 @@ export function createMatrixHandlerTestHarness( startupMs: options.startupMs ?? 0, startupGraceMs: options.startupGraceMs ?? 0, dropPreStartupMessages: options.dropPreStartupMessages ?? true, + inboundDeduper: options.inboundDeduper, directTracker: { isDirectMessage: async () => options.isDirectMessage ?? true, }, diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 289623631fa..8e842e38baa 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -720,12 +720,36 @@ describe("matrix monitor handler pairing account scope", () => { dispatcher: {}, replyOptions: {}, markDispatchIdle: () => {}, + markRunComplete: () => {}, }), resolveHumanDelayConfig: () => undefined, dispatchReplyFromConfig: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), + withReplyDispatcher: async ({ + dispatcher, + run, + onSettled, + }: { + dispatcher: { + markComplete?: () => void; + waitForIdle?: () => Promise; + }; + run: () => Promise; + onSettled?: () => void | Promise; + }) => { + try { + return await run(); + } finally { + dispatcher.markComplete?.(); + try { + await dispatcher.waitForIdle?.(); + } finally { + await onSettled?.(); + } + } + }, }, reactions: { shouldAckReaction: () => false, @@ -989,3 +1013,282 @@ describe("matrix monitor handler pairing account scope", () => { expect(resolveAgentRoute).toHaveBeenCalledTimes(1); }); }); + +describe("matrix monitor handler durable inbound dedupe", () => { + it("skips replayed inbound events before session recording", async () => { + const inboundDeduper = { + claimEvent: vi.fn(() => false), + commitEvent: vi.fn(async () => undefined), + releaseEvent: vi.fn(), + }; + const { handler, recordInboundSession } = createMatrixHandlerTestHarness({ + inboundDeduper, + dispatchReplyFromConfig: vi.fn(async () => ({ + queuedFinal: true, + counts: { final: 1, block: 0, tool: 0 }, + })), + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$dup", + body: "hello", + }), + ); + + expect(inboundDeduper.claimEvent).toHaveBeenCalledWith({ + roomId: "!room:example.org", + eventId: "$dup", + }); + expect(recordInboundSession).not.toHaveBeenCalled(); + expect(inboundDeduper.commitEvent).not.toHaveBeenCalled(); + expect(inboundDeduper.releaseEvent).not.toHaveBeenCalled(); + }); + + it("commits inbound events only after queued replies finish delivering", async () => { + const callOrder: string[] = []; + const inboundDeduper = { + claimEvent: vi.fn(() => { + callOrder.push("claim"); + return true; + }), + commitEvent: vi.fn(async () => { + callOrder.push("commit"); + }), + releaseEvent: vi.fn(() => { + callOrder.push("release"); + }), + }; + const recordInboundSession = vi.fn(async () => { + callOrder.push("record"); + }); + const dispatchReplyFromConfig = vi.fn(async () => { + callOrder.push("dispatch"); + return { + queuedFinal: true, + counts: { final: 1, block: 0, tool: 0 }, + }; + }); + const { handler } = createMatrixHandlerTestHarness({ + inboundDeduper, + recordInboundSession, + dispatchReplyFromConfig, + createReplyDispatcherWithTyping: () => ({ + dispatcher: { + markComplete: () => { + callOrder.push("mark-complete"); + }, + waitForIdle: async () => { + callOrder.push("wait-for-idle"); + }, + }, + replyOptions: {}, + markDispatchIdle: () => { + callOrder.push("dispatch-idle"); + }, + markRunComplete: () => { + callOrder.push("run-complete"); + }, + }), + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$commit-order", + body: "hello", + }), + ); + + expect(callOrder).toEqual([ + "claim", + "record", + "dispatch", + "run-complete", + "mark-complete", + "wait-for-idle", + "dispatch-idle", + "commit", + ]); + expect(inboundDeduper.releaseEvent).not.toHaveBeenCalled(); + }); + + it("releases a claimed event when reply dispatch fails before completion", async () => { + const inboundDeduper = { + claimEvent: vi.fn(() => true), + commitEvent: vi.fn(async () => undefined), + releaseEvent: vi.fn(), + }; + const runtime = { + error: vi.fn(), + }; + const { handler } = createMatrixHandlerTestHarness({ + inboundDeduper, + runtime: runtime as never, + recordInboundSession: vi.fn(async () => { + throw new Error("disk failed"); + }), + dispatchReplyFromConfig: vi.fn(async () => ({ + queuedFinal: true, + counts: { final: 1, block: 0, tool: 0 }, + })), + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$release-on-error", + body: "hello", + }), + ); + + expect(inboundDeduper.commitEvent).not.toHaveBeenCalled(); + expect(inboundDeduper.releaseEvent).toHaveBeenCalledWith({ + roomId: "!room:example.org", + eventId: "$release-on-error", + }); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("matrix handler failed")); + }); + + it("releases a claimed event when queued final delivery fails", async () => { + const inboundDeduper = { + claimEvent: vi.fn(() => true), + commitEvent: vi.fn(async () => undefined), + releaseEvent: vi.fn(), + }; + const runtime = { + error: vi.fn(), + }; + const { handler } = createMatrixHandlerTestHarness({ + inboundDeduper, + runtime: runtime as never, + dispatchReplyFromConfig: vi.fn(async () => ({ + queuedFinal: true, + counts: { final: 1, block: 0, tool: 0 }, + })), + createReplyDispatcherWithTyping: (params) => ({ + dispatcher: { + markComplete: () => {}, + waitForIdle: async () => { + params?.onError?.(new Error("send failed"), { kind: "final" }); + }, + }, + replyOptions: {}, + markDispatchIdle: () => {}, + markRunComplete: () => {}, + }), + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$release-on-final-delivery-error", + body: "hello", + }), + ); + + expect(inboundDeduper.commitEvent).not.toHaveBeenCalled(); + expect(inboundDeduper.releaseEvent).toHaveBeenCalledWith({ + roomId: "!room:example.org", + eventId: "$release-on-final-delivery-error", + }); + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("matrix final reply failed"), + ); + }); + + it.each(["tool", "block"] as const)( + "releases a claimed event when queued %s delivery fails and no final reply exists", + async (kind) => { + const inboundDeduper = { + claimEvent: vi.fn(() => true), + commitEvent: vi.fn(async () => undefined), + releaseEvent: vi.fn(), + }; + const runtime = { + error: vi.fn(), + }; + const { handler } = createMatrixHandlerTestHarness({ + inboundDeduper, + runtime: runtime as never, + dispatchReplyFromConfig: vi.fn(async () => ({ + queuedFinal: false, + counts: { + final: 0, + block: kind === "block" ? 1 : 0, + tool: kind === "tool" ? 1 : 0, + }, + })), + createReplyDispatcherWithTyping: (params) => ({ + dispatcher: { + markComplete: () => {}, + waitForIdle: async () => { + params?.onError?.(new Error("send failed"), { kind }); + }, + }, + replyOptions: {}, + markDispatchIdle: () => {}, + markRunComplete: () => {}, + }), + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: `$release-on-${kind}-delivery-error`, + body: "hello", + }), + ); + + expect(inboundDeduper.commitEvent).not.toHaveBeenCalled(); + expect(inboundDeduper.releaseEvent).toHaveBeenCalledWith({ + roomId: "!room:example.org", + eventId: `$release-on-${kind}-delivery-error`, + }); + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining(`matrix ${kind} reply failed`), + ); + }, + ); + + it("commits a claimed event when dispatch completes without a final reply", async () => { + const callOrder: string[] = []; + const inboundDeduper = { + claimEvent: vi.fn(() => { + callOrder.push("claim"); + return true; + }), + commitEvent: vi.fn(async () => { + callOrder.push("commit"); + }), + releaseEvent: vi.fn(() => { + callOrder.push("release"); + }), + }; + const { handler } = createMatrixHandlerTestHarness({ + inboundDeduper, + recordInboundSession: vi.fn(async () => { + callOrder.push("record"); + }), + dispatchReplyFromConfig: vi.fn(async () => { + callOrder.push("dispatch"); + return { + queuedFinal: false, + counts: { final: 0, block: 0, tool: 0 }, + }; + }), + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$no-final", + body: "hello", + }), + ); + + expect(callOrder).toEqual(["claim", "record", "dispatch", "commit"]); + expect(inboundDeduper.releaseEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts index 51f5a07bdd0..aea230f3afc 100644 --- a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; import { describe, expect, it, vi } from "vitest"; +import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "../../../runtime-api.js"; import { setMatrixRuntime } from "../../runtime.js"; import type { MatrixClient } from "../sdk.js"; import { createMatrixRoomMessageHandler } from "./handler.js"; diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index b7295009bcd..40c386e3820 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -30,6 +30,7 @@ import { } from "../send.js"; import { resolveMatrixMonitorAccessState } from "./access-state.js"; import { resolveMatrixAckReactionConfig } from "./ack-config.js"; +import type { MatrixInboundEventDeduper } from "./inbound-dedupe.js"; import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js"; import { downloadMatrixMedia } from "./media.js"; import { resolveMentions } from "./mentions.js"; @@ -72,6 +73,7 @@ export type MatrixMonitorHandlerParams = { startupMs: number; startupGraceMs: number; dropPreStartupMessages: boolean; + inboundDeduper?: Pick; directTracker: { isDirectMessage: (params: { roomId: string; @@ -163,6 +165,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam startupMs, startupGraceMs, dropPreStartupMessages, + inboundDeduper, directTracker, getRoomInfo, getMemberDisplayName, @@ -219,6 +222,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }; return async (roomId: string, event: MatrixRawEvent) => { + const eventId = typeof event.event_id === "string" ? event.event_id.trim() : ""; + let claimedInboundEvent = false; try { const eventType = event.type; if (eventType === EventType.RoomMessageEncrypted) { @@ -256,6 +261,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } const eventTs = event.origin_server_ts; const eventAge = event.unsigned?.age; + const commitInboundEventIfClaimed = async () => { + if (!claimedInboundEvent || !inboundDeduper || !eventId) { + return; + } + await inboundDeduper.commitEvent({ roomId, eventId }); + claimedInboundEvent = false; + }; if (dropPreStartupMessages) { if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) { return; @@ -293,6 +305,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } } + if (eventId && inboundDeduper) { + claimedInboundEvent = inboundDeduper.claimEvent({ roomId, eventId }); + if (!claimedInboundEvent) { + logVerboseMessage(`matrix: skip duplicate inbound event room=${roomId} id=${eventId}`); + return; + } + } const isDirectMessage = await directTracker.isDirectMessage({ roomId, @@ -302,6 +321,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const isRoom = !isDirectMessage; if (isRoom && groupPolicy === "disabled") { + await commitInboundEventIfClaimed(); return; } @@ -332,20 +352,24 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam logVerboseMessage( `matrix: drop configured bot sender=${senderId} (allowBots=false${isDirectMessage ? "" : `, ${roomMatchMeta}`})`, ); + await commitInboundEventIfClaimed(); return; } if (isRoom && roomConfig && !roomConfigInfo?.allowed) { logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); + await commitInboundEventIfClaimed(); return; } if (isRoom && groupPolicy === "allowlist") { if (!roomConfigInfo?.allowlistConfigured) { logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); + await commitInboundEventIfClaimed(); return; } if (!roomConfig) { logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); + await commitInboundEventIfClaimed(); return; } } @@ -378,6 +402,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam if (isDirectMessage) { if (!dmEnabled || dmPolicy === "disabled") { + await commitInboundEventIfClaimed(); return; } if (dmPolicy !== "open") { @@ -414,19 +439,23 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam accountId, }, ); + await commitInboundEventIfClaimed(); } catch (err) { logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); + return; } } else { logVerboseMessage( `matrix pairing reminder suppressed sender=${senderId} (cooldown)`, ); + await commitInboundEventIfClaimed(); } } if (isReactionEvent || dmPolicy !== "pairing") { logVerboseMessage( `matrix: blocked ${isReactionEvent ? "reaction" : "dm"} sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, ); + await commitInboundEventIfClaimed(); } return; } @@ -439,6 +468,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam roomUserMatch, )})`, ); + await commitInboundEventIfClaimed(); return; } if ( @@ -453,6 +483,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam groupAllowMatch, )})`, ); + await commitInboundEventIfClaimed(); return; } } @@ -475,6 +506,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam isDirectMessage, logVerboseMessage, }); + await commitInboundEventIfClaimed(); return; } @@ -491,6 +523,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam : undefined; const mediaUrl = contentUrl ?? contentFile?.url; if (!mentionPrecheckText && !mediaUrl && !isPollEvent) { + await commitInboundEventIfClaimed(); return; } @@ -509,6 +542,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam logVerboseMessage( `matrix: drop configured bot sender=${senderId} (allowBots=mentions, missing mention, ${roomMatchMeta})`, ); + await commitInboundEventIfClaimed(); return; } const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ @@ -534,6 +568,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam reason: "control command (unauthorized)", target: senderId, }); + await commitInboundEventIfClaimed(); return; } const shouldRequireMention = isRoom @@ -556,6 +591,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention; if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { logger.info("skipping room message", { roomId, reason: "no-mention" }); + await commitInboundEventIfClaimed(); return; } @@ -631,6 +667,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam mediaDownloadFailed, }); if (!bodyText) { + await commitInboundEventIfClaimed(); return; } const senderName = await getSenderName(); @@ -799,6 +836,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam accountId: route.accountId, }); const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); + let finalReplyDeliveryFailed = false; + let nonFinalReplyDeliveryFailed = false; const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ cfg, agentId: route.agentId, @@ -827,7 +866,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }); }, }); - const { dispatcher, replyOptions, markDispatchIdle } = + const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), @@ -847,32 +886,66 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }); }, onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => { + if (info.kind === "final") { + finalReplyDeliveryFailed = true; + } else { + nonFinalReplyDeliveryFailed = true; + } runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); }, onReplyStart: typingCallbacks.onReplyStart, onIdle: typingCallbacks.onIdle, }); - const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, + const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({ dispatcher, - replyOptions: { - ...replyOptions, - skillFilter: roomConfig?.skills, - onModelSelected, + onSettled: () => { + markDispatchIdle(); + }, + run: async () => { + try { + return await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + skillFilter: roomConfig?.skills, + onModelSelected, + }, + }); + } finally { + markRunComplete(); + } }, }); - markDispatchIdle(); + if (finalReplyDeliveryFailed) { + logVerboseMessage( + `matrix: final reply delivery failed room=${roomId} id=${messageId}; leaving event uncommitted`, + ); + return; + } + if (!queuedFinal && nonFinalReplyDeliveryFailed) { + logVerboseMessage( + `matrix: non-final reply delivery failed room=${roomId} id=${messageId}; leaving event uncommitted`, + ); + return; + } if (!queuedFinal) { + await commitInboundEventIfClaimed(); return; } const finalCount = counts.final; logVerboseMessage( `matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, ); + await commitInboundEventIfClaimed(); } catch (err) { runtime.error?.(`matrix handler failed: ${String(err)}`); + } finally { + if (claimedInboundEvent && inboundDeduper && eventId) { + inboundDeduper.releaseEvent({ roomId, eventId }); + } } }; } diff --git a/extensions/matrix/src/matrix/monitor/inbound-dedupe.test.ts b/extensions/matrix/src/matrix/monitor/inbound-dedupe.test.ts new file mode 100644 index 00000000000..e0ad423c1f1 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/inbound-dedupe.test.ts @@ -0,0 +1,146 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMatrixInboundEventDeduper } from "./inbound-dedupe.js"; + +describe("Matrix inbound event dedupe", () => { + const tempDirs: string[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function createStoragePath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-inbound-dedupe-")); + tempDirs.push(dir); + return path.join(dir, "inbound-dedupe.json"); + } + + const auth = { + accountId: "ops", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + deviceId: "DEVICE", + } as const; + + it("persists committed events across restarts", async () => { + const storagePath = createStoragePath(); + const first = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath, + }); + + expect(first.claimEvent({ roomId: "!room:example.org", eventId: "$event-1" })).toBe(true); + await first.commitEvent({ + roomId: "!room:example.org", + eventId: "$event-1", + }); + await first.stop(); + + const second = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath, + }); + expect(second.claimEvent({ roomId: "!room:example.org", eventId: "$event-1" })).toBe(false); + }); + + it("does not persist released pending claims", async () => { + const storagePath = createStoragePath(); + const first = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath, + }); + + expect(first.claimEvent({ roomId: "!room:example.org", eventId: "$event-2" })).toBe(true); + first.releaseEvent({ roomId: "!room:example.org", eventId: "$event-2" }); + await first.stop(); + + const second = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath, + }); + expect(second.claimEvent({ roomId: "!room:example.org", eventId: "$event-2" })).toBe(true); + }); + + it("prunes expired and overflowed entries on load", async () => { + const storagePath = createStoragePath(); + fs.writeFileSync( + storagePath, + JSON.stringify({ + version: 1, + entries: [ + { key: "!room:example.org|$old", ts: 10 }, + { key: "!room:example.org|$keep-1", ts: 90 }, + { key: "!room:example.org|$keep-2", ts: 95 }, + { key: "!room:example.org|$keep-3", ts: 100 }, + ], + }), + "utf8", + ); + + const deduper = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath, + ttlMs: 20, + maxEntries: 2, + nowMs: () => 100, + }); + + expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$old" })).toBe(true); + expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$keep-1" })).toBe(true); + expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$keep-2" })).toBe(false); + expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$keep-3" })).toBe(false); + }); + + it("retains replayed backlog events based on processing time", async () => { + const storagePath = createStoragePath(); + let now = 100; + const first = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath, + ttlMs: 20, + nowMs: () => now, + }); + + expect(first.claimEvent({ roomId: "!room:example.org", eventId: "$backlog" })).toBe(true); + await first.commitEvent({ + roomId: "!room:example.org", + eventId: "$backlog", + }); + await first.stop(); + + now = 110; + const second = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath, + ttlMs: 20, + nowMs: () => now, + }); + expect(second.claimEvent({ roomId: "!room:example.org", eventId: "$backlog" })).toBe(false); + }); + + it("treats stop persistence failures as best-effort cleanup", async () => { + const blockingPath = createStoragePath(); + fs.writeFileSync(blockingPath, "blocking file", "utf8"); + const deduper = await createMatrixInboundEventDeduper({ + auth: auth as never, + storagePath: path.join(blockingPath, "nested", "inbound-dedupe.json"), + }); + + expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$persist-fail" })).toBe( + true, + ); + await deduper.commitEvent({ + roomId: "!room:example.org", + eventId: "$persist-fail", + }); + + await expect(deduper.stop()).resolves.toBeUndefined(); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/inbound-dedupe.ts b/extensions/matrix/src/matrix/monitor/inbound-dedupe.ts new file mode 100644 index 00000000000..2e2b3b8461d --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/inbound-dedupe.ts @@ -0,0 +1,285 @@ +import path from "node:path"; +import { readJsonFileWithFallback, writeJsonFileAtomically } from "../../runtime-api.js"; +import { resolveMatrixStoragePaths } from "../client/storage.js"; +import type { MatrixAuth } from "../client/types.js"; +import { LogService } from "../sdk/logger.js"; + +const INBOUND_DEDUPE_FILENAME = "inbound-dedupe.json"; +const STORE_VERSION = 1; +const DEFAULT_MAX_ENTRIES = 20_000; +const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const PERSIST_DEBOUNCE_MS = 250; + +type StoredMatrixInboundDedupeEntry = { + key: string; + ts: number; +}; + +type StoredMatrixInboundDedupeState = { + version: number; + entries: StoredMatrixInboundDedupeEntry[]; +}; + +export type MatrixInboundEventDeduper = { + claimEvent: (params: { roomId: string; eventId: string }) => boolean; + commitEvent: (params: { roomId: string; eventId: string }) => Promise; + releaseEvent: (params: { roomId: string; eventId: string }) => void; + flush: () => Promise; + stop: () => Promise; +}; + +function createAsyncLock() { + let lock: Promise = Promise.resolve(); + return async function withLock(fn: () => Promise): Promise { + const previous = lock; + let release: (() => void) | undefined; + lock = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await fn(); + } finally { + release?.(); + } + }; +} + +function normalizeEventPart(value: string): string { + return value.trim(); +} + +function buildEventKey(params: { roomId: string; eventId: string }): string { + const roomId = normalizeEventPart(params.roomId); + const eventId = normalizeEventPart(params.eventId); + return roomId && eventId ? `${roomId}|${eventId}` : ""; +} + +function resolveInboundDedupeStatePath(params: { + auth: MatrixAuth; + env?: NodeJS.ProcessEnv; + stateDir?: string; +}): string { + const storagePaths = resolveMatrixStoragePaths({ + homeserver: params.auth.homeserver, + userId: params.auth.userId, + accessToken: params.auth.accessToken, + accountId: params.auth.accountId, + deviceId: params.auth.deviceId, + env: params.env, + stateDir: params.stateDir, + }); + return path.join(storagePaths.rootDir, INBOUND_DEDUPE_FILENAME); +} + +function normalizeTimestamp(raw: unknown): number | null { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return null; + } + return Math.max(0, Math.floor(raw)); +} + +function pruneSeenEvents(params: { + seen: Map; + ttlMs: number; + maxEntries: number; + nowMs: number; +}) { + const { seen, ttlMs, maxEntries, nowMs } = params; + if (ttlMs > 0) { + const cutoff = nowMs - ttlMs; + for (const [key, ts] of seen) { + if (ts < cutoff) { + seen.delete(key); + } + } + } + const max = Math.max(0, Math.floor(maxEntries)); + if (max <= 0) { + seen.clear(); + return; + } + while (seen.size > max) { + const oldestKey = seen.keys().next().value; + if (typeof oldestKey !== "string") { + break; + } + seen.delete(oldestKey); + } +} + +function toStoredState(params: { + seen: Map; + ttlMs: number; + maxEntries: number; + nowMs: number; +}): StoredMatrixInboundDedupeState { + pruneSeenEvents(params); + return { + version: STORE_VERSION, + entries: Array.from(params.seen.entries()).map(([key, ts]) => ({ key, ts })), + }; +} + +async function readStoredState( + storagePath: string, +): Promise { + const { value } = await readJsonFileWithFallback( + storagePath, + null, + ); + if (value?.version !== STORE_VERSION || !Array.isArray(value.entries)) { + return null; + } + return value; +} + +export async function createMatrixInboundEventDeduper(params: { + auth: MatrixAuth; + env?: NodeJS.ProcessEnv; + stateDir?: string; + storagePath?: string; + ttlMs?: number; + maxEntries?: number; + nowMs?: () => number; +}): Promise { + const nowMs = params.nowMs ?? (() => Date.now()); + const ttlMs = + typeof params.ttlMs === "number" && Number.isFinite(params.ttlMs) + ? Math.max(0, Math.floor(params.ttlMs)) + : DEFAULT_TTL_MS; + const maxEntries = + typeof params.maxEntries === "number" && Number.isFinite(params.maxEntries) + ? Math.max(0, Math.floor(params.maxEntries)) + : DEFAULT_MAX_ENTRIES; + const storagePath = + params.storagePath ?? + resolveInboundDedupeStatePath({ + auth: params.auth, + env: params.env, + stateDir: params.stateDir, + }); + + const seen = new Map(); + const pending = new Set(); + const persistLock = createAsyncLock(); + + try { + const stored = await readStoredState(storagePath); + for (const entry of stored?.entries ?? []) { + if (!entry || typeof entry.key !== "string") { + continue; + } + const key = entry.key.trim(); + const ts = normalizeTimestamp(entry.ts); + if (!key || ts === null) { + continue; + } + seen.set(key, ts); + } + pruneSeenEvents({ seen, ttlMs, maxEntries, nowMs: nowMs() }); + } catch (err) { + LogService.warn("MatrixInboundDedupe", "Failed loading Matrix inbound dedupe store:", err); + } + + let dirty = false; + let persistTimer: NodeJS.Timeout | null = null; + let persistPromise: Promise | null = null; + + const persist = async () => { + dirty = false; + const payload = toStoredState({ + seen, + ttlMs, + maxEntries, + nowMs: nowMs(), + }); + try { + await persistLock(async () => { + await writeJsonFileAtomically(storagePath, payload); + }); + } catch (err) { + dirty = true; + throw err; + } + }; + + const flush = async (): Promise => { + if (persistTimer) { + clearTimeout(persistTimer); + persistTimer = null; + } + while (dirty || persistPromise) { + if (dirty && !persistPromise) { + persistPromise = persist().finally(() => { + persistPromise = null; + }); + } + await persistPromise; + } + }; + + const schedulePersist = () => { + dirty = true; + if (persistTimer) { + return; + } + persistTimer = setTimeout(() => { + persistTimer = null; + void flush().catch((err) => { + LogService.warn( + "MatrixInboundDedupe", + "Failed persisting Matrix inbound dedupe store:", + err, + ); + }); + }, PERSIST_DEBOUNCE_MS); + persistTimer.unref?.(); + }; + + return { + claimEvent: ({ roomId, eventId }) => { + const key = buildEventKey({ roomId, eventId }); + if (!key) { + return true; + } + pruneSeenEvents({ seen, ttlMs, maxEntries, nowMs: nowMs() }); + if (seen.has(key) || pending.has(key)) { + return false; + } + pending.add(key); + return true; + }, + commitEvent: async ({ roomId, eventId }) => { + const key = buildEventKey({ roomId, eventId }); + if (!key) { + return; + } + pending.delete(key); + const ts = nowMs(); + seen.delete(key); + seen.set(key, ts); + pruneSeenEvents({ seen, ttlMs, maxEntries, nowMs: nowMs() }); + schedulePersist(); + }, + releaseEvent: ({ roomId, eventId }) => { + const key = buildEventKey({ roomId, eventId }); + if (!key) { + return; + } + pending.delete(key); + }, + flush, + stop: async () => { + try { + await flush(); + } catch (err) { + LogService.warn( + "MatrixInboundDedupe", + "Failed to flush Matrix inbound dedupe store during stop():", + err, + ); + } + }, + }; +} diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts index b7ddb8f9656..1e7db90d4df 100644 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ b/extensions/matrix/src/matrix/monitor/index.test.ts @@ -5,9 +5,18 @@ const hoisted = vi.hoisted(() => { const state = { startClientError: null as Error | null, }; + const inboundDeduper = { + claimEvent: vi.fn(() => true), + commitEvent: vi.fn(async () => undefined), + releaseEvent: vi.fn(), + flush: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + }; const client = { id: "matrix-client", hasPersistedSyncState: vi.fn(() => false), + stopSyncWithoutPersist: vi.fn(), + drainPendingDecryptions: vi.fn(async () => undefined), }; const createMatrixRoomMessageHandler = vi.fn(() => vi.fn()); const resolveTextChunkLimit = vi.fn< @@ -26,7 +35,9 @@ const hoisted = vi.hoisted(() => { callOrder, client, createMatrixRoomMessageHandler, + inboundDeduper, logger, + registeredOnRoomMessage: null as null | ((roomId: string, event: unknown) => Promise), releaseSharedClientInstance, resolveTextChunkLimit, setActiveMatrixClient, @@ -35,7 +46,7 @@ const hoisted = vi.hoisted(() => { }; }); -vi.mock("openclaw/plugin-sdk/matrix", () => ({ +vi.mock("../../runtime-api.js", () => ({ GROUP_POLICY_BLOCKED_LABEL: { room: "room", }, @@ -181,15 +192,22 @@ vi.mock("./direct.js", () => ({ })); vi.mock("./events.js", () => ({ - registerMatrixMonitorEvents: vi.fn(() => { - hoisted.callOrder.push("register-events"); - }), + registerMatrixMonitorEvents: vi.fn( + (params: { onRoomMessage: (roomId: string, event: unknown) => Promise }) => { + hoisted.callOrder.push("register-events"); + hoisted.registeredOnRoomMessage = params.onRoomMessage; + }, + ), })); vi.mock("./handler.js", () => ({ createMatrixRoomMessageHandler: hoisted.createMatrixRoomMessageHandler, })); +vi.mock("./inbound-dedupe.js", () => ({ + createMatrixInboundEventDeduper: vi.fn(async () => hoisted.inboundDeduper), +})); + vi.mock("./legacy-crypto-restore.js", () => ({ maybeRestoreLegacyMatrixBackup: vi.fn(), })); @@ -214,9 +232,17 @@ describe("monitorMatrixProvider", () => { hoisted.state.startClientError = null; hoisted.resolveTextChunkLimit.mockReset().mockReturnValue(4000); hoisted.releaseSharedClientInstance.mockReset().mockResolvedValue(true); + hoisted.registeredOnRoomMessage = null; hoisted.setActiveMatrixClient.mockReset(); hoisted.stopThreadBindingManager.mockReset(); hoisted.client.hasPersistedSyncState.mockReset().mockReturnValue(false); + hoisted.client.stopSyncWithoutPersist.mockReset(); + hoisted.client.drainPendingDecryptions.mockReset().mockResolvedValue(undefined); + hoisted.inboundDeduper.claimEvent.mockReset().mockReturnValue(true); + hoisted.inboundDeduper.commitEvent.mockReset().mockResolvedValue(undefined); + hoisted.inboundDeduper.releaseEvent.mockReset(); + hoisted.inboundDeduper.flush.mockReset().mockResolvedValue(undefined); + hoisted.inboundDeduper.stop.mockReset().mockResolvedValue(undefined); hoisted.createMatrixRoomMessageHandler.mockReset().mockReturnValue(vi.fn()); Object.values(hoisted.logger).forEach((mock) => mock.mockReset()); }); @@ -278,4 +304,77 @@ describe("monitorMatrixProvider", () => { }), ); }); + + it("stops sync, drains decryptions, then waits for in-flight handlers before persisting", async () => { + const { monitorMatrixProvider } = await import("./index.js"); + const abortController = new AbortController(); + let resolveHandler: (() => void) | null = null; + + hoisted.createMatrixRoomMessageHandler.mockReturnValue( + vi.fn(() => { + hoisted.callOrder.push("handler-start"); + return new Promise((resolve) => { + resolveHandler = () => { + hoisted.callOrder.push("handler-done"); + resolve(); + }; + }); + }), + ); + hoisted.client.stopSyncWithoutPersist.mockImplementation(() => { + hoisted.callOrder.push("pause-client"); + }); + hoisted.client.drainPendingDecryptions.mockImplementation(async () => { + hoisted.callOrder.push("drain-decrypts"); + }); + hoisted.stopThreadBindingManager.mockImplementation(() => { + hoisted.callOrder.push("stop-manager"); + }); + hoisted.releaseSharedClientInstance.mockImplementation(async () => { + hoisted.callOrder.push("release-client"); + return true; + }); + hoisted.inboundDeduper.stop.mockImplementation(async () => { + hoisted.callOrder.push("stop-deduper"); + }); + + const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal }); + await vi.waitFor(() => { + expect(hoisted.callOrder).toContain("start-client"); + }); + const onRoomMessage = hoisted.registeredOnRoomMessage; + if (!onRoomMessage) { + throw new Error("expected room message handler to be registered"); + } + + const roomMessagePromise = onRoomMessage("!room:example.org", { event_id: "$event" }); + abortController.abort(); + await vi.waitFor(() => { + expect(hoisted.callOrder).toContain("pause-client"); + }); + expect(hoisted.callOrder).not.toContain("stop-deduper"); + + if (resolveHandler === null) { + throw new Error("expected in-flight handler to be pending"); + } + (resolveHandler as () => void)(); + await roomMessagePromise; + await monitorPromise; + + expect(hoisted.callOrder.indexOf("pause-client")).toBeLessThan( + hoisted.callOrder.indexOf("drain-decrypts"), + ); + expect(hoisted.callOrder.indexOf("drain-decrypts")).toBeLessThan( + hoisted.callOrder.indexOf("handler-done"), + ); + expect(hoisted.callOrder.indexOf("handler-done")).toBeLessThan( + hoisted.callOrder.indexOf("stop-manager"), + ); + expect(hoisted.callOrder.indexOf("stop-manager")).toBeLessThan( + hoisted.callOrder.indexOf("stop-deduper"), + ); + expect(hoisted.callOrder.indexOf("stop-deduper")).toBeLessThan( + hoisted.callOrder.indexOf("release-client"), + ); + }); }); diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 62ea41b0169..71efc539424 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -25,6 +25,7 @@ import { resolveMatrixMonitorConfig } from "./config.js"; import { createDirectRoomTracker } from "./direct.js"; import { registerMatrixMonitorEvents } from "./events.js"; import { createMatrixRoomMessageHandler } from "./handler.js"; +import { createMatrixInboundEventDeduper } from "./inbound-dedupe.js"; import { createMatrixRoomInfoResolver } from "./room-info.js"; import { runMatrixStartupMaintenance } from "./startup.js"; @@ -136,15 +137,29 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi setActiveMatrixClient(client, auth.accountId); let cleanedUp = false; let threadBindingManager: { accountId: string; stop: () => void } | null = null; + const inboundDeduper = await createMatrixInboundEventDeduper({ + auth, + env: process.env, + }); + const inFlightRoomMessages = new Set>(); + const waitForInFlightRoomMessages = async () => { + while (inFlightRoomMessages.size > 0) { + await Promise.allSettled(Array.from(inFlightRoomMessages)); + } + }; const cleanup = async () => { if (cleanedUp) { return; } cleanedUp = true; try { + client.stopSyncWithoutPersist(); + await client.drainPendingDecryptions("matrix monitor shutdown"); + await waitForInFlightRoomMessages(); threadBindingManager?.stop(); - } finally { + await inboundDeduper.stop(); await releaseSharedClientInstance(client, "persist"); + } finally { setActiveMatrixClient(null, auth.accountId); } }; @@ -219,11 +234,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi startupMs, startupGraceMs, dropPreStartupMessages, + inboundDeduper, directTracker, getRoomInfo, getMemberDisplayName, needsRoomAliasesForConfig, }); + const trackRoomMessage = (roomId: string, event: Parameters[1]) => { + const task = Promise.resolve(handleRoomMessage(roomId, event)).finally(() => { + inFlightRoomMessages.delete(task); + }); + inFlightRoomMessages.add(task); + return task; + }; try { threadBindingManager = await createMatrixThreadBindingManager({ @@ -249,7 +272,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi warnedCryptoMissingRooms, logger, formatNativeDependencyHint: core.system.formatNativeDependencyHint, - onRoomMessage: handleRoomMessage, + onRoomMessage: trackRoomMessage, }); // Register Matrix thread bindings before the client starts syncing so threaded diff --git a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts index 887dd25624a..68e81a48e41 100644 --- a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts +++ b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveMatrixAccountStorageRoot } from "openclaw/plugin-sdk/matrix"; import { describe, expect, it, vi } from "vitest"; import { withTempHome } from "../../../../../test/helpers/temp-home.js"; +import { resolveMatrixAccountStorageRoot } from "../../../runtime-api.js"; import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js"; function createBackupStatus() { diff --git a/extensions/matrix/src/matrix/monitor/media.test.ts b/extensions/matrix/src/matrix/monitor/media.test.ts index 19ee48cb57e..73abd2feb80 100644 --- a/extensions/matrix/src/matrix/monitor/media.test.ts +++ b/extensions/matrix/src/matrix/monitor/media.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime } from "../../../runtime-api.js"; import { setMatrixRuntime } from "../../runtime.js"; import { downloadMatrixMedia } from "./media.js"; diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts index 33ed0bba226..92146fa4901 100644 --- a/extensions/matrix/src/matrix/monitor/replies.test.ts +++ b/extensions/matrix/src/matrix/monitor/replies.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime, RuntimeEnv } from "../../../runtime-api.js"; import type { MatrixClient } from "../sdk.js"; const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" })); diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 8b7330294e6..dd84a7f6eb2 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -684,6 +684,52 @@ describe("MatrixClient event bridge", () => { expect(delivered).toEqual(["m.room.message"]); }); + it("can drain pending decrypt retries after sync stops", async () => { + vi.useFakeTimers(); + const client = new MatrixClient("https://matrix.example.org", "token"); + const delivered: string[] = []; + + client.on("room.message", (_roomId, event) => { + delivered.push(event.type); + }); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + decryptionFailure: true, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + + matrixJsClient.decryptEventIfNeeded = vi.fn(async () => { + encrypted.emit("decrypted", decrypted); + }); + + await client.start(); + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", encrypted, new Error("missing room key")); + + client.stopSyncWithoutPersist(); + await client.drainPendingDecryptions("test shutdown"); + + expect(matrixJsClient.stopClient).toHaveBeenCalledTimes(1); + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + expect(delivered).toEqual(["m.room.message"]); + }); + it("retries failed decryptions immediately on crypto key update signals", async () => { vi.useFakeTimers(); const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index f394974106a..4fb0b53389c 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -365,11 +365,21 @@ export class MatrixClient { await this.startSyncSession({ bootstrapCrypto: false }); } - stop(): void { + stopSyncWithoutPersist(): void { if (this.idbPersistTimer) { clearInterval(this.idbPersistTimer); this.idbPersistTimer = null; } + this.client.stopClient(); + this.started = false; + } + + async drainPendingDecryptions(reason = "matrix client shutdown"): Promise { + await this.decryptBridge.drainPendingDecryptions(reason); + } + + stop(): void { + this.stopSyncWithoutPersist(); this.decryptBridge.stop(); // Final persist on shutdown this.syncStore?.markCleanShutdown(); @@ -380,8 +390,6 @@ export class MatrixClient { }).catch(noop), this.syncStore?.flush().catch(noop), ]).then(() => undefined); - this.client.stopClient(); - this.started = false; } async stopAndPersist(): Promise { diff --git a/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts index 1df9e8748bd..1ca35993e91 100644 --- a/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts +++ b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts @@ -51,6 +51,8 @@ export class MatrixDecryptBridge { private readonly decryptedMessageDedupe = new Map(); private readonly decryptRetries = new Map(); private readonly failedDecryptionsNotified = new Set(); + private activeRetryRuns = 0; + private readonly retryIdleResolvers = new Set<() => void>(); private cryptoRetrySignalsBound = false; constructor( @@ -139,6 +141,22 @@ export class MatrixDecryptBridge { } } + async drainPendingDecryptions(reason: string): Promise { + for (let attempts = 0; attempts < MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS; attempts += 1) { + if (this.decryptRetries.size === 0) { + return; + } + this.retryPendingNow(reason); + await this.waitForActiveRetryRunsToFinish(); + const hasPendingRetryTimers = Array.from(this.decryptRetries.values()).some( + (state) => state.timer || state.inFlight, + ); + if (!hasPendingRetryTimers) { + return; + } + } + } + private handleEncryptedEventDecrypted(params: { roomId: string; encryptedEvent: MatrixEvent; @@ -246,9 +264,12 @@ export class MatrixDecryptBridge { state.inFlight = true; state.timer = null; + this.activeRetryRuns += 1; const canDecrypt = typeof this.deps.client.decryptEventIfNeeded === "function"; if (!canDecrypt) { this.clearDecryptRetry(retryKey); + this.activeRetryRuns = Math.max(0, this.activeRetryRuns - 1); + this.resolveRetryIdleIfNeeded(); return; } @@ -260,8 +281,13 @@ export class MatrixDecryptBridge { // Retry with backoff until we hit the configured retry cap. } finally { state.inFlight = false; + this.activeRetryRuns = Math.max(0, this.activeRetryRuns - 1); + this.resolveRetryIdleIfNeeded(); } + if (this.decryptRetries.get(retryKey) !== state) { + return; + } if (isDecryptionFailure(state.event)) { this.scheduleDecryptRetry(state); return; @@ -304,4 +330,27 @@ export class MatrixDecryptBridge { this.decryptedMessageDedupe.delete(oldest); } } + + private async waitForActiveRetryRunsToFinish(): Promise { + if (this.activeRetryRuns === 0) { + return; + } + await new Promise((resolve) => { + this.retryIdleResolvers.add(resolve); + if (this.activeRetryRuns === 0) { + this.retryIdleResolvers.delete(resolve); + resolve(); + } + }); + } + + private resolveRetryIdleIfNeeded(): void { + if (this.activeRetryRuns !== 0) { + return; + } + for (const resolve of this.retryIdleResolvers) { + resolve(); + } + this.retryIdleResolvers.clear(); + } } diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 5b0f9ff8a07..20e5ba8fd67 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime } from "../../runtime-api.js"; import { setMatrixRuntime } from "../runtime.js"; const loadWebMediaMock = vi.fn().mockResolvedValue({ diff --git a/extensions/matrix/src/matrix/thread-bindings-shared.ts b/extensions/matrix/src/matrix/thread-bindings-shared.ts index 7b5adb5eeda..6c63a731490 100644 --- a/extensions/matrix/src/matrix/thread-bindings-shared.ts +++ b/extensions/matrix/src/matrix/thread-bindings-shared.ts @@ -1,8 +1,8 @@ -import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/channel-runtime"; import type { BindingTargetKind, SessionBindingRecord, -} from "openclaw/plugin-sdk/conversation-runtime"; +} from "openclaw/plugin-sdk/thread-bindings-runtime"; +import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/thread-bindings-runtime"; export type MatrixThreadBindingTargetKind = "subagent" | "acp"; diff --git a/extensions/matrix/src/matrix/thread-bindings.test.ts b/extensions/matrix/src/matrix/thread-bindings.test.ts index 2b447447c81..be193a920a1 100644 --- a/extensions/matrix/src/matrix/thread-bindings.test.ts +++ b/extensions/matrix/src/matrix/thread-bindings.test.ts @@ -1,12 +1,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getSessionBindingService, __testing, } from "../../../../src/infra/outbound/session-binding-service.js"; +import type { PluginRuntime } from "../../runtime-api.js"; import { setMatrixRuntime } from "../runtime.js"; import { resolveMatrixStoragePaths } from "./client/storage.js"; import { @@ -16,31 +16,14 @@ import { setMatrixThreadBindingMaxAgeBySessionKey, } from "./thread-bindings.js"; -const pluginSdkActual = vi.hoisted(() => ({ - writeJsonFileAtomically: null as null | ((filePath: string, value: unknown) => Promise), -})); - const sendMessageMatrixMock = vi.hoisted(() => vi.fn(async (_to: string, _message: string, opts?: { threadId?: string }) => ({ messageId: opts?.threadId ? "$reply" : "$root", roomId: "!room:example", })), ); -const writeJsonFileAtomicallyMock = vi.hoisted(() => - vi.fn<(filePath: string, value: unknown) => Promise>(), -); - -vi.mock("openclaw/plugin-sdk/matrix", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/matrix", - ); - pluginSdkActual.writeJsonFileAtomically = actual.writeJsonFileAtomically; - return { - ...actual, - writeJsonFileAtomically: (filePath: string, value: unknown) => - writeJsonFileAtomicallyMock(filePath, value), - }; -}); +const actualRename = fs.rename.bind(fs); +const renameMock = vi.spyOn(fs, "rename"); vi.mock("./send.js", async () => { const actual = await vi.importActual("./send.js"); @@ -83,10 +66,8 @@ describe("matrix thread bindings", () => { __testing.resetSessionBindingAdaptersForTests(); resetMatrixThreadBindingsForTests(); sendMessageMatrixMock.mockClear(); - writeJsonFileAtomicallyMock.mockReset(); - writeJsonFileAtomicallyMock.mockImplementation(async (filePath: string, value: unknown) => { - await pluginSdkActual.writeJsonFileAtomically?.(filePath, value); - }); + renameMock.mockReset(); + renameMock.mockImplementation(actualRename); setMatrixRuntime({ state: { resolveStateDir: () => stateDir, @@ -217,7 +198,7 @@ describe("matrix thread bindings", () => { } }); - it("persists a batch of expired bindings once per sweep", async () => { + it("persists expired bindings after a sweep", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z")); try { @@ -252,12 +233,8 @@ describe("matrix thread bindings", () => { placement: "current", }); - writeJsonFileAtomicallyMock.mockClear(); await vi.advanceTimersByTimeAsync(61_000); - - await vi.waitFor(() => { - expect(writeJsonFileAtomicallyMock).toHaveBeenCalledTimes(1); - }); + await Promise.resolve(); await vi.waitFor(async () => { const persistedRaw = await fs.readFile(resolveBindingsFilePath(), "utf-8"); @@ -297,13 +274,23 @@ describe("matrix thread bindings", () => { placement: "current", }); - writeJsonFileAtomicallyMock.mockClear(); - writeJsonFileAtomicallyMock.mockRejectedValueOnce(new Error("disk full")); + renameMock.mockRejectedValueOnce(new Error("disk full")); await vi.advanceTimersByTimeAsync(61_000); + await Promise.resolve(); + + await vi.waitFor(() => { + expect( + logVerboseMessage.mock.calls.some( + ([message]) => + typeof message === "string" && + message.includes("failed auto-unbinding expired bindings"), + ), + ).toBe(true); + }); await vi.waitFor(() => { expect(logVerboseMessage).toHaveBeenCalledWith( - expect.stringContaining("failed auto-unbinding expired bindings"), + expect.stringContaining("matrix: auto-unbinding $thread due to idle-expired"), ); }); diff --git a/extensions/matrix/src/onboarding.resolve.test.ts b/extensions/matrix/src/onboarding.resolve.test.ts index f1d610aa5d4..270343b7509 100644 --- a/extensions/matrix/src/onboarding.resolve.test.ts +++ b/extensions/matrix/src/onboarding.resolve.test.ts @@ -1,5 +1,5 @@ -import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/matrix"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv, WizardPrompter } from "../runtime-api.js"; import type { CoreConfig } from "./types.js"; const resolveMatrixTargetsMock = vi.hoisted(() => diff --git a/extensions/matrix/src/onboarding.test.ts b/extensions/matrix/src/onboarding.test.ts index cb5fd1ef445..b27dbf8189f 100644 --- a/extensions/matrix/src/onboarding.test.ts +++ b/extensions/matrix/src/onboarding.test.ts @@ -1,5 +1,5 @@ -import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/matrix"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv, WizardPrompter } from "../runtime-api.js"; import { matrixOnboardingAdapter } from "./onboarding.js"; import { setMatrixRuntime } from "./runtime.js"; import type { CoreConfig } from "./types.js"; diff --git a/extensions/matrix/src/outbound.test.ts b/extensions/matrix/src/outbound.test.ts index 8f695efec3a..29de2346868 100644 --- a/extensions/matrix/src/outbound.test.ts +++ b/extensions/matrix/src/outbound.test.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../runtime-api.js"; const mocks = vi.hoisted(() => ({ sendMessageMatrix: vi.fn(), diff --git a/extensions/matrix/src/resolve-targets.test.ts b/extensions/matrix/src/resolve-targets.test.ts index 801d61f71f5..3f0eb8dfefe 100644 --- a/extensions/matrix/src/resolve-targets.test.ts +++ b/extensions/matrix/src/resolve-targets.test.ts @@ -1,5 +1,5 @@ -import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/matrix"; import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ChannelDirectoryEntry } from "../runtime-api.js"; import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; import { resolveMatrixTargets } from "./resolve-targets.js"; diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index b23758626c0..79a283ac39a 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -1,4 +1,4 @@ -export * from "openclaw/plugin-sdk/matrix"; +export * from "../../../src/plugin-sdk/matrix.js"; export { assertHttpUrlTargetsPrivateNetwork, closeDispatcher, @@ -8,6 +8,12 @@ export { type LookupFn, type SsrFPolicy, } from "openclaw/plugin-sdk/infra-runtime"; +export { + dispatchReplyFromConfigWithSettledDispatcher, + ensureConfiguredAcpBindingReady, + maybeCreateMatrixMigrationSnapshot, + resolveConfiguredAcpBindingRecord, +} from "openclaw/plugin-sdk/matrix-runtime-heavy"; // Keep auth-precedence available internally without re-exporting helper-api // twice through both plugin-sdk/matrix and ../runtime-api.js. export * from "./auth-precedence.js"; diff --git a/extensions/mattermost/runtime-api.ts b/extensions/mattermost/runtime-api.ts index d4e591c8c1e..2bc65439262 100644 --- a/extensions/mattermost/runtime-api.ts +++ b/extensions/mattermost/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Mattermost extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/mattermost"; +export * from "../../src/plugin-sdk/mattermost.js"; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 94c5bbff092..476c2c2d19e 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -1,17 +1,19 @@ import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; +import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-actions"; import { createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, + ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-contract"; +import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel-pairing"; import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; -import { - createAttachedChannelResultAdapter, - createChannelDirectoryAdapter, - createLoggedPairingApprovalNotifier, - createMessageToolButtonsSchema, - createScopedAccountReplyToModeResolver, - type ChannelMessageToolDiscovery, -} from "openclaw/plugin-sdk/channel-runtime"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; +import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; +import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import { MattermostConfigSchema } from "./config-schema.js"; import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; @@ -39,8 +41,6 @@ import { DEFAULT_ACCOUNT_ID, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, - type ChannelMessageActionAdapter, - type ChannelMessageActionName, type ChannelPlugin, } from "./runtime-api.js"; import { getMattermostRuntime } from "./runtime.js"; diff --git a/extensions/mattermost/src/session-route.ts b/extensions/mattermost/src/session-route.ts index 14352708986..39f12e37127 100644 --- a/extensions/mattermost/src/session-route.ts +++ b/extensions/mattermost/src/session-route.ts @@ -1,11 +1,11 @@ import { buildChannelOutboundSessionRoute, - normalizeOutboundThreadId, resolveThreadSessionKeys, stripChannelTargetPrefix, stripTargetKindPrefix, type ChannelOutboundSessionRouteParams, } from "openclaw/plugin-sdk/core"; +import { normalizeOutboundThreadId } from "openclaw/plugin-sdk/routing"; export function resolveMattermostOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) { let trimmed = stripChannelTargetPrefix(params.target, "mattermost"); diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts index 36954819fd5..14576f4f5d4 100644 --- a/extensions/mattermost/src/setup-core.ts +++ b/extensions/mattermost/src/setup-core.ts @@ -1,4 +1,4 @@ -import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup"; import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; import { diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 7dfd9816264..aca00927171 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -10,7 +10,7 @@ import { ensureAuthProfileStore, listProfilesForProvider, } from "openclaw/plugin-sdk/provider-auth"; -import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; import { fetchMinimaxUsage } from "openclaw/plugin-sdk/provider-usage"; import { minimaxMediaUnderstandingProvider, diff --git a/extensions/minimax/oauth.ts b/extensions/minimax/oauth.ts index 20296b2a710..818b29b0372 100644 --- a/extensions/minimax/oauth.ts +++ b/extensions/minimax/oauth.ts @@ -1,8 +1,5 @@ import { randomBytes, randomUUID } from "node:crypto"; -import { - generatePkceVerifierChallenge, - toFormUrlEncoded, -} from "openclaw/plugin-sdk/provider-oauth"; +import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth"; export type MiniMaxRegion = "cn" | "global"; diff --git a/extensions/msteams/runtime-api.ts b/extensions/msteams/runtime-api.ts index d32cb7b65d5..e2b75780399 100644 --- a/extensions/msteams/runtime-api.ts +++ b/extensions/msteams/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Microsoft Teams extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/msteams"; +export * from "../../src/plugin-sdk/msteams.js"; diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index dc328e46ffc..8a4e66fab9c 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,22 +1,24 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; +import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-actions"; import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; +import type { + ChannelMessageActionAdapter, + ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-contract"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-pairing"; import { createAllowlistProviderGroupPolicyWarningCollector, projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { createChannelDirectoryAdapter, - createMessageToolCardSchema, - createPairingPrefixStripper, createRuntimeDirectoryLiveAdapter, - createRuntimeOutboundDelegates, - createTextPairingAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { - ChannelMessageActionAdapter, - ChannelMessageToolDiscovery, -} from "openclaw/plugin-sdk/channel-runtime"; -import { listDirectoryEntriesFromSources } from "openclaw/plugin-sdk/directory-runtime"; + listDirectoryEntriesFromSources, +} from "openclaw/plugin-sdk/directory-runtime"; +import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/infra-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionName, ChannelPlugin, OpenClawConfig } from "../runtime-api.js"; import { diff --git a/extensions/msteams/src/graph-upload.test.ts b/extensions/msteams/src/graph-upload.test.ts index 43a66e95c3f..45c736e2e1d 100644 --- a/extensions/msteams/src/graph-upload.test.ts +++ b/extensions/msteams/src/graph-upload.test.ts @@ -141,7 +141,7 @@ describe("resolveGraphChatId", () => { }), ); // Should filter by user AAD object ID - const callUrl = (fetchFn.mock.calls[0] as unknown as [string, unknown])[0]; + const callUrl = (fetchFn.mock.calls[0] as unknown[])[0]; expect(callUrl).toContain("user-aad-object-id-123"); expect(result).toBe("19:dm-chat-id@unq.gbl.spaces"); }); diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index cf482825ed2..0e34f637736 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -1,5 +1,5 @@ -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; import type { ChannelOutboundAdapter } from "../runtime-api.js"; import { createMSTeamsPollStoreFs } from "./polls.js"; import { getMSTeamsRuntime } from "./runtime.js"; diff --git a/extensions/msteams/src/resolve-allowlist.ts b/extensions/msteams/src/resolve-allowlist.ts index 3e28cf8a8cb..a5145bebf0f 100644 --- a/extensions/msteams/src/resolve-allowlist.ts +++ b/extensions/msteams/src/resolve-allowlist.ts @@ -1,4 +1,4 @@ -import { mapAllowlistResolutionInputs } from "openclaw/plugin-sdk/allowlist-resolution"; +import { mapAllowlistResolutionInputs } from "openclaw/plugin-sdk/allow-from"; import { searchGraphUsers } from "./graph-users.js"; import { listChannelsForTeam, diff --git a/extensions/nextcloud-talk/runtime-api.ts b/extensions/nextcloud-talk/runtime-api.ts index b2093a7a057..80bc1b1dc7b 100644 --- a/extensions/nextcloud-talk/runtime-api.ts +++ b/extensions/nextcloud-talk/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Nextcloud Talk extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/nextcloud-talk"; +export * from "../../src/plugin-sdk/nextcloud-talk.js"; diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index ff316e3a533..880be995ab8 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -4,12 +4,12 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; -import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { - createAttachedChannelResultAdapter, createLoggedPairingApprovalNotifier, createPairingPrefixStripper, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-pairing"; +import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared"; import { buildBaseChannelStatusSummary, diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 6aaf7aafbe8..1059cd0a63a 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -1,5 +1,4 @@ -import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts index 776a9a4fe3e..4aa27c91009 100644 --- a/extensions/nextcloud-talk/src/setup-surface.ts +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -1,7 +1,7 @@ -import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; +import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input"; import { setSetupChannelEnabled } from "openclaw/plugin-sdk/setup"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "openclaw/plugin-sdk/setup"; diff --git a/extensions/nostr/runtime-api.ts b/extensions/nostr/runtime-api.ts index 29825771891..602b0ac81b7 100644 --- a/extensions/nostr/runtime-api.ts +++ b/extensions/nostr/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Nostr extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/nostr"; +export * from "../../src/plugin-sdk/nostr.js"; diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts index 9c7a1512624..bdcb2ca31bf 100644 --- a/extensions/nostr/src/setup-surface.ts +++ b/extensions/nostr/src/setup-surface.ts @@ -1,4 +1,4 @@ -import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts new file mode 100644 index 00000000000..d1cef565af1 --- /dev/null +++ b/extensions/openai/index.test.ts @@ -0,0 +1,397 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import OpenAI from "openai"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { loadConfig } from "../../src/config/config.js"; +import { encodePngRgba, fillPixel } from "../../src/media/png-encode.js"; +import type { ResolvedTtsConfig } from "../../src/tts/tts.js"; +import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; +import plugin from "./index.js"; + +const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? ""; +const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_OPENAI_PLUGIN_MODEL?.trim() || "gpt-5.4-nano"; +const LIVE_IMAGE_MODEL = process.env.OPENCLAW_LIVE_OPENAI_IMAGE_MODEL?.trim() || "gpt-image-1"; +const LIVE_VISION_MODEL = process.env.OPENCLAW_LIVE_OPENAI_VISION_MODEL?.trim() || "gpt-4.1-mini"; +const liveEnabled = OPENAI_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1"; +const describeLive = liveEnabled ? describe : describe.skip; +const EMPTY_AUTH_STORE = { version: 1, profiles: {} } as const; + +function createTemplateModel(modelId: string) { + switch (modelId) { + case "gpt-5.4": + return { + id: "gpt-5.2", + name: "GPT-5.2", + provider: "openai", + api: "openai-completions", + 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, + }; + case "gpt-5.4-mini": + return { + id: "gpt-5-mini", + name: "GPT-5 mini", + provider: "openai", + api: "openai-completions", + 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, + }; + case "gpt-5.4-nano": + return { + id: "gpt-5-nano", + name: "GPT-5 nano", + provider: "openai", + api: "openai-completions", + 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, + }; + default: + throw new Error(`Unsupported live OpenAI plugin model: ${modelId}`); + } +} + +function registerOpenAIPlugin() { + const providers: unknown[] = []; + const speechProviders: unknown[] = []; + const mediaProviders: unknown[] = []; + const imageProviders: unknown[] = []; + + plugin.register( + createTestPluginApi({ + id: "openai", + name: "OpenAI Provider", + source: "test", + config: {}, + runtime: {} as never, + registerProvider: (provider) => { + providers.push(provider); + }, + registerSpeechProvider: (provider) => { + speechProviders.push(provider); + }, + registerMediaUnderstandingProvider: (provider) => { + mediaProviders.push(provider); + }, + registerImageGenerationProvider: (provider) => { + imageProviders.push(provider); + }, + }), + ); + + return { providers, speechProviders, mediaProviders, imageProviders }; +} + +function createReferencePng(): Buffer { + const width = 96; + const height = 96; + 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, 225, 242, 255, 255); + } + } + + for (let y = 24; y < 72; y += 1) { + for (let x = 24; x < 72; x += 1) { + fillPixel(buf, x, y, width, 255, 153, 51, 255); + } + } + + return encodePngRgba(buf, width, height); +} + +function createLiveConfig(): OpenClawConfig { + const cfg = loadConfig(); + return { + ...cfg, + models: { + ...cfg.models, + providers: { + ...cfg.models?.providers, + openai: { + ...cfg.models?.providers?.openai, + apiKey: OPENAI_API_KEY, + baseUrl: "https://api.openai.com/v1", + }, + }, + }, + } as OpenClawConfig; +} + +function createLiveTtsConfig(): ResolvedTtsConfig { + return { + auto: "off", + mode: "final", + provider: "openai", + providerSource: "config", + modelOverrides: { + enabled: true, + allowText: true, + allowProvider: true, + allowVoice: true, + allowModelId: true, + allowVoiceSettings: true, + allowNormalization: true, + allowSeed: true, + }, + elevenlabs: { + baseUrl: "https://api.elevenlabs.io", + voiceId: "", + modelId: "eleven_multilingual_v2", + voiceSettings: { + stability: 0.5, + similarityBoost: 0.75, + style: 0, + useSpeakerBoost: true, + speed: 1, + }, + }, + openai: { + apiKey: OPENAI_API_KEY, + baseUrl: "https://api.openai.com/v1", + model: "gpt-4o-mini-tts", + voice: "alloy", + }, + edge: { + enabled: false, + voice: "en-US-AriaNeural", + lang: "en-US", + outputFormat: "audio-24khz-48kbitrate-mono-mp3", + outputFormatConfigured: false, + saveSubtitles: false, + }, + maxTextLength: 4_000, + timeoutMs: 30_000, + }; +} + +async function createTempAgentDir(): Promise { + return await fs.mkdtemp(path.join(os.tmpdir(), "openai-plugin-live-")); +} + +describe("openai plugin", () => { + it("registers the expected provider surfaces", () => { + const { providers, speechProviders, mediaProviders, imageProviders } = registerOpenAIPlugin(); + + expect(providers).toHaveLength(2); + expect( + providers.map( + (provider) => + // oxlint-disable-next-line typescript/no-explicit-any + (provider as any).id, + ), + ).toEqual(["openai", "openai-codex"]); + expect(speechProviders).toHaveLength(1); + expect(mediaProviders).toHaveLength(1); + expect(imageProviders).toHaveLength(1); + }); +}); + +describeLive("openai plugin live", () => { + it("registers an OpenAI provider that can complete a live request", async () => { + const { providers } = registerOpenAIPlugin(); + const provider = + // oxlint-disable-next-line typescript/no-explicit-any + providers.find((entry) => (entry as any).id === "openai"); + + expect(provider).toBeDefined(); + + // oxlint-disable-next-line typescript/no-explicit-any + const resolved = (provider as any).resolveDynamicModel?.({ + provider: "openai", + modelId: LIVE_MODEL_ID, + modelRegistry: { + find(providerId: string, id: string) { + if (providerId !== "openai") { + return null; + } + const template = createTemplateModel(LIVE_MODEL_ID); + return id === template.id ? template : null; + }, + }, + }); + + expect(resolved).toBeDefined(); + + // oxlint-disable-next-line typescript/no-explicit-any + const normalized = (provider as any).normalizeResolvedModel?.({ + provider: "openai", + modelId: resolved.id, + model: resolved, + }); + + expect(normalized).toMatchObject({ + provider: "openai", + id: LIVE_MODEL_ID, + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + + const client = new OpenAI({ + apiKey: OPENAI_API_KEY, + baseURL: normalized?.baseUrl, + }); + const response = await client.responses.create({ + model: normalized?.id ?? LIVE_MODEL_ID, + input: "Reply with exactly OK.", + max_output_tokens: 16, + }); + + expect(response.output_text.trim()).toMatch(/^OK[.!]?$/); + }, 30_000); + + it("lists voices and synthesizes audio through the registered speech provider", async () => { + const { speechProviders } = registerOpenAIPlugin(); + const speechProvider = + // oxlint-disable-next-line typescript/no-explicit-any + speechProviders.find((entry) => (entry as any).id === "openai"); + + expect(speechProvider).toBeDefined(); + + // oxlint-disable-next-line typescript/no-explicit-any + const voices = await (speechProvider as any).listVoices?.({}); + expect(Array.isArray(voices)).toBe(true); + expect(voices.map((voice: { id: string }) => voice.id)).toContain("alloy"); + + const cfg = createLiveConfig(); + const ttsConfig = createLiveTtsConfig(); + + // oxlint-disable-next-line typescript/no-explicit-any + const audioFile = await (speechProvider as any).synthesize({ + text: "OpenClaw integration test OK.", + cfg, + config: ttsConfig, + target: "audio-file", + }); + expect(audioFile.outputFormat).toBe("mp3"); + expect(audioFile.fileExtension).toBe(".mp3"); + expect(audioFile.audioBuffer.byteLength).toBeGreaterThan(512); + + // oxlint-disable-next-line typescript/no-explicit-any + const telephony = await (speechProvider as any).synthesizeTelephony?.({ + text: "Telephony check OK.", + cfg, + config: ttsConfig, + }); + expect(telephony?.outputFormat).toBe("pcm"); + expect(telephony?.sampleRate).toBe(24_000); + expect(telephony?.audioBuffer.byteLength).toBeGreaterThan(512); + }, 45_000); + + it("transcribes synthesized speech through the registered media provider", async () => { + const { speechProviders, mediaProviders } = registerOpenAIPlugin(); + const speechProvider = + // oxlint-disable-next-line typescript/no-explicit-any + speechProviders.find((entry) => (entry as any).id === "openai"); + const mediaProvider = + // oxlint-disable-next-line typescript/no-explicit-any + mediaProviders.find((entry) => (entry as any).id === "openai"); + + expect(speechProvider).toBeDefined(); + expect(mediaProvider).toBeDefined(); + + const cfg = createLiveConfig(); + const ttsConfig = createLiveTtsConfig(); + + // oxlint-disable-next-line typescript/no-explicit-any + const synthesized = await (speechProvider as any).synthesize({ + text: "OpenClaw integration test OK.", + cfg, + config: ttsConfig, + target: "audio-file", + }); + + // oxlint-disable-next-line typescript/no-explicit-any + const transcription = await (mediaProvider as any).transcribeAudio?.({ + buffer: synthesized.audioBuffer, + fileName: "openai-plugin-live.mp3", + mime: "audio/mpeg", + apiKey: OPENAI_API_KEY, + timeoutMs: 30_000, + }); + + const text = String(transcription?.text ?? "").toLowerCase(); + expect(text.length).toBeGreaterThan(0); + expect(text).toContain("openclaw"); + expect(text).toMatch(/\bok\b/); + }, 45_000); + + it("generates an image through the registered image provider", async () => { + const { imageProviders } = registerOpenAIPlugin(); + const imageProvider = + // oxlint-disable-next-line typescript/no-explicit-any + imageProviders.find((entry) => (entry as any).id === "openai"); + + expect(imageProvider).toBeDefined(); + + const cfg = createLiveConfig(); + const agentDir = await createTempAgentDir(); + + try { + // oxlint-disable-next-line typescript/no-explicit-any + const generated = await (imageProvider as any).generateImage({ + provider: "openai", + model: LIVE_IMAGE_MODEL, + prompt: "Create a minimal flat orange square centered on a white background.", + cfg, + agentDir, + authStore: EMPTY_AUTH_STORE, + timeoutMs: 45_000, + size: "1024x1024", + }); + + expect(generated.model).toBe(LIVE_IMAGE_MODEL); + expect(generated.images.length).toBeGreaterThan(0); + expect(generated.images[0]?.mimeType).toBe("image/png"); + expect(generated.images[0]?.buffer.byteLength).toBeGreaterThan(1_000); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }, 60_000); + + it("describes a deterministic image through the registered media provider", async () => { + const { mediaProviders } = registerOpenAIPlugin(); + const mediaProvider = + // oxlint-disable-next-line typescript/no-explicit-any + mediaProviders.find((entry) => (entry as any).id === "openai"); + + expect(mediaProvider).toBeDefined(); + + const cfg = createLiveConfig(); + const agentDir = await createTempAgentDir(); + + try { + // oxlint-disable-next-line typescript/no-explicit-any + const description = await (mediaProvider as any).describeImage?.({ + buffer: createReferencePng(), + fileName: "reference.png", + mime: "image/png", + prompt: "Reply with one lowercase word for the dominant center color.", + timeoutMs: 30_000, + agentDir, + cfg, + model: LIVE_VISION_MODEL, + provider: "openai", + }); + + expect(String(description?.text ?? "").toLowerCase()).toContain("orange"); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }, 60_000); +}); diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 5027f486bb0..36af1146758 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -9,6 +9,7 @@ import { listProfilesForProvider, type OAuthCredential, } from "openclaw/plugin-sdk/provider-auth"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; import { loginOpenAICodexOAuth } from "openclaw/plugin-sdk/provider-auth-login"; import { DEFAULT_CONTEXT_TOKENS, @@ -16,7 +17,6 @@ import { normalizeProviderId, type ProviderPlugin, } from "openclaw/plugin-sdk/provider-models"; -import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; import { createOpenAIAttributionHeadersWrapper } from "openclaw/plugin-sdk/provider-stream"; import { fetchCodexUsage } from "openclaw/plugin-sdk/provider-usage"; import { buildOpenAICodexProvider } from "./openai-codex-catalog.js"; diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index 04ef3700fb3..52182c2b44a 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -1,6 +1,73 @@ +import OpenAI from "openai"; import { describe, expect, it } from "vitest"; import { buildOpenAIProvider } from "./openai-provider.js"; +const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? ""; +const DEFAULT_LIVE_MODEL_IDS = ["gpt-5.4-mini", "gpt-5.4-nano"] as const; +const liveEnabled = OPENAI_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1"; +const describeLive = liveEnabled ? describe : describe.skip; + +type LiveModelCase = { + modelId: string; + templateId: string; + templateName: string; + cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + contextWindow: number; + maxTokens: number; +}; + +function resolveLiveModelCase(modelId: string): LiveModelCase { + switch (modelId) { + case "gpt-5.4": + return { + modelId, + templateId: "gpt-5.2", + templateName: "GPT-5.2", + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }; + case "gpt-5.4-pro": + return { + modelId, + templateId: "gpt-5.2-pro", + templateName: "GPT-5.2 Pro", + cost: { input: 15, output: 60, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }; + case "gpt-5.4-mini": + return { + modelId, + templateId: "gpt-5-mini", + templateName: "GPT-5 mini", + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }; + case "gpt-5.4-nano": + return { + modelId, + templateId: "gpt-5-nano", + templateName: "GPT-5 nano", + cost: { input: 0.5, output: 1, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 64_000, + }; + default: + throw new Error(`Unsupported live OpenAI model: ${modelId}`); + } +} + +function resolveLiveModelCases(raw?: string): LiveModelCase[] { + const requested = raw + ?.split(",") + .map((value) => value.trim()) + .filter(Boolean); + const modelIds = requested?.length ? requested : [...DEFAULT_LIVE_MODEL_IDS]; + return [...new Set(modelIds)].map((modelId) => resolveLiveModelCase(modelId)); +} + describe("buildOpenAIProvider", () => { it("resolves gpt-5.4 mini and nano from GPT-5 small-model templates", () => { const provider = buildOpenAIProvider(); @@ -106,3 +173,69 @@ describe("buildOpenAIProvider", () => { }); }); }); + +describeLive("buildOpenAIProvider live", () => { + it.each(resolveLiveModelCases(process.env.OPENCLAW_LIVE_OPENAI_MODELS))( + "resolves %s and completes through the OpenAI responses API", + async (liveCase) => { + const provider = buildOpenAIProvider(); + const registry = { + find(providerId: string, id: string) { + if (providerId !== "openai") { + return null; + } + if (id === liveCase.templateId) { + return { + id: liveCase.templateId, + name: liveCase.templateName, + provider: "openai", + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: liveCase.cost, + contextWindow: liveCase.contextWindow, + maxTokens: liveCase.maxTokens, + }; + } + return null; + }, + }; + + const resolved = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: liveCase.modelId, + modelRegistry: registry as never, + }); + + expect(resolved).toBeDefined(); + + const normalized = provider.normalizeResolvedModel?.({ + provider: "openai", + modelId: resolved!.id, + model: resolved!, + }); + + expect(normalized).toMatchObject({ + provider: "openai", + id: liveCase.modelId, + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + + const client = new OpenAI({ + apiKey: OPENAI_API_KEY, + baseURL: normalized?.baseUrl, + }); + + const response = await client.responses.create({ + model: normalized?.id ?? liveCase.modelId, + input: "Reply with exactly OK.", + max_output_tokens: 16, + }); + + expect(response.output_text.trim()).toMatch(/^OK[.!]?$/); + }, + 30_000, + ); +}); diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts new file mode 100644 index 00000000000..fa4cbda6cd2 --- /dev/null +++ b/extensions/openrouter/index.test.ts @@ -0,0 +1,101 @@ +import OpenAI from "openai"; +import { describe, expect, it } from "vitest"; +import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; +import plugin from "./index.js"; + +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? ""; +const LIVE_MODEL_ID = + process.env.OPENCLAW_LIVE_OPENROUTER_PLUGIN_MODEL?.trim() || "openai/gpt-5.4-nano"; +const liveEnabled = OPENROUTER_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1"; +const describeLive = liveEnabled ? describe : describe.skip; + +function registerOpenRouterPlugin() { + const providers: unknown[] = []; + const speechProviders: unknown[] = []; + const mediaProviders: unknown[] = []; + const imageProviders: unknown[] = []; + + plugin.register( + createTestPluginApi({ + id: "openrouter", + name: "OpenRouter Provider", + source: "test", + config: {}, + runtime: {} as never, + registerProvider: (provider) => { + providers.push(provider); + }, + registerSpeechProvider: (provider) => { + speechProviders.push(provider); + }, + registerMediaUnderstandingProvider: (provider) => { + mediaProviders.push(provider); + }, + registerImageGenerationProvider: (provider) => { + imageProviders.push(provider); + }, + }), + ); + + return { providers, speechProviders, mediaProviders, imageProviders }; +} + +describe("openrouter plugin", () => { + it("registers the expected provider surfaces", () => { + const { providers, speechProviders, mediaProviders, imageProviders } = + registerOpenRouterPlugin(); + + expect(providers).toHaveLength(1); + expect( + providers.map( + (provider) => + // oxlint-disable-next-line typescript/no-explicit-any + (provider as any).id, + ), + ).toEqual(["openrouter"]); + expect(speechProviders).toHaveLength(0); + expect(mediaProviders).toHaveLength(0); + expect(imageProviders).toHaveLength(0); + }); +}); + +describeLive("openrouter plugin live", () => { + it("registers an OpenRouter provider that can complete a live request", async () => { + const { providers } = registerOpenRouterPlugin(); + const provider = + // oxlint-disable-next-line typescript/no-explicit-any + providers.find((entry) => (entry as any).id === "openrouter"); + + expect(provider).toBeDefined(); + + // oxlint-disable-next-line typescript/no-explicit-any + const resolved = (provider as any).resolveDynamicModel?.({ + provider: "openrouter", + modelId: LIVE_MODEL_ID, + modelRegistry: { + find() { + return null; + }, + }, + }); + + expect(resolved).toMatchObject({ + provider: "openrouter", + id: LIVE_MODEL_ID, + api: "openai-completions", + baseUrl: "https://openrouter.ai/api/v1", + }); + + const client = new OpenAI({ + apiKey: OPENROUTER_API_KEY, + baseURL: resolved?.baseUrl, + }); + const response = await client.chat.completions.create({ + model: resolved?.id ?? LIVE_MODEL_ID, + messages: [{ role: "user", content: "Reply with exactly OK." }], + max_tokens: 16, + }); + + expect(response.choices[0]?.message?.content?.trim()).toMatch(/^OK[.!]?$/); + }, 30_000); +}); diff --git a/extensions/qwen-portal-auth/runtime-api.ts b/extensions/qwen-portal-auth/runtime-api.ts index 52ad77bf6f0..5fbd1e571b4 100644 --- a/extensions/qwen-portal-auth/runtime-api.ts +++ b/extensions/qwen-portal-auth/runtime-api.ts @@ -1,10 +1,7 @@ -export { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; +export { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; export type { ProviderAuthContext, ProviderCatalogContext } from "openclaw/plugin-sdk/plugin-entry"; export { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/provider-auth"; export { QWEN_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime"; -export { - generatePkceVerifierChallenge, - toFormUrlEncoded, -} from "openclaw/plugin-sdk/provider-oauth"; +export { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth"; export { refreshQwenPortalCredentials } from "./refresh.js"; diff --git a/extensions/shared/passive-monitor.ts b/extensions/shared/passive-monitor.ts index 435f934b123..f9cd2ed58ab 100644 --- a/extensions/shared/passive-monitor.ts +++ b/extensions/shared/passive-monitor.ts @@ -1,4 +1,4 @@ -import { runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-runtime"; +import { runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-lifecycle"; type StoppableMonitor = { stop: () => void; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 6ba7fce6084..9612951c3b4 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,16 +1,17 @@ import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - attachChannelToResult, - createAttachedChannelResultAdapter, createPairingPrefixStripper, createTextPairingAdapter, - resolveOutboundSendDep, -} from "openclaw/plugin-sdk/channel-runtime"; -import { attachChannelToResults } from "openclaw/plugin-sdk/channel-send-result"; +} from "openclaw/plugin-sdk/channel-pairing"; +import { + attachChannelToResult, + attachChannelToResults, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; -import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import { type RoutePeer } from "openclaw/plugin-sdk/routing"; +import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; import { markdownToSignalTextChunks } from "./format.js"; import { diff --git a/extensions/signal/src/message-actions.ts b/extensions/signal/src/message-actions.ts index c6082848f02..2645908f3e9 100644 --- a/extensions/signal/src/message-actions.ts +++ b/extensions/signal/src/message-actions.ts @@ -1,11 +1,9 @@ -import { - createActionGate, - jsonResult, - readStringParam, - resolveReactionMessageId, - type ChannelMessageActionAdapter, - type ChannelMessageActionName, -} from "openclaw/plugin-sdk/channel-runtime"; +import { createActionGate, jsonResult, readStringParam } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, +} from "openclaw/plugin-sdk/channel-contract"; import { listEnabledSignalAccounts, resolveSignalAccount } from "./accounts.js"; import { resolveSignalReactionLevel } from "./reaction-level.js"; import { removeReactionSignal, sendReactionSignal } from "./send-reactions.js"; diff --git a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index e8ee7403e38..14fa9bf1f19 100644 --- a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -1,7 +1,7 @@ +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; -import { normalizeE164 } from "../../../src/utils.js"; import type { SignalDaemonExitEvent } from "./daemon.js"; import { createMockSignalDaemonHandle, @@ -16,16 +16,14 @@ installSignalToolResultTestHooks(); // Import after the harness registers `vi.mock(...)` for Signal internals. vi.resetModules(); -const [{ peekSystemEvents }, { monitorSignalProvider }] = await Promise.all([ - import("openclaw/plugin-sdk/infra-runtime"), - import("./monitor.js"), -]); +const { monitorSignalProvider } = await import("./monitor.js"); const { replyMock, sendMock, streamMock, updateLastRouteMock, + enqueueSystemEventMock, upsertPairingRequestMock, waitForTransportReadyMock, spawnSignalDaemonMock, @@ -109,14 +107,23 @@ async function receiveSignalPayloads(params: { await flush(); } -function getDirectSignalEventsFor(sender: string) { +function hasQueuedReactionEventFor(sender: string) { const route = resolveAgentRoute({ cfg: config as OpenClawConfig, channel: "signal", accountId: "default", peer: { kind: "direct", id: normalizeE164(sender) }, }); - return peekSystemEvents(route.sessionKey); + return enqueueSystemEventMock.mock.calls.some(([text, options]) => { + return ( + typeof text === "string" && + text.includes("Signal reaction added") && + typeof options === "object" && + options !== null && + "sessionKey" in options && + (options as { sessionKey?: string }).sessionKey === route.sessionKey + ); + }); } function makeBaseEnvelope(overrides: Record = {}) { @@ -383,8 +390,7 @@ describe("monitorSignalProvider tool results", () => { }, }); - const events = getDirectSignalEventsFor("+15550001111"); - expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true); + expect(hasQueuedReactionEventFor("+15550001111")).toBe(true); }); it.each([ @@ -424,8 +430,7 @@ describe("monitorSignalProvider tool results", () => { }, }); - const events = getDirectSignalEventsFor("+15550001111"); - expect(events.some((text) => text.includes("Signal reaction added"))).toBe(shouldEnqueue); + expect(hasQueuedReactionEventFor("+15550001111")).toBe(shouldEnqueue); expect(sendMock).not.toHaveBeenCalled(); expect(upsertPairingRequestMock).not.toHaveBeenCalled(); }); @@ -442,8 +447,7 @@ describe("monitorSignalProvider tool results", () => { }, }); - const events = getDirectSignalEventsFor("+15550001111"); - expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true); + expect(hasQueuedReactionEventFor("+15550001111")).toBe(true); }); it("processes messages when reaction metadata is present", async () => { diff --git a/extensions/signal/src/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts index 7f1c8b7d7cf..364b86c5bdf 100644 --- a/extensions/signal/src/monitor.tool-result.test-harness.ts +++ b/extensions/signal/src/monitor.tool-result.test-harness.ts @@ -4,6 +4,7 @@ import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js"; type SignalToolResultTestMocks = { waitForTransportReadyMock: MockFn; + enqueueSystemEventMock: MockFn; sendMock: MockFn; replyMock: MockFn; updateLastRouteMock: MockFn; @@ -16,6 +17,7 @@ type SignalToolResultTestMocks = { }; const waitForTransportReadyMock = vi.hoisted(() => vi.fn()) as unknown as MockFn; +const enqueueSystemEventMock = vi.hoisted(() => vi.fn()) as unknown as MockFn; const sendMock = vi.hoisted(() => vi.fn()) as unknown as MockFn; const replyMock = vi.hoisted(() => vi.fn()) as unknown as MockFn; const updateLastRouteMock = vi.hoisted(() => vi.fn()) as unknown as MockFn; @@ -29,6 +31,7 @@ const spawnSignalDaemonMock = vi.hoisted(() => vi.fn()) as unknown as MockFn; export function getSignalToolResultTestMocks(): SignalToolResultTestMocks { return { waitForTransportReadyMock, + enqueueSystemEventMock, sendMock, replyMock, updateLastRouteMock, @@ -162,6 +165,10 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async () => { return { ...actual, waitForTransportReady: (...args: unknown[]) => waitForTransportReadyMock(...args), + enqueueSystemEvent: (...args: Parameters) => { + enqueueSystemEventMock(...args); + return actual.enqueueSystemEvent(...args); + }, }; }); @@ -189,6 +196,7 @@ export function installSignalToolResultTestHooks() { readAllowFromStoreMock.mockReset().mockResolvedValue([]); upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); waitForTransportReadyMock.mockReset().mockResolvedValue(undefined); + enqueueSystemEventMock.mockReset(); resetSystemEventsForTest(); }); diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index b0e601fc01e..9aa32731b1d 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -9,6 +9,7 @@ import { import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime"; import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; +import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { deliverTextOrMediaReply, resolveSendableOutboundReplyParts, @@ -19,7 +20,6 @@ import { resolveChunkMode, resolveTextChunkLimit, } from "openclaw/plugin-sdk/reply-runtime"; -import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 23eb676ae82..58ff8d4f8d7 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -1,32 +1,33 @@ import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; +import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; import { + buildMentionRegexes, createChannelInboundDebouncer, - shouldDebounceTextInbound, -} from "openclaw/plugin-sdk/channel-runtime"; -import { logInboundDrop, logTypingFailure } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime"; -import { normalizeSignalMessagingTarget } from "openclaw/plugin-sdk/channel-runtime"; -import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/config-runtime"; -import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; -import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; -import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; -import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; -import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; -import { formatInboundEnvelope, formatInboundFromLabel, + matchesMentionPatterns, resolveEnvelopeFormatOptions, -} from "openclaw/plugin-sdk/reply-runtime"; + shouldDebounceTextInbound, +} from "openclaw/plugin-sdk/channel-inbound"; +import { + logInboundDrop, + resolveMentionGatingWithBypass, +} from "openclaw/plugin-sdk/channel-inbound"; +import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; +import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/config-runtime"; +import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, recordPendingHistoryEntryIfEnabled, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; +import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; -import { buildMentionRegexes, matchesMentionPatterns } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; @@ -46,6 +47,7 @@ import { resolveSignalSender, type SignalSender, } from "../identity.js"; +import { normalizeSignalMessagingTarget } from "../runtime-api.js"; import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js"; import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js"; import type { diff --git a/extensions/signal/src/monitor/event-handler.types.ts b/extensions/signal/src/monitor/event-handler.types.ts index 82a96af73cc..4ccb85cde5d 100644 --- a/extensions/signal/src/monitor/event-handler.types.ts +++ b/extensions/signal/src/monitor/event-handler.types.ts @@ -4,7 +4,7 @@ import type { GroupPolicy, SignalReactionNotificationMode, } from "openclaw/plugin-sdk/config-runtime"; -import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { SignalSender } from "../identity.js"; diff --git a/extensions/signal/src/outbound-adapter.ts b/extensions/signal/src/outbound-adapter.ts index 4471871b69b..08d54ddd052 100644 --- a/extensions/signal/src/outbound-adapter.ts +++ b/extensions/signal/src/outbound-adapter.ts @@ -1,12 +1,12 @@ -import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { attachChannelToResult, attachChannelToResults, createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/infra-runtime"; +import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/media-runtime"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { markdownToSignalTextChunks } from "./format.js"; import { sendMessageSignal } from "./send.js"; diff --git a/extensions/signal/src/probe.ts b/extensions/signal/src/probe.ts index ac7dce428e8..4fd26f12355 100644 --- a/extensions/signal/src/probe.ts +++ b/extensions/signal/src/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import { signalCheck, signalRpcRequest } from "./client.js"; export type SignalProbe = BaseProbeResult & { diff --git a/extensions/signal/src/runtime-api.ts b/extensions/signal/src/runtime-api.ts index 6aeeef0adb1..172943641f8 100644 --- a/extensions/signal/src/runtime-api.ts +++ b/extensions/signal/src/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Signal extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/signal"; +export * from "../../../src/plugin-sdk/signal.js"; diff --git a/extensions/slack/src/account-inspect.ts b/extensions/slack/src/account-inspect.ts index a1620cfe33b..f465ccf2d79 100644 --- a/extensions/slack/src/account-inspect.ts +++ b/extensions/slack/src/account-inspect.ts @@ -6,7 +6,7 @@ import { import { hasConfiguredSecretInput, normalizeSecretInputString, -} from "openclaw/plugin-sdk/config-runtime"; +} from "openclaw/plugin-sdk/secret-input"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { mergeSlackAccountConfig, diff --git a/extensions/slack/src/channel-actions.ts b/extensions/slack/src/channel-actions.ts index 3d9c2417306..4502ddb36a4 100644 --- a/extensions/slack/src/channel-actions.ts +++ b/extensions/slack/src/channel-actions.ts @@ -2,7 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { type ChannelMessageActionAdapter, type ChannelMessageToolDiscovery, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-contract"; import type { SlackActionContext } from "./action-runtime.js"; import { handleSlackAction } from "./action-runtime.js"; import { handleSlackMessageAction } from "./message-action-dispatch.js"; diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 691b6126557..e9659c14d7c 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -1,7 +1,7 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/slack"; import { describe, expect, it, vi } from "vitest"; import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import { slackOutbound } from "./outbound-adapter.js"; +import type { OpenClawConfig } from "./runtime-api.js"; const handleSlackActionMock = vi.fn(); diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 7a27e73aa8d..3a2646c0152 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -4,20 +4,29 @@ import { createFlatAllowlistOverrideResolver, } from "openclaw/plugin-sdk/allowlist-config-edit"; import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; -import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { - createAttachedChannelResultAdapter, - createChannelDirectoryAdapter, createPairingPrefixStripper, - createScopedAccountReplyToModeResolver, - createRuntimeDirectoryLiveAdapter, createTextPairingAdapter, - resolveOutboundSendDep, - resolveTargetsWithOptionalToken, -} from "openclaw/plugin-sdk/channel-runtime"; -import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/channel-pairing"; +import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; +import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/channel-targets"; +import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; +import { + createChannelDirectoryAdapter, + createRuntimeDirectoryLiveAdapter, +} from "openclaw/plugin-sdk/directory-runtime"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; -import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; +import { + createRuntimeOutboundDelegates, + resolveOutboundSendDep, +} from "openclaw/plugin-sdk/infra-runtime"; +import { + buildOutboundBaseSessionKey, + normalizeOutboundThreadId, + resolveThreadSessionKeys, + type RoutePeer, +} from "openclaw/plugin-sdk/routing"; import { listEnabledSlackAccounts, resolveSlackAccount, diff --git a/extensions/slack/src/config-schema.ts b/extensions/slack/src/config-schema.ts index d5f28cf7905..5b2e38e1665 100644 --- a/extensions/slack/src/config-schema.ts +++ b/extensions/slack/src/config-schema.ts @@ -1,3 +1,3 @@ -import { buildChannelConfigSchema, SlackConfigSchema } from "openclaw/plugin-sdk/slack-core"; +import { buildChannelConfigSchema, SlackConfigSchema } from "./runtime-api.js"; export const SlackChannelConfigSchema = buildChannelConfigSchema(SlackConfigSchema); diff --git a/extensions/slack/src/directory-live.ts b/extensions/slack/src/directory-live.ts index 0a8bd04af22..93d83978268 100644 --- a/extensions/slack/src/directory-live.ts +++ b/extensions/slack/src/directory-live.ts @@ -1,5 +1,7 @@ -import type { DirectoryConfigParams } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/channel-runtime"; +import type { + ChannelDirectoryEntry, + DirectoryConfigParams, +} from "openclaw/plugin-sdk/directory-runtime"; import { resolveSlackAccount } from "./accounts.js"; import { createSlackWebClient } from "./client.js"; diff --git a/extensions/slack/src/draft-stream.ts b/extensions/slack/src/draft-stream.ts index f122e2664c5..c4840b938fe 100644 --- a/extensions/slack/src/draft-stream.ts +++ b/extensions/slack/src/draft-stream.ts @@ -1,4 +1,4 @@ -import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-runtime"; +import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle"; import { deleteSlackMessage, editSlackMessage } from "./actions.js"; import { sendMessageSlack } from "./send.js"; diff --git a/extensions/slack/src/group-policy.ts b/extensions/slack/src/group-policy.ts index d49138fb5f8..b77a63c7a81 100644 --- a/extensions/slack/src/group-policy.ts +++ b/extensions/slack/src/group-policy.ts @@ -1,9 +1,9 @@ +import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract"; import { resolveToolsBySender, type GroupToolPolicyBySenderConfig, type GroupToolPolicyConfig, } from "openclaw/plugin-sdk/channel-policy"; -import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime"; import { normalizeHyphenSlug } from "openclaw/plugin-sdk/core"; import { inspectSlackAccount } from "./account-inspect.js"; diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 55576d9e822..372ae915700 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1,9 +1,9 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; -import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/slack-core"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { buildSlackInteractiveBlocks } from "./blocks-render.js"; +import { readNumberParam, readStringParam } from "./runtime-api.js"; type SlackActionInvoke = ( action: Record, diff --git a/extensions/slack/src/message-actions.ts b/extensions/slack/src/message-actions.ts index 938659c9354..5eb3bdb9e76 100644 --- a/extensions/slack/src/message-actions.ts +++ b/extensions/slack/src/message-actions.ts @@ -1,9 +1,7 @@ import { createActionGate } from "openclaw/plugin-sdk/agent-runtime"; -import type { - ChannelMessageActionName, - ChannelToolSend, -} from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ChannelToolSend } from "openclaw/plugin-sdk/tool-send"; import { listEnabledSlackAccounts } from "./accounts.js"; export function listSlackMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { diff --git a/extensions/slack/src/monitor/allow-list.ts b/extensions/slack/src/monitor/allow-list.ts index 32fb7f40530..0ae6de23ec1 100644 --- a/extensions/slack/src/monitor/allow-list.ts +++ b/extensions/slack/src/monitor/allow-list.ts @@ -2,7 +2,7 @@ import { compileAllowlist, resolveCompiledAllowlistMatch, type AllowlistMatch, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/allow-from"; import { normalizeHyphenSlug, normalizeStringEntries, diff --git a/extensions/slack/src/monitor/channel-config.ts b/extensions/slack/src/monitor/channel-config.ts index 32ad0e6f022..4aca5fc1422 100644 --- a/extensions/slack/src/monitor/channel-config.ts +++ b/extensions/slack/src/monitor/channel-config.ts @@ -3,7 +3,7 @@ import { buildChannelKeyCandidates, resolveChannelEntryMatchWithFallback, type ChannelMatchSource, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-targets"; import type { SlackReactionNotificationMode } from "openclaw/plugin-sdk/config-runtime"; import type { SlackMessageEvent } from "../types.js"; import { allowListMatches, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js"; diff --git a/extensions/slack/src/monitor/context.ts b/extensions/slack/src/monitor/context.ts index f39a92ce207..0d3f5706697 100644 --- a/extensions/slack/src/monitor/context.ts +++ b/extensions/slack/src/monitor/context.ts @@ -1,5 +1,5 @@ import type { App } from "@slack/bolt"; -import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime"; +import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from"; import type { OpenClawConfig, SlackReactionNotificationMode, @@ -7,7 +7,7 @@ import type { import { resolveSessionKey, type SessionScope } from "openclaw/plugin-sdk/config-runtime"; import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/config-runtime"; import { createDedupeCache } from "openclaw/plugin-sdk/infra-runtime"; -import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/slack/src/monitor/dm-auth.ts b/extensions/slack/src/monitor/dm-auth.ts index 75a0515bce7..0783fa17acf 100644 --- a/extensions/slack/src/monitor/dm-auth.ts +++ b/extensions/slack/src/monitor/dm-auth.ts @@ -1,5 +1,5 @@ +import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from"; import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; -import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime"; import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; import { resolveSlackAllowListMatch } from "./allow-list.js"; import type { SlackMonitorContext } from "./context.js"; diff --git a/extensions/slack/src/monitor/events/channels.ts b/extensions/slack/src/monitor/events/channels.ts index e4940f80d9f..47fdc2647c4 100644 --- a/extensions/slack/src/monitor/events/channels.ts +++ b/extensions/slack/src/monitor/events/channels.ts @@ -1,5 +1,5 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers"; import { loadConfig, writeConfigFile } from "openclaw/plugin-sdk/config-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; import { danger, warn } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/slack/src/monitor/message-handler.ts b/extensions/slack/src/monitor/message-handler.ts index feaddff98df..fb700b78350 100644 --- a/extensions/slack/src/monitor/message-handler.ts +++ b/extensions/slack/src/monitor/message-handler.ts @@ -1,7 +1,7 @@ import { createChannelInboundDebouncer, shouldDebounceTextInbound, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-inbound"; import type { ResolvedSlackAccount } from "../accounts.js"; import type { SlackMessageEvent } from "../types.js"; import { stripSlackMentionsForCommandDetection } from "./commands.js"; diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 2b31791284e..f3860c2f6bd 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -1,12 +1,15 @@ import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { + logAckFailure, + logTypingFailure, + removeAckReactionAfterReply, +} from "openclaw/plugin-sdk/channel-feedback"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { removeAckReactionAfterReply } from "openclaw/plugin-sdk/channel-runtime"; -import { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-runtime"; import { resolveStorePath, updateLastRoute } from "openclaw/plugin-sdk/config-runtime"; import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; +import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; -import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts b/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts index 5d4020f1b46..e1cfc33088a 100644 --- a/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts +++ b/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts @@ -1,5 +1,5 @@ +import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound"; import { readSessionUpdatedAt } from "openclaw/plugin-sdk/config-runtime"; -import { formatInboundEnvelope } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; @@ -30,7 +30,7 @@ export async function resolveSlackThreadContextData(params: { storePath: string; sessionKey: string; envelopeOptions: ReturnType< - typeof import("openclaw/plugin-sdk/reply-runtime").resolveEnvelopeFormatOptions + typeof import("openclaw/plugin-sdk/channel-inbound").resolveEnvelopeFormatOptions >; effectiveDirectMedia: SlackMediaResult[] | null; }): Promise { diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index e6bc3a23446..1f36eef491c 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -2,26 +2,29 @@ import { resolveAckReaction } from "openclaw/plugin-sdk/agent-runtime"; import { shouldAckReaction as shouldAckReactionGate, type AckReactionScope, -} from "openclaw/plugin-sdk/channel-runtime"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveConversationLabel } from "openclaw/plugin-sdk/channel-runtime"; -import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime"; -import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; -import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; -import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; -import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; -import { shouldHandleTextCommands } from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/channel-feedback"; import { + buildMentionRegexes, formatInboundEnvelope, + logInboundDrop, + matchesMentionWithExplicit, resolveEnvelopeFormatOptions, -} from "openclaw/plugin-sdk/reply-runtime"; + resolveMentionGatingWithBypass, +} from "openclaw/plugin-sdk/channel-inbound"; +import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; +import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-auth"; +import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { + recordInboundSession, + resolveConversationLabel, +} from "openclaw/plugin-sdk/conversation-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; import { buildPendingHistoryContextFromMap, recordPendingHistoryEntryIfEnabled, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; -import { buildMentionRegexes, matchesMentionWithExplicit } from "openclaw/plugin-sdk/reply-runtime"; import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 5a382551b47..1af83676e93 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -6,7 +6,7 @@ import { mergeAllowlist, patchAllowlistUsersInConfigEntries, summarizeMapping, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/allow-from"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { @@ -15,15 +15,15 @@ import { warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/config-runtime"; import type { SessionScope } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { computeBackoff, sleepWithAbort } from "openclaw/plugin-sdk/infra-runtime"; import { installRequestBodyLimitGuard } from "openclaw/plugin-sdk/infra-runtime"; +import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-runtime"; import { normalizeMainKey } from "openclaw/plugin-sdk/routing"; import { warn } from "openclaw/plugin-sdk/runtime-env"; import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; import { resolveSlackAccount } from "../accounts.js"; import { resolveSlackWebClientOptions } from "../client.js"; diff --git a/extensions/slack/src/monitor/slash-commands.runtime.ts b/extensions/slack/src/monitor/slash-commands.runtime.ts index aaae82a0602..6659ae61031 100644 --- a/extensions/slack/src/monitor/slash-commands.runtime.ts +++ b/extensions/slack/src/monitor/slash-commands.runtime.ts @@ -4,17 +4,17 @@ import { listNativeCommandSpecsForConfig as listNativeCommandSpecsForConfigImpl, parseCommandArgs as parseCommandArgsImpl, resolveCommandArgMenu as resolveCommandArgMenuImpl, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/command-auth"; type BuildCommandTextFromArgs = - typeof import("openclaw/plugin-sdk/reply-runtime").buildCommandTextFromArgs; + typeof import("openclaw/plugin-sdk/command-auth").buildCommandTextFromArgs; type FindCommandByNativeName = - typeof import("openclaw/plugin-sdk/reply-runtime").findCommandByNativeName; + typeof import("openclaw/plugin-sdk/command-auth").findCommandByNativeName; type ListNativeCommandSpecsForConfig = - typeof import("openclaw/plugin-sdk/reply-runtime").listNativeCommandSpecsForConfig; -type ParseCommandArgs = typeof import("openclaw/plugin-sdk/reply-runtime").parseCommandArgs; + typeof import("openclaw/plugin-sdk/command-auth").listNativeCommandSpecsForConfig; +type ParseCommandArgs = typeof import("openclaw/plugin-sdk/command-auth").parseCommandArgs; type ResolveCommandArgMenu = - typeof import("openclaw/plugin-sdk/reply-runtime").resolveCommandArgMenu; + typeof import("openclaw/plugin-sdk/command-auth").resolveCommandArgMenu; export function buildCommandTextFromArgs( ...args: Parameters diff --git a/extensions/slack/src/monitor/slash-dispatch.runtime.ts b/extensions/slack/src/monitor/slash-dispatch.runtime.ts index affa13c01dd..a9c7eaba1d3 100644 --- a/extensions/slack/src/monitor/slash-dispatch.runtime.ts +++ b/extensions/slack/src/monitor/slash-dispatch.runtime.ts @@ -1,8 +1,8 @@ +import { resolveMarkdownTableMode as resolveMarkdownTableModeImpl } from "openclaw/plugin-sdk/config-runtime"; import { recordInboundSessionMetaSafe as recordInboundSessionMetaSafeImpl, resolveConversationLabel as resolveConversationLabelImpl, -} from "openclaw/plugin-sdk/channel-runtime"; -import { resolveMarkdownTableMode as resolveMarkdownTableModeImpl } from "openclaw/plugin-sdk/config-runtime"; +} from "openclaw/plugin-sdk/conversation-runtime"; import { dispatchReplyWithDispatcher as dispatchReplyWithDispatcherImpl, finalizeInboundContext as finalizeInboundContextImpl, @@ -17,9 +17,9 @@ type FinalizeInboundContext = type DispatchReplyWithDispatcher = typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher; type ResolveConversationLabel = - typeof import("openclaw/plugin-sdk/channel-runtime").resolveConversationLabel; + typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConversationLabel; type RecordInboundSessionMetaSafe = - typeof import("openclaw/plugin-sdk/channel-runtime").recordInboundSessionMetaSafe; + typeof import("openclaw/plugin-sdk/conversation-runtime").recordInboundSessionMetaSafe; type ResolveMarkdownTableMode = typeof import("openclaw/plugin-sdk/config-runtime").resolveMarkdownTableMode; type ResolveAgentRoute = typeof import("openclaw/plugin-sdk/routing").resolveAgentRoute; diff --git a/extensions/slack/src/monitor/slash-skill-commands.runtime.ts b/extensions/slack/src/monitor/slash-skill-commands.runtime.ts index ec25e104fec..926eb5a3932 100644 --- a/extensions/slack/src/monitor/slash-skill-commands.runtime.ts +++ b/extensions/slack/src/monitor/slash-skill-commands.runtime.ts @@ -1,7 +1,7 @@ -import { listSkillCommandsForAgents as listSkillCommandsForAgentsImpl } from "openclaw/plugin-sdk/reply-runtime"; +import { listSkillCommandsForAgents as listSkillCommandsForAgentsImpl } from "openclaw/plugin-sdk/command-auth"; type ListSkillCommandsForAgents = - typeof import("openclaw/plugin-sdk/reply-runtime").listSkillCommandsForAgents; + typeof import("openclaw/plugin-sdk/command-auth").listSkillCommandsForAgents; export function listSkillCommandsForAgents( ...args: Parameters diff --git a/extensions/slack/src/monitor/slash.test-harness.ts b/extensions/slack/src/monitor/slash.test-harness.ts index 48a11cf3460..c8d4fb811b0 100644 --- a/extensions/slack/src/monitor/slash.test-harness.ts +++ b/extensions/slack/src/monitor/slash.test-harness.ts @@ -7,7 +7,6 @@ const mocks = vi.hoisted(() => ({ resolveAgentRouteMock: vi.fn(), finalizeInboundContextMock: vi.fn(), resolveConversationLabelMock: vi.fn(), - createReplyPrefixOptionsMock: vi.fn(), recordSessionMetaFromInboundMock: vi.fn(), resolveStorePathMock: vi.fn(), })); @@ -21,15 +20,6 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { }; }); -vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readChannelAllowFromStore: (...args: unknown[]) => mocks.readAllowFromStoreMock(...args), - upsertChannelPairingRequest: (...args: unknown[]) => mocks.upsertPairingRequestMock(...args), - }; -}); - vi.mock("openclaw/plugin-sdk/routing", async (importOriginal) => { const actual = await importOriginal(); return { @@ -38,12 +28,11 @@ vi.mock("openclaw/plugin-sdk/routing", async (importOriginal) => { }; }); -vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveConversationLabel: (...args: unknown[]) => mocks.resolveConversationLabelMock(...args), - createReplyPrefixOptions: (...args: unknown[]) => mocks.createReplyPrefixOptionsMock(...args), recordInboundSessionMetaSafe: (...args: unknown[]) => mocks.recordSessionMetaFromInboundMock(...args), }; @@ -64,7 +53,6 @@ type SlashHarnessMocks = { resolveAgentRouteMock: ReturnType; finalizeInboundContextMock: ReturnType; resolveConversationLabelMock: ReturnType; - createReplyPrefixOptionsMock: ReturnType; recordSessionMetaFromInboundMock: ReturnType; resolveStorePathMock: ReturnType; }; @@ -84,7 +72,6 @@ export function resetSlackSlashMocks() { }); mocks.finalizeInboundContextMock.mockReset().mockImplementation((ctx: unknown) => ctx); mocks.resolveConversationLabelMock.mockReset().mockReturnValue(undefined); - mocks.createReplyPrefixOptionsMock.mockReset().mockReturnValue({ onModelSelected: () => {} }); mocks.recordSessionMetaFromInboundMock.mockReset().mockResolvedValue(undefined); mocks.resolveStorePathMock.mockReset().mockReturnValue("/tmp/openclaw-sessions.json"); } diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index e06b22d2e91..6ff790e42b2 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -1,12 +1,14 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/channel-runtime"; +import { + resolveCommandAuthorizedFromAuthorizers, + resolveNativeCommandSessionTargets, +} from "openclaw/plugin-sdk/command-auth"; +import { type ChatCommandDefinition, type CommandArgs } from "openclaw/plugin-sdk/command-auth"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, } from "openclaw/plugin-sdk/config-runtime"; -import { type ChatCommandDefinition, type CommandArgs } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { chunkItems } from "openclaw/plugin-sdk/text-runtime"; diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index ed107d4c63f..ee3946dde9b 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -1,20 +1,19 @@ -import { - resolvePayloadMediaUrls, - sendPayloadMediaSequenceAndFinalize, - sendTextMediaPayload, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { attachChannelToResult, + type ChannelOutboundAdapter, createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result"; -import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveOutboundSendDep, type OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; import { resolveInteractiveTextFallback, type InteractiveReply, } from "openclaw/plugin-sdk/interactive-runtime"; import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime"; +import { + resolvePayloadMediaUrls, + sendPayloadMediaSequenceAndFinalize, + sendTextMediaPayload, +} from "openclaw/plugin-sdk/reply-payload"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js"; import { sendMessageSlack, type SlackSendIdentity } from "./send.js"; diff --git a/extensions/slack/src/probe.ts b/extensions/slack/src/probe.ts index c370b11be9b..a0d698e54b5 100644 --- a/extensions/slack/src/probe.ts +++ b/extensions/slack/src/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import { withTimeout } from "openclaw/plugin-sdk/text-runtime"; import { createSlackWebClient } from "./client.js"; diff --git a/extensions/slack/src/runtime-api.ts b/extensions/slack/src/runtime-api.ts index 5dac68be756..84f7b9d480b 100644 --- a/extensions/slack/src/runtime-api.ts +++ b/extensions/slack/src/runtime-api.ts @@ -9,7 +9,7 @@ export { type ChannelPlugin, type OpenClawConfig, type SlackAccountConfig, -} from "openclaw/plugin-sdk/slack"; +} from "../../../src/plugin-sdk/slack.js"; export { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, @@ -25,5 +25,5 @@ export { readStringParam, SlackConfigSchema, withNormalizedTimestamp, -} from "openclaw/plugin-sdk/slack-core"; +} from "../../../src/plugin-sdk/slack-core.js"; export { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; diff --git a/extensions/slack/src/targets.ts b/extensions/slack/src/targets.ts index 43162a447d5..356f990d600 100644 --- a/extensions/slack/src/targets.ts +++ b/extensions/slack/src/targets.ts @@ -6,7 +6,7 @@ import { type MessagingTarget, type MessagingTargetKind, type MessagingTargetParseOptions, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-targets"; export type SlackTargetKind = MessagingTargetKind; diff --git a/extensions/slack/src/threading-tool-context.ts b/extensions/slack/src/threading-tool-context.ts index 30451be5b6b..a6b59189dee 100644 --- a/extensions/slack/src/threading-tool-context.ts +++ b/extensions/slack/src/threading-tool-context.ts @@ -1,7 +1,7 @@ import type { ChannelThreadingContext, ChannelThreadingToolContext, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveSlackAccount, resolveSlackReplyToMode } from "./accounts.js"; diff --git a/extensions/slack/src/token.ts b/extensions/slack/src/token.ts index 36f31c89383..03c8c653344 100644 --- a/extensions/slack/src/token.ts +++ b/extensions/slack/src/token.ts @@ -1,4 +1,4 @@ -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; export function normalizeSlackToken(raw?: unknown): string | undefined { return normalizeResolvedSecretInputString({ diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index ef01c240e10..e4ae0bc857d 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -9,15 +9,13 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; +import { createTextPairingAdapter } from "openclaw/plugin-sdk/channel-pairing"; import { createConditionalWarningCollector, projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; -import { - attachChannelToResult, - createEmptyChannelDirectoryAdapter, - createTextPairingAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; +import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; +import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; import { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-ingress"; import { z } from "zod"; diff --git a/extensions/tavily/src/config.ts b/extensions/tavily/src/config.ts index 752a721d17c..7bef2dcdd51 100644 --- a/extensions/tavily/src/config.ts +++ b/extensions/tavily/src/config.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { normalizeSecretInput } from "openclaw/plugin-sdk/provider-auth"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; export const DEFAULT_TAVILY_BASE_URL = "https://api.tavily.com"; export const DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS = 30; diff --git a/extensions/tavily/src/tavily-extract-tool.ts b/extensions/tavily/src/tavily-extract-tool.ts index 1a3c381fc64..29a7b04399a 100644 --- a/extensions/tavily/src/tavily-extract-tool.ts +++ b/extensions/tavily/src/tavily-extract-tool.ts @@ -1,6 +1,6 @@ import { Type } from "@sinclair/typebox"; -import { optionalStringEnum } from "openclaw/plugin-sdk/agent-runtime"; import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/agent-runtime"; +import { optionalStringEnum } from "openclaw/plugin-sdk/core"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime"; import { runTavilyExtract } from "./tavily-client.js"; diff --git a/extensions/tavily/src/tavily-search-tool.ts b/extensions/tavily/src/tavily-search-tool.ts index 1d925973fe0..08cfe3e6606 100644 --- a/extensions/tavily/src/tavily-search-tool.ts +++ b/extensions/tavily/src/tavily-search-tool.ts @@ -1,6 +1,6 @@ import { Type } from "@sinclair/typebox"; -import { optionalStringEnum } from "openclaw/plugin-sdk/agent-runtime"; import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/agent-runtime"; +import { optionalStringEnum } from "openclaw/plugin-sdk/core"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime"; import { runTavilySearch } from "./tavily-client.js"; diff --git a/extensions/telegram/runtime-api.ts b/extensions/telegram/runtime-api.ts index c069a35e40e..28c7788ef9d 100644 --- a/extensions/telegram/runtime-api.ts +++ b/extensions/telegram/runtime-api.ts @@ -7,7 +7,7 @@ export type { TelegramAccountConfig, TelegramActionConfig, TelegramNetworkConfig, -} from "openclaw/plugin-sdk/telegram"; +} from "../../src/plugin-sdk/telegram.js"; export type { OpenClawPluginService, OpenClawPluginServiceContext, @@ -37,7 +37,7 @@ export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, resolveTelegramPollVisibility, -} from "openclaw/plugin-sdk/telegram"; +} from "../../src/plugin-sdk/telegram.js"; export { buildChannelConfigSchema, getChatChannelMeta, @@ -49,7 +49,7 @@ export { readStringParam, resolvePollMaxSelections, TelegramConfigSchema, -} from "openclaw/plugin-sdk/telegram-core"; +} from "../../src/plugin-sdk/telegram-core.js"; export type { TelegramProbe } from "./src/probe.js"; export { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./src/audit.js"; export { telegramMessageActions } from "./src/channel-actions.js"; diff --git a/extensions/telegram/src/account-inspect.ts b/extensions/telegram/src/account-inspect.ts index 5d131a70586..47c6183fb8b 100644 --- a/extensions/telegram/src/account-inspect.ts +++ b/extensions/telegram/src/account-inspect.ts @@ -1,13 +1,13 @@ import { resolveAccountWithDefaultFallback } from "openclaw/plugin-sdk/account-resolution"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { - coerceSecretRef, - hasConfiguredSecretInput, - normalizeSecretInputString, -} from "openclaw/plugin-sdk/config-runtime"; +import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "openclaw/plugin-sdk/secret-input"; import type { TelegramAccountConfig } from "../runtime-api.js"; import { mergeTelegramAccountConfig, diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index c07dae07681..436f7d84874 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -1,6 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; -import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions"; import { resolveTelegramPollVisibility } from "../runtime-api.js"; import { jsonResult, diff --git a/extensions/telegram/src/api-fetch.test.ts b/extensions/telegram/src/api-fetch.test.ts index e65499ef25c..5de45f6ee75 100644 --- a/extensions/telegram/src/api-fetch.test.ts +++ b/extensions/telegram/src/api-fetch.test.ts @@ -54,4 +54,28 @@ describe("fetchTelegramChatId", () => { undefined, ); }); + + it("uses caller-provided fetch impl when present", async () => { + const customFetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ ok: true, result: { id: 12345 } }), + })); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("global fetch should not be called"); + }), + ); + + await fetchTelegramChatId({ + token: "abc", + chatId: "@user", + fetchImpl: customFetch as unknown as typeof fetch, + }); + + expect(customFetch).toHaveBeenCalledWith( + "https://api.telegram.org/botabc/getChat?chat_id=%40user", + undefined, + ); + }); }); diff --git a/extensions/telegram/src/api-fetch.ts b/extensions/telegram/src/api-fetch.ts index 8831caa2b8a..21dd8fd64e5 100644 --- a/extensions/telegram/src/api-fetch.ts +++ b/extensions/telegram/src/api-fetch.ts @@ -1,11 +1,48 @@ +import type { TelegramNetworkConfig } from "../runtime-api.js"; +import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js"; +import { makeProxyFetch } from "./proxy.js"; + +export function resolveTelegramChatLookupFetch(params?: { + proxyUrl?: string; + network?: TelegramNetworkConfig; +}): typeof fetch { + const proxyUrl = params?.proxyUrl?.trim(); + const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; + return resolveTelegramFetch(proxyFetch, { network: params?.network }); +} + +export async function lookupTelegramChatId(params: { + token: string; + chatId: string; + signal?: AbortSignal; + apiRoot?: string; + proxyUrl?: string; + network?: TelegramNetworkConfig; +}): Promise { + return fetchTelegramChatId({ + token: params.token, + chatId: params.chatId, + signal: params.signal, + apiRoot: params.apiRoot, + fetchImpl: resolveTelegramChatLookupFetch({ + proxyUrl: params.proxyUrl, + network: params.network, + }), + }); +} + export async function fetchTelegramChatId(params: { token: string; chatId: string; signal?: AbortSignal; + apiRoot?: string; + fetchImpl?: typeof fetch; }): Promise { - const url = `https://api.telegram.org/bot${params.token}/getChat?chat_id=${encodeURIComponent(params.chatId)}`; + const apiBase = resolveTelegramApiBase(params.apiRoot); + const url = `${apiBase}/bot${params.token}/getChat?chat_id=${encodeURIComponent(params.chatId)}`; + const fetchImpl = params.fetchImpl ?? fetch; try { - const res = await fetch(url, params.signal ? { signal: params.signal } : undefined); + const res = await fetchImpl(url, params.signal ? { signal: params.signal } : undefined); if (!res.ok) { return null; } diff --git a/extensions/telegram/src/audit-membership-runtime.ts b/extensions/telegram/src/audit-membership-runtime.ts index 930d768778e..a8cc98f4701 100644 --- a/extensions/telegram/src/audit-membership-runtime.ts +++ b/extensions/telegram/src/audit-membership-runtime.ts @@ -5,11 +5,9 @@ import type { TelegramGroupMembershipAudit, TelegramGroupMembershipAuditEntry, } from "./audit.js"; -import { resolveTelegramFetch } from "./fetch.js"; +import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; -const TELEGRAM_API_BASE = "https://api.telegram.org"; - type TelegramApiOk = { ok: true; result: T }; type TelegramApiErr = { ok: false; description?: string }; type TelegramGroupMembershipAuditData = Omit; @@ -18,8 +16,11 @@ export async function auditTelegramGroupMembershipImpl( params: AuditTelegramGroupMembershipParams, ): Promise { const proxyFetch = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : undefined; - const fetcher = resolveTelegramFetch(proxyFetch, { network: params.network }); - const base = `${TELEGRAM_API_BASE}/bot${params.token}`; + const fetcher = resolveTelegramFetch(proxyFetch, { + network: params.network, + }); + const apiBase = resolveTelegramApiBase(params.apiRoot); + const base = `${apiBase}/bot${params.token}`; const groups: TelegramGroupMembershipAuditEntry[] = []; for (const chatId of params.groupIds) { diff --git a/extensions/telegram/src/audit.ts b/extensions/telegram/src/audit.ts index f7fb0969090..f205dc49127 100644 --- a/extensions/telegram/src/audit.ts +++ b/extensions/telegram/src/audit.ts @@ -66,6 +66,7 @@ export type AuditTelegramGroupMembershipParams = { groupIds: string[]; proxyUrl?: string; network?: TelegramNetworkConfig; + apiRoot?: string; timeoutMs: number; }; diff --git a/extensions/telegram/src/bot-access.ts b/extensions/telegram/src/bot-access.ts index c89a8fe6f48..82034aeadb2 100644 --- a/extensions/telegram/src/bot-access.ts +++ b/extensions/telegram/src/bot-access.ts @@ -2,8 +2,8 @@ import { firstDefined, isSenderIdAllowed, mergeDmAllowFromSources, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { AllowlistMatch } from "openclaw/plugin-sdk/channel-runtime"; + type AllowlistMatch, +} from "openclaw/plugin-sdk/allow-from"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; export type NormalizedAllowFrom = { diff --git a/extensions/telegram/src/bot-deps.ts b/extensions/telegram/src/bot-deps.ts index a21c4f0c586..93aac0c8b8f 100644 --- a/extensions/telegram/src/bot-deps.ts +++ b/extensions/telegram/src/bot-deps.ts @@ -1,12 +1,12 @@ +import { + buildModelsProviderData, + listSkillCommandsForAgents, +} from "openclaw/plugin-sdk/command-auth"; import { loadConfig, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; -import { - buildModelsProviderData, - dispatchReplyWithBufferedBlockDispatcher, - listSkillCommandsForAgents, -} from "openclaw/plugin-sdk/reply-runtime"; +import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import { wasSentByBot } from "./sent-message-cache.js"; export type TelegramBotDeps = { diff --git a/extensions/telegram/src/bot-handlers.buffers.ts b/extensions/telegram/src/bot-handlers.buffers.ts index 41dcee18aa4..7d301251176 100644 --- a/extensions/telegram/src/bot-handlers.buffers.ts +++ b/extensions/telegram/src/bot-handlers.buffers.ts @@ -1,10 +1,10 @@ import type { Message } from "@grammyjs/types"; -import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { createInboundDebouncer, resolveInboundDebounceMs, -} from "openclaw/plugin-sdk/reply-runtime"; + shouldDebounceTextInbound, +} from "openclaw/plugin-sdk/channel-inbound"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; import { hasInboundMedia, diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 00dc35041c9..96726785db2 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -1,8 +1,18 @@ import type { Message, ReactionTypeEmoji } from "@grammyjs/types"; import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; -import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers"; +import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-inbound"; +import { + createInboundDebouncer, + resolveInboundDebounceMs, +} from "openclaw/plugin-sdk/channel-inbound"; +import { + buildCommandsMessagePaginated, + buildCommandsPaginationKeyboard, + formatModelsAvailableHeader, + resolveStoredModelOverride, +} from "openclaw/plugin-sdk/command-auth"; import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, @@ -22,14 +32,6 @@ import { resolvePluginConversationBindingApproval, } from "openclaw/plugin-sdk/conversation-runtime"; import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime"; -import { - createInboundDebouncer, - resolveInboundDebounceMs, -} from "openclaw/plugin-sdk/reply-runtime"; -import { buildCommandsPaginationKeyboard } from "openclaw/plugin-sdk/reply-runtime"; -import { formatModelsAvailableHeader } from "openclaw/plugin-sdk/reply-runtime"; -import { resolveStoredModelOverride } from "openclaw/plugin-sdk/reply-runtime"; -import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; @@ -359,7 +361,13 @@ export const registerTelegramHandlers = ({ for (const { ctx } of entry.messages) { let media; try { - media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport); + media = await resolveMedia( + ctx, + mediaMaxBytes, + opts.token, + telegramTransport, + telegramCfg.apiRoot, + ); } catch (mediaErr) { if (!isRecoverableMediaGroupError(mediaErr)) { throw mediaErr; @@ -464,6 +472,7 @@ export const registerTelegramHandlers = ({ mediaMaxBytes, opts.token, telegramTransport, + telegramCfg.apiRoot, ); if (!media) { return []; @@ -975,7 +984,13 @@ export const registerTelegramHandlers = ({ let media: Awaited> = null; try { - media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport); + media = await resolveMedia( + ctx, + mediaMaxBytes, + opts.token, + telegramTransport, + telegramCfg.apiRoot, + ); } catch (mediaErr) { if (isMediaSizeLimitError(mediaErr)) { if (sendOversizeWarning) { diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts index 63e6aaa12dd..04e5739d663 100644 --- a/extensions/telegram/src/bot-message-context.body.ts +++ b/extensions/telegram/src/bot-message-context.body.ts @@ -4,22 +4,26 @@ import { modelSupportsVision, } from "openclaw/plugin-sdk/agent-runtime"; import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; -import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime"; -import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime"; +import { + buildMentionRegexes, + formatLocationText, + logInboundDrop, + matchesMentionWithExplicit, + resolveMentionGatingWithBypass, + type NormalizedLocation, +} from "openclaw/plugin-sdk/channel-inbound"; +import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, } from "openclaw/plugin-sdk/config-runtime"; -import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; import { recordPendingHistoryEntryIfEnabled, type HistoryEntry, -} from "openclaw/plugin-sdk/reply-runtime"; -import { buildMentionRegexes, matchesMentionWithExplicit } from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { NormalizedAllowFrom } from "./bot-access.js"; 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 e51c7920ae7..33d1e35e470 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,8 +6,8 @@ import { import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined); -vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index 47bcda8592f..2581e1d398b 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -1,5 +1,10 @@ -import { toLocationContext } from "openclaw/plugin-sdk/channel-runtime"; -import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, + toLocationContext, + type NormalizedLocation, +} from "openclaw/plugin-sdk/channel-inbound"; +import { normalizeCommandBody } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import type { @@ -7,15 +12,11 @@ import type { TelegramGroupConfig, TelegramTopicConfig, } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeCommandBody } from "openclaw/plugin-sdk/reply-runtime"; -import { - formatInboundEnvelope, - resolveEnvelopeFormatOptions, -} from "openclaw/plugin-sdk/reply-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { buildPendingHistoryContextFromMap, type HistoryEntry, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; @@ -63,7 +64,7 @@ export async function buildTelegramInboundContextPayload(params: { stickerCacheHit: boolean; effectiveWasMentioned: boolean; commandAuthorized: boolean; - locationData?: import("openclaw/plugin-sdk/channel-runtime").NormalizedLocation; + locationData?: NormalizedLocation; options?: TelegramMessageContextOptions; dmAllowFrom?: Array; }): Promise<{ diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index 3c90a344708..046717b8175 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -1,10 +1,10 @@ import { resolveAckReaction } from "openclaw/plugin-sdk/agent-runtime"; -import { shouldAckReaction as shouldAckReactionGate } from "openclaw/plugin-sdk/channel-runtime"; -import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; import { createStatusReactionController, + shouldAckReaction as shouldAckReactionGate, type StatusReactionController, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-feedback"; +import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramDirectConfig, TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime"; import { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; diff --git a/extensions/telegram/src/bot-message-context.types.ts b/extensions/telegram/src/bot-message-context.types.ts index ff782c0a1fa..a7e00397b33 100644 --- a/extensions/telegram/src/bot-message-context.types.ts +++ b/extensions/telegram/src/bot-message-context.types.ts @@ -6,7 +6,7 @@ import type { TelegramGroupConfig, TelegramTopicConfig, } from "openclaw/plugin-sdk/config-runtime"; -import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import type { StickerMetadata, TelegramContext } from "./bot/types.js"; export type TelegramMediaRef = { diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index a5f9cb58c89..70e5acf0922 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -6,9 +6,12 @@ import { modelSupportsVision, } from "openclaw/plugin-sdk/agent-runtime"; import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; +import { + logAckFailure, + logTypingFailure, + removeAckReactionAfterReply, +} from "openclaw/plugin-sdk/channel-feedback"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { removeAckReactionAfterReply } from "openclaw/plugin-sdk/channel-runtime"; -import { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, @@ -21,9 +24,9 @@ import type { TelegramAccountConfig, } from "openclaw/plugin-sdk/config-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; -import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/telegram/src/bot-native-commands.menu-test-support.ts b/extensions/telegram/src/bot-native-commands.menu-test-support.ts index e74220b248a..9701802bb2a 100644 --- a/extensions/telegram/src/bot-native-commands.menu-test-support.ts +++ b/extensions/telegram/src/bot-native-commands.menu-test-support.ts @@ -34,8 +34,8 @@ const deliveryMocks = vi.hoisted(() => ({ export const listSkillCommandsForAgents = skillCommandMocks.listSkillCommandsForAgents; export const deliverReplies = deliveryMocks.deliverReplies; -vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/command-auth", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, listSkillCommandsForAgents, 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 bfe314d4140..eef2f76abda 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -73,23 +73,6 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { ...actual, resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute, ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady, - readChannelAllowFromStore: conversationStoreMocks.readChannelAllowFromStore, - upsertChannelPairingRequest: conversationStoreMocks.upsertChannelPairingRequest, - 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; @@ -112,6 +95,23 @@ vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { } }, ), + readChannelAllowFromStore: conversationStoreMocks.readChannelAllowFromStore, + upsertChannelPairingRequest: conversationStoreMocks.upsertChannelPairingRequest, + 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/command-auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listSkillCommandsForAgents: vi.fn(() => []), }; }); vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { @@ -120,7 +120,6 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { ...actual, finalizeInboundContext: vi.fn((ctx: unknown) => ctx), dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher, - listSkillCommandsForAgents: vi.fn(() => []), }; }); vi.mock("../../../src/config/sessions.js", () => ({ diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index 973d62485ab..65e3baf411d 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -22,7 +22,7 @@ type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< ReturnType >; type RecordInboundSessionMetaSafeFn = - typeof import("openclaw/plugin-sdk/channel-runtime").recordInboundSessionMetaSafe; + typeof import("openclaw/plugin-sdk/conversation-runtime").recordInboundSessionMetaSafe; type AnyMock = MockFn<(...args: unknown[]) => unknown>; type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; type NativeCommandHarness = { @@ -74,11 +74,12 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher, }; }); -vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, recordInboundSessionMetaSafe: replyPipelineMocks.recordInboundSessionMetaSafe, + readChannelAllowFromStore: vi.fn(async () => []), }; }); vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", async (importOriginal) => { @@ -95,13 +96,6 @@ const deliveryMocks = vi.hoisted(() => ({ })); export const deliverReplies = deliveryMocks.deliverReplies; vi.mock("./bot/delivery.js", () => ({ deliverReplies: deliveryMocks.deliverReplies })); -vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readChannelAllowFromStore: vi.fn(async () => []), - }; -}); export { createNativeCommandTestParams }; export function createNativeCommandsHarness(params?: { diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index e85a444369b..2674762b1e0 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -17,8 +17,8 @@ const deliveryMocks = vi.hoisted(() => ({ deliverReplies: vi.fn(async () => ({ delivered: true })), })); -vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/command-auth", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, listSkillCommandsForAgents: skillCommandMocks.listSkillCommandsForAgents, diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 103cca984e0..e81713956cd 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -1,8 +1,19 @@ import type { Bot, Context } from "grammy"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/channel-runtime"; -import { recordInboundSessionMetaSafe } from "openclaw/plugin-sdk/channel-runtime"; +import { + resolveCommandAuthorization, + resolveCommandAuthorizedFromAuthorizers, + resolveNativeCommandSessionTargets, +} from "openclaw/plugin-sdk/command-auth"; +import { + buildCommandTextFromArgs, + findCommandByNativeName, + listNativeCommandSpecs, + listNativeCommandSpecsForConfig, + parseCommandArgs, + resolveCommandArgMenu, + type CommandArgs, +} from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; @@ -18,7 +29,10 @@ import type { TelegramGroupConfig, TelegramTopicConfig, } from "openclaw/plugin-sdk/config-runtime"; -import { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; +import { + ensureConfiguredBindingRouteReady, + recordInboundSessionMetaSafe, +} from "openclaw/plugin-sdk/conversation-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { executePluginCommand, @@ -26,16 +40,6 @@ import { matchPluginCommand, } from "openclaw/plugin-sdk/plugin-runtime"; import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; -import { resolveCommandAuthorization } from "openclaw/plugin-sdk/reply-runtime"; -import type { CommandArgs } from "openclaw/plugin-sdk/reply-runtime"; -import { - buildCommandTextFromArgs, - findCommandByNativeName, - listNativeCommandSpecs, - listNativeCommandSpecsForConfig, - parseCommandArgs, - resolveCommandArgMenu, -} from "openclaw/plugin-sdk/reply-runtime"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; 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 a9793692b21..6009b16947a 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -230,28 +230,40 @@ function createModelsProviderDataFromConfig(cfg: OpenClawConfig): { return { byProvider, providers, resolvedDefault }; } +vi.doMock("openclaw/plugin-sdk/command-auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents, + buildModelsProviderData, + }; +}); +vi.doMock("openclaw/plugin-sdk/command-auth.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents, + buildModelsProviderData, + }; +}); vi.doMock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents, getReplyFromConfig: replySpyHoisted.replySpy, __replySpy: replySpyHoisted.replySpy, dispatchReplyWithBufferedBlockDispatcher: dispatchReplyHoisted.dispatchReplyWithBufferedBlockDispatcher, - buildModelsProviderData, }; }); vi.doMock("openclaw/plugin-sdk/reply-runtime.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents, getReplyFromConfig: replySpyHoisted.replySpy, __replySpy: replySpyHoisted.replySpy, dispatchReplyWithBufferedBlockDispatcher: dispatchReplyHoisted.dispatchReplyWithBufferedBlockDispatcher, - buildModelsProviderData, }; }); diff --git a/extensions/telegram/src/bot.ts b/extensions/telegram/src/bot.ts index 36dcc0f5db2..11c394518c4 100644 --- a/extensions/telegram/src/bot.ts +++ b/extensions/telegram/src/bot.ts @@ -1,9 +1,4 @@ import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; -import { - resolveThreadBindingIdleTimeoutMsForChannel, - resolveThreadBindingMaxAgeMsForChannel, - resolveThreadBindingSpawnPolicy, -} from "openclaw/plugin-sdk/channel-runtime"; import { isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, @@ -15,9 +10,14 @@ import { resolveChannelGroupRequireMention, } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingMaxAgeMsForChannel, + resolveThreadBindingSpawnPolicy, +} from "openclaw/plugin-sdk/conversation-runtime"; import { formatUncaughtError } from "openclaw/plugin-sdk/infra-runtime"; +import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; @@ -230,11 +230,13 @@ export function createTelegramBot(opts: TelegramBotOptions) { typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds) ? Math.max(1, Math.floor(telegramCfg.timeoutSeconds)) : undefined; + const apiRoot = telegramCfg.apiRoot?.trim() || undefined; const client: ApiClientOptions | undefined = - finalFetch || timeoutSeconds + finalFetch || timeoutSeconds || apiRoot ? { ...(finalFetch ? { fetch: finalFetch } : {}), ...(timeoutSeconds ? { timeoutSeconds } : {}), + ...(apiRoot ? { apiRoot } : {}), } : undefined; 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 b1cd7eb4d8a..86d6e608dce 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -360,6 +360,38 @@ describe("resolveMedia getFile retry", () => { }), ); }); + + it("uses local absolute file paths directly for media downloads", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" }); + + const result = await resolveMedia(makeCtx("document", getFile), MAX_MEDIA_BYTES, BOT_TOKEN); + + expect(fetchRemoteMedia).not.toHaveBeenCalled(); + expect(saveMediaBuffer).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + path: "/var/lib/telegram-bot-api/file.pdf", + placeholder: "", + }), + ); + }); + + it("uses local absolute file paths directly for sticker downloads", async () => { + const getFile = vi + .fn() + .mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/sticker.webp" }); + + const result = await resolveMedia(makeCtx("sticker", getFile), MAX_MEDIA_BYTES, BOT_TOKEN); + + expect(fetchRemoteMedia).not.toHaveBeenCalled(); + expect(saveMediaBuffer).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + path: "/var/lib/telegram-bot-api/sticker.webp", + placeholder: "", + }), + ); + }); }); describe("resolveMedia original filename preservation", () => { diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts index 52f6eef966c..2e552529dec 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -1,21 +1,39 @@ +import path from "node:path"; import { GrammyError } from "grammy"; import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; import { retryAsync } from "openclaw/plugin-sdk/infra-runtime"; import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; import { logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; -import { shouldRetryTelegramTransportFallback, type TelegramTransport } from "../fetch.js"; +import { + resolveTelegramApiBase, + 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"; const FILE_TOO_BIG_RE = /file is too big/i; -const TELEGRAM_MEDIA_SSRF_POLICY = { - // Telegram file downloads should trust api.telegram.org even when DNS/proxy - // resolution maps to private/internal ranges in restricted networks. - allowedHostnames: ["api.telegram.org"], - allowRfc2544BenchmarkRange: true, -}; +function buildTelegramMediaSsrfPolicy(apiRoot?: string) { + const hostnames = ["api.telegram.org"]; + if (apiRoot) { + try { + const customHost = new URL(apiRoot).hostname; + if (customHost && !hostnames.includes(customHost)) { + hostnames.push(customHost); + } + } catch { + // invalid URL; fall through to default + } + } + return { + // Telegram file downloads should trust the API hostname even when DNS/proxy + // resolution maps to private/internal ranges in restricted networks. + allowedHostnames: hostnames, + allowRfc2544BenchmarkRange: true, + }; +} /** * Returns true if the error is Telegram's "file is too big" error. @@ -124,8 +142,13 @@ async function downloadAndSaveTelegramFile(params: { transport: TelegramTransport; maxBytes: number; telegramFileName?: string; + apiRoot?: string; }) { - const url = `https://api.telegram.org/file/bot${params.token}/${params.filePath}`; + if (path.isAbsolute(params.filePath)) { + return { path: params.filePath, contentType: undefined }; + } + const apiBase = resolveTelegramApiBase(params.apiRoot); + const url = `${apiBase}/file/bot${params.token}/${params.filePath}`; const fetched = await fetchRemoteMedia({ url, fetchImpl: params.transport.sourceFetch, @@ -134,7 +157,7 @@ async function downloadAndSaveTelegramFile(params: { filePathHint: params.filePath, maxBytes: params.maxBytes, readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS, - ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY, + ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot), }); const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath; return saveMediaBuffer( @@ -152,6 +175,7 @@ async function resolveStickerMedia(params: { maxBytes: number; token: string; transport?: TelegramTransport; + apiRoot?: string; }): Promise< | { path: string; @@ -192,6 +216,7 @@ async function resolveStickerMedia(params: { token, transport: resolvedTransport, maxBytes, + apiRoot: params.apiRoot, }); // Check sticker cache for existing description @@ -247,6 +272,7 @@ export async function resolveMedia( maxBytes: number, token: string, transport?: TelegramTransport, + apiRoot?: string, ): Promise<{ path: string; contentType?: string; @@ -260,6 +286,7 @@ export async function resolveMedia( maxBytes, token, transport, + apiRoot, }); if (stickerResolved !== undefined) { return stickerResolved; @@ -283,6 +310,7 @@ export async function resolveMedia( transport: resolveRequiredTelegramTransport(transport), maxBytes, telegramFileName: resolveTelegramFileName(msg), + apiRoot, }); const placeholder = resolveTelegramMediaPlaceholder(msg) ?? ""; return { path: saved.path, contentType: saved.contentType, placeholder }; diff --git a/extensions/telegram/src/bot/helpers.ts b/extensions/telegram/src/bot/helpers.ts index 98ec1f1aaf6..29561953466 100644 --- a/extensions/telegram/src/bot/helpers.ts +++ b/extensions/telegram/src/bot/helpers.ts @@ -1,5 +1,5 @@ import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types"; -import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime"; +import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound"; import { resolveTelegramPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramDirectConfig, diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index d01c5f91839..5cb17a2ee12 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -1,13 +1,15 @@ import { - createMessageToolButtonsSchema, createUnionActionGate, listTokenSourcedAccounts, resolveReactionMessageId, - type ChannelMessageActionAdapter, - type ChannelMessageActionName, - type ChannelMessageToolDiscovery, - type ChannelMessageToolSchemaContribution, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-actions"; +import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-actions"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelMessageToolSchemaContribution, +} from "openclaw/plugin-sdk/channel-contract"; import type { TelegramActionConfig } from "openclaw/plugin-sdk/config-runtime"; import { extractToolSend } from "openclaw/plugin-sdk/tool-send"; import { diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index c9e8df40be0..1a174f7200f 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -1,10 +1,10 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; import type { ChannelAccountSnapshot, ChannelGatewayContext, - OpenClawConfig, - PluginRuntime, -} from "openclaw/plugin-sdk/telegram"; -import { afterEach, describe, expect, it, vi } from "vitest"; +} from "../../../src/channels/plugins/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { PluginRuntime } from "../../../src/plugins/runtime/types.js"; import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { ResolvedTelegramAccount } from "./accounts.js"; import * as auditModule from "./audit.js"; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 25c81509820..5a481ba8ac3 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -3,22 +3,27 @@ import { createNestedAllowlistOverrideResolver, } from "openclaw/plugin-sdk/allowlist-config-edit"; import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-pairing"; import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { attachChannelToResult, createAttachedChannelResultAdapter, - createChannelDirectoryAdapter, - createPairingPrefixStripper, - createTopLevelChannelReplyToModeResolver, - createTextPairingAdapter, - normalizeMessageChannel, - type OutboundSendDeps, - resolveOutboundSendDep, -} from "openclaw/plugin-sdk/channel-runtime"; -import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/channel-send-result"; +import { createTopLevelChannelReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; +import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra-runtime"; -import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; +import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/infra-runtime"; +import { + buildOutboundBaseSessionKey, + normalizeMessageChannel, + normalizeOutboundThreadId, + resolveThreadSessionKeys, + type RoutePeer, +} from "openclaw/plugin-sdk/routing"; import { parseTelegramTopicConversation } from "../runtime-api.js"; import { buildTokenChannelStatusSummary, @@ -581,6 +586,7 @@ export const telegramPlugin: ChannelPlugin { const lines = []; @@ -632,6 +638,7 @@ export const telegramPlugin: ChannelPlugin vi.fn()); vi.mock("./fetch.js", () => ({ resolveTelegramFetch, + resolveTelegramApiBase: (apiRoot?: string) => + apiRoot?.trim()?.replace(/\/+$/, "") || "https://api.telegram.org", })); vi.mock("./proxy.js", () => ({ @@ -190,6 +192,7 @@ describe("probeTelegram retry logic", () => { autoSelectFamily: false, dnsResultOrder: "ipv4first", }, + apiRoot: undefined, }); }); diff --git a/extensions/telegram/src/probe.ts b/extensions/telegram/src/probe.ts index 60d9b3a3a40..bec56269927 100644 --- a/extensions/telegram/src/probe.ts +++ b/extensions/telegram/src/probe.ts @@ -1,11 +1,9 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime"; import type { TelegramNetworkConfig } from "../runtime-api.js"; -import { resolveTelegramFetch } from "./fetch.js"; +import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; -const TELEGRAM_API_BASE = "https://api.telegram.org"; - export type TelegramProbe = BaseProbeResult & { status?: number | null; elapsedMs: number; @@ -23,6 +21,7 @@ export type TelegramProbeOptions = { proxyUrl?: string; network?: TelegramNetworkConfig; accountId?: string; + apiRoot?: string; }; const probeFetcherCache = new Map(); @@ -56,7 +55,8 @@ function buildProbeFetcherCacheKey(token: string, options?: TelegramProbeOptions const autoSelectFamilyKey = typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default"; const dnsResultOrderKey = options?.network?.dnsResultOrder ?? "default"; - return `${cacheIdentityKind}:${cacheIdentity}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}`; + const apiRootKey = options?.apiRoot?.trim() ?? ""; + return `${cacheIdentityKind}:${cacheIdentity}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${apiRootKey}`; } function setCachedProbeFetcher(cacheKey: string, fetcher: typeof fetch): typeof fetch { @@ -82,7 +82,9 @@ function resolveProbeFetcher(token: string, options?: TelegramProbeOptions): typ const proxyUrl = options?.proxyUrl?.trim(); const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; - const resolved = resolveTelegramFetch(proxyFetch, { network: options?.network }); + const resolved = resolveTelegramFetch(proxyFetch, { + network: options?.network, + }); if (cacheKey) { return setCachedProbeFetcher(cacheKey, resolved); @@ -100,7 +102,8 @@ export async function probeTelegram( const deadlineMs = started + timeoutBudgetMs; const options = resolveProbeOptions(proxyOrOptions); const fetcher = resolveProbeFetcher(token, options); - const base = `${TELEGRAM_API_BASE}/bot${token}`; + const apiBase = resolveTelegramApiBase(options?.apiRoot); + const base = `${apiBase}/bot${token}`; const retryDelayMs = Math.max(50, Math.min(1000, Math.floor(timeoutBudgetMs / 5))); const resolveRemainingBudgetMs = () => Math.max(0, deadlineMs - Date.now()); diff --git a/extensions/telegram/src/send.proxy.test.ts b/extensions/telegram/src/send.proxy.test.ts index 6c17b33fe38..4f5709e581e 100644 --- a/extensions/telegram/src/send.proxy.test.ts +++ b/extensions/telegram/src/send.proxy.test.ts @@ -21,8 +21,10 @@ const { resolveTelegramFetch } = vi.hoisted(() => ({ resolveTelegramFetch: vi.fn(), })); -vi.mock("../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/config-runtime", + ); return { ...actual, loadConfig, @@ -35,6 +37,8 @@ vi.mock("./proxy.js", () => ({ vi.mock("./fetch.js", () => ({ resolveTelegramFetch, + resolveTelegramApiBase: (apiRoot?: string) => + apiRoot?.trim()?.replace(/\/+$/, "") || "https://api.telegram.org", })); vi.mock("grammy", () => ({ diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index ec824d88ec7..55f1d689359 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -25,7 +25,7 @@ import { withTelegramApiErrorLogging } from "./api-logging.js"; import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js"; import type { TelegramInlineButtons } from "./button-types.js"; import { splitTelegramCaption } from "./caption.js"; -import { resolveTelegramFetch } from "./fetch.js"; +import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js"; import { renderTelegramHtmlText, splitTelegramHtmlChunks } from "./format.js"; import { isRecoverableTelegramNetworkError, @@ -192,9 +192,10 @@ function buildTelegramClientOptionsCacheKey(params: { const autoSelectFamilyKey = typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default"; const dnsResultOrderKey = params.account.config.network?.dnsResultOrder ?? "default"; + const apiRootKey = params.account.config.apiRoot?.trim() ?? ""; const timeoutSecondsKey = typeof params.timeoutSeconds === "number" ? String(params.timeoutSeconds) : "default"; - return `${params.account.accountId}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${timeoutSecondsKey}`; + return `${params.account.accountId}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${apiRootKey}::${timeoutSecondsKey}`; } function setCachedTelegramClientOptions( @@ -233,14 +234,16 @@ function resolveTelegramClientOptions( const proxyUrl = account.config.proxy?.trim(); const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; + const apiRoot = account.config.apiRoot?.trim() || undefined; const fetchImpl = resolveTelegramFetch(proxyFetch, { network: account.config.network, }); const clientOptions = - fetchImpl || timeoutSeconds + fetchImpl || timeoutSeconds || apiRoot ? { ...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}), ...(timeoutSeconds ? { timeoutSeconds } : {}), + ...(apiRoot ? { apiRoot } : {}), } : undefined; if (cacheKey) { diff --git a/extensions/telegram/src/setup-core.test.ts b/extensions/telegram/src/setup-core.test.ts new file mode 100644 index 00000000000..5cf316c54d6 --- /dev/null +++ b/extensions/telegram/src/setup-core.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveTelegramAllowFromEntries } from "./setup-core.js"; + +describe("resolveTelegramAllowFromEntries", () => { + it("passes apiRoot through username lookups", async () => { + const globalFetch = vi.fn(async () => { + throw new Error("global fetch should not be called"); + }); + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ ok: true, result: { id: 12345 } }), + })); + vi.stubGlobal("fetch", globalFetch); + const proxyFetch = vi.fn(); + const fetchModule = await import("./fetch.js"); + const proxyModule = await import("./proxy.js"); + const resolveTelegramFetch = vi.spyOn(fetchModule, "resolveTelegramFetch"); + const makeProxyFetch = vi.spyOn(proxyModule, "makeProxyFetch"); + makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); + resolveTelegramFetch.mockReturnValue(fetchMock as unknown as typeof fetch); + + try { + const resolved = await resolveTelegramAllowFromEntries({ + entries: ["@user"], + credentialValue: "tok", + apiRoot: "https://custom.telegram.test/root/", + proxyUrl: "http://127.0.0.1:8080", + network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" }, + }); + + expect(resolved).toEqual([{ input: "@user", resolved: true, id: "12345" }]); + expect(makeProxyFetch).toHaveBeenCalledWith("http://127.0.0.1:8080"); + expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { + network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" }, + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://custom.telegram.test/root/bottok/getChat?chat_id=%40user", + undefined, + ); + } finally { + makeProxyFetch.mockRestore(); + resolveTelegramFetch.mockRestore(); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index afc302500bf..6e24563a9c9 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -9,8 +9,9 @@ import { } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; +import type { TelegramNetworkConfig } from "../runtime-api.js"; import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; -import { fetchTelegramChatId } from "./api-fetch.js"; +import { lookupTelegramChatId } from "./api-fetch.js"; const channel = "telegram" as const; @@ -46,6 +47,9 @@ export function parseTelegramAllowFromId(raw: string): string | null { export async function resolveTelegramAllowFromEntries(params: { entries: string[]; credentialValue?: string; + apiRoot?: string; + proxyUrl?: string; + network?: TelegramNetworkConfig; }) { return await Promise.all( params.entries.map(async (entry) => { @@ -58,9 +62,12 @@ export async function resolveTelegramAllowFromEntries(params: { return { input: entry, resolved: false, id: null }; } const username = stripped.startsWith("@") ? stripped : `@${stripped}`; - const id = await fetchTelegramChatId({ + const id = await lookupTelegramChatId({ token: params.credentialValue, chatId: username, + apiRoot: params.apiRoot, + proxyUrl: params.proxyUrl, + network: params.network, }); return { input: entry, resolved: Boolean(id), id }; }), @@ -96,6 +103,9 @@ export async function promptTelegramAllowFromForAccount(params: { resolveTelegramAllowFromEntries({ credentialValue: token, entries, + apiRoot: resolved.config.apiRoot, + proxyUrl: resolved.config.proxy, + network: resolved.config.network, }), }); return patchChannelConfigForAccount({ diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index 75ebee401a2..f7b0c3e5ebb 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -119,10 +119,11 @@ export const telegramSetupWizard: ChannelSetupWizard = { "Telegram token missing; use numeric sender ids (usernames require a bot token).", parseInputs: splitSetupEntries, parseId: parseTelegramAllowFromId, - resolveEntries: async ({ credentialValues, entries }) => + resolveEntries: async ({ cfg, accountId, credentialValues, entries }) => resolveTelegramAllowFromEntries({ credentialValue: credentialValues.token, entries, + apiRoot: resolveTelegramAccount({ cfg, accountId }).config.apiRoot, }), apply: async ({ cfg, accountId, allowFrom }) => patchChannelConfigForAccount({ diff --git a/extensions/telegram/src/status-issues.ts b/extensions/telegram/src/status-issues.ts index 0178c0c7346..b819308503a 100644 --- a/extensions/telegram/src/status-issues.ts +++ b/extensions/telegram/src/status-issues.ts @@ -1,13 +1,13 @@ +import type { + ChannelAccountSnapshot, + ChannelStatusIssue, +} from "openclaw/plugin-sdk/channel-contract"; import { appendMatchMetadata, asString, isRecord, resolveEnabledConfiguredAccountId, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { - ChannelAccountSnapshot, - ChannelStatusIssue, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/status-helpers"; type TelegramAccountStatus = { accountId?: unknown; diff --git a/extensions/telegram/src/status-reaction-variants.ts b/extensions/telegram/src/status-reaction-variants.ts index 8c04a87554e..7d995a23168 100644 --- a/extensions/telegram/src/status-reaction-variants.ts +++ b/extensions/telegram/src/status-reaction-variants.ts @@ -1,4 +1,4 @@ -import { DEFAULT_EMOJIS, type StatusReactionEmojis } from "openclaw/plugin-sdk/channel-runtime"; +import { DEFAULT_EMOJIS, type StatusReactionEmojis } from "openclaw/plugin-sdk/channel-feedback"; type StatusReactionEmojiKey = keyof Required; diff --git a/extensions/telegram/src/thread-bindings.ts b/extensions/telegram/src/thread-bindings.ts index 0078c3362e6..be734804efb 100644 --- a/extensions/telegram/src/thread-bindings.ts +++ b/extensions/telegram/src/thread-bindings.ts @@ -1,11 +1,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveThreadBindingEffectiveExpiresAt } from "openclaw/plugin-sdk/channel-runtime"; -import { formatThreadBindingDurationLabel } from "openclaw/plugin-sdk/channel-runtime"; import { + formatThreadBindingDurationLabel, registerSessionBindingAdapter, + resolveThreadBindingConversationIdFromBindingId, + resolveThreadBindingEffectiveExpiresAt, unregisterSessionBindingAdapter, type BindingTargetKind, type SessionBindingRecord, diff --git a/extensions/telegram/src/token.test.ts b/extensions/telegram/src/token.test.ts index c81e5d57b2c..74218f83ddd 100644 --- a/extensions/telegram/src/token.test.ts +++ b/extensions/telegram/src/token.test.ts @@ -188,6 +188,24 @@ describe("resolveTelegramToken", () => { expect(res.source).toBe("none"); }); + it("does not fall through to channel-level token when non-default accountId is not in config", () => { + vi.stubEnv("TELEGRAM_BOT_TOKEN", ""); + const cfg = { + channels: { + telegram: { + botToken: "wrong-bot-token", + accounts: { + knownBot: { botToken: "known-bot-token" }, + }, + }, + }, + } as OpenClawConfig; + + const res = resolveTelegramToken(cfg, { accountId: "unknownBot" }); + expect(res.token).toBe(""); + expect(res.source).toBe("none"); + }); + it("throws when botToken is an unresolved SecretRef object", () => { const cfg = { channels: { diff --git a/extensions/telegram/src/token.ts b/extensions/telegram/src/token.ts index 6727e9a7ee4..c2482772c61 100644 --- a/extensions/telegram/src/token.ts +++ b/extensions/telegram/src/token.ts @@ -1,8 +1,8 @@ -import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-runtime"; +import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import type { TelegramAccountConfig } from "../runtime-api.js"; export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none"; @@ -44,6 +44,17 @@ export function resolveTelegramToken( const accountCfg = resolveAccountCfg( accountId !== DEFAULT_ACCOUNT_ID ? accountId : DEFAULT_ACCOUNT_ID, ); + + // When a non-default accountId is explicitly specified but not found in config, + // return empty immediately β€” do NOT fall through to channel-level defaults, + // which would silently route the message via the wrong bot's token. + if (accountId !== DEFAULT_ACCOUNT_ID && !accountCfg) { + opts.logMissingFile?.( + `channels.telegram.accounts: unknown accountId "${accountId}" β€” not found in config, refusing channel-level fallback`, + ); + return { token: "", source: "none" }; + } + const accountTokenFile = accountCfg?.tokenFile?.trim(); if (accountTokenFile) { const token = tryReadSecretFileSync( diff --git a/extensions/tlon/runtime-api.ts b/extensions/tlon/runtime-api.ts index 3c2c83655c5..3ba9718868f 100644 --- a/extensions/tlon/runtime-api.ts +++ b/extensions/tlon/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Tlon extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/tlon"; +export * from "../../src/plugin-sdk/tlon.js"; diff --git a/extensions/tlon/src/channel.runtime.ts b/extensions/tlon/src/channel.runtime.ts index 56d59d6003b..c00199eeb9b 100644 --- a/extensions/tlon/src/channel.runtime.ts +++ b/extensions/tlon/src/channel.runtime.ts @@ -1,8 +1,6 @@ import crypto from "node:crypto"; -import type { - ChannelAccountSnapshot, - ChannelOutboundAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract"; +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import { createLoggerBackedRuntime } from "openclaw/plugin-sdk/runtime"; diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 89e4a235b60..71752c4d1a3 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,10 +1,8 @@ import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import { - createRuntimeOutboundDelegates, - type ChannelAccountSnapshot, - type ChannelPlugin, -} from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; +import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/infra-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { resolveTlonOutboundSessionRoute } from "./session-route.js"; diff --git a/extensions/twitch/runtime-api.ts b/extensions/twitch/runtime-api.ts index 87433b1997f..9d055202a39 100644 --- a/extensions/twitch/runtime-api.ts +++ b/extensions/twitch/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Twitch extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/twitch"; +export * from "../../src/plugin-sdk/twitch.js"; diff --git a/extensions/voice-call/runtime-api.ts b/extensions/voice-call/runtime-api.ts index 9dd4fb0f3bc..f0b32548645 100644 --- a/extensions/voice-call/runtime-api.ts +++ b/extensions/voice-call/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Voice Call extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/voice-call"; +export * from "../../src/plugin-sdk/voice-call.js"; diff --git a/extensions/whatsapp/api.ts b/extensions/whatsapp/api.ts index 8bf50cefccd..c9d2ae0bcee 100644 --- a/extensions/whatsapp/api.ts +++ b/extensions/whatsapp/api.ts @@ -7,4 +7,4 @@ export { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, } from "./src/directory-config.js"; -export { resolveWhatsAppGroupIntroHint } from "openclaw/plugin-sdk/whatsapp-core"; +export { resolveWhatsAppGroupIntroHint } from "./src/runtime-api.js"; diff --git a/extensions/whatsapp/src/agent-tools-login.ts b/extensions/whatsapp/src/agent-tools-login.ts index d53f5105ca2..653f4c5ef6b 100644 --- a/extensions/whatsapp/src/agent-tools-login.ts +++ b/extensions/whatsapp/src/agent-tools-login.ts @@ -1,6 +1,6 @@ import { Type } from "@sinclair/typebox"; -import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-runtime"; -import { startWebLoginWithQr, waitForWebLogin } from "openclaw/plugin-sdk/whatsapp-login-qr"; +import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-contract"; +import { startWebLoginWithQr, waitForWebLogin } from "../login-qr-api.js"; export function createWhatsAppLoginTool(): ChannelAgentTool { return { diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts index 8fb27a39fe4..8c8c8639734 100644 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts +++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts @@ -1,5 +1,4 @@ import { appendCronStyleCurrentTimeLine } from "openclaw/plugin-sdk/agent-runtime"; -import { resolveWhatsAppHeartbeatRecipients } from "openclaw/plugin-sdk/channel-runtime"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, @@ -25,6 +24,7 @@ import { normalizeMainKey } from "openclaw/plugin-sdk/routing"; import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import { redactIdentifier } from "openclaw/plugin-sdk/text-runtime"; import { newConnectionId } from "../reconnect.js"; +import { resolveWhatsAppHeartbeatRecipients } from "../runtime-api.js"; import { sendMessageWhatsApp } from "../send.js"; import { formatError } from "../session.js"; import { whatsappHeartbeatLog } from "./loggers.js"; diff --git a/extensions/whatsapp/src/auto-reply/mentions.ts b/extensions/whatsapp/src/auto-reply/mentions.ts index ad42c814c26..967b4c1c61b 100644 --- a/extensions/whatsapp/src/auto-reply/mentions.ts +++ b/extensions/whatsapp/src/auto-reply/mentions.ts @@ -1,5 +1,5 @@ +import { buildMentionRegexes, normalizeMentionText } from "openclaw/plugin-sdk/channel-inbound"; import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; -import { buildMentionRegexes, normalizeMentionText } from "openclaw/plugin-sdk/reply-runtime"; import { isSelfChatMode, jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import type { WebInboundMsg } from "./types.js"; diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index 2f83e65079a..1997ddc38a1 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -1,13 +1,13 @@ +import { resolveInboundDebounceMs } from "openclaw/plugin-sdk/channel-inbound"; import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; import { waitForever } from "openclaw/plugin-sdk/cli-runtime"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { formatDurationPrecise } from "openclaw/plugin-sdk/infra-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; -import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; -import { resolveInboundDebounceMs } from "openclaw/plugin-sdk/reply-runtime"; +import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history"; import { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime"; -import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts index 126c485ec6f..bb6e1a181ab 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts @@ -1,4 +1,4 @@ -import { shouldAckReactionForWhatsApp } from "openclaw/plugin-sdk/channel-runtime"; +import { shouldAckReactionForWhatsApp } from "openclaw/plugin-sdk/channel-feedback"; import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { sendReactionWhatsApp } from "../../send.js"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts index 847e5e3182f..d639e9e182a 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts @@ -1,8 +1,8 @@ -import { resolveMentionGating } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveMentionGating } from "openclaw/plugin-sdk/channel-inbound"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; -import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; +import { recordPendingHistoryEntryIfEnabled } from "openclaw/plugin-sdk/reply-history"; import { parseActivationCommand } from "openclaw/plugin-sdk/reply-runtime"; -import { recordPendingHistoryEntryIfEnabled } from "openclaw/plugin-sdk/reply-runtime"; import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import type { MentionConfig } from "../mentions.js"; import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/message-line.ts b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts index b9494f0325c..4b33649da43 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/message-line.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts @@ -1,9 +1,9 @@ import { resolveMessagePrefix } from "openclaw/plugin-sdk/agent-runtime"; -import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { formatInboundEnvelope, type EnvelopeFormatOptions, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/channel-inbound"; +import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import type { WebInboundMsg } from "../types.js"; export function formatReplyContext(msg: WebInboundMsg) { diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index 067087f87d3..255c211f0ee 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -1,20 +1,22 @@ import { resolveIdentityNamePrefix } from "openclaw/plugin-sdk/agent-runtime"; +import { + resolveInboundSessionEnvelopeContext, + toLocationContext, +} from "openclaw/plugin-sdk/channel-inbound"; +import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { toLocationContext } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveInboundSessionEnvelopeContext } from "openclaw/plugin-sdk/channel-runtime"; +import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/command-auth"; import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { recordSessionMetaFromInbound } from "openclaw/plugin-sdk/config-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; -import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; -import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; -import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/reply-runtime"; -import { formatInboundEnvelope } from "openclaw/plugin-sdk/reply-runtime"; -import type { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime"; import { buildHistoryContextFromEntries, type HistoryEntry, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "openclaw/plugin-sdk/reply-history"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; +import type { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; diff --git a/extensions/whatsapp/src/channel.directory.test.ts b/extensions/whatsapp/src/channel.directory.test.ts index 3fd58b31d4d..d9a072c86f1 100644 --- a/extensions/whatsapp/src/channel.directory.test.ts +++ b/extensions/whatsapp/src/channel.directory.test.ts @@ -1,10 +1,10 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/whatsapp"; import { describe, expect, it } from "vitest"; import { createDirectoryTestRuntime, expectDirectorySurface, } from "../../../test/helpers/extensions/directory.ts"; import { whatsappPlugin } from "./channel.js"; +import type { OpenClawConfig } from "./runtime-api.js"; describe("whatsapp directory", () => { const runtimeEnv = createDirectoryTestRuntime() as never; diff --git a/extensions/whatsapp/src/config-schema.ts b/extensions/whatsapp/src/config-schema.ts index 23f7de4058f..89681ce2d54 100644 --- a/extensions/whatsapp/src/config-schema.ts +++ b/extensions/whatsapp/src/config-schema.ts @@ -1,3 +1,3 @@ -import { buildChannelConfigSchema, WhatsAppConfigSchema } from "openclaw/plugin-sdk/whatsapp-core"; +import { buildChannelConfigSchema, WhatsAppConfigSchema } from "./runtime-api.js"; export const WhatsAppChannelConfigSchema = buildChannelConfigSchema(WhatsAppConfigSchema); diff --git a/extensions/whatsapp/src/inbound.media.test.ts b/extensions/whatsapp/src/inbound.media.test.ts index 7ed52cace45..d83ef1dfea5 100644 --- a/extensions/whatsapp/src/inbound.media.test.ts +++ b/extensions/whatsapp/src/inbound.media.test.ts @@ -8,8 +8,10 @@ const readAllowFromStoreMock = vi.fn().mockResolvedValue([]); const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true }); const saveMediaBufferSpy = vi.fn(); -vi.mock("../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/config-runtime", + ); return { ...actual, loadConfig: vi.fn().mockReturnValue({ @@ -37,8 +39,10 @@ vi.mock("../../../src/pairing/pairing-store.js", () => { }; }); -vi.mock("../../../src/media/store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/media-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/media-runtime", + ); return { ...actual, saveMediaBuffer: vi.fn(async (...args: Parameters) => { diff --git a/extensions/whatsapp/src/inbound/extract.ts b/extensions/whatsapp/src/inbound/extract.ts index 9fa663847a6..b1b64e4fe91 100644 --- a/extensions/whatsapp/src/inbound/extract.ts +++ b/extensions/whatsapp/src/inbound/extract.ts @@ -4,7 +4,7 @@ import { getContentType, normalizeMessageContent, } from "@whiskeysockets/baileys"; -import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime"; +import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { jidToE164 } from "openclaw/plugin-sdk/text-runtime"; import { parseVcard } from "../vcard.js"; diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 35669bc1b49..b19e37feb69 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -1,9 +1,8 @@ import type { AnyMessageContent, proto, WAMessage } from "@whiskeysockets/baileys"; import { DisconnectReason, isJidGroup } from "@whiskeysockets/baileys"; -import { formatLocationText } from "openclaw/plugin-sdk/channel-runtime"; +import { createInboundDebouncer, formatLocationText } from "openclaw/plugin-sdk/channel-inbound"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; -import { createInboundDebouncer } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/text-runtime"; diff --git a/extensions/whatsapp/src/inbound/types.ts b/extensions/whatsapp/src/inbound/types.ts index 42e4b5121d1..731dcd2c8cc 100644 --- a/extensions/whatsapp/src/inbound/types.ts +++ b/extensions/whatsapp/src/inbound/types.ts @@ -1,5 +1,5 @@ import type { AnyMessageContent } from "@whiskeysockets/baileys"; -import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime"; +import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound"; export type WebListenerCloseReason = { status?: number; diff --git a/extensions/whatsapp/src/login.coverage.test.ts b/extensions/whatsapp/src/login.coverage.test.ts index dda665ccdce..7215d3ac862 100644 --- a/extensions/whatsapp/src/login.coverage.test.ts +++ b/extensions/whatsapp/src/login.coverage.test.ts @@ -19,25 +19,30 @@ function resolveTestAuthDir() { const authDir = resolveTestAuthDir(); -vi.mock("../../../src/config/config.js", () => ({ - loadConfig: () => - ({ - channels: { - whatsapp: { - accounts: { - default: { enabled: true, authDir: resolveTestAuthDir() }, +vi.mock("openclaw/plugin-sdk/config-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/config-runtime", + ); + return { + ...actual, + loadConfig: () => + ({ + channels: { + whatsapp: { + accounts: { + default: { enabled: true, authDir: resolveTestAuthDir() }, + }, }, }, - }, - }) as never, -})); + }) as never, + }; +}); vi.mock("./session.js", () => { const authDir = resolveTestAuthDir(); const sockA = { ws: { close: vi.fn() } }; const sockB = { ws: { close: vi.fn() } }; - let call = 0; - const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB)); + const createWaSocket = vi.fn(async () => (createWaSocket.mock.calls.length <= 1 ? sockA : sockB)); const waitForWaConnection = vi.fn(); const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`); const getStatusCode = vi.fn( @@ -78,6 +83,10 @@ describe("loginWeb coverage", () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); + createWaSocketMock.mockClear(); + waitForWaConnectionMock.mockReset().mockResolvedValue(undefined); + waitForCredsSaveQueueWithTimeoutMock.mockReset().mockResolvedValue(undefined); + formatErrorMock.mockReset().mockImplementation((err: unknown) => `formatted:${String(err)}`); rmMock.mockClear(); }); afterEach(() => { diff --git a/extensions/whatsapp/src/normalize.ts b/extensions/whatsapp/src/normalize.ts index d0506cd5883..63a1c8279bb 100644 --- a/extensions/whatsapp/src/normalize.ts +++ b/extensions/whatsapp/src/normalize.ts @@ -4,4 +4,4 @@ export { normalizeWhatsAppAllowFromEntries, normalizeWhatsAppMessagingTarget, normalizeWhatsAppTarget, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "./runtime-api.js"; diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index 4800e2ded43..45fa8d046e7 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -1,11 +1,13 @@ -import { sendTextMediaPayload } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { + type ChannelOutboundAdapter, createAttachedChannelResultAdapter, createEmptyChannelResult, } from "openclaw/plugin-sdk/channel-send-result"; -import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; +import { + resolveSendableOutboundReplyParts, + sendTextMediaPayload, +} from "openclaw/plugin-sdk/reply-payload"; import { chunkText } from "openclaw/plugin-sdk/reply-runtime"; import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveWhatsAppOutboundTarget } from "./runtime-api.js"; diff --git a/extensions/whatsapp/src/resolve-target.test.ts b/extensions/whatsapp/src/resolve-target.test.ts index c24b6812cae..ca5cef77b9b 100644 --- a/extensions/whatsapp/src/resolve-target.test.ts +++ b/extensions/whatsapp/src/resolve-target.test.ts @@ -1,10 +1,8 @@ import { installCommonResolveTargetErrorCases } from "openclaw/plugin-sdk/testing"; import { describe, expect, it, vi } from "vitest"; -vi.mock("openclaw/plugin-sdk/whatsapp", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/whatsapp", - ); +vi.mock("./runtime-api.js", async () => { + const actual = await vi.importActual("./runtime-api.js"); const normalizeWhatsAppTarget = (value: string) => { if (value === "invalid-target") return null; // Simulate E.164 normalization: strip leading + and whatsapp: prefix. @@ -84,7 +82,7 @@ describe("whatsapp resolveTarget", () => { if (!result.ok) { throw result.error; } - expect(result.to).toBe("+5511999999999"); + expect(result.to).toBe("5511999999999@s.whatsapp.net"); }); it("should resolve target in implicit mode with wildcard", () => { @@ -98,7 +96,7 @@ describe("whatsapp resolveTarget", () => { if (!result.ok) { throw result.error; } - expect(result.to).toBe("+5511999999999"); + expect(result.to).toBe("5511999999999@s.whatsapp.net"); }); it("should resolve target in implicit mode when in allowlist", () => { @@ -112,7 +110,7 @@ describe("whatsapp resolveTarget", () => { if (!result.ok) { throw result.error; } - expect(result.to).toBe("+5511999999999"); + expect(result.to).toBe("5511999999999@s.whatsapp.net"); }); it("should allow group JID regardless of allowlist", () => { diff --git a/extensions/whatsapp/src/runtime-api.ts b/extensions/whatsapp/src/runtime-api.ts index 515040ffb42..a98c264b2b2 100644 --- a/extensions/whatsapp/src/runtime-api.ts +++ b/extensions/whatsapp/src/runtime-api.ts @@ -9,16 +9,21 @@ export { readReactionParams, readStringParam, resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, resolveWhatsAppOutboundTarget, ToolAuthorizationError, WhatsAppConfigSchema, type ChannelPlugin, type OpenClawConfig, -} from "openclaw/plugin-sdk/whatsapp-core"; +} from "../../../src/plugin-sdk/whatsapp-core.js"; export { createWhatsAppOutboundBase, isWhatsAppGroupJid, + looksLikeWhatsAppTargetId, + normalizeWhatsAppAllowFromEntries, + normalizeWhatsAppMessagingTarget, normalizeWhatsAppTarget, resolveWhatsAppHeartbeatRecipients, resolveWhatsAppMentionStripRegexes, @@ -26,6 +31,6 @@ export { type DmPolicy, type GroupPolicy, type WhatsAppAccountConfig, -} from "openclaw/plugin-sdk/whatsapp-shared"; +} from "../../../src/plugin-sdk/whatsapp-shared.js"; export { monitorWebChannel } from "./channel.runtime.js"; diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 3e241c9f94c..fcc5bb92421 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -5,6 +5,12 @@ import { import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { createDelegatedSetupWizardProxy } from "openclaw/plugin-sdk/setup"; +import { + listWhatsAppAccountIds, + resolveDefaultWhatsAppAccountId, + resolveWhatsAppAccount, + type ResolvedWhatsAppAccount, +} from "./accounts.js"; import { buildChannelConfigSchema, formatWhatsAppConfigAllowFromEntries, @@ -15,13 +21,7 @@ import { resolveWhatsAppGroupToolPolicy, WhatsAppConfigSchema, type ChannelPlugin, -} from "openclaw/plugin-sdk/whatsapp-core"; -import { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAccount, - type ResolvedWhatsAppAccount, -} from "./accounts.js"; +} from "./runtime-api.js"; export const WHATSAPP_CHANNEL = "whatsapp" as const; diff --git a/extensions/whatsapp/src/status-issues.ts b/extensions/whatsapp/src/status-issues.ts index f369ba29cda..15e6e6b216f 100644 --- a/extensions/whatsapp/src/status-issues.ts +++ b/extensions/whatsapp/src/status-issues.ts @@ -1,13 +1,13 @@ +import type { + ChannelAccountSnapshot, + ChannelStatusIssue, +} from "openclaw/plugin-sdk/channel-contract"; +import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; import { asString, collectIssuesForEnabledAccounts, isRecord, -} from "openclaw/plugin-sdk/channel-runtime"; -import type { - ChannelAccountSnapshot, - ChannelStatusIssue, -} from "openclaw/plugin-sdk/channel-runtime"; -import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; +} from "openclaw/plugin-sdk/status-helpers"; type WhatsAppAccountStatus = { accountId?: unknown; diff --git a/extensions/xai/model-definitions.ts b/extensions/xai/model-definitions.ts index 87d18484264..a925f7848ca 100644 --- a/extensions/xai/model-definitions.ts +++ b/extensions/xai/model-definitions.ts @@ -59,14 +59,14 @@ const XAI_MODEL_CATALOG = [ contextWindow: XAI_LARGE_CONTEXT_WINDOW, }, { - id: "grok-4.20-experimental-beta-0304-reasoning", - name: "Grok 4.20 Experimental Beta 0304 (Reasoning)", + id: "grok-4.20-reasoning", + name: "Grok 4.20 (Reasoning)", reasoning: true, contextWindow: XAI_LARGE_CONTEXT_WINDOW, }, { - id: "grok-4.20-experimental-beta-0304-non-reasoning", - name: "Grok 4.20 Experimental Beta 0304 (Non-Reasoning)", + id: "grok-4.20-non-reasoning", + name: "Grok 4.20 (Non-Reasoning)", reasoning: false, contextWindow: XAI_LARGE_CONTEXT_WINDOW, }, diff --git a/extensions/xai/provider-models.test.ts b/extensions/xai/provider-models.test.ts index 175209f4975..d0d025a852a 100644 --- a/extensions/xai/provider-models.test.ts +++ b/extensions/xai/provider-models.test.ts @@ -16,8 +16,21 @@ describe("xai provider models", () => { }); }); + it("publishes Grok 4.20 reasoning and non-reasoning models", () => { + expect(resolveXaiCatalogEntry("grok-4.20-reasoning")).toMatchObject({ + id: "grok-4.20-reasoning", + reasoning: true, + contextWindow: 2_000_000, + }); + expect(resolveXaiCatalogEntry("grok-4.20-non-reasoning")).toMatchObject({ + id: "grok-4.20-non-reasoning", + reasoning: false, + contextWindow: 2_000_000, + }); + }); + it("marks current Grok families as modern while excluding multi-agent ids", () => { - expect(isModernXaiModel("grok-4.20-experimental-beta-0304-reasoning")).toBe(true); + expect(isModernXaiModel("grok-4.20-reasoning")).toBe(true); expect(isModernXaiModel("grok-code-fast-1")).toBe(true); expect(isModernXaiModel("grok-3-mini-fast")).toBe(false); expect(isModernXaiModel("grok-4.20-multi-agent-experimental-beta-0304")).toBe(false); @@ -40,7 +53,7 @@ describe("xai provider models", () => { providerId: "xai", ctx: { provider: "xai", - modelId: "grok-4.20-experimental-beta-0304-reasoning", + modelId: "grok-4.20-reasoning", modelRegistry: { find: () => null } as never, providerConfig: { api: "openai-completions", @@ -59,7 +72,7 @@ describe("xai provider models", () => { }); expect(grok420).toMatchObject({ provider: "xai", - id: "grok-4.20-experimental-beta-0304-reasoning", + id: "grok-4.20-reasoning", api: "openai-completions", baseUrl: "https://api.x.ai/v1", reasoning: true, diff --git a/extensions/xai/src/web-search-shared.ts b/extensions/xai/src/web-search-shared.ts index 47616bcf13c..85ea11aa49d 100644 --- a/extensions/xai/src/web-search-shared.ts +++ b/extensions/xai/src/web-search-shared.ts @@ -1,3 +1,4 @@ +import { normalizeXaiModelId } from "openclaw/plugin-sdk/provider-models"; import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/provider-web-search"; export const XAI_WEB_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses"; @@ -79,7 +80,7 @@ export function resolveXaiSearchConfig(searchConfig?: Record): export function resolveXaiWebSearchModel(searchConfig?: Record): string { const config = resolveXaiSearchConfig(searchConfig); return typeof config.model === "string" && config.model.trim() - ? config.model.trim() + ? normalizeXaiModelId(config.model.trim()) : XAI_DEFAULT_WEB_SEARCH_MODEL; } diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index 29433ec7efa..a6dfff40633 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -44,6 +44,19 @@ describe("xai web search config resolution", () => { ); }); + it("normalizes deprecated grok 4.20 beta model ids to GA ids", () => { + expect( + resolveXaiWebSearchModel({ + grok: { model: "grok-4.20-experimental-beta-0304-reasoning" }, + }), + ).toBe("grok-4.20-reasoning"); + expect( + resolveXaiWebSearchModel({ + grok: { model: "grok-4.20-experimental-beta-0304-non-reasoning" }, + }), + ).toBe("grok-4.20-non-reasoning"); + }); + it("defaults inlineCitations to false", () => { expect(resolveXaiInlineCitations({})).toBe(false); expect(resolveXaiInlineCitations(undefined)).toBe(false); diff --git a/extensions/zalo/runtime-api.ts b/extensions/zalo/runtime-api.ts index 90ced0da803..082f65d43b8 100644 --- a/extensions/zalo/runtime-api.ts +++ b/extensions/zalo/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Zalo extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/zalo"; +export * from "../../src/plugin-sdk/zalo.js"; diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index b8d11b50937..165fe5bac52 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -9,11 +9,11 @@ import { createOpenProviderGroupPolicyWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { - createChannelDirectoryAdapter, createEmptyChannelResult, createRawChannelSendResultAdapter, - createStaticReplyToModeResolver, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-send-result"; +import { createStaticReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; +import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { listResolvedDirectoryUserEntriesFromAllowFrom } from "openclaw/plugin-sdk/directory-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { diff --git a/extensions/zalouser/runtime-api.ts b/extensions/zalouser/runtime-api.ts index 7d931f2d118..1b63edaea42 100644 --- a/extensions/zalouser/runtime-api.ts +++ b/extensions/zalouser/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Zalo Personal extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/zalouser"; +export * from "../../src/plugin-sdk/zalouser.js"; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 571ad31c164..c9b6fc17a67 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,12 +1,14 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { - createEmptyChannelResult, createPairingPrefixStripper, - createRawChannelSendResultAdapter, - createStaticReplyToModeResolver, createTextPairingAdapter, -} from "openclaw/plugin-sdk/channel-runtime"; +} from "openclaw/plugin-sdk/channel-pairing"; +import { + createEmptyChannelResult, + createRawChannelSendResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; +import { createStaticReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import type { ChannelAccountSnapshot, diff --git a/package.json b/package.json index 91abc6172a7..99529029aed 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,10 @@ "types": "./dist/plugin-sdk/infra-runtime.d.ts", "default": "./dist/plugin-sdk/infra-runtime.js" }, + "./plugin-sdk/ssrf-runtime": { + "types": "./dist/plugin-sdk/ssrf-runtime.d.ts", + "default": "./dist/plugin-sdk/ssrf-runtime.js" + }, "./plugin-sdk/media-runtime": { "types": "./dist/plugin-sdk/media-runtime.d.ts", "default": "./dist/plugin-sdk/media-runtime.js" @@ -133,6 +137,18 @@ "types": "./dist/plugin-sdk/conversation-runtime.d.ts", "default": "./dist/plugin-sdk/conversation-runtime.js" }, + "./plugin-sdk/matrix-runtime-heavy": { + "types": "./dist/plugin-sdk/matrix-runtime-heavy.d.ts", + "default": "./dist/plugin-sdk/matrix-runtime-heavy.js" + }, + "./plugin-sdk/matrix-runtime-shared": { + "types": "./dist/plugin-sdk/matrix-runtime-shared.d.ts", + "default": "./dist/plugin-sdk/matrix-runtime-shared.js" + }, + "./plugin-sdk/thread-bindings-runtime": { + "types": "./dist/plugin-sdk/thread-bindings-runtime.d.ts", + "default": "./dist/plugin-sdk/thread-bindings-runtime.js" + }, "./plugin-sdk/text-runtime": { "types": "./dist/plugin-sdk/text-runtime.d.ts", "default": "./dist/plugin-sdk/text-runtime.js" @@ -177,126 +193,6 @@ "types": "./dist/plugin-sdk/acp-runtime.d.ts", "default": "./dist/plugin-sdk/acp-runtime.js" }, - "./plugin-sdk/telegram": { - "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/feishu": { - "types": "./dist/plugin-sdk/feishu.d.ts", - "default": "./dist/plugin-sdk/feishu.js" - }, - "./plugin-sdk/googlechat": { - "types": "./dist/plugin-sdk/googlechat.d.ts", - "default": "./dist/plugin-sdk/googlechat.js" - }, - "./plugin-sdk/irc": { - "types": "./dist/plugin-sdk/irc.d.ts", - "default": "./dist/plugin-sdk/irc.js" - }, - "./plugin-sdk/line": { - "types": "./dist/plugin-sdk/line.d.ts", - "default": "./dist/plugin-sdk/line.js" - }, - "./plugin-sdk/line-core": { - "types": "./dist/plugin-sdk/line-core.d.ts", - "default": "./dist/plugin-sdk/line-core.js" - }, - "./plugin-sdk/matrix": { - "types": "./dist/plugin-sdk/matrix.d.ts", - "default": "./dist/plugin-sdk/matrix.js" - }, - "./plugin-sdk/mattermost": { - "types": "./dist/plugin-sdk/mattermost.d.ts", - "default": "./dist/plugin-sdk/mattermost.js" - }, - "./plugin-sdk/msteams": { - "types": "./dist/plugin-sdk/msteams.d.ts", - "default": "./dist/plugin-sdk/msteams.js" - }, - "./plugin-sdk/nextcloud-talk": { - "types": "./dist/plugin-sdk/nextcloud-talk.d.ts", - "default": "./dist/plugin-sdk/nextcloud-talk.js" - }, - "./plugin-sdk/nostr": { - "types": "./dist/plugin-sdk/nostr.d.ts", - "default": "./dist/plugin-sdk/nostr.js" - }, - "./plugin-sdk/signal": { - "types": "./dist/plugin-sdk/signal.d.ts", - "default": "./dist/plugin-sdk/signal.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/tlon": { - "types": "./dist/plugin-sdk/tlon.d.ts", - "default": "./dist/plugin-sdk/tlon.js" - }, - "./plugin-sdk/twitch": { - "types": "./dist/plugin-sdk/twitch.d.ts", - "default": "./dist/plugin-sdk/twitch.js" - }, - "./plugin-sdk/voice-call": { - "types": "./dist/plugin-sdk/voice-call.d.ts", - "default": "./dist/plugin-sdk/voice-call.js" - }, - "./plugin-sdk/zalo": { - "types": "./dist/plugin-sdk/zalo.d.ts", - "default": "./dist/plugin-sdk/zalo.js" - }, - "./plugin-sdk/zalouser": { - "types": "./dist/plugin-sdk/zalouser.d.ts", - "default": "./dist/plugin-sdk/zalouser.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-shared": { - "types": "./dist/plugin-sdk/whatsapp-shared.d.ts", - "default": "./dist/plugin-sdk/whatsapp-shared.js" - }, - "./plugin-sdk/whatsapp-action-runtime": { - "types": "./dist/plugin-sdk/whatsapp-action-runtime.d.ts", - "default": "./dist/plugin-sdk/whatsapp-action-runtime.js" - }, - "./plugin-sdk/whatsapp-login-qr": { - "types": "./dist/plugin-sdk/whatsapp-login-qr.d.ts", - "default": "./dist/plugin-sdk/whatsapp-login-qr.js" - }, - "./plugin-sdk/whatsapp-core": { - "types": "./dist/plugin-sdk/whatsapp-core.d.ts", - "default": "./dist/plugin-sdk/whatsapp-core.js" - }, - "./plugin-sdk/bluebubbles": { - "types": "./dist/plugin-sdk/bluebubbles.d.ts", - "default": "./dist/plugin-sdk/bluebubbles.js" - }, "./plugin-sdk/lazy-runtime": { "types": "./dist/plugin-sdk/lazy-runtime.d.ts", "default": "./dist/plugin-sdk/lazy-runtime.js" @@ -321,10 +217,6 @@ "types": "./dist/plugin-sdk/allow-from.d.ts", "default": "./dist/plugin-sdk/allow-from.js" }, - "./plugin-sdk/allowlist-resolution": { - "types": "./dist/plugin-sdk/allowlist-resolution.d.ts", - "default": "./dist/plugin-sdk/allowlist-resolution.js" - }, "./plugin-sdk/allowlist-config-edit": { "types": "./dist/plugin-sdk/allowlist-config-edit.d.ts", "default": "./dist/plugin-sdk/allowlist-config-edit.js" @@ -333,6 +225,10 @@ "types": "./dist/plugin-sdk/boolean-param.d.ts", "default": "./dist/plugin-sdk/boolean-param.js" }, + "./plugin-sdk/command-auth": { + "types": "./dist/plugin-sdk/command-auth.d.ts", + "default": "./dist/plugin-sdk/command-auth.js" + }, "./plugin-sdk/device-bootstrap": { "types": "./dist/plugin-sdk/device-bootstrap.d.ts", "default": "./dist/plugin-sdk/device-bootstrap.js" @@ -357,6 +253,22 @@ "types": "./dist/plugin-sdk/channel-config-schema.d.ts", "default": "./dist/plugin-sdk/channel-config-schema.js" }, + "./plugin-sdk/channel-actions": { + "types": "./dist/plugin-sdk/channel-actions.d.ts", + "default": "./dist/plugin-sdk/channel-actions.js" + }, + "./plugin-sdk/channel-contract": { + "types": "./dist/plugin-sdk/channel-contract.d.ts", + "default": "./dist/plugin-sdk/channel-contract.js" + }, + "./plugin-sdk/channel-feedback": { + "types": "./dist/plugin-sdk/channel-feedback.d.ts", + "default": "./dist/plugin-sdk/channel-feedback.js" + }, + "./plugin-sdk/channel-inbound": { + "types": "./dist/plugin-sdk/channel-inbound.d.ts", + "default": "./dist/plugin-sdk/channel-inbound.js" + }, "./plugin-sdk/channel-lifecycle": { "types": "./dist/plugin-sdk/channel-lifecycle.d.ts", "default": "./dist/plugin-sdk/channel-lifecycle.js" @@ -373,6 +285,10 @@ "types": "./dist/plugin-sdk/channel-send-result.d.ts", "default": "./dist/plugin-sdk/channel-send-result.js" }, + "./plugin-sdk/channel-targets": { + "types": "./dist/plugin-sdk/channel-targets.d.ts", + "default": "./dist/plugin-sdk/channel-targets.js" + }, "./plugin-sdk/group-access": { "types": "./dist/plugin-sdk/group-access.d.ts", "default": "./dist/plugin-sdk/group-access.js" @@ -401,10 +317,6 @@ "types": "./dist/plugin-sdk/provider-auth.d.ts", "default": "./dist/plugin-sdk/provider-auth.js" }, - "./plugin-sdk/provider-oauth": { - "types": "./dist/plugin-sdk/provider-oauth.d.ts", - "default": "./dist/plugin-sdk/provider-oauth.js" - }, "./plugin-sdk/provider-auth-api-key": { "types": "./dist/plugin-sdk/provider-auth-api-key.d.ts", "default": "./dist/plugin-sdk/provider-auth-api-key.js" @@ -465,14 +377,6 @@ "types": "./dist/plugin-sdk/media-understanding.d.ts", "default": "./dist/plugin-sdk/media-understanding.js" }, - "./plugin-sdk/secret-input-runtime": { - "types": "./dist/plugin-sdk/secret-input-runtime.d.ts", - "default": "./dist/plugin-sdk/secret-input-runtime.js" - }, - "./plugin-sdk/secret-input-schema": { - "types": "./dist/plugin-sdk/secret-input-schema.d.ts", - "default": "./dist/plugin-sdk/secret-input-schema.js" - }, "./plugin-sdk/request-url": { "types": "./dist/plugin-sdk/request-url.d.ts", "default": "./dist/plugin-sdk/request-url.js" @@ -489,6 +393,10 @@ "types": "./dist/plugin-sdk/runtime-store.d.ts", "default": "./dist/plugin-sdk/runtime-store.js" }, + "./plugin-sdk/status-helpers": { + "types": "./dist/plugin-sdk/status-helpers.d.ts", + "default": "./dist/plugin-sdk/status-helpers.js" + }, "./plugin-sdk/secret-input": { "types": "./dist/plugin-sdk/secret-input.d.ts", "default": "./dist/plugin-sdk/secret-input.js" @@ -501,6 +409,14 @@ "types": "./dist/plugin-sdk/web-media.d.ts", "default": "./dist/plugin-sdk/web-media.js" }, + "./plugin-sdk/zalo": { + "types": "./dist/plugin-sdk/zalo.d.ts", + "default": "./dist/plugin-sdk/zalo.js" + }, + "./plugin-sdk/zalouser": { + "types": "./dist/plugin-sdk/zalouser.d.ts", + "default": "./dist/plugin-sdk/zalouser.js" + }, "./plugin-sdk/speech": { "types": "./dist/plugin-sdk/speech.d.ts", "default": "./dist/plugin-sdk/speech.js" @@ -513,6 +429,7 @@ "types": "./dist/plugin-sdk/tool-send.d.ts", "default": "./dist/plugin-sdk/tool-send.js" }, + "./extension-api": "./dist/extensionAPI.js", "./cli-entry": "./openclaw.mjs" }, "scripts": { @@ -676,6 +593,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "0.16.1", + "@anthropic-ai/vertex-sdk": "^0.14.4", "@aws-sdk/client-bedrock": "^3.1011.0", "@clack/prompts": "^1.1.0", "@homebridge/ciao": "^1.3.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f821a4aa3c4..7f438d0a2e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@agentclientprotocol/sdk': specifier: 0.16.1 version: 0.16.1(zod@4.3.6) + '@anthropic-ai/vertex-sdk': + specifier: ^0.14.4 + version: 0.14.4(zod@4.3.6) '@aws-sdk/client-bedrock': specifier: ^3.1011.0 version: 3.1011.0 @@ -688,6 +691,9 @@ packages: zod: optional: true + '@anthropic-ai/vertex-sdk@0.14.4': + resolution: {integrity: sha512-BZUPRWghZxfSFtAxU563wH+jfWBPoedAwsVxG35FhmNsjeV8tyfN+lFriWhCpcZApxA4NdT6Soov+PzfnxxD5g==} + '@asamuzakjp/css-color@5.0.1': resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1480,10 +1486,6 @@ packages: cpu: [x64] os: [win32] - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -2619,10 +2621,6 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -4125,9 +4123,6 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -4140,9 +4135,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} @@ -4359,10 +4351,6 @@ packages: debug: optional: true - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - form-data@2.5.4: resolution: {integrity: sha512-Y/3MmRiR8Nd+0CUtrbvcKtKzLWiUfpQ7DFVggH8PwmGt/0r7RSy32GuP4hpCJlQNEBusisSx1DLtD8uD386HJQ==} engines: {node: '>= 0.12'} @@ -4409,14 +4397,18 @@ packages: engines: {node: '>=10'} deprecated: This package is no longer supported. - gaxios@7.1.3: - resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} - engines: {node: '>=18'} + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} gaxios@7.1.4: resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} engines: {node: '>=18'} + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + gcp-metadata@8.1.2: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} @@ -4459,11 +4451,6 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -4472,14 +4459,18 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - google-auth-library@10.6.1: - resolution: {integrity: sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==} - engines: {node: '>=18'} - google-auth-library@10.6.2: resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} engines: {node: '>=18'} + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + google-logging-utils@1.1.3: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} @@ -4495,6 +4486,10 @@ packages: resolution: {integrity: sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==} engines: {node: ^12.20.0 || >=14.13.1} + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -4721,9 +4716,6 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jimp@1.6.0: resolution: {integrity: sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==} engines: {node: '>=18'} @@ -4993,9 +4985,6 @@ packages: resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==} engines: {node: '>=18'} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.7: resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} engines: {node: 20 || >=22} @@ -5423,9 +5412,6 @@ packages: resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} engines: {node: '>= 14'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -5483,10 +5469,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -5794,10 +5776,6 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - rimraf@5.0.10: - resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} - hasBin: true - rolldown-plugin-dts@0.22.5: resolution: {integrity: sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw==} engines: {node: '>=20.19.0'} @@ -6089,10 +6067,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -6402,6 +6376,10 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + validate-npm-package-name@7.0.2: resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} engines: {node: ^20.17.0 || >=22.9.0} @@ -6557,10 +6535,6 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -6668,6 +6642,15 @@ snapshots: optionalDependencies: zod: 4.3.6 + '@anthropic-ai/vertex-sdk@0.14.4(zod@4.3.6)': + dependencies: + '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) + google-auth-library: 9.15.1 + transitivePeerDependencies: + - encoding + - supports-color + - zod + '@asamuzakjp/css-color@5.0.1': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -7804,7 +7787,7 @@ snapshots: '@google/genai@1.44.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))': dependencies: - google-auth-library: 10.6.1 + google-auth-library: 10.6.2 p-retry: 4.6.2 protobufjs: 7.5.4 ws: 8.19.0 @@ -7969,15 +7952,6 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -9320,9 +9294,6 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@pkgjs/parseargs@0.11.0': - optional: true - '@polka/url@1.0.0-next.29': {} '@protobufjs/aspromise@1.1.2': {} @@ -11012,8 +10983,6 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} - ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -11024,8 +10993,6 @@ snapshots: emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} - empathic@2.0.0: {} encodeurl@2.0.0: {} @@ -11278,11 +11245,6 @@ snapshots: follow-redirects@1.15.11: {} - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - form-data@2.5.4: dependencies: asynckit: 0.4.0 @@ -11336,13 +11298,15 @@ snapshots: wide-align: 1.1.5 optional: true - gaxios@7.1.3: + gaxios@6.7.1: dependencies: extend: 3.0.2 https-proxy-agent: 7.0.6 - node-fetch: 3.3.2 - rimraf: 5.0.10 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 transitivePeerDependencies: + - encoding - supports-color gaxios@7.1.4: @@ -11353,6 +11317,15 @@ snapshots: transitivePeerDependencies: - supports-color + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + gcp-metadata@8.1.2: dependencies: gaxios: 7.1.4 @@ -11411,15 +11384,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 10.2.4 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - glob@13.0.6: dependencies: minimatch: 10.2.4 @@ -11436,17 +11400,6 @@ snapshots: path-is-absolute: 1.0.1 optional: true - google-auth-library@10.6.1: - dependencies: - base64-js: 1.5.1 - ecdsa-sig-formatter: 1.0.11 - gaxios: 7.1.3 - gcp-metadata: 8.1.2 - google-logging-utils: 1.1.3 - jws: 4.0.1 - transitivePeerDependencies: - - supports-color - google-auth-library@10.6.2: dependencies: base64-js: 1.5.1 @@ -11458,6 +11411,20 @@ snapshots: transitivePeerDependencies: - supports-color + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + google-logging-utils@0.0.2: {} + google-logging-utils@1.1.3: {} gopd@1.2.0: {} @@ -11474,6 +11441,14 @@ snapshots: - encoding - supports-color + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + has-flag@4.0.0: {} has-own@1.0.1: {} @@ -11725,12 +11700,6 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jimp@1.6.0: dependencies: '@jimp/core': 1.6.0 @@ -12037,8 +12006,6 @@ snapshots: dependencies: steno: 4.0.2 - lru-cache@10.4.3: {} - lru-cache@11.2.7: {} lru-cache@6.0.0: @@ -12634,8 +12601,6 @@ snapshots: degenerator: 5.0.1 netmask: 2.0.2 - package-json-from-dist@1.0.1: {} - pako@1.0.11: {} pako@2.1.0: {} @@ -12681,11 +12646,6 @@ snapshots: path-parse@1.0.7: {} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.3 - path-scurry@2.0.2: dependencies: lru-cache: 11.2.7 @@ -13036,10 +12996,6 @@ snapshots: glob: 7.2.3 optional: true - rimraf@5.0.10: - dependencies: - glob: 10.5.0 - rolldown-plugin-dts@0.22.5(@typescript/native-preview@7.0.0-dev.20260317.1)(rolldown@1.0.0-rc.9)(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.2 @@ -13394,12 +13350,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 - string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -13687,6 +13637,8 @@ snapshots: uuid@8.3.2: {} + uuid@9.0.1: {} + validate-npm-package-name@7.0.2: {} vary@1.1.2: {} @@ -13809,12 +13761,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 - wrappy@1.0.2: {} ws@8.19.0: {} diff --git a/scripts/check-plugin-extension-import-boundary.mjs b/scripts/check-plugin-extension-import-boundary.mjs index bbe9f9702f5..ac9c5e178a4 100644 --- a/scripts/check-plugin-extension-import-boundary.mjs +++ b/scripts/check-plugin-extension-import-boundary.mjs @@ -195,6 +195,7 @@ function scanWebSearchRegistrySmells(sourceFile, filePath) { function shouldSkipFile(filePath) { const relativeFile = normalizePath(filePath); return ( + relativeFile === "src/plugins/bundled-web-search-registry.ts" || relativeFile.startsWith("src/plugins/contracts/") || /^src\/plugins\/runtime\/runtime-[^/]+-contract\.[cm]?[jt]s$/u.test(relativeFile) ); diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 1dc306bd9b7..656dd6a72bb 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -20,9 +20,13 @@ "channel-runtime", "interactive-runtime", "infra-runtime", + "ssrf-runtime", "media-runtime", "media-understanding-runtime", "conversation-runtime", + "matrix-runtime-heavy", + "matrix-runtime-shared", + "thread-bindings-runtime", "text-runtime", "agent-runtime", "speech-runtime", @@ -34,55 +38,30 @@ "process-runtime", "windows-spawn", "acp-runtime", - "telegram", - "telegram-core", - "discord", - "discord-core", - "feishu", - "googlechat", - "irc", - "line", - "line-core", - "matrix", - "mattermost", - "msteams", - "nextcloud-talk", - "nostr", - "signal", - "slack", - "slack-core", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "imessage", - "imessage-core", - "whatsapp", - "whatsapp-shared", - "whatsapp-action-runtime", - "whatsapp-login-qr", - "whatsapp-core", - "bluebubbles", "lazy-runtime", "testing", "account-helpers", "account-id", "account-resolution", "allow-from", - "allowlist-resolution", "allowlist-config-edit", "boolean-param", + "command-auth", "device-bootstrap", "diagnostics-otel", "diffs", "extension-shared", "channel-config-helpers", "channel-config-schema", + "channel-actions", + "channel-contract", + "channel-feedback", + "channel-inbound", "channel-lifecycle", "channel-pairing", "channel-policy", "channel-send-result", + "channel-targets", "group-access", "directory-runtime", "json-store", @@ -90,7 +69,6 @@ "llm-task", "memory-lancedb", "provider-auth", - "provider-oauth", "provider-auth-api-key", "provider-auth-login", "plugin-entry", @@ -106,15 +84,16 @@ "image-generation", "reply-history", "media-understanding", - "secret-input-runtime", - "secret-input-schema", "request-url", "webhook-ingress", "webhook-path", "runtime-store", + "status-helpers", "secret-input", "thread-ownership", "web-media", + "zalo", + "zalouser", "speech", "state-paths", "tool-send" diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 5bbd4c94ac6..41a4d285d05 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -297,7 +297,7 @@ const defaultHeavyUnitFileLimit = : isMacMiniProfile ? 90 : testProfile === "low" - ? 20 + ? 36 : highMemLocalHost ? 80 : 60; @@ -307,7 +307,7 @@ const defaultHeavyUnitLaneCount = : isMacMiniProfile ? 6 : testProfile === "low" - ? 2 + ? 4 : highMemLocalHost ? 5 : 4; @@ -365,11 +365,13 @@ const defaultSingletonBatchLaneCount = ? 0 : isCI ? Math.ceil(unitSingletonBatchFiles.length / 6) - : highMemLocalHost - ? Math.ceil(unitSingletonBatchFiles.length / 8) - : lowMemLocalHost - ? Math.ceil(unitSingletonBatchFiles.length / 12) - : Math.ceil(unitSingletonBatchFiles.length / 10); + : testProfile === "low" && highMemLocalHost + ? Math.ceil(unitSingletonBatchFiles.length / 8) + 1 + : highMemLocalHost + ? Math.ceil(unitSingletonBatchFiles.length / 8) + : lowMemLocalHost + ? Math.ceil(unitSingletonBatchFiles.length / 12) + : Math.ceil(unitSingletonBatchFiles.length / 10); const singletonBatchLaneCount = unitSingletonBatchFiles.length === 0 ? 0 @@ -437,6 +439,22 @@ const unitSingletonEntries = unitSingletonBuckets.map((files, index) => ({ unitSingletonBuckets.length === 1 ? "unit-singleton" : `unit-singleton-${String(index + 1)}`, args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files], })); +const unitThreadEntries = + unitThreadSingletonFiles.length > 0 + ? [ + { + name: "unit-threads", + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + "--pool=threads", + ...unitThreadSingletonFiles, + ], + }, + ] + : []; const baseRuns = [ ...(shouldSplitUnitRuns ? [ @@ -469,10 +487,7 @@ const baseRuns = [ file, ], })), - ...unitThreadSingletonFiles.map((file) => ({ - name: `${path.basename(file, ".test.ts")}-threads`, - args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=threads", file], - })), + ...unitThreadEntries, ...unitVmForkSingletonFiles.map((file) => ({ name: `${path.basename(file, ".test.ts")}-vmforks`, args: [ @@ -695,7 +710,9 @@ const defaultTopLevelParallelLimit = testProfile === "serial" ? 1 : testProfile === "low" - ? 2 + ? lowMemLocalHost + ? 2 + : 3 : testProfile === "max" ? 5 : highMemLocalHost @@ -1287,9 +1304,16 @@ if (serialPrefixRuns.length > 0) { if (failedSerialPrefix !== undefined) { process.exit(failedSerialPrefix); } + const deferredRunConcurrency = isMacMiniProfile ? 3 : testProfile === "low" ? 2 : undefined; const failedDeferredParallel = isMacMiniProfile - ? await runEntriesWithLimit(deferredParallelRuns, passthroughOptionArgs, 3) - : await runEntries(deferredParallelRuns, passthroughOptionArgs); + ? await runEntriesWithLimit(deferredParallelRuns, passthroughOptionArgs, deferredRunConcurrency) + : deferredRunConcurrency + ? await runEntriesWithLimit( + deferredParallelRuns, + passthroughOptionArgs, + deferredRunConcurrency, + ) + : await runEntries(deferredParallelRuns, passthroughOptionArgs); if (failedDeferredParallel !== undefined) { process.exit(failedDeferredParallel); } diff --git a/scripts/test-runner-manifest.mjs b/scripts/test-runner-manifest.mjs index ee5644f3328..ce34d28c59b 100644 --- a/scripts/test-runner-manifest.mjs +++ b/scripts/test-runner-manifest.mjs @@ -25,14 +25,25 @@ const readJson = (filePath, fallback) => { }; const normalizeRepoPath = (value) => value.split(path.sep).join("/"); +const repoRoot = path.resolve(process.cwd()); +const normalizeTrackedRepoPath = (value) => { + const normalizedValue = typeof value === "string" ? value : String(value ?? ""); + const repoRelative = path.isAbsolute(normalizedValue) + ? path.relative(repoRoot, path.resolve(normalizedValue)) + : normalizedValue; + if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") { + return normalizeRepoPath(normalizedValue); + } + return normalizeRepoPath(repoRelative); +}; const normalizeManifestEntries = (entries) => entries .map((entry) => typeof entry === "string" - ? { file: normalizeRepoPath(entry), reason: "" } + ? { file: normalizeTrackedRepoPath(entry), reason: "" } : { - file: normalizeRepoPath(String(entry?.file ?? "")), + file: normalizeTrackedRepoPath(String(entry?.file ?? "")), reason: typeof entry?.reason === "string" ? entry.reason : "", }, ) @@ -60,7 +71,7 @@ export function loadUnitTimingManifest() { const files = Object.fromEntries( Object.entries(raw.files ?? {}) .map(([file, value]) => { - const normalizedFile = normalizeRepoPath(file); + const normalizedFile = normalizeTrackedRepoPath(file); const durationMs = Number.isFinite(value?.durationMs) && value.durationMs >= 0 ? value.durationMs : null; const testCount = @@ -97,7 +108,7 @@ export function loadUnitMemoryHotspotManifest() { const files = Object.fromEntries( Object.entries(raw.files ?? {}) .map(([file, value]) => { - const normalizedFile = normalizeRepoPath(file); + const normalizedFile = normalizeTrackedRepoPath(file); const deltaKb = Number.isFinite(value?.deltaKb) && value.deltaKb > 0 ? Math.round(value.deltaKb) : null; const sources = Array.isArray(value?.sources) diff --git a/scripts/test-update-memory-hotspots.mjs b/scripts/test-update-memory-hotspots.mjs index 2abbf2b2d02..af4cb7c624c 100644 --- a/scripts/test-update-memory-hotspots.mjs +++ b/scripts/test-update-memory-hotspots.mjs @@ -57,10 +57,24 @@ function parseArgs(argv) { return args; } +const normalizeRepoPath = (value) => value.split(path.sep).join("/"); +const repoRoot = path.resolve(process.cwd()); +const normalizeTrackedRepoPath = (value) => { + const normalizedValue = typeof value === "string" ? value : String(value ?? ""); + const repoRelative = path.isAbsolute(normalizedValue) + ? path.relative(repoRoot, path.resolve(normalizedValue)) + : normalizedValue; + if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") { + return normalizeRepoPath(normalizedValue); + } + return normalizeRepoPath(repoRelative); +}; + function mergeHotspotEntry(aggregated, file, value) { if (!(Number.isFinite(value?.deltaKb) && value.deltaKb > 0)) { return; } + const normalizedFile = normalizeTrackedRepoPath(file); const normalizeSourceLabel = (source) => { const separator = source.lastIndexOf(":"); if (separator === -1) { @@ -75,9 +89,9 @@ function mergeHotspotEntry(aggregated, file, value) { .filter((source) => typeof source === "string" && source.length > 0) .map(normalizeSourceLabel) : []; - const previous = aggregated.get(file); + const previous = aggregated.get(normalizedFile); if (!previous) { - aggregated.set(file, { + aggregated.set(normalizedFile, { deltaKb: Math.round(value.deltaKb), sources: [...new Set(nextSources)], }); diff --git a/scripts/test-update-timings.mjs b/scripts/test-update-timings.mjs index 722d3539f7a..e450ff9cd31 100644 --- a/scripts/test-update-timings.mjs +++ b/scripts/test-update-timings.mjs @@ -9,7 +9,7 @@ function parseArgs(argv) { config: "vitest.unit.config.ts", out: unitTimingManifestPath, reportPath: "", - limit: 128, + limit: 256, defaultDurationMs: 250, }; for (let i = 0; i < argv.length; i += 1) { @@ -50,6 +50,17 @@ function parseArgs(argv) { } const normalizeRepoPath = (value) => value.split(path.sep).join("/"); +const repoRoot = path.resolve(process.cwd()); +const normalizeTrackedRepoPath = (value) => { + const normalizedValue = typeof value === "string" ? value : String(value ?? ""); + const repoRelative = path.isAbsolute(normalizedValue) + ? path.relative(repoRoot, path.resolve(normalizedValue)) + : normalizedValue; + if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") { + return normalizeRepoPath(normalizedValue); + } + return normalizeRepoPath(repoRelative); +}; const opts = parseArgs(process.argv.slice(2)); const reportPath = @@ -74,7 +85,7 @@ const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); const files = Object.fromEntries( (report.testResults ?? []) .map((result) => { - const file = typeof result.name === "string" ? normalizeRepoPath(result.name) : ""; + const file = typeof result.name === "string" ? normalizeTrackedRepoPath(result.name) : ""; const start = typeof result.startTime === "number" ? result.startTime : 0; const end = typeof result.endTime === "number" ? result.endTime : 0; const testCount = Array.isArray(result.assertionResults) ? result.assertionResults.length : 0; diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts index b9fc0c9e9b3..2be5eabe372 100644 --- a/src/acp/persistent-bindings.test.ts +++ b/src/acp/persistent-bindings.test.ts @@ -1,11 +1,11 @@ -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 { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { parseFeishuConversationId } from "../../extensions/feishu/src/conversation-id.js"; +import { parseTelegramTopicConversation } from "../../extensions/telegram/runtime-api.js"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import type { ChannelConfiguredBindingProvider, 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 { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import * as persistentBindingsResolveModule from "./persistent-bindings.resolve.js"; import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js"; const managerMocks = vi.hoisted(() => ({ @@ -39,6 +39,10 @@ type PersistentBindingsModule = Pick< "ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace" >; let persistentBindings: PersistentBindingsModule; +let lifecycleBindingsModule: Pick< + typeof import("./persistent-bindings.lifecycle.js"), + "ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace" +>; type ConfiguredBinding = NonNullable[number]; type BindingRecordInput = Parameters< @@ -58,6 +62,131 @@ const baseCfg = { const defaultDiscordConversationId = "1478836151241412759"; const defaultDiscordAccountId = "default"; +const discordBindings: ChannelConfiguredBindingProvider = { + compileConfiguredBinding: ({ conversationId }) => { + const normalized = conversationId.trim(); + return normalized ? { conversationId: normalized } : null; + }, + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { + if (compiledBinding.conversationId === conversationId) { + return { conversationId, matchPriority: 2 }; + } + if ( + parentConversationId && + parentConversationId !== conversationId && + compiledBinding.conversationId === parentConversationId + ) { + return { conversationId: parentConversationId, matchPriority: 1 }; + } + return null; + }, +}; + +const telegramBindings: ChannelConfiguredBindingProvider = { + compileConfiguredBinding: ({ conversationId }) => { + const parsed = parseTelegramTopicConversation({ conversationId }); + if (!parsed || !parsed.chatId.startsWith("-")) { + return null; + } + return { + conversationId: parsed.canonicalConversationId, + parentConversationId: parsed.chatId, + }; + }, + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { + const incoming = parseTelegramTopicConversation({ + conversationId, + parentConversationId, + }); + if (!incoming || !incoming.chatId.startsWith("-")) { + return null; + } + if (compiledBinding.conversationId !== incoming.canonicalConversationId) { + return null; + } + return { + conversationId: incoming.canonicalConversationId, + parentConversationId: incoming.chatId, + matchPriority: 2, + }; + }, +}; + +function isSupportedFeishuDirectConversationId(conversationId: string): boolean { + const trimmed = conversationId.trim(); + if (!trimmed || trimmed.includes(":")) { + return false; + } + if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) { + return false; + } + return true; +} + +const feishuBindings: ChannelConfiguredBindingProvider = { + compileConfiguredBinding: ({ conversationId }) => { + const parsed = parseFeishuConversationId({ conversationId }); + if ( + !parsed || + (parsed.scope !== "group_topic" && + parsed.scope !== "group_topic_sender" && + !isSupportedFeishuDirectConversationId(parsed.canonicalConversationId)) + ) { + return null; + } + return { + conversationId: parsed.canonicalConversationId, + parentConversationId: + parsed.scope === "group_topic" || parsed.scope === "group_topic_sender" + ? parsed.chatId + : undefined, + }; + }, + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { + const incoming = parseFeishuConversationId({ + conversationId, + parentConversationId, + }); + if ( + !incoming || + (incoming.scope !== "group_topic" && + incoming.scope !== "group_topic_sender" && + !isSupportedFeishuDirectConversationId(incoming.canonicalConversationId)) + ) { + return null; + } + const matchesCanonicalConversation = + compiledBinding.conversationId === incoming.canonicalConversationId; + const matchesParentTopicForSenderScopedConversation = + incoming.scope === "group_topic_sender" && + compiledBinding.parentConversationId === incoming.chatId && + compiledBinding.conversationId === `${incoming.chatId}:topic:${incoming.topicId}`; + if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) { + return null; + } + return { + conversationId: matchesParentTopicForSenderScopedConversation + ? compiledBinding.conversationId + : incoming.canonicalConversationId, + parentConversationId: + incoming.scope === "group_topic" || incoming.scope === "group_topic_sender" + ? incoming.chatId + : undefined, + matchPriority: matchesCanonicalConversation ? 2 : 1, + }; + }, +}; + +function createConfiguredBindingTestPlugin( + id: ChannelPlugin["id"], + bindings: ChannelConfiguredBindingProvider, +): Pick { + return { + ...createChannelTestPluginBase({ id }), + bindings, + }; +} + function createCfgWithBindings( bindings: ConfiguredBinding[], overrides?: Partial, @@ -185,20 +314,26 @@ beforeEach(() => { persistentBindingsResolveModule.resolveConfiguredAcpBindingRecord, resolveConfiguredAcpBindingSpecBySessionKey: persistentBindingsResolveModule.resolveConfiguredAcpBindingSpecBySessionKey, - ensureConfiguredAcpBindingSession: async (...args) => { - const lifecycleModule = await import("./persistent-bindings.lifecycle.js"); - return await lifecycleModule.ensureConfiguredAcpBindingSession(...args); - }, - resetAcpSessionInPlace: async (...args) => { - const lifecycleModule = await import("./persistent-bindings.lifecycle.js"); - return await lifecycleModule.resetAcpSessionInPlace(...args); - }, + ensureConfiguredAcpBindingSession: lifecycleBindingsModule.ensureConfiguredAcpBindingSession, + resetAcpSessionInPlace: lifecycleBindingsModule.resetAcpSessionInPlace, }; setActivePluginRegistry( createTestRegistry([ - { pluginId: "discord", plugin: discordPlugin, source: "test" }, - { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, - { pluginId: "feishu", plugin: feishuPlugin, source: "test" }, + { + pluginId: "discord", + plugin: createConfiguredBindingTestPlugin("discord", discordBindings), + source: "test", + }, + { + pluginId: "telegram", + plugin: createConfiguredBindingTestPlugin("telegram", telegramBindings), + source: "test", + }, + { + pluginId: "feishu", + plugin: createConfiguredBindingTestPlugin("feishu", feishuBindings), + source: "test", + }, ]), ); managerMocks.resolveSession.mockReset(); @@ -211,6 +346,10 @@ beforeEach(() => { sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined); }); +beforeAll(async () => { + lifecycleBindingsModule = await import("./persistent-bindings.lifecycle.js"); +}); + describe("resolveConfiguredAcpBindingRecord", () => { it("resolves discord channel ACP binding from top-level typed bindings", () => { const cfg = createCfgWithBindings([ diff --git a/src/agents/anthropic-vertex-provider.ts b/src/agents/anthropic-vertex-provider.ts new file mode 100644 index 00000000000..17df481f1e5 --- /dev/null +++ b/src/agents/anthropic-vertex-provider.ts @@ -0,0 +1,124 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; +import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; + +const ANTHROPIC_VERTEX_DEFAULT_REGION = "global"; +const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/; +const GCLOUD_DEFAULT_ADC_PATH = join( + homedir(), + ".config", + "gcloud", + "application_default_credentials.json", +); + +type AdcProjectFile = { + project_id?: unknown; + quota_project_id?: unknown; +}; + +export function resolveAnthropicVertexProjectId( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + return ( + normalizeOptionalSecretInput(env.ANTHROPIC_VERTEX_PROJECT_ID) || + normalizeOptionalSecretInput(env.GOOGLE_CLOUD_PROJECT) || + normalizeOptionalSecretInput(env.GOOGLE_CLOUD_PROJECT_ID) || + resolveAnthropicVertexProjectIdFromAdc(env) + ); +} + +export function resolveAnthropicVertexRegion(env: NodeJS.ProcessEnv = process.env): string { + const region = + normalizeOptionalSecretInput(env.GOOGLE_CLOUD_LOCATION) || + normalizeOptionalSecretInput(env.CLOUD_ML_REGION); + + return region && ANTHROPIC_VERTEX_REGION_RE.test(region) + ? region + : ANTHROPIC_VERTEX_DEFAULT_REGION; +} + +export function resolveAnthropicVertexRegionFromBaseUrl(baseUrl?: string): string | undefined { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return undefined; + } + + try { + const host = new URL(trimmed).hostname.toLowerCase(); + if (host === "aiplatform.googleapis.com") { + return "global"; + } + const match = /^([a-z0-9-]+)-aiplatform\.googleapis\.com$/.exec(host); + return match?.[1]; + } catch { + return undefined; + } +} + +export function resolveAnthropicVertexClientRegion(params?: { + baseUrl?: string; + env?: NodeJS.ProcessEnv; +}): string { + return ( + resolveAnthropicVertexRegionFromBaseUrl(params?.baseUrl) || + resolveAnthropicVertexRegion(params?.env) + ); +} + +function hasAnthropicVertexMetadataServerAdc(env: NodeJS.ProcessEnv = process.env): boolean { + const explicitMetadataOptIn = normalizeOptionalSecretInput(env.ANTHROPIC_VERTEX_USE_GCP_METADATA); + return explicitMetadataOptIn === "1" || explicitMetadataOptIn?.toLowerCase() === "true"; +} + +function resolveAnthropicVertexDefaultAdcPath(env: NodeJS.ProcessEnv = process.env): string { + return platform() === "win32" + ? join( + env.APPDATA ?? join(homedir(), "AppData", "Roaming"), + "gcloud", + "application_default_credentials.json", + ) + : GCLOUD_DEFAULT_ADC_PATH; +} + +function resolveAnthropicVertexAdcCredentialsPath( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const explicitCredentialsPath = normalizeOptionalSecretInput(env.GOOGLE_APPLICATION_CREDENTIALS); + if (explicitCredentialsPath) { + return existsSync(explicitCredentialsPath) ? explicitCredentialsPath : undefined; + } + + const defaultAdcPath = resolveAnthropicVertexDefaultAdcPath(env); + return existsSync(defaultAdcPath) ? defaultAdcPath : undefined; +} + +function resolveAnthropicVertexProjectIdFromAdc( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const credentialsPath = resolveAnthropicVertexAdcCredentialsPath(env); + if (!credentialsPath) { + return undefined; + } + + try { + const parsed = JSON.parse(readFileSync(credentialsPath, "utf8")) as AdcProjectFile; + return ( + normalizeOptionalSecretInput(parsed.project_id) || + normalizeOptionalSecretInput(parsed.quota_project_id) + ); + } catch { + return undefined; + } +} + +export function hasAnthropicVertexCredentials(env: NodeJS.ProcessEnv = process.env): boolean { + return ( + hasAnthropicVertexMetadataServerAdc(env) || + resolveAnthropicVertexAdcCredentialsPath(env) !== undefined + ); +} + +export function hasAnthropicVertexAvailableAuth(env: NodeJS.ProcessEnv = process.env): boolean { + return hasAnthropicVertexCredentials(env); +} diff --git a/src/agents/anthropic-vertex-stream.test.ts b/src/agents/anthropic-vertex-stream.test.ts new file mode 100644 index 00000000000..3209bc0fb02 --- /dev/null +++ b/src/agents/anthropic-vertex-stream.test.ts @@ -0,0 +1,221 @@ +import type { Model } from "@mariozechner/pi-ai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => { + const streamAnthropicMock = vi.fn<(model: unknown, context: unknown, options: unknown) => symbol>( + () => Symbol("anthropic-vertex-stream"), + ); + const anthropicVertexCtorMock = vi.fn(); + + return { + streamAnthropicMock, + anthropicVertexCtorMock, + }; +}); + +vi.mock("@mariozechner/pi-ai", () => { + return { + streamAnthropic: (model: unknown, context: unknown, options: unknown) => + hoisted.streamAnthropicMock(model, context, options), + }; +}); + +vi.mock("@anthropic-ai/vertex-sdk", () => ({ + AnthropicVertex: vi.fn(function MockAnthropicVertex(options: unknown) { + hoisted.anthropicVertexCtorMock(options); + return { options }; + }), +})); + +import { + resolveAnthropicVertexRegion, + resolveAnthropicVertexRegionFromBaseUrl, +} from "./anthropic-vertex-provider.js"; +import { + createAnthropicVertexStreamFn, + createAnthropicVertexStreamFnForModel, +} from "./anthropic-vertex-stream.js"; + +function makeModel(params: { id: string; maxTokens?: number }): Model<"anthropic-messages"> { + return { + id: params.id, + api: "anthropic-messages", + provider: "anthropic-vertex", + ...(params.maxTokens !== undefined ? { maxTokens: params.maxTokens } : {}), + } as Model<"anthropic-messages">; +} + +describe("createAnthropicVertexStreamFn", () => { + beforeEach(() => { + hoisted.streamAnthropicMock.mockClear(); + hoisted.anthropicVertexCtorMock.mockClear(); + }); + + it("omits projectId when ADC credentials are used without an explicit project", () => { + const streamFn = createAnthropicVertexStreamFn(undefined, "global"); + + void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {}); + + expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + region: "global", + }); + }); + + it("passes an explicit baseURL through to the Vertex client", () => { + const streamFn = createAnthropicVertexStreamFn( + "vertex-project", + "us-east5", + "https://proxy.example.test/vertex/v1", + ); + + void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {}); + + expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + projectId: "vertex-project", + region: "us-east5", + baseURL: "https://proxy.example.test/vertex/v1", + }); + }); + + it("defaults maxTokens to the model limit instead of the old 32000 cap", () => { + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const model = makeModel({ id: "claude-opus-4-6", maxTokens: 128000 }); + + void streamFn(model, { messages: [] }, {}); + + expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith( + model, + { messages: [] }, + expect.objectContaining({ + maxTokens: 128000, + }), + ); + }); + + it("clamps explicit maxTokens to the selected model limit", () => { + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }); + + void streamFn(model, { messages: [] }, { maxTokens: 999999 }); + + expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith( + model, + { messages: [] }, + expect.objectContaining({ + maxTokens: 128000, + }), + ); + }); + + it("maps xhigh reasoning to max effort for adaptive Opus models", () => { + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const model = makeModel({ id: "claude-opus-4-6", maxTokens: 64000 }); + + void streamFn(model, { messages: [] }, { reasoning: "xhigh" }); + + expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith( + model, + { messages: [] }, + expect.objectContaining({ + thinkingEnabled: true, + effort: "max", + }), + ); + }); + + it("omits maxTokens when neither the model nor request provide a finite limit", () => { + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const model = makeModel({ id: "claude-sonnet-4-6" }); + + void streamFn(model, { messages: [] }, { maxTokens: Number.NaN }); + + expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith( + model, + { messages: [] }, + expect.not.objectContaining({ + maxTokens: expect.anything(), + }), + ); + }); +}); + +describe("resolveAnthropicVertexRegionFromBaseUrl", () => { + it("accepts well-formed regional env values", () => { + expect( + resolveAnthropicVertexRegion({ + GOOGLE_CLOUD_LOCATION: "us-east1", + } as NodeJS.ProcessEnv), + ).toBe("us-east1"); + }); + + it("falls back to the default region for malformed env values", () => { + expect( + resolveAnthropicVertexRegion({ + GOOGLE_CLOUD_LOCATION: "us-central1.attacker.example", + } as NodeJS.ProcessEnv), + ).toBe("global"); + }); + + it("parses regional Vertex endpoints", () => { + expect( + resolveAnthropicVertexRegionFromBaseUrl("https://europe-west4-aiplatform.googleapis.com"), + ).toBe("europe-west4"); + }); + + it("treats the global Vertex endpoint as global", () => { + expect(resolveAnthropicVertexRegionFromBaseUrl("https://aiplatform.googleapis.com")).toBe( + "global", + ); + }); +}); + +describe("createAnthropicVertexStreamFnForModel", () => { + beforeEach(() => { + hoisted.anthropicVertexCtorMock.mockClear(); + }); + + it("derives project and region from the model and env", () => { + const streamFn = createAnthropicVertexStreamFnForModel( + { baseUrl: "https://europe-west4-aiplatform.googleapis.com" }, + { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, + ); + + void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {}); + + expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + projectId: "vertex-project", + region: "europe-west4", + baseURL: "https://europe-west4-aiplatform.googleapis.com/v1", + }); + }); + + it("preserves explicit custom provider base URLs", () => { + const streamFn = createAnthropicVertexStreamFnForModel( + { baseUrl: "https://proxy.example.test/custom-root/v1" }, + { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, + ); + + void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {}); + + expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + projectId: "vertex-project", + region: "global", + baseURL: "https://proxy.example.test/custom-root/v1", + }); + }); + + it("adds /v1 for path-prefixed custom provider base URLs", () => { + const streamFn = createAnthropicVertexStreamFnForModel( + { baseUrl: "https://proxy.example.test/custom-root" }, + { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, + ); + + void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {}); + + expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({ + projectId: "vertex-project", + region: "global", + baseURL: "https://proxy.example.test/custom-root/v1", + }); + }); +}); diff --git a/src/agents/anthropic-vertex-stream.ts b/src/agents/anthropic-vertex-stream.ts new file mode 100644 index 00000000000..de808f5cdd6 --- /dev/null +++ b/src/agents/anthropic-vertex-stream.ts @@ -0,0 +1,137 @@ +import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"; +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { streamAnthropic, type AnthropicOptions, type Model } from "@mariozechner/pi-ai"; +import { + resolveAnthropicVertexClientRegion, + resolveAnthropicVertexProjectId, +} from "./anthropic-vertex-provider.js"; + +type AnthropicVertexEffort = NonNullable; + +function resolveAnthropicVertexMaxTokens(params: { + modelMaxTokens: number | undefined; + requestedMaxTokens: number | undefined; +}): number | undefined { + const modelMax = + typeof params.modelMaxTokens === "number" && + Number.isFinite(params.modelMaxTokens) && + params.modelMaxTokens > 0 + ? Math.floor(params.modelMaxTokens) + : undefined; + const requested = + typeof params.requestedMaxTokens === "number" && + Number.isFinite(params.requestedMaxTokens) && + params.requestedMaxTokens > 0 + ? Math.floor(params.requestedMaxTokens) + : undefined; + + if (modelMax !== undefined && requested !== undefined) { + return Math.min(requested, modelMax); + } + return requested ?? modelMax; +} + +/** + * Create a StreamFn that routes through pi-ai's `streamAnthropic` with an + * injected `AnthropicVertex` client. All streaming, message conversion, and + * event handling is handled by pi-ai β€” we only supply the GCP-authenticated + * client and map SimpleStreamOptions β†’ AnthropicOptions. + */ +export function createAnthropicVertexStreamFn( + projectId: string | undefined, + region: string, + baseURL?: string, +): StreamFn { + const client = new AnthropicVertex({ + region, + ...(baseURL ? { baseURL } : {}), + ...(projectId ? { projectId } : {}), + }); + + return (model, context, options) => { + const maxTokens = resolveAnthropicVertexMaxTokens({ + modelMaxTokens: model.maxTokens, + requestedMaxTokens: options?.maxTokens, + }); + const opts: AnthropicOptions = { + client: client as unknown as AnthropicOptions["client"], + temperature: options?.temperature, + ...(maxTokens !== undefined ? { maxTokens } : {}), + signal: options?.signal, + cacheRetention: options?.cacheRetention, + sessionId: options?.sessionId, + headers: options?.headers, + onPayload: options?.onPayload, + maxRetryDelayMs: options?.maxRetryDelayMs, + metadata: options?.metadata, + }; + + if (options?.reasoning) { + const isAdaptive = + model.id.includes("opus-4-6") || + model.id.includes("opus-4.6") || + model.id.includes("sonnet-4-6") || + model.id.includes("sonnet-4.6"); + + if (isAdaptive) { + opts.thinkingEnabled = true; + const effortMap: Record = { + minimal: "low", + low: "low", + medium: "medium", + high: "high", + xhigh: model.id.includes("opus-4-6") || model.id.includes("opus-4.6") ? "max" : "high", + }; + opts.effort = effortMap[options.reasoning] ?? "high"; + } else { + opts.thinkingEnabled = true; + const budgets = options.thinkingBudgets; + opts.thinkingBudgetTokens = + (budgets && options.reasoning in budgets + ? budgets[options.reasoning as keyof typeof budgets] + : undefined) ?? 10000; + } + } else { + opts.thinkingEnabled = false; + } + + return streamAnthropic(model as Model<"anthropic-messages">, context, opts); + }; +} + +function resolveAnthropicVertexSdkBaseUrl(baseUrl?: string): string | undefined { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return undefined; + } + + try { + const url = new URL(trimmed); + const normalizedPath = url.pathname.replace(/\/+$/, ""); + if (!normalizedPath || normalizedPath === "") { + url.pathname = "/v1"; + return url.toString().replace(/\/$/, ""); + } + if (!normalizedPath.endsWith("/v1")) { + url.pathname = `${normalizedPath}/v1`; + return url.toString().replace(/\/$/, ""); + } + return trimmed; + } catch { + return trimmed; + } +} + +export function createAnthropicVertexStreamFnForModel( + model: { baseUrl?: string }, + env: NodeJS.ProcessEnv = process.env, +): StreamFn { + return createAnthropicVertexStreamFn( + resolveAnthropicVertexProjectId(env), + resolveAnthropicVertexClientRegion({ + baseUrl: model.baseUrl, + env, + }), + resolveAnthropicVertexSdkBaseUrl(model.baseUrl), + ); +} diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts index 766bfe22107..247c21aede9 100644 --- a/src/agents/bash-tools.exec.path.test.ts +++ b/src/agents/bash-tools.exec.path.test.ts @@ -130,6 +130,22 @@ describe("exec PATH login shell merge", () => { expect(shellPathMock).not.toHaveBeenCalled(); }); + it("fails closed when a blocked runtime override key is requested", async () => { + if (isWin) { + return; + } + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); + + await expect( + tool.execute("call-blocked-runtime-env", { + command: "echo ok", + env: { CLASSPATH: "/tmp/evil-classpath" }, + }), + ).rejects.toThrow( + /Security Violation: Environment variable 'CLASSPATH' is forbidden during host execution\./, + ); + }); + it("does not apply login-shell PATH when probe rejects unregistered absolute SHELL", async () => { if (isWin) { return; diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 5fe0f7deac4..dcb50c0344c 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { type ExecHost, loadExecApprovals, maxAsk, minSecurity } from "../infra/exec-approvals.js"; import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; +import { sanitizeHostExecEnvWithDiagnostics } from "../infra/host-env-security.js"; import { getShellPathFromLoginShell, resolveShellEnvFallbackTimeoutMs, @@ -25,9 +26,7 @@ import { renderExecHostLabel, resolveApprovalRunningNoticeMs, runExecProcess, - sanitizeHostBaseEnv, execSchema, - validateHostEnv, } from "./bash-tools.exec-runtime.js"; import type { ExecElevatedDefaults, @@ -362,24 +361,58 @@ export function createExecTool( } const inheritedBaseEnv = coerceEnv(process.env); - const baseEnv = host === "sandbox" ? inheritedBaseEnv : sanitizeHostBaseEnv(inheritedBaseEnv); - - // Logic: Sandbox gets raw env. Host (gateway/node) must pass validation. - // We validate BEFORE merging to prevent any dangerous vars from entering the stream. - if (host !== "sandbox" && params.env) { - validateHostEnv(params.env); + const hostEnvResult = + host === "sandbox" + ? null + : sanitizeHostExecEnvWithDiagnostics({ + baseEnv: inheritedBaseEnv, + overrides: params.env, + blockPathOverrides: true, + }); + if ( + hostEnvResult && + params.env && + (hostEnvResult.rejectedOverrideBlockedKeys.length > 0 || + hostEnvResult.rejectedOverrideInvalidKeys.length > 0) + ) { + const blockedKeys = hostEnvResult.rejectedOverrideBlockedKeys; + const invalidKeys = hostEnvResult.rejectedOverrideInvalidKeys; + const pathBlocked = blockedKeys.includes("PATH"); + if (pathBlocked && blockedKeys.length === 1 && invalidKeys.length === 0) { + throw new Error( + "Security Violation: Custom 'PATH' variable is forbidden during host execution.", + ); + } + if (blockedKeys.length === 1 && invalidKeys.length === 0) { + throw new Error( + `Security Violation: Environment variable '${blockedKeys[0]}' is forbidden during host execution.`, + ); + } + const details: string[] = []; + if (blockedKeys.length > 0) { + details.push(`blocked override keys: ${blockedKeys.join(", ")}`); + } + if (invalidKeys.length > 0) { + details.push(`invalid non-portable override keys: ${invalidKeys.join(", ")}`); + } + const suffix = details.join("; "); + if (pathBlocked) { + throw new Error( + `Security Violation: Custom 'PATH' variable is forbidden during host execution (${suffix}).`, + ); + } + throw new Error(`Security Violation: ${suffix}.`); } - const mergedEnv = params.env ? { ...baseEnv, ...params.env } : baseEnv; - - const env = sandbox - ? buildSandboxEnv({ - defaultPath: DEFAULT_PATH, - paramsEnv: params.env, - sandboxEnv: sandbox.env, - containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir, - }) - : mergedEnv; + const env = + sandbox && host === "sandbox" + ? buildSandboxEnv({ + defaultPath: DEFAULT_PATH, + paramsEnv: params.env, + sandboxEnv: sandbox.env, + containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir, + }) + : (hostEnvResult?.env ?? inheritedBaseEnv); if (!sandbox && host === "gateway" && !params.env?.PATH) { const shellPath = getShellPathFromLoginShell({ diff --git a/src/agents/model-auth-markers.test.ts b/src/agents/model-auth-markers.test.ts index 960a648675b..96b7aa96317 100644 --- a/src/agents/model-auth-markers.test.ts +++ b/src/agents/model-auth-markers.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js"; import { + GCP_VERTEX_CREDENTIALS_MARKER, isKnownEnvApiKeyMarker, isNonSecretApiKeyMarker, NON_ENV_SECRETREF_MARKER, @@ -13,6 +14,7 @@ describe("model auth markers", () => { expect(isNonSecretApiKeyMarker("qwen-oauth")).toBe(true); expect(isNonSecretApiKeyMarker(resolveOAuthApiKeyMarker("chutes"))).toBe(true); expect(isNonSecretApiKeyMarker("ollama-local")).toBe(true); + expect(isNonSecretApiKeyMarker(GCP_VERTEX_CREDENTIALS_MARKER)).toBe(true); }); it("recognizes known env marker names but not arbitrary all-caps keys", () => { diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts index 37ec67ba2c0..4009630afc8 100644 --- a/src/agents/model-auth-markers.ts +++ b/src/agents/model-auth-markers.ts @@ -6,6 +6,7 @@ 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"; +export const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; export const NON_ENV_SECRETREF_MARKER = "secretref-managed"; // pragma: allowlist secret export const SECRETREF_ENV_HEADER_MARKER_PREFIX = "secretref-env:"; // pragma: allowlist secret @@ -83,6 +84,7 @@ export function isNonSecretApiKeyMarker( isOAuthApiKeyMarker(trimmed) || trimmed === OLLAMA_LOCAL_AUTH_MARKER || trimmed === CUSTOM_LOCAL_AUTH_MARKER || + trimmed === GCP_VERTEX_CREDENTIALS_MARKER || trimmed === NON_ENV_SECRETREF_MARKER || isAwsSdkAuthMarker(trimmed); if (isKnownMarker) { diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index f9395373024..3213ef7be32 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -506,4 +506,55 @@ describe("getApiKeyForModel", () => { }, ); }); + + it("resolveEnvApiKey('anthropic-vertex') uses the provided env snapshot", async () => { + const resolved = resolveEnvApiKey("anthropic-vertex", { + GOOGLE_CLOUD_PROJECT_ID: "vertex-project", + } as NodeJS.ProcessEnv); + + expect(resolved).toBeNull(); + }); + + it("resolveEnvApiKey('anthropic-vertex') accepts GOOGLE_APPLICATION_CREDENTIALS with project_id", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-")); + const credentialsPath = path.join(tempDir, "adc.json"); + await fs.writeFile(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8"); + + try { + const resolved = resolveEnvApiKey("anthropic-vertex", { + GOOGLE_APPLICATION_CREDENTIALS: credentialsPath, + } as NodeJS.ProcessEnv); + + expect(resolved?.apiKey).toBe("gcp-vertex-credentials"); + expect(resolved?.source).toBe("gcloud adc"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("resolveEnvApiKey('anthropic-vertex') accepts GOOGLE_APPLICATION_CREDENTIALS without a local project field", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-")); + const credentialsPath = path.join(tempDir, "adc.json"); + await fs.writeFile(credentialsPath, "{}", "utf8"); + + try { + const resolved = resolveEnvApiKey("anthropic-vertex", { + GOOGLE_APPLICATION_CREDENTIALS: credentialsPath, + } as NodeJS.ProcessEnv); + + expect(resolved?.apiKey).toBe("gcp-vertex-credentials"); + expect(resolved?.source).toBe("gcloud adc"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("resolveEnvApiKey('anthropic-vertex') accepts explicit metadata auth opt-in", async () => { + const resolved = resolveEnvApiKey("anthropic-vertex", { + ANTHROPIC_VERTEX_USE_GCP_METADATA: "true", + } as NodeJS.ProcessEnv); + + expect(resolved?.apiKey).toBe("gcp-vertex-credentials"); + expect(resolved?.source).toBe("gcloud adc"); + }); }); diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 31fdee5496c..3949a4655a5 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -2,7 +2,11 @@ import { streamSimpleOpenAICompletions, type Model } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; import type { AuthProfileStore } from "./auth-profiles.js"; -import { CUSTOM_LOCAL_AUTH_MARKER, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { + CUSTOM_LOCAL_AUTH_MARKER, + GCP_VERTEX_CREDENTIALS_MARKER, + NON_ENV_SECRETREF_MARKER, +} from "./model-auth-markers.js"; import { applyLocalNoAuthHeaderOverride, hasUsableCustomProviderApiKey, @@ -169,6 +173,24 @@ describe("resolveUsableCustomProviderApiKey", () => { expect(resolved).toBeNull(); }); + it("does not treat the Vertex ADC marker as a usable models.json credential", () => { + const resolved = resolveUsableCustomProviderApiKey({ + cfg: { + models: { + providers: { + "anthropic-vertex": { + baseUrl: "https://us-central1-aiplatform.googleapis.com", + apiKey: GCP_VERTEX_CREDENTIALS_MARKER, + models: [], + }, + }, + }, + }, + provider: "anthropic-vertex", + }); + expect(resolved).toBeNull(); + }); + it("resolves known env marker names from process env for custom providers", () => { const previous = process.env.OPENAI_API_KEY; process.env.OPENAI_API_KEY = "sk-from-env"; // pragma: allowlist secret diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index e494cc71b8c..42665cc4713 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -10,6 +10,7 @@ import { normalizeOptionalSecretInput, normalizeSecretInput, } from "../utils/normalize-secret-input.js"; +import { hasAnthropicVertexAvailableAuth } from "./anthropic-vertex-provider.js"; import { type AuthProfileStore, ensureAuthProfileStore, @@ -21,6 +22,7 @@ import { import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js"; import { CUSTOM_LOCAL_AUTH_MARKER, + GCP_VERTEX_CREDENTIALS_MARKER, isKnownEnvApiKeyMarker, isNonSecretApiKeyMarker, OLLAMA_LOCAL_AUTH_MARKER, @@ -428,6 +430,16 @@ export function resolveEnvApiKey( } return { apiKey: envKey, source: "gcloud adc" }; } + + if (normalized === "anthropic-vertex") { + // Vertex AI uses GCP credentials (SA JSON or ADC), not API keys. + // Return a sentinel so the model resolver considers this provider available. + if (hasAnthropicVertexAvailableAuth(env)) { + return { apiKey: GCP_VERTEX_CREDENTIALS_MARKER, source: "gcloud adc" }; + } + return null; + } + return null; } diff --git a/src/agents/model-id-normalization.test.ts b/src/agents/model-id-normalization.test.ts new file mode 100644 index 00000000000..7ae0d1b736b --- /dev/null +++ b/src/agents/model-id-normalization.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { normalizeXaiModelId } from "./model-id-normalization.js"; + +describe("normalizeXaiModelId", () => { + it("maps deprecated grok 4.20 beta ids to GA ids", () => { + expect(normalizeXaiModelId("grok-4.20-experimental-beta-0304-reasoning")).toBe( + "grok-4.20-reasoning", + ); + expect(normalizeXaiModelId("grok-4.20-experimental-beta-0304-non-reasoning")).toBe( + "grok-4.20-non-reasoning", + ); + }); + + it("leaves current xai model ids unchanged", () => { + expect(normalizeXaiModelId("grok-4.20-reasoning")).toBe("grok-4.20-reasoning"); + expect(normalizeXaiModelId("grok-4")).toBe("grok-4"); + }); +}); diff --git a/src/agents/model-id-normalization.ts b/src/agents/model-id-normalization.ts index 9b0b27a7f01..8131c5a1d29 100644 --- a/src/agents/model-id-normalization.ts +++ b/src/agents/model-id-normalization.ts @@ -21,3 +21,13 @@ export function normalizeGoogleModelId(id: string): string { } return id; } + +export function normalizeXaiModelId(id: string): string { + if (id === "grok-4.20-experimental-beta-0304-reasoning") { + return "grok-4.20-reasoning"; + } + if (id === "grok-4.20-experimental-beta-0304-non-reasoning") { + return "grok-4.20-non-reasoning"; + } + return id; +} diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index e7d583d106f..5d81afc4970 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -194,6 +194,15 @@ describe("model-selection", () => { defaultProvider: "google", expected: { provider: "google", model: "gemini-3.1-flash-lite-preview" }, }, + { + name: "normalizes deprecated xai grok 4.20 beta ids", + variants: [ + "xai/grok-4.20-experimental-beta-0304-reasoning", + "grok-4.20-experimental-beta-0304-reasoning", + ], + defaultProvider: "xai", + expected: { provider: "xai", model: "grok-4.20-reasoning" }, + }, { name: "keeps OpenAI codex refs on the openai provider", variants: ["openai/gpt-5.3-codex", "gpt-5.3-codex"], diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index acc29a32bf9..7e654dd24f3 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -14,7 +14,7 @@ import { } from "./agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import type { ModelCatalogEntry } from "./model-catalog.js"; -import { normalizeGoogleModelId } from "./model-id-normalization.js"; +import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; import { findNormalizedProviderKey, @@ -121,6 +121,9 @@ function normalizeProviderModelId(provider: string, model: string): string { if (provider === "google" || provider === "google-vertex") { return normalizeGoogleModelId(model); } + if (provider === "xai") { + return normalizeXaiModelId(model); + } // OpenRouter-native models (e.g. "openrouter/aurora-alpha") need the full // "openrouter/" as the model ID sent to the API. Models from external // providers already contain a slash (e.g. "anthropic/claude-sonnet-4-5") and diff --git a/src/agents/models-config.e2e-harness.ts b/src/agents/models-config.e2e-harness.ts index 81518ec9aee..bd01edc86be 100644 --- a/src/agents/models-config.e2e-harness.ts +++ b/src/agents/models-config.e2e-harness.ts @@ -112,9 +112,15 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [ "KIMI_API_KEY", "KIMICODE_API_KEY", "GEMINI_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_LOCATION", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_PROJECT_ID", "VENICE_API_KEY", "VLLM_API_KEY", "XIAOMI_API_KEY", + "ANTHROPIC_VERTEX_PROJECT_ID", + "CLOUD_ML_REGION", // Avoid ambient AWS creds unintentionally enabling Bedrock discovery. "AWS_ACCESS_KEY_ID", "AWS_CONFIG_FILE", diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index 5e0f870e476..8906800aa8e 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; @@ -333,6 +334,53 @@ describe("models-config", () => { }); }); }); + + it("fills anthropic-vertex apiKey with the ADC sentinel when models exist", async () => { + await withTempHome(async () => { + const adcDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-")); + const credentialsPath = path.join(adcDir, "application_default_credentials.json"); + await fs.writeFile(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8"); + const previousCredentials = process.env.GOOGLE_APPLICATION_CREDENTIALS; + + try { + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; + + await ensureOpenClawModelsJson({ + models: { + providers: { + "anthropic-vertex": { + baseUrl: "https://us-central1-aiplatform.googleapis.com", + api: "anthropic-messages", + models: [ + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + reasoning: true, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 200000, + maxTokens: 64000, + }, + ], + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers["anthropic-vertex"]?.apiKey).toBe("gcp-vertex-credentials"); + } finally { + if (previousCredentials === undefined) { + delete process.env.GOOGLE_APPLICATION_CREDENTIALS; + } else { + process.env.GOOGLE_APPLICATION_CREDENTIALS = previousCredentials; + } + await fs.rm(adcDir, { recursive: true, force: true }); + } + }); + }); it("merges providers by default", async () => { await withTempHome(async () => { await writeAgentModelsJson({ diff --git a/src/agents/models-config.providers.anthropic-vertex.test.ts b/src/agents/models-config.providers.anthropic-vertex.test.ts new file mode 100644 index 00000000000..207abe0c5b1 --- /dev/null +++ b/src/agents/models-config.providers.anthropic-vertex.test.ts @@ -0,0 +1,190 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { captureEnv } from "../test-utils/env.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; + +describe("anthropic-vertex implicit provider", () => { + it("offers Claude models when GOOGLE_CLOUD_PROJECT_ID is set", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["GOOGLE_CLOUD_PROJECT_ID"]); + process.env.GOOGLE_CLOUD_PROJECT_ID = "vertex-project"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]).toBeUndefined(); + } finally { + envSnapshot.restore(); + } + }); + + it("accepts ADC credentials when the file includes a project_id", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]); + const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-")); + const credentialsPath = join(adcDir, "application_default_credentials.json"); + writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8"); + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; + process.env.GOOGLE_CLOUD_LOCATION = "us-east1"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]?.baseUrl).toBe( + "https://us-east1-aiplatform.googleapis.com", + ); + expect(providers?.["anthropic-vertex"]?.models).toMatchObject([ + { id: "claude-opus-4-6", maxTokens: 128000, contextWindow: 1_000_000 }, + { id: "claude-sonnet-4-6", maxTokens: 128000, contextWindow: 1_000_000 }, + ]); + } finally { + rmSync(adcDir, { recursive: true, force: true }); + envSnapshot.restore(); + } + }); + + it("accepts ADC credentials when the file only includes a quota_project_id", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]); + const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-")); + const credentialsPath = join(adcDir, "application_default_credentials.json"); + writeFileSync(credentialsPath, JSON.stringify({ quota_project_id: "vertex-quota" }), "utf8"); + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; + process.env.GOOGLE_CLOUD_LOCATION = "us-east5"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]?.baseUrl).toBe( + "https://us-east5-aiplatform.googleapis.com", + ); + } finally { + rmSync(adcDir, { recursive: true, force: true }); + envSnapshot.restore(); + } + }); + + it("accepts ADC credentials when project_id is resolved at runtime", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]); + const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-")); + const credentialsPath = join(adcDir, "application_default_credentials.json"); + writeFileSync(credentialsPath, "{}", "utf8"); + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; + process.env.GOOGLE_CLOUD_LOCATION = "europe-west4"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]?.baseUrl).toBe( + "https://europe-west4-aiplatform.googleapis.com", + ); + } finally { + rmSync(adcDir, { recursive: true, force: true }); + envSnapshot.restore(); + } + }); + + it("falls back to the default region when GOOGLE_CLOUD_LOCATION is invalid", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]); + const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-")); + const credentialsPath = join(adcDir, "application_default_credentials.json"); + writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8"); + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; + process.env.GOOGLE_CLOUD_LOCATION = "us-central1.attacker.example"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]?.baseUrl).toBe("https://aiplatform.googleapis.com"); + } finally { + rmSync(adcDir, { recursive: true, force: true }); + envSnapshot.restore(); + } + }); + + it("uses the Vertex global endpoint when GOOGLE_CLOUD_LOCATION=global", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]); + const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-")); + const credentialsPath = join(adcDir, "application_default_credentials.json"); + writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8"); + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; + process.env.GOOGLE_CLOUD_LOCATION = "global"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]?.baseUrl).toBe("https://aiplatform.googleapis.com"); + } finally { + rmSync(adcDir, { recursive: true, force: true }); + envSnapshot.restore(); + } + }); + + it("accepts explicit metadata auth opt-in without local credential files", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["ANTHROPIC_VERTEX_USE_GCP_METADATA", "GOOGLE_CLOUD_LOCATION"]); + process.env.ANTHROPIC_VERTEX_USE_GCP_METADATA = "true"; + process.env.GOOGLE_CLOUD_LOCATION = "us-east5"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]?.baseUrl).toBe( + "https://us-east5-aiplatform.googleapis.com", + ); + } finally { + envSnapshot.restore(); + } + }); + + it("merges the bundled catalog into explicit anthropic-vertex provider overrides", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]); + const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-")); + const credentialsPath = join(adcDir, "application_default_credentials.json"); + writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8"); + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; + process.env.GOOGLE_CLOUD_LOCATION = "us-east5"; + + try { + const providers = await resolveImplicitProvidersForTest({ + agentDir, + config: { + models: { + providers: { + "anthropic-vertex": { + baseUrl: "https://europe-west4-aiplatform.googleapis.com", + headers: { "x-test-header": "1" }, + }, + }, + }, + } as unknown as OpenClawConfig, + }); + + expect(providers?.["anthropic-vertex"]?.baseUrl).toBe( + "https://europe-west4-aiplatform.googleapis.com", + ); + expect(providers?.["anthropic-vertex"]?.headers).toEqual({ "x-test-header": "1" }); + expect(providers?.["anthropic-vertex"]?.models?.map((model) => model.id)).toEqual([ + "claude-opus-4-6", + "claude-sonnet-4-6", + ]); + } finally { + rmSync(adcDir, { recursive: true, force: true }); + envSnapshot.restore(); + } + }); + + it("does not accept generic Kubernetes env without a GCP ADC signal", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["KUBERNETES_SERVICE_HOST", "GOOGLE_CLOUD_LOCATION"]); + process.env.KUBERNETES_SERVICE_HOST = "10.0.0.1"; + process.env.GOOGLE_CLOUD_LOCATION = "us-east5"; + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["anthropic-vertex"]).toBeUndefined(); + } finally { + envSnapshot.restore(); + } + }); +}); diff --git a/src/agents/models-config.providers.static.ts b/src/agents/models-config.providers.static.ts index 71184e12286..dea2c4e6f2f 100644 --- a/src/agents/models-config.providers.static.ts +++ b/src/agents/models-config.providers.static.ts @@ -1,3 +1,7 @@ +export { + ANTHROPIC_VERTEX_DEFAULT_MODEL_ID, + buildAnthropicVertexProvider, +} from "../../extensions/anthropic-vertex/provider-catalog.js"; export { buildBytePlusCodingProvider, buildBytePlusProvider, diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index af9c3d6e34a..f4f6172dc09 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1,3 +1,4 @@ +import { buildAnthropicVertexProvider } from "../../extensions/anthropic-vertex/provider-catalog.js"; import { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID, @@ -7,9 +8,10 @@ import type { OpenClawConfig } from "../config/config.js"; import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; import { isRecord } from "../utils.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; +import { hasAnthropicVertexAvailableAuth } from "./anthropic-vertex-provider.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { discoverBedrockModels } from "./bedrock-discovery.js"; -import { normalizeGoogleModelId } from "./model-id-normalization.js"; +import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js"; import { resolveOllamaApiBase } from "./models-config.providers.discovery.js"; export { buildKimiCodingProvider } from "../../extensions/kimi-coding/provider-catalog.js"; export { buildKilocodeProvider } from "../../extensions/kilocode/provider-catalog.js"; @@ -42,7 +44,7 @@ import { } from "./model-auth-markers.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; export { resolveOllamaApiBase } from "./models-config.providers.discovery.js"; -export { normalizeGoogleModelId }; +export { normalizeGoogleModelId, normalizeXaiModelId }; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; @@ -552,7 +554,10 @@ export function normalizeProviders(params: { mutated = true; normalizedProvider = { ...normalizedProvider, apiKey }; } else { - const fromEnv = resolveEnvApiKeyVarName(normalizedKey, env); + const fromEnv = + normalizedKey === "anthropic-vertex" + ? resolveEnvApiKey(normalizedKey, env)?.apiKey + : resolveEnvApiKeyVarName(normalizedKey, env); const apiKey = fromEnv ?? profileApiKey?.apiKey; if (apiKey?.trim()) { if (profileApiKey && profileApiKey.source !== "plaintext") { @@ -812,9 +817,34 @@ export async function resolveImplicitProviders( : implicitBedrock; } + const implicitAnthropicVertex = resolveImplicitAnthropicVertexProvider({ env }); + if (implicitAnthropicVertex) { + const existing = providers["anthropic-vertex"]; + providers["anthropic-vertex"] = existing + ? { + ...implicitAnthropicVertex, + ...existing, + models: + Array.isArray(existing.models) && existing.models.length > 0 + ? existing.models + : implicitAnthropicVertex.models, + } + : implicitAnthropicVertex; + } + return providers; } +export function resolveImplicitAnthropicVertexProvider(params: { + env?: NodeJS.ProcessEnv; +}): ProviderConfig | null { + const env = params.env ?? process.env; + if (!hasAnthropicVertexAvailableAuth(env)) { + return null; + } + + return buildAnthropicVertexProvider({ env }); +} export async function resolveImplicitBedrockProvider(params: { agentDir: string; config?: OpenClawConfig; diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index 35fc741db58..47460c5efa7 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -125,6 +125,27 @@ describe("formatAssistantErrorText", () => { const msg = makeAssistantError("request ended without sending any chunks"); expect(formatAssistantErrorText(msg)).toBe("LLM request timed out."); }); + + it("returns a connection-refused message for ECONNREFUSED failures", () => { + const msg = makeAssistantError("connect ECONNREFUSED 127.0.0.1:443 during upstream call"); + expect(formatAssistantErrorText(msg)).toBe( + "LLM request failed: connection refused by the provider endpoint.", + ); + }); + + it("returns a DNS-specific message for provider lookup failures", () => { + const msg = makeAssistantError("dial tcp: lookup api.example.com: no such host (ENOTFOUND)"); + expect(formatAssistantErrorText(msg)).toBe( + "LLM request failed: DNS lookup for the provider endpoint failed.", + ); + }); + + it("returns an interrupted-connection message for socket hang ups", () => { + const msg = makeAssistantError("socket hang up"); + expect(formatAssistantErrorText(msg)).toBe( + "LLM request failed: network connection was interrupted.", + ); + }); }); describe("formatRawAssistantErrorForUi", () => { diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts index 2808d320cc5..82fe67c47f4 100644 --- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts +++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts @@ -88,6 +88,14 @@ describe("sanitizeUserFacingText", () => { ); }); + it("returns a transport-specific message for prefixed ECONNREFUSED errors", () => { + expect( + sanitizeUserFacingText("Error: connect ECONNREFUSED 127.0.0.1:443", { + errorContext: true, + }), + ).toBe("LLM request failed: connection refused by the provider endpoint."); + }); + it.each([ { input: "Hello there!\n\nHello there!", diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 7719ecb41a0..bb3d6b78206 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -65,6 +65,57 @@ function formatRateLimitOrOverloadedErrorCopy(raw: string): string | undefined { return undefined; } +function formatTransportErrorCopy(raw: string): string | undefined { + if (!raw) { + return undefined; + } + const lower = raw.toLowerCase(); + + if ( + /\beconnrefused\b/i.test(raw) || + lower.includes("connection refused") || + lower.includes("actively refused") + ) { + return "LLM request failed: connection refused by the provider endpoint."; + } + + if ( + /\beconnreset\b|\beconnaborted\b|\benetreset\b|\bepipe\b/i.test(raw) || + lower.includes("socket hang up") || + lower.includes("connection reset") || + lower.includes("connection aborted") + ) { + return "LLM request failed: network connection was interrupted."; + } + + if ( + /\benotfound\b|\beai_again\b/i.test(raw) || + lower.includes("getaddrinfo") || + lower.includes("no such host") || + lower.includes("dns") + ) { + return "LLM request failed: DNS lookup for the provider endpoint failed."; + } + + if ( + /\benetunreach\b|\behostunreach\b|\behostdown\b/i.test(raw) || + lower.includes("network is unreachable") || + lower.includes("host is unreachable") + ) { + return "LLM request failed: the provider endpoint is unreachable from this host."; + } + + if ( + lower.includes("fetch failed") || + lower.includes("connection error") || + lower.includes("network request failed") + ) { + return "LLM request failed: network connection error."; + } + + return undefined; +} + function isReasoningConstraintErrorMessage(raw: string): boolean { if (!raw) { return false; @@ -566,6 +617,11 @@ export function formatAssistantErrorText( return transientCopy; } + const transportCopy = formatTransportErrorCopy(raw); + if (transportCopy) { + return transportCopy; + } + if (isTimeoutErrorMessage(raw)) { return "LLM request timed out."; } @@ -626,6 +682,10 @@ export function sanitizeUserFacingText(text: string, opts?: { errorContext?: boo if (prefixedCopy) { return prefixedCopy; } + const transportCopy = formatTransportErrorCopy(trimmed); + if (transportCopy) { + return transportCopy; + } if (isTimeoutErrorMessage(trimmed)) { return "LLM request timed out."; } diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 1a97501959e..f8f486f230f 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -623,6 +623,36 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { } }); + it("runs maintain after successful compaction with a transcript rewrite helper", async () => { + const maintain = vi.fn(async (_params?: unknown) => ({ + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + })); + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: true }, + compact: contextEngineCompactMock, + maintain, + } as never); + + const result = await compactEmbeddedPiSession(wrappedCompactionArgs()); + + expect(result.ok).toBe(true); + expect(maintain).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: TEST_SESSION_KEY, + sessionFile: TEST_SESSION_FILE, + runtimeContext: expect.objectContaining({ + workspaceDir: TEST_WORKSPACE_DIR, + }), + }), + ); + const runtimeContext = ( + maintain.mock.calls[0]?.[0] as { runtimeContext?: Record } | undefined + )?.runtimeContext; + expect(typeof runtimeContext?.rewriteTranscriptEntries).toBe("function"); + }); + it("does not fire after_compaction when compaction fails", async () => { hookRunner.hasHooks.mockReturnValue(true); const sync = vi.fn(async () => {}); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 6c753e9d723..dd5806421a0 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -10,7 +10,7 @@ import { import { resolveTelegramInlineButtonsScope, resolveTelegramReactionLevel, -} from "openclaw/plugin-sdk/telegram"; +} from "../../../extensions/telegram/api.js"; import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; @@ -83,6 +83,7 @@ import { compactWithSafetyTimeout, resolveCompactionTimeoutMs, } from "./compaction-safety-timeout.js"; +import { runContextEngineMaintenance } from "./context-engine-maintenance.js"; import { buildEmbeddedExtensionFactories } from "./extensions.js"; import { logToolSchemasForGoogle, @@ -1226,6 +1227,16 @@ export async function compactEmbeddedPiSession( force: params.trigger === "manual", runtimeContext: params as Record, }); + if (result.ok && result.compacted) { + await runContextEngineMaintenance({ + contextEngine, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + reason: "compaction", + runtimeContext: params as Record, + }); + } if (engineOwnsCompaction && result.ok && result.compacted) { await runPostCompactionSideEffects({ config: params.config, diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts new file mode 100644 index 00000000000..3c62e463620 --- /dev/null +++ b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const rewriteTranscriptEntriesInSessionManagerMock = vi.fn((_params?: unknown) => ({ + changed: true, + bytesFreed: 77, + rewrittenEntries: 1, +})); +const rewriteTranscriptEntriesInSessionFileMock = vi.fn(async (_params?: unknown) => ({ + changed: true, + bytesFreed: 123, + rewrittenEntries: 2, +})); + +vi.mock("./transcript-rewrite.js", () => ({ + rewriteTranscriptEntriesInSessionManager: (params: unknown) => + rewriteTranscriptEntriesInSessionManagerMock(params), + rewriteTranscriptEntriesInSessionFile: (params: unknown) => + rewriteTranscriptEntriesInSessionFileMock(params), +})); + +import { + buildContextEngineMaintenanceRuntimeContext, + runContextEngineMaintenance, +} from "./context-engine-maintenance.js"; + +describe("buildContextEngineMaintenanceRuntimeContext", () => { + beforeEach(() => { + rewriteTranscriptEntriesInSessionManagerMock.mockClear(); + rewriteTranscriptEntriesInSessionFileMock.mockClear(); + }); + + it("adds a transcript rewrite helper that targets the current session file", async () => { + const runtimeContext = buildContextEngineMaintenanceRuntimeContext({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + runtimeContext: { workspaceDir: "/tmp/workspace" }, + }); + + expect(runtimeContext.workspaceDir).toBe("/tmp/workspace"); + expect(typeof runtimeContext.rewriteTranscriptEntries).toBe("function"); + + const result = await runtimeContext.rewriteTranscriptEntries?.({ + replacements: [ + { entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } }, + ], + }); + + expect(result).toEqual({ + changed: true, + bytesFreed: 123, + rewrittenEntries: 2, + }); + expect(rewriteTranscriptEntriesInSessionFileMock).toHaveBeenCalledWith({ + sessionFile: "/tmp/session.jsonl", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + request: { + replacements: [ + { entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } }, + ], + }, + }); + }); + + it("reuses the active session manager when one is provided", async () => { + const sessionManager = { appendMessage: vi.fn() } as unknown as Parameters< + typeof buildContextEngineMaintenanceRuntimeContext + >[0]["sessionManager"]; + const runtimeContext = buildContextEngineMaintenanceRuntimeContext({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + sessionManager, + }); + + const result = await runtimeContext.rewriteTranscriptEntries?.({ + replacements: [ + { entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } }, + ], + }); + + expect(result).toEqual({ + changed: true, + bytesFreed: 77, + rewrittenEntries: 1, + }); + expect(rewriteTranscriptEntriesInSessionManagerMock).toHaveBeenCalledWith({ + sessionManager, + replacements: [ + { entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } }, + ], + }); + expect(rewriteTranscriptEntriesInSessionFileMock).not.toHaveBeenCalled(); + }); +}); + +describe("runContextEngineMaintenance", () => { + beforeEach(() => { + rewriteTranscriptEntriesInSessionManagerMock.mockClear(); + rewriteTranscriptEntriesInSessionFileMock.mockClear(); + }); + + it("passes a rewrite-capable runtime context into maintain()", async () => { + const maintain = vi.fn(async (_params?: unknown) => ({ + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + })); + + const result = await runContextEngineMaintenance({ + contextEngine: { + info: { id: "test", name: "Test Engine" }, + ingest: async () => ({ ingested: true }), + assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }), + compact: async () => ({ ok: true, compacted: false }), + maintain, + }, + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + reason: "turn", + runtimeContext: { workspaceDir: "/tmp/workspace" }, + }); + + expect(result).toEqual({ + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + }); + expect(maintain).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + runtimeContext: expect.objectContaining({ + workspaceDir: "/tmp/workspace", + }), + }), + ); + const runtimeContext = ( + maintain.mock.calls[0]?.[0] as + | { runtimeContext?: { rewriteTranscriptEntries?: (request: unknown) => Promise } } + | undefined + )?.runtimeContext as + | { rewriteTranscriptEntries?: (request: unknown) => Promise } + | undefined; + expect(typeof runtimeContext?.rewriteTranscriptEntries).toBe("function"); + }); +}); diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.ts b/src/agents/pi-embedded-runner/context-engine-maintenance.ts new file mode 100644 index 00000000000..88e417f5757 --- /dev/null +++ b/src/agents/pi-embedded-runner/context-engine-maintenance.ts @@ -0,0 +1,83 @@ +import type { + ContextEngine, + ContextEngineMaintenanceResult, + ContextEngineRuntimeContext, +} from "../../context-engine/types.js"; +import { log } from "./logger.js"; +import { + rewriteTranscriptEntriesInSessionFile, + rewriteTranscriptEntriesInSessionManager, +} from "./transcript-rewrite.js"; + +/** + * Attach runtime-owned transcript rewrite helpers to an existing + * context-engine runtime context payload. + */ +export function buildContextEngineMaintenanceRuntimeContext(params: { + sessionId: string; + sessionKey?: string; + sessionFile: string; + sessionManager?: Parameters[0]["sessionManager"]; + runtimeContext?: ContextEngineRuntimeContext; +}): ContextEngineRuntimeContext { + return { + ...params.runtimeContext, + rewriteTranscriptEntries: async (request) => { + if (params.sessionManager) { + return rewriteTranscriptEntriesInSessionManager({ + sessionManager: params.sessionManager, + replacements: request.replacements, + }); + } + return await rewriteTranscriptEntriesInSessionFile({ + sessionFile: params.sessionFile, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + request, + }); + }, + }; +} + +/** + * Run optional context-engine transcript maintenance and normalize the result. + */ +export async function runContextEngineMaintenance(params: { + contextEngine?: ContextEngine; + sessionId: string; + sessionKey?: string; + sessionFile: string; + reason: "bootstrap" | "compaction" | "turn"; + sessionManager?: Parameters[0]["sessionManager"]; + runtimeContext?: ContextEngineRuntimeContext; +}): Promise { + if (typeof params.contextEngine?.maintain !== "function") { + return undefined; + } + + try { + const result = await params.contextEngine.maintain({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + runtimeContext: buildContextEngineMaintenanceRuntimeContext({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + sessionManager: params.sessionManager, + runtimeContext: params.runtimeContext, + }), + }); + if (result.changed) { + log.info( + `[context-engine] maintenance(${params.reason}) changed transcript ` + + `rewrittenEntries=${result.rewrittenEntries} bytesFreed=${result.bytesFreed} ` + + `sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`, + ); + } + return result; + } catch (err) { + log.warn(`context engine maintain failed (${params.reason}): ${String(err)}`); + return undefined; + } +} diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts index 9e7853ef7d5..10c13dfe6fc 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts @@ -66,6 +66,7 @@ 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 mockedRunContextEngineMaintenance = vi.fn(async () => undefined); export const mockedSessionLikelyHasOversizedToolResults = vi.fn(() => false); export const mockedTruncateOversizedToolResultsInSession = vi.fn< () => Promise @@ -173,6 +174,8 @@ export function resetRunOverflowCompactionHarnessMocks(): void { mockedPrepareProviderRuntimeAuth.mockReset(); mockedPrepareProviderRuntimeAuth.mockResolvedValue(undefined); mockedRunEmbeddedAttempt.mockReset(); + mockedRunContextEngineMaintenance.mockReset(); + mockedRunContextEngineMaintenance.mockResolvedValue(undefined); mockedSessionLikelyHasOversizedToolResults.mockReset(); mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false); mockedTruncateOversizedToolResultsInSession.mockReset(); @@ -303,6 +306,10 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ runEmbeddedAttempt: mockedRunEmbeddedAttempt, })); + vi.doMock("./context-engine-maintenance.js", () => ({ + runContextEngineMaintenance: mockedRunContextEngineMaintenance, + })); + vi.doMock("./model.js", () => ({ resolveModelAsync: vi.fn(async () => ({ model: { 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 1f5f0b6de35..56b4fbf0186 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -16,6 +16,7 @@ import { mockedContextEngine, mockedCompactDirect, mockedRunEmbeddedAttempt, + mockedRunContextEngineMaintenance, resetRunOverflowCompactionHarnessMocks, mockedSessionLikelyHasOversizedToolResults, mockedTruncateOversizedToolResultsInSession, @@ -35,6 +36,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { beforeEach(() => { mockedRunEmbeddedAttempt.mockReset(); + mockedRunContextEngineMaintenance.mockReset(); mockedCompactDirect.mockReset(); mockedCoerceToFailoverError.mockReset(); mockedDescribeFailoverError.mockReset(); @@ -50,6 +52,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { compacted: false, reason: "nothing to compact", }); + mockedRunContextEngineMaintenance.mockResolvedValue(undefined); mockedCoerceToFailoverError.mockReturnValue(null); mockedDescribeFailoverError.mockImplementation((err: unknown) => ({ message: err instanceof Error ? err.message : String(err), @@ -241,6 +244,37 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { ); }); + it("runs maintenance after successful overflow-recovery compaction", async () => { + mockedContextEngine.info.ownsCompaction = true; + mockedRunEmbeddedAttempt + .mockResolvedValueOnce(makeAttemptResult({ promptError: makeOverflowError() })) + .mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + mockedCompactDirect.mockResolvedValueOnce({ + ok: true, + compacted: true, + result: { + summary: "engine-owned compaction", + tokensAfter: 50, + }, + }); + + await runEmbeddedPiAgent(overflowBaseRunParams); + + expect(mockedRunContextEngineMaintenance).toHaveBeenCalledWith( + expect.objectContaining({ + contextEngine: mockedContextEngine, + sessionId: "test-session", + sessionKey: "test-key", + sessionFile: "/tmp/session.json", + reason: "compaction", + runtimeContext: expect.objectContaining({ + trigger: "overflow", + authProfileId: "test-profile", + }), + }), + ); + }); + it("guards thrown engine-owned overflow compaction attempts", async () => { mockedContextEngine.info.ownsCompaction = true; mockedGlobalHookRunner.hasHooks.mockImplementation( diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index a35c03d98ca..0c66203992f 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -66,6 +66,7 @@ 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 { runContextEngineMaintenance } from "./context-engine-maintenance.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; import { resolveModelAsync } from "./model.js"; @@ -1131,6 +1132,39 @@ export async function runEmbeddedPiAgent( } } try { + const overflowCompactionRuntimeContext = { + ...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, + trigger: "overflow", + ...(observedOverflowTokens !== undefined + ? { currentTokenCount: observedOverflowTokens } + : {}), + diagId: overflowDiagId, + attempt: overflowCompactionAttempts, + maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS, + }; compactResult = await contextEngine.compact({ sessionId: params.sessionId, sessionKey: params.sessionKey, @@ -1141,40 +1175,18 @@ export async function runEmbeddedPiAgent( : {}), force: true, compactionTarget: "budget", - runtimeContext: { - ...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, - trigger: "overflow", - ...(observedOverflowTokens !== undefined - ? { currentTokenCount: observedOverflowTokens } - : {}), - diagId: overflowDiagId, - attempt: overflowCompactionAttempts, - maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS, - }, + runtimeContext: overflowCompactionRuntimeContext, }); + if (compactResult.ok && compactResult.compacted) { + await runContextEngineMaintenance({ + contextEngine, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + reason: "compaction", + runtimeContext: overflowCompactionRuntimeContext, + }); + } } catch (compactErr) { log.warn( `contextEngine.compact() threw during overflow recovery for ${provider}/${modelId}: ${String(compactErr)}`, diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts index 082442045d3..20617816e6e 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts @@ -40,6 +40,7 @@ const hoisted = vi.hoisted(() => { })); const getGlobalHookRunnerMock = vi.fn<() => unknown>(() => undefined); const initializeGlobalHookRunnerMock = vi.fn(); + const runContextEngineMaintenanceMock = vi.fn(async (_params?: unknown) => undefined); const sessionManager = { getLeafEntry: vi.fn(() => null), branch: vi.fn(), @@ -57,6 +58,7 @@ const hoisted = vi.hoisted(() => { resolveBootstrapContextForRunMock, getGlobalHookRunnerMock, initializeGlobalHookRunnerMock, + runContextEngineMaintenanceMock, sessionManager, }; }); @@ -126,6 +128,10 @@ vi.mock("../skills-runtime.js", () => ({ }), })); +vi.mock("../context-engine-maintenance.js", () => ({ + runContextEngineMaintenance: (params: unknown) => hoisted.runContextEngineMaintenanceMock(params), +})); + vi.mock("../../docs-path.js", () => ({ resolveOpenClawDocsPath: async () => undefined, })); @@ -300,6 +306,7 @@ function resetEmbeddedAttemptHarness( contextFiles: [], }); hoisted.getGlobalHookRunnerMock.mockReset().mockReturnValue(undefined); + hoisted.runContextEngineMaintenanceMock.mockReset().mockResolvedValue(undefined); hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null); hoisted.sessionManager.branch.mockReset(); hoisted.sessionManager.resetLeaf.mockReset(); @@ -852,4 +859,55 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { }), ).toBe(true); }); + + it("skips maintenance when afterTurn fails", async () => { + const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); + const afterTurn = vi.fn(async () => { + throw new Error("afterTurn failed"); + }); + + const result = await runAttemptWithContextEngine({ + bootstrap, + assemble, + afterTurn, + }); + + expect(result.promptError).toBeNull(); + expect(afterTurn).toHaveBeenCalled(); + expect(hoisted.runContextEngineMaintenanceMock).not.toHaveBeenCalledWith( + expect.objectContaining({ reason: "turn" }), + ); + }); + + it("runs startup maintenance for existing sessions even without bootstrap()", async () => { + const { assemble } = createContextEngineBootstrapAndAssemble(); + + const result = await runAttemptWithContextEngine({ + assemble, + }); + + expect(result.promptError).toBeNull(); + expect(hoisted.runContextEngineMaintenanceMock).toHaveBeenCalledWith( + expect.objectContaining({ reason: "bootstrap" }), + ); + }); + + it("skips maintenance when ingestBatch fails", async () => { + const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble(); + const ingestBatch = vi.fn(async () => { + throw new Error("ingestBatch failed"); + }); + + const result = await runAttemptWithContextEngine({ + bootstrap, + assemble, + ingestBatch, + }); + + expect(result.promptError).toBeNull(); + expect(ingestBatch).toHaveBeenCalled(); + expect(hoisted.runContextEngineMaintenanceMock).not.toHaveBeenCalledWith( + expect.objectContaining({ reason: "turn" }), + ); + }); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 20bf752587b..39b2abe4da7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -16,6 +16,7 @@ import { decodeHtmlEntitiesInObject, wrapOllamaCompatNumCtx, wrapStreamFnRepairMalformedToolCallArguments, + wrapStreamFnSanitizeMalformedToolCalls, wrapStreamFnTrimToolCallNames, } from "./attempt.js"; @@ -779,6 +780,552 @@ describe("wrapStreamFnTrimToolCallNames", () => { }); }); +describe("wrapStreamFnSanitizeMalformedToolCalls", () => { + it("drops malformed assistant tool calls from outbound context before provider replay", async () => { + const messages = [ + { + role: "assistant", + stopReason: "error", + content: [{ type: "toolCall", name: "read", arguments: {} }], + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] }; + expect(seenContext.messages).toEqual([ + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]); + expect(seenContext.messages).not.toBe(messages); + }); + + it("preserves outbound context when all assistant tool calls are valid", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] }; + expect(seenContext.messages).toBe(messages); + }); + + it("preserves sessions_spawn attachment payloads on replay", async () => { + const attachmentContent = "INLINE_ATTACHMENT_PAYLOAD"; + const messages = [ + { + role: "assistant", + content: [ + { + type: "toolUse", + id: "call_1", + name: " SESSIONS_SPAWN ", + input: { + task: "inspect attachment", + attachments: [{ name: "snapshot.txt", content: attachmentContent }], + }, + }, + ], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls( + baseFn as never, + new Set(["sessions_spawn"]), + ); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ content?: Array> }>; + }; + const toolCall = seenContext.messages[0]?.content?.[0] as { + name?: string; + input?: { attachments?: Array<{ content?: string }> }; + }; + expect(toolCall.name).toBe("sessions_spawn"); + expect(toolCall.input?.attachments?.[0]?.content).toBe(attachmentContent); + }); + + it("preserves allowlisted tool names that contain punctuation", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolUse", id: "call_1", name: "admin.export", input: { scope: "all" } }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls( + baseFn as never, + new Set(["admin.export"]), + ); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] }; + expect(seenContext.messages).toBe(messages); + }); + + it("normalizes provider-prefixed replayed tool names before provider replay", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolUse", id: "call_1", name: "functions.read", input: { path: "." } }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ content?: Array<{ name?: string }> }>; + }; + expect(seenContext.messages[0]?.content?.[0]?.name).toBe("read"); + }); + + it("canonicalizes mixed-case allowlisted tool names on replay", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "readfile", arguments: {} }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["ReadFile"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ content?: Array<{ name?: string }> }>; + }; + expect(seenContext.messages[0]?.content?.[0]?.name).toBe("ReadFile"); + }); + + it("recovers blank replayed tool names from their ids", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "functionswrite4", name: " ", arguments: {} }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["write"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ content?: Array<{ name?: string }> }>; + }; + expect(seenContext.messages[0]?.content?.[0]?.name).toBe("write"); + }); + + it("recovers mangled replayed tool names before dropping the call", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "functionsread3", arguments: {} }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ content?: Array<{ name?: string }> }>; + }; + expect(seenContext.messages[0]?.content?.[0]?.name).toBe("read"); + }); + + it("drops orphaned tool results after replay sanitization removes a tool-call turn", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", name: "read", arguments: {} }], + stopReason: "error", + }, + { + role: "toolResult", + toolCallId: "call_missing", + toolName: "read", + content: [{ type: "text", text: "stale result" }], + isError: false, + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ role?: string }>; + }; + expect(seenContext.messages).toEqual([ + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]); + }); + + it("drops replayed tool calls that are no longer allowlisted", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "write", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "write", + content: [{ type: "text", text: "stale result" }], + isError: false, + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ role?: string }>; + }; + expect(seenContext.messages).toEqual([ + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]); + }); + it("drops replayed tool names that are no longer allowlisted", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolUse", id: "call_1", name: "unknown_tool", input: { path: "." } }], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "unknown_tool", + content: [{ type: "text", text: "stale result" }], + isError: false, + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] }; + expect(seenContext.messages).toEqual([]); + }); + + it("drops ambiguous mangled replay names instead of guessing a tool", async () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "functions.exec2", arguments: {} }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls( + baseFn as never, + new Set(["exec", "exec2"]), + ); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] }; + expect(seenContext.messages).toEqual([]); + }); + + it("preserves matching tool results for retained errored assistant turns", async () => { + const messages = [ + { + role: "assistant", + stopReason: "error", + content: [ + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + { type: "toolCall", name: "read", arguments: {} }, + ], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "kept result" }], + isError: false, + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"])); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] }; + expect(seenContext.messages).toEqual([ + { + role: "assistant", + stopReason: "error", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "kept result" }], + isError: false, + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]); + }); + + it("revalidates turn ordering after dropping an assistant replay turn", async () => { + const messages = [ + { + role: "user", + content: [{ type: "text", text: "first" }], + }, + { + role: "assistant", + stopReason: "error", + content: [{ type: "toolCall", name: "read", arguments: {} }], + }, + { + role: "user", + content: [{ type: "text", text: "second" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), { + validateGeminiTurns: false, + validateAnthropicTurns: true, + }); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ role?: string; content?: unknown[] }>; + }; + expect(seenContext.messages).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + }, + ]); + }); + + it("drops orphaned Anthropic user tool_result blocks after replay sanitization", async () => { + const messages = [ + { + role: "assistant", + content: [ + { type: "text", text: "partial response" }, + { type: "toolUse", name: "read", input: { path: "." } }, + ], + }, + { + role: "user", + content: [ + { type: "toolResult", toolUseId: "call_1", content: [{ type: "text", text: "stale" }] }, + { type: "text", text: "retry" }, + ], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), { + validateGeminiTurns: false, + validateAnthropicTurns: true, + }); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ role?: string; content?: unknown[] }>; + }; + expect(seenContext.messages).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "partial response" }], + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]); + }); + + it("drops orphaned Anthropic user tool_result blocks after dropping an assistant replay turn", async () => { + const messages = [ + { + role: "user", + content: [{ type: "text", text: "first" }], + }, + { + role: "assistant", + stopReason: "error", + content: [{ type: "toolUse", name: "read", input: { path: "." } }], + }, + { + role: "user", + content: [ + { type: "toolResult", toolUseId: "call_1", content: [{ type: "text", text: "stale" }] }, + { type: "text", text: "second" }, + ], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), { + validateGeminiTurns: false, + validateAnthropicTurns: true, + }); + const stream = wrapped({} as never, { messages } as never, {} as never) as + | FakeWrappedStream + | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { + messages: Array<{ role?: string; content?: unknown[] }>; + }; + expect(seenContext.messages).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + }, + ]); + }); +}); + describe("wrapStreamFnRepairMalformedToolCallArguments", () => { async function invokeWrappedStream(baseFn: (...args: never[]) => unknown) { return await invokeWrappedTestStream( diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 71db23d0f5b..d785218f819 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -10,7 +10,7 @@ import { import { resolveTelegramInlineButtonsScope, resolveTelegramReactionLevel, -} from "openclaw/plugin-sdk/telegram"; +} from "../../../../extensions/telegram/api.js"; import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import type { OpenClawConfig } from "../../../config/config.js"; @@ -36,6 +36,7 @@ import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; import { resolveOpenClawAgentDir } from "../../agent-paths.js"; import { resolveSessionAgentIds } from "../../agent-scope.js"; import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js"; +import { createAnthropicVertexStreamFnForModel } from "../../anthropic-vertex-stream.js"; import { analyzeBootstrapBudget, buildBootstrapPromptWarning, @@ -97,6 +98,7 @@ import { buildSystemPromptReport } from "../../system-prompt-report.js"; import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js"; import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js"; import { normalizeToolName } from "../../tool-policy.js"; +import type { TranscriptPolicy } from "../../transcript-policy.js"; import { resolveTranscriptPolicy } from "../../transcript-policy.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; import { isRunnerAbortError } from "../abort.js"; @@ -104,6 +106,7 @@ import { appendCacheTtlTimestamp, isCacheTtlEligibleProvider } from "../cache-tt import type { CompactEmbeddedPiSessionParams } from "../compact.js"; import { buildEmbeddedCompactionRuntimeContext } from "../compaction-runtime-context.js"; import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js"; +import { runContextEngineMaintenance } from "../context-engine-maintenance.js"; import { buildEmbeddedExtensionFactories } from "../extensions.js"; import { applyExtraParamsToAgent } from "../extra-params.js"; import { @@ -648,6 +651,200 @@ function isToolCallBlockType(type: unknown): boolean { return type === "toolCall" || type === "toolUse" || type === "functionCall"; } +const REPLAY_TOOL_CALL_NAME_MAX_CHARS = 64; + +type ReplayToolCallBlock = { + type?: unknown; + id?: unknown; + name?: unknown; + input?: unknown; + arguments?: unknown; +}; + +type ReplayToolCallSanitizeReport = { + messages: AgentMessage[]; + droppedAssistantMessages: number; +}; + +type AnthropicToolResultContentBlock = { + type?: unknown; + toolUseId?: unknown; +}; + +function isReplayToolCallBlock(block: unknown): block is ReplayToolCallBlock { + if (!block || typeof block !== "object") { + return false; + } + return isToolCallBlockType((block as { type?: unknown }).type); +} + +function replayToolCallHasInput(block: ReplayToolCallBlock): boolean { + const hasInput = "input" in block ? block.input !== undefined && block.input !== null : false; + const hasArguments = + "arguments" in block ? block.arguments !== undefined && block.arguments !== null : false; + return hasInput || hasArguments; +} + +function replayToolCallNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function resolveReplayToolCallName( + rawName: string, + rawId: string, + allowedToolNames?: Set, +): string | null { + if (rawName.length > REPLAY_TOOL_CALL_NAME_MAX_CHARS * 2) { + return null; + } + const normalized = normalizeToolCallNameForDispatch(rawName, allowedToolNames, rawId); + const trimmed = normalized.trim(); + if (!trimmed || trimmed.length > REPLAY_TOOL_CALL_NAME_MAX_CHARS || /\s/.test(trimmed)) { + return null; + } + if (!allowedToolNames || allowedToolNames.size === 0) { + return trimmed; + } + return resolveExactAllowedToolName(trimmed, allowedToolNames); +} + +function sanitizeReplayToolCallInputs( + messages: AgentMessage[], + allowedToolNames?: Set, +): ReplayToolCallSanitizeReport { + let changed = false; + let droppedAssistantMessages = 0; + const out: AgentMessage[] = []; + + for (const message of messages) { + if (!message || typeof message !== "object" || message.role !== "assistant") { + out.push(message); + continue; + } + if (!Array.isArray(message.content)) { + out.push(message); + continue; + } + + const nextContent: typeof message.content = []; + let messageChanged = false; + + for (const block of message.content) { + if (!isReplayToolCallBlock(block)) { + nextContent.push(block); + continue; + } + const replayBlock = block as ReplayToolCallBlock; + + if (!replayToolCallHasInput(replayBlock) || !replayToolCallNonEmptyString(replayBlock.id)) { + changed = true; + messageChanged = true; + continue; + } + + const rawName = typeof replayBlock.name === "string" ? replayBlock.name : ""; + const resolvedName = resolveReplayToolCallName(rawName, replayBlock.id, allowedToolNames); + if (!resolvedName) { + changed = true; + messageChanged = true; + continue; + } + + if (replayBlock.name !== resolvedName) { + nextContent.push({ ...(block as object), name: resolvedName } as typeof block); + changed = true; + messageChanged = true; + continue; + } + nextContent.push(block); + } + + if (messageChanged) { + changed = true; + if (nextContent.length > 0) { + out.push({ ...message, content: nextContent }); + } else { + droppedAssistantMessages += 1; + } + continue; + } + + out.push(message); + } + + return { + messages: changed ? out : messages, + droppedAssistantMessages, + }; +} + +function sanitizeAnthropicReplayToolResults(messages: AgentMessage[]): AgentMessage[] { + let changed = false; + const out: AgentMessage[] = []; + + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if (!message || typeof message !== "object" || message.role !== "user") { + out.push(message); + continue; + } + if (!Array.isArray(message.content)) { + out.push(message); + continue; + } + + const previous = messages[index - 1]; + const validToolUseIds = new Set(); + if (previous && typeof previous === "object" && previous.role === "assistant") { + const previousContent = (previous as { content?: unknown }).content; + if (Array.isArray(previousContent)) { + for (const block of previousContent) { + if (!block || typeof block !== "object") { + continue; + } + const typedBlock = block as { type?: unknown; id?: unknown }; + if (typedBlock.type !== "toolUse" || typeof typedBlock.id !== "string") { + continue; + } + const trimmedId = typedBlock.id.trim(); + if (trimmedId) { + validToolUseIds.add(trimmedId); + } + } + } + } + + const nextContent = message.content.filter((block) => { + if (!block || typeof block !== "object") { + return true; + } + const typedBlock = block as AnthropicToolResultContentBlock; + if (typedBlock.type !== "toolResult" || typeof typedBlock.toolUseId !== "string") { + return true; + } + return validToolUseIds.size > 0 && validToolUseIds.has(typedBlock.toolUseId); + }); + + if (nextContent.length === message.content.length) { + out.push(message); + continue; + } + + changed = true; + if (nextContent.length > 0) { + out.push({ ...message, content: nextContent }); + continue; + } + + out.push({ + ...message, + content: [{ type: "text", text: "[tool results omitted]" }], + } as AgentMessage); + } + + return changed ? out : messages; +} + function normalizeToolCallIdsInMessage(message: unknown): void { if (!message || typeof message !== "object") { return; @@ -796,6 +993,43 @@ export function wrapStreamFnTrimToolCallNames( }; } +export function wrapStreamFnSanitizeMalformedToolCalls( + baseFn: StreamFn, + allowedToolNames?: Set, + transcriptPolicy?: Pick, +): StreamFn { + return (model, context, options) => { + const ctx = context as unknown as { messages?: unknown }; + const messages = ctx?.messages; + if (!Array.isArray(messages)) { + return baseFn(model, context, options); + } + const sanitized = sanitizeReplayToolCallInputs(messages as AgentMessage[], allowedToolNames); + if (sanitized.messages === messages) { + return baseFn(model, context, options); + } + let nextMessages = sanitizeToolUseResultPairing(sanitized.messages, { + preserveErroredAssistantResults: true, + }); + if (transcriptPolicy?.validateAnthropicTurns) { + nextMessages = sanitizeAnthropicReplayToolResults(nextMessages); + } + if (sanitized.droppedAssistantMessages > 0 || transcriptPolicy?.validateAnthropicTurns) { + if (transcriptPolicy?.validateGeminiTurns) { + nextMessages = validateGeminiTurns(nextMessages); + } + if (transcriptPolicy?.validateAnthropicTurns) { + nextMessages = validateAnthropicTurns(nextMessages); + } + } + const nextContext = { + ...(context as unknown as Record), + messages: nextMessages, + } as unknown; + return baseFn(model, nextContext as typeof context, options); + }; +} + function extractBalancedJsonPrefix(raw: string): string | null { let start = 0; while (start < raw.length && /\s/.test(raw[start] ?? "")) { @@ -1802,12 +2036,27 @@ export async function runEmbeddedAttempt( }); trackSessionManagerAccess(params.sessionFile); - if (hadSessionFile && params.contextEngine?.bootstrap) { + if (hadSessionFile && (params.contextEngine?.bootstrap || params.contextEngine?.maintain)) { try { - await params.contextEngine.bootstrap({ + if (typeof params.contextEngine?.bootstrap === "function") { + await params.contextEngine.bootstrap({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + }); + } + await runContextEngineMaintenance({ + contextEngine: params.contextEngine, sessionId: params.sessionId, sessionKey: params.sessionKey, sessionFile: params.sessionFile, + reason: "bootstrap", + sessionManager, + runtimeContext: buildAfterTurnRuntimeContext({ + attempt: params, + workspaceDir: effectiveWorkspace, + agentDir, + }), }); } catch (bootstrapErr) { log.warn(`context engine bootstrap failed: ${String(bootstrapErr)}`); @@ -1964,6 +2213,10 @@ export async function runEmbeddedAttempt( log.warn(`[ws-stream] no API key for provider=${params.provider}; using HTTP transport`); activeSession.agent.streamFn = streamSimple; } + } else if (params.model.provider === "anthropic-vertex") { + // Anthropic Vertex AI: inject AnthropicVertex client into pi-ai's + // streamAnthropic for GCP IAM auth instead of Anthropic API keys. + activeSession.agent.streamFn = createAnthropicVertexStreamFnForModel(params.model); } else { // Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai. activeSession.agent.streamFn = streamSimple; @@ -2100,6 +2353,11 @@ export async function runEmbeddedAttempt( // Some models emit tool names with surrounding whitespace (e.g. " read "). // pi-agent-core dispatches tool calls with exact string matching, so normalize // names on the live response stream before tool execution. + activeSession.agent.streamFn = wrapStreamFnSanitizeMalformedToolCalls( + activeSession.agent.streamFn, + allowedToolNames, + transcriptPolicy, + ); activeSession.agent.streamFn = wrapStreamFnTrimToolCallNames( activeSession.agent.streamFn, allowedToolNames, @@ -2168,6 +2426,7 @@ export async function runEmbeddedAttempt( messages: activeSession.messages, tokenBudget: params.contextTokenBudget, model: params.modelId, + ...(params.prompt !== undefined ? { prompt: params.prompt } : {}), }); if (assembled.messages !== activeSession.messages) { activeSession.agent.replaceMessages(assembled.messages); @@ -2736,6 +2995,7 @@ export async function runEmbeddedAttempt( workspaceDir: effectiveWorkspace, agentDir, }); + let postTurnFinalizationSucceeded = true; if (typeof params.contextEngine.afterTurn === "function") { try { @@ -2749,6 +3009,7 @@ export async function runEmbeddedAttempt( runtimeContext: afterTurnRuntimeContext, }); } catch (afterTurnErr) { + postTurnFinalizationSucceeded = false; log.warn(`context engine afterTurn failed: ${String(afterTurnErr)}`); } } else { @@ -2763,6 +3024,7 @@ export async function runEmbeddedAttempt( messages: newMessages, }); } catch (ingestErr) { + postTurnFinalizationSucceeded = false; log.warn(`context engine ingest failed: ${String(ingestErr)}`); } } else { @@ -2774,12 +3036,25 @@ export async function runEmbeddedAttempt( message: msg, }); } catch (ingestErr) { + postTurnFinalizationSucceeded = false; log.warn(`context engine ingest failed: ${String(ingestErr)}`); } } } } } + + if (!promptError && !aborted && !yieldAborted && postTurnFinalizationSucceeded) { + await runContextEngineMaintenance({ + contextEngine: params.contextEngine, + sessionId: sessionIdUsed, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + reason: "turn", + sessionManager, + runtimeContext: afterTurnRuntimeContext, + }); + } } cacheTrace?.recordStage("session:after", { 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 b65ed0a65e8..016130ff23d 100644 --- a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts +++ b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts @@ -1,13 +1,26 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; -import { describe, expect, it } from "vitest"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; + +const acquireSessionWriteLockReleaseMock = vi.hoisted(() => vi.fn(async () => {})); +const acquireSessionWriteLockMock = vi.hoisted(() => + vi.fn(async (_params?: unknown) => ({ release: acquireSessionWriteLockReleaseMock })), +); + +vi.mock("../session-write-lock.js", () => ({ + acquireSessionWriteLock: (params: unknown) => acquireSessionWriteLockMock(params), +})); + import { truncateToolResultText, truncateToolResultMessage, calculateMaxToolResultChars, getToolResultTextLength, truncateOversizedToolResultsInMessages, + truncateOversizedToolResultsInSession, isOversizedToolResult, sessionLikelyHasOversizedToolResults, HARD_MAX_TOOL_RESULT_CHARS, @@ -16,6 +29,12 @@ import { let testTimestamp = 1; const nextTimestamp = () => testTimestamp++; +beforeEach(() => { + testTimestamp = 1; + acquireSessionWriteLockMock.mockClear(); + acquireSessionWriteLockReleaseMock.mockClear(); +}); + function makeToolResult(text: string, toolCallId = "call_1"): ToolResultMessage { return { role: "toolResult", @@ -248,6 +267,54 @@ describe("truncateOversizedToolResultsInMessages", () => { }); }); +describe("truncateOversizedToolResultsInSession", () => { + it("acquires the session write lock before rewriting oversized tool results", async () => { + const sessionFile = "/tmp/tool-result-truncation-session.jsonl"; + const sessionManager = SessionManager.inMemory(); + sessionManager.appendMessage(makeUserMessage("hello")); + sessionManager.appendMessage(makeAssistantMessage("reading file")); + sessionManager.appendMessage(makeToolResult("x".repeat(500_000))); + + const openSpy = vi + .spyOn(SessionManager, "open") + .mockReturnValue(sessionManager as unknown as ReturnType); + const listener = vi.fn(); + const cleanup = onSessionTranscriptUpdate(listener); + + try { + const result = await truncateOversizedToolResultsInSession({ + sessionFile, + contextWindowTokens: 128_000, + sessionKey: "agent:main:test", + }); + + expect(result.truncated).toBe(true); + expect(result.truncatedCount).toBe(1); + expect(acquireSessionWriteLockMock).toHaveBeenCalledWith({ sessionFile }); + expect(acquireSessionWriteLockReleaseMock).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ sessionFile }); + + const branch = sessionManager.getBranch(); + const rewrittenToolResult = branch.find( + (entry) => entry.type === "message" && entry.message.role === "toolResult", + ); + expect(rewrittenToolResult?.type).toBe("message"); + if ( + rewrittenToolResult?.type !== "message" || + rewrittenToolResult.message.role !== "toolResult" + ) { + throw new Error("expected rewritten tool result"); + } + const rewrittenText = getFirstToolResultText(rewrittenToolResult.message); + expect(rewrittenText.length).toBeLessThan(500_000); + expect(rewrittenText).toContain("truncated"); + } finally { + cleanup(); + openSpy.mockRestore(); + } + }); +}); + describe("sessionLikelyHasOversizedToolResults", () => { it("returns false when no tool results are oversized", () => { const messages = [makeUserMessage("hello"), makeToolResult("small result")]; diff --git a/src/agents/pi-embedded-runner/tool-result-truncation.ts b/src/agents/pi-embedded-runner/tool-result-truncation.ts index c8cbd1124bb..675c70228a3 100644 --- a/src/agents/pi-embedded-runner/tool-result-truncation.ts +++ b/src/agents/pi-embedded-runner/tool-result-truncation.ts @@ -1,7 +1,10 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { TextContent } from "@mariozechner/pi-ai"; import { SessionManager } from "@mariozechner/pi-coding-agent"; +import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; +import { acquireSessionWriteLock } from "../session-write-lock.js"; import { log } from "./logger.js"; +import { rewriteTranscriptEntriesInSessionManager } from "./transcript-rewrite.js"; /** * Maximum share of the context window a single tool result should occupy. @@ -211,8 +214,10 @@ export async function truncateOversizedToolResultsInSession(params: { }): Promise<{ truncated: boolean; truncatedCount: number; reason?: string }> { const { sessionFile, contextWindowTokens } = params; const maxChars = calculateMaxToolResultChars(contextWindowTokens); + let sessionLock: Awaited> | undefined; try { + sessionLock = await acquireSessionWriteLock({ sessionFile }); const sessionManager = SessionManager.open(sessionFile); const branch = sessionManager.getBranch(); @@ -246,87 +251,46 @@ export async function truncateOversizedToolResultsInSession(params: { return { truncated: false, truncatedCount: 0, reason: "no oversized tool results" }; } - // Branch from the parent of the first oversized entry - const firstOversizedIdx = oversizedIndices[0]; - const firstOversizedEntry = branch[firstOversizedIdx]; - const branchFromId = firstOversizedEntry.parentId; - - if (!branchFromId) { - // The oversized entry is the root - very unusual but handle it - sessionManager.resetLeaf(); - } else { - sessionManager.branch(branchFromId); - } - - // Re-append all entries from the first oversized one onwards, - // with truncated tool results - const oversizedSet = new Set(oversizedIndices); - let truncatedCount = 0; - - for (let i = firstOversizedIdx; i < branch.length; i++) { - const entry = branch[i]; - - if (entry.type === "message") { - let message = entry.message; - - if (oversizedSet.has(i)) { - message = truncateToolResultMessage(message, maxChars); - truncatedCount++; - const newLength = getToolResultTextLength(message); - log.info( - `[tool-result-truncation] Truncated tool result: ` + - `originalEntry=${entry.id} newChars=${newLength} ` + - `sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`, - ); - } - - // appendMessage expects Message | CustomMessage | BashExecutionMessage - sessionManager.appendMessage(message as Parameters[0]); - } else if (entry.type === "compaction") { - sessionManager.appendCompaction( - entry.summary, - entry.firstKeptEntryId, - entry.tokensBefore, - entry.details, - entry.fromHook, - ); - } else if (entry.type === "thinking_level_change") { - sessionManager.appendThinkingLevelChange(entry.thinkingLevel); - } else if (entry.type === "model_change") { - sessionManager.appendModelChange(entry.provider, entry.modelId); - } else if (entry.type === "custom") { - sessionManager.appendCustomEntry(entry.customType, entry.data); - } else if (entry.type === "custom_message") { - sessionManager.appendCustomMessageEntry( - entry.customType, - entry.content, - entry.display, - entry.details, - ); - } else if (entry.type === "branch_summary") { - // Branch summaries reference specific entry IDs - skip to avoid inconsistency - continue; - } else if (entry.type === "label") { - // Labels reference specific entry IDs - skip to avoid inconsistency - continue; - } else if (entry.type === "session_info") { - if (entry.name) { - sessionManager.appendSessionInfo(entry.name); - } + const replacements = oversizedIndices.flatMap((index) => { + const entry = branch[index]; + if (!entry || entry.type !== "message") { + return []; } + const message = truncateToolResultMessage(entry.message, maxChars); + const newLength = getToolResultTextLength(message); + log.info( + `[tool-result-truncation] Truncated tool result: ` + + `originalEntry=${entry.id} newChars=${newLength} ` + + `sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`, + ); + return [{ entryId: entry.id, message }]; + }); + + const rewriteResult = rewriteTranscriptEntriesInSessionManager({ + sessionManager, + replacements, + }); + if (rewriteResult.changed) { + emitSessionTranscriptUpdate(sessionFile); } log.info( - `[tool-result-truncation] Truncated ${truncatedCount} tool result(s) in session ` + + `[tool-result-truncation] Truncated ${rewriteResult.rewrittenEntries} tool result(s) in session ` + `(contextWindow=${contextWindowTokens} maxChars=${maxChars}) ` + `sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`, ); - return { truncated: true, truncatedCount }; + return { + truncated: rewriteResult.changed, + truncatedCount: rewriteResult.rewrittenEntries, + reason: rewriteResult.reason, + }; } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); log.warn(`[tool-result-truncation] Failed to truncate: ${errMsg}`); return { truncated: false, truncatedCount: 0, reason: errMsg }; + } finally { + await sessionLock?.release(); } } diff --git a/src/agents/pi-embedded-runner/transcript-rewrite.test.ts b/src/agents/pi-embedded-runner/transcript-rewrite.test.ts new file mode 100644 index 00000000000..0e698244962 --- /dev/null +++ b/src/agents/pi-embedded-runner/transcript-rewrite.test.ts @@ -0,0 +1,402 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; +import { installSessionToolResultGuard } from "../session-tool-result-guard.js"; + +const acquireSessionWriteLockReleaseMock = vi.hoisted(() => vi.fn(async () => {})); +const acquireSessionWriteLockMock = vi.hoisted(() => + vi.fn(async (_params?: unknown) => ({ release: acquireSessionWriteLockReleaseMock })), +); + +vi.mock("../session-write-lock.js", () => ({ + acquireSessionWriteLock: (params: unknown) => acquireSessionWriteLockMock(params), +})); + +import { + rewriteTranscriptEntriesInSessionFile, + rewriteTranscriptEntriesInSessionManager, +} from "./transcript-rewrite.js"; + +type AppendMessage = Parameters[0]; + +function asAppendMessage(message: unknown): AppendMessage { + return message as AppendMessage; +} + +function getBranchMessages(sessionManager: SessionManager): AgentMessage[] { + return sessionManager + .getBranch() + .filter((entry) => entry.type === "message") + .map((entry) => entry.message); +} + +beforeEach(() => { + acquireSessionWriteLockMock.mockClear(); + acquireSessionWriteLockReleaseMock.mockClear(); +}); + +describe("rewriteTranscriptEntriesInSessionManager", () => { + it("branches from the first replaced message and re-appends the remaining suffix", () => { + const sessionManager = SessionManager.inMemory(); + sessionManager.appendMessage( + asAppendMessage({ + role: "user", + content: "read file", + timestamp: 1, + }), + ); + sessionManager.appendMessage( + asAppendMessage({ + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + timestamp: 2, + }), + ); + sessionManager.appendMessage( + asAppendMessage({ + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "x".repeat(8_000) }], + isError: false, + timestamp: 3, + }), + ); + sessionManager.appendMessage( + asAppendMessage({ + role: "assistant", + content: [{ type: "text", text: "summarized" }], + timestamp: 4, + }), + ); + + const toolResultEntry = sessionManager + .getBranch() + .find((entry) => entry.type === "message" && entry.message.role === "toolResult"); + expect(toolResultEntry).toBeDefined(); + + const result = rewriteTranscriptEntriesInSessionManager({ + sessionManager, + replacements: [ + { + entryId: toolResultEntry!.id, + message: { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "[externalized file_123]" }], + isError: false, + timestamp: 3, + }, + }, + ], + }); + + expect(result).toMatchObject({ + changed: true, + rewrittenEntries: 1, + }); + expect(result.bytesFreed).toBeGreaterThan(0); + + const branchMessages = getBranchMessages(sessionManager); + expect(branchMessages.map((message) => message.role)).toEqual([ + "user", + "assistant", + "toolResult", + "assistant", + ]); + const rewrittenToolResult = branchMessages[2] as Extract; + expect(rewrittenToolResult.content).toEqual([ + { type: "text", text: "[externalized file_123]" }, + ]); + }); + + it("preserves active-branch labels after rewritten entries are re-appended", () => { + const sessionManager = SessionManager.inMemory(); + sessionManager.appendMessage( + asAppendMessage({ + role: "user", + content: "read file", + timestamp: 1, + }), + ); + sessionManager.appendMessage( + asAppendMessage({ + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + timestamp: 2, + }), + ); + const toolResultEntryId = sessionManager.appendMessage( + asAppendMessage({ + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "x".repeat(8_000) }], + isError: false, + timestamp: 3, + }), + ); + sessionManager.appendMessage( + asAppendMessage({ + role: "assistant", + content: [{ type: "text", text: "summarized" }], + timestamp: 4, + }), + ); + + const summaryEntry = sessionManager + .getBranch() + .find( + (entry) => + entry.type === "message" && + entry.message.role === "assistant" && + Array.isArray(entry.message.content) && + entry.message.content.some((part) => part.type === "text" && part.text === "summarized"), + ); + expect(summaryEntry).toBeDefined(); + sessionManager.appendLabelChange(summaryEntry!.id, "bookmark"); + + const result = rewriteTranscriptEntriesInSessionManager({ + sessionManager, + replacements: [ + { + entryId: toolResultEntryId, + message: { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "[externalized file_123]" }], + isError: false, + timestamp: 3, + }, + }, + ], + }); + + expect(result.changed).toBe(true); + const rewrittenSummaryEntry = sessionManager + .getBranch() + .find( + (entry) => + entry.type === "message" && + entry.message.role === "assistant" && + Array.isArray(entry.message.content) && + entry.message.content.some((part) => part.type === "text" && part.text === "summarized"), + ); + expect(rewrittenSummaryEntry).toBeDefined(); + expect(sessionManager.getLabel(rewrittenSummaryEntry!.id)).toBe("bookmark"); + expect(sessionManager.getBranch().some((entry) => entry.type === "label")).toBe(true); + }); + + it("remaps compaction keep markers when rewritten entries change ids", () => { + const sessionManager = SessionManager.inMemory(); + sessionManager.appendMessage( + asAppendMessage({ + role: "user", + content: "read file", + timestamp: 1, + }), + ); + sessionManager.appendMessage( + asAppendMessage({ + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + timestamp: 2, + }), + ); + const toolResultEntryId = sessionManager.appendMessage( + asAppendMessage({ + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "x".repeat(8_000) }], + isError: false, + timestamp: 3, + }), + ); + const keptAssistantEntryId = sessionManager.appendMessage( + asAppendMessage({ + role: "assistant", + content: [{ type: "text", text: "keep me" }], + timestamp: 4, + }), + ); + sessionManager.appendCompaction("summary", keptAssistantEntryId, 123); + + const result = rewriteTranscriptEntriesInSessionManager({ + sessionManager, + replacements: [ + { + entryId: toolResultEntryId, + message: { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "[externalized file_123]" }], + isError: false, + timestamp: 3, + }, + }, + ], + }); + + expect(result.changed).toBe(true); + const branch = sessionManager.getBranch(); + const keptAssistantEntry = branch.find( + (entry) => + entry.type === "message" && + entry.message.role === "assistant" && + Array.isArray(entry.message.content) && + entry.message.content.some((part) => part.type === "text" && part.text === "keep me"), + ); + const compactionEntry = branch.find((entry) => entry.type === "compaction"); + + expect(keptAssistantEntry).toBeDefined(); + expect(compactionEntry).toBeDefined(); + expect(compactionEntry?.firstKeptEntryId).toBe(keptAssistantEntry?.id); + expect(compactionEntry?.firstKeptEntryId).not.toBe(keptAssistantEntryId); + }); + + it("bypasses persistence hooks when replaying rewritten messages", () => { + const sessionManager = SessionManager.inMemory(); + sessionManager.appendMessage( + asAppendMessage({ + role: "user", + content: "run tool", + timestamp: 1, + }), + ); + const toolResultEntryId = sessionManager.appendMessage( + asAppendMessage({ + role: "toolResult", + toolCallId: "call_1", + toolName: "exec", + content: [{ type: "text", text: "before rewrite" }], + isError: false, + timestamp: 2, + }), + ); + sessionManager.appendMessage( + asAppendMessage({ + role: "assistant", + content: [{ type: "text", text: "summarized" }], + timestamp: 3, + }), + ); + installSessionToolResultGuard(sessionManager, { + transformToolResultForPersistence: (message) => ({ + ...(message as Extract), + content: [{ type: "text", text: "[hook transformed]" }], + }), + beforeMessageWriteHook: ({ message }) => + message.role === "assistant" ? { block: true } : undefined, + }); + + const result = rewriteTranscriptEntriesInSessionManager({ + sessionManager, + replacements: [ + { + entryId: toolResultEntryId, + message: { + role: "toolResult", + toolCallId: "call_1", + toolName: "exec", + content: [{ type: "text", text: "[exact replacement]" }], + isError: false, + timestamp: 2, + }, + }, + ], + }); + + expect(result.changed).toBe(true); + const branchMessages = getBranchMessages(sessionManager); + expect(branchMessages.map((message) => message.role)).toEqual([ + "user", + "toolResult", + "assistant", + ]); + expect((branchMessages[1] as Extract).content).toEqual([ + { type: "text", text: "[exact replacement]" }, + ]); + expect(branchMessages[2]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "summarized" }], + }); + }); +}); + +describe("rewriteTranscriptEntriesInSessionFile", () => { + it("emits transcript updates when the active branch changes", async () => { + const sessionFile = "/tmp/session.jsonl"; + const sessionManager = SessionManager.inMemory(); + sessionManager.appendMessage( + asAppendMessage({ + role: "user", + content: "run tool", + timestamp: 1, + }), + ); + sessionManager.appendMessage( + asAppendMessage({ + role: "toolResult", + toolCallId: "call_1", + toolName: "exec", + content: [{ type: "text", text: "y".repeat(6_000) }], + isError: false, + timestamp: 2, + }), + ); + + const toolResultEntry = sessionManager + .getBranch() + .find((entry) => entry.type === "message" && entry.message.role === "toolResult"); + expect(toolResultEntry).toBeDefined(); + + const openSpy = vi + .spyOn(SessionManager, "open") + .mockReturnValue(sessionManager as unknown as ReturnType); + const listener = vi.fn(); + const cleanup = onSessionTranscriptUpdate(listener); + + try { + const result = await rewriteTranscriptEntriesInSessionFile({ + sessionFile, + sessionKey: "agent:main:test", + request: { + replacements: [ + { + entryId: toolResultEntry!.id, + message: { + role: "toolResult", + toolCallId: "call_1", + toolName: "exec", + content: [{ type: "text", text: "[file_ref:file_abc]" }], + isError: false, + timestamp: 2, + }, + }, + ], + }, + }); + + expect(result.changed).toBe(true); + expect(acquireSessionWriteLockMock).toHaveBeenCalledWith({ + sessionFile, + }); + expect(acquireSessionWriteLockReleaseMock).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ sessionFile }); + + const rewrittenToolResult = getBranchMessages(sessionManager)[1] as Extract< + AgentMessage, + { role: "toolResult" } + >; + expect(rewrittenToolResult.content).toEqual([{ type: "text", text: "[file_ref:file_abc]" }]); + } finally { + cleanup(); + openSpy.mockRestore(); + } + }); +}); diff --git a/src/agents/pi-embedded-runner/transcript-rewrite.ts b/src/agents/pi-embedded-runner/transcript-rewrite.ts new file mode 100644 index 00000000000..48d93d445b6 --- /dev/null +++ b/src/agents/pi-embedded-runner/transcript-rewrite.ts @@ -0,0 +1,232 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import type { + TranscriptRewriteReplacement, + TranscriptRewriteRequest, + TranscriptRewriteResult, +} from "../../context-engine/types.js"; +import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; +import { getRawSessionAppendMessage } from "../session-tool-result-guard.js"; +import { acquireSessionWriteLock } from "../session-write-lock.js"; +import { log } from "./logger.js"; + +type SessionManagerLike = ReturnType; +type SessionBranchEntry = ReturnType[number]; + +function estimateMessageBytes(message: AgentMessage): number { + return Buffer.byteLength(JSON.stringify(message), "utf8"); +} + +function remapEntryId( + entryId: string | null | undefined, + rewrittenEntryIds: ReadonlyMap, +): string | null { + if (!entryId) { + return null; + } + return rewrittenEntryIds.get(entryId) ?? entryId; +} + +function appendBranchEntry(params: { + sessionManager: SessionManagerLike; + entry: SessionBranchEntry; + rewrittenEntryIds: ReadonlyMap; + appendMessage: SessionManagerLike["appendMessage"]; +}): string { + const { sessionManager, entry, rewrittenEntryIds, appendMessage } = params; + if (entry.type === "message") { + return appendMessage(entry.message as Parameters[0]); + } + if (entry.type === "compaction") { + return sessionManager.appendCompaction( + entry.summary, + remapEntryId(entry.firstKeptEntryId, rewrittenEntryIds) ?? entry.firstKeptEntryId, + entry.tokensBefore, + entry.details, + entry.fromHook, + ); + } + if (entry.type === "thinking_level_change") { + return sessionManager.appendThinkingLevelChange(entry.thinkingLevel); + } + if (entry.type === "model_change") { + return sessionManager.appendModelChange(entry.provider, entry.modelId); + } + if (entry.type === "custom") { + return sessionManager.appendCustomEntry(entry.customType, entry.data); + } + if (entry.type === "custom_message") { + return sessionManager.appendCustomMessageEntry( + entry.customType, + entry.content, + entry.display, + entry.details, + ); + } + if (entry.type === "session_info") { + if (entry.name) { + return sessionManager.appendSessionInfo(entry.name); + } + return sessionManager.appendSessionInfo(""); + } + if (entry.type === "branch_summary") { + return sessionManager.branchWithSummary( + remapEntryId(entry.parentId, rewrittenEntryIds), + entry.summary, + entry.details, + entry.fromHook, + ); + } + return sessionManager.appendLabelChange( + remapEntryId(entry.targetId, rewrittenEntryIds) ?? entry.targetId, + entry.label, + ); +} + +/** + * Safely rewrites transcript message entries on the active branch by branching + * from the first rewritten message's parent and re-appending the suffix. + */ +export function rewriteTranscriptEntriesInSessionManager(params: { + sessionManager: SessionManagerLike; + replacements: TranscriptRewriteReplacement[]; +}): TranscriptRewriteResult { + const replacementsById = new Map( + params.replacements + .filter((replacement) => replacement.entryId.trim().length > 0) + .map((replacement) => [replacement.entryId, replacement.message]), + ); + if (replacementsById.size === 0) { + return { + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + reason: "no replacements requested", + }; + } + + const branch = params.sessionManager.getBranch(); + if (branch.length === 0) { + return { + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + reason: "empty session", + }; + } + + const matchedIndices: number[] = []; + let bytesFreed = 0; + + for (let index = 0; index < branch.length; index++) { + const entry = branch[index]; + if (entry.type !== "message") { + continue; + } + const replacement = replacementsById.get(entry.id); + if (!replacement) { + continue; + } + const originalBytes = estimateMessageBytes(entry.message); + const replacementBytes = estimateMessageBytes(replacement); + matchedIndices.push(index); + bytesFreed += Math.max(0, originalBytes - replacementBytes); + } + + if (matchedIndices.length === 0) { + return { + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + reason: "no matching message entries", + }; + } + + const firstMatchedEntry = branch[matchedIndices[0]] as + | Extract + | undefined; + // matchedIndices only contains indices of branch "message" entries. + if (!firstMatchedEntry) { + return { + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + reason: "invalid first rewrite target", + }; + } + + if (!firstMatchedEntry.parentId) { + params.sessionManager.resetLeaf(); + } else { + params.sessionManager.branch(firstMatchedEntry.parentId); + } + + // Maintenance rewrites should preserve the exact requested history without + // re-running persistence hooks or size truncation on replayed messages. + const appendMessage = getRawSessionAppendMessage(params.sessionManager); + const rewrittenEntryIds = new Map(); + for (let index = matchedIndices[0]; index < branch.length; index++) { + const entry = branch[index]; + const replacement = entry.type === "message" ? replacementsById.get(entry.id) : undefined; + const newEntryId = + replacement === undefined + ? appendBranchEntry({ + sessionManager: params.sessionManager, + entry, + rewrittenEntryIds, + appendMessage, + }) + : appendMessage(replacement as Parameters[0]); + rewrittenEntryIds.set(entry.id, newEntryId); + } + + return { + changed: true, + bytesFreed, + rewrittenEntries: matchedIndices.length, + }; +} + +/** + * Open a transcript file, rewrite message entries on the active branch, and + * emit a transcript update when the active branch changed. + */ +export async function rewriteTranscriptEntriesInSessionFile(params: { + sessionFile: string; + sessionId?: string; + sessionKey?: string; + request: TranscriptRewriteRequest; +}): Promise { + let sessionLock: Awaited> | undefined; + try { + sessionLock = await acquireSessionWriteLock({ + sessionFile: params.sessionFile, + }); + const sessionManager = SessionManager.open(params.sessionFile); + const result = rewriteTranscriptEntriesInSessionManager({ + sessionManager, + replacements: params.request.replacements, + }); + if (result.changed) { + emitSessionTranscriptUpdate(params.sessionFile); + log.info( + `[transcript-rewrite] rewrote ${result.rewrittenEntries} entr` + + `${result.rewrittenEntries === 1 ? "y" : "ies"} ` + + `bytesFreed=${result.bytesFreed} ` + + `sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`, + ); + } + return result; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + log.warn(`[transcript-rewrite] failed: ${reason}`); + return { + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + reason, + }; + } finally { + await sessionLock?.release(); + } +} diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts index 911b124113a..9ffd7a53a72 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts @@ -58,14 +58,16 @@ describe("handleAgentEnd", () => { expect(warn.mock.calls[0]?.[1]).toMatchObject({ event: "embedded_run_agent_end", runId: "run-1", - error: "connection refused", + error: "LLM request failed: connection refused by the provider endpoint.", rawErrorPreview: "connection refused", + consoleMessage: + "embedded run agent end: runId=run-1 isError=true model=unknown provider=unknown error=LLM request failed: connection refused by the provider endpoint. rawError=connection refused", }); expect(onAgentEvent).toHaveBeenCalledWith({ stream: "lifecycle", data: { phase: "error", - error: "connection refused", + error: "LLM request failed: connection refused by the provider endpoint.", }, }); }); @@ -92,7 +94,7 @@ describe("handleAgentEnd", () => { failoverReason: "overloaded", providerErrorType: "overloaded_error", consoleMessage: - "embedded run agent end: runId=run-1 isError=true model=claude-test provider=anthropic error=The AI service is temporarily overloaded. Please try again in a moment.", + 'embedded run agent end: runId=run-1 isError=true model=claude-test provider=anthropic error=The AI service is temporarily overloaded. Please try again in a moment. rawError={"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', }); }); @@ -112,7 +114,7 @@ describe("handleAgentEnd", () => { const meta = warn.mock.calls[0]?.[1]; expect(meta).toMatchObject({ consoleMessage: - "embedded run agent end: runId=run-1 isError=true model=claude sonnet 4 provider=anthropic]8;;https://evil.test error=connection refused", + "embedded run agent end: runId=run-1 isError=true model=claude sonnet 4 provider=anthropic]8;;https://evil.test error=LLM request failed: connection refused by the provider endpoint. rawError=connection refused", }); expect(meta?.consoleMessage).not.toContain("\n"); expect(meta?.consoleMessage).not.toContain("\r"); diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts index 973de1ebefc..7edc299460c 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts @@ -50,6 +50,8 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { const safeRunId = sanitizeForConsole(ctx.params.runId) ?? "-"; const safeModel = sanitizeForConsole(lastAssistant.model) ?? "unknown"; const safeProvider = sanitizeForConsole(lastAssistant.provider) ?? "unknown"; + const safeRawErrorPreview = sanitizeForConsole(observedError.rawErrorPreview); + const rawErrorConsoleSuffix = safeRawErrorPreview ? ` rawError=${safeRawErrorPreview}` : ""; ctx.log.warn("embedded run agent end", { event: "embedded_run_agent_end", tags: ["error_handling", "lifecycle", "agent_end", "assistant_error"], @@ -60,7 +62,7 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { model: lastAssistant.model, provider: lastAssistant.provider, ...observedError, - consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true model=${safeModel} provider=${safeProvider} error=${safeErrorText}`, + consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true model=${safeModel} provider=${safeProvider} error=${safeErrorText}${rawErrorConsoleSuffix}`, }); emitAgentEvent({ runId: ctx.params.runId, diff --git a/src/agents/pi-embedded-subscribe.tools.extract.test.ts b/src/agents/pi-embedded-subscribe.tools.extract.test.ts index cd99ee6b674..044edc93a6d 100644 --- a/src/agents/pi-embedded-subscribe.tools.extract.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.extract.test.ts @@ -1,13 +1,22 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { normalizeTelegramMessagingTarget } from "../../extensions/telegram/api.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 { extractMessagingToolSend } from "./pi-embedded-subscribe.tools.js"; describe("extractMessagingToolSend", () => { beforeEach(() => { setActivePluginRegistry( - createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]), + createTestRegistry([ + { + pluginId: "telegram", + plugin: { + ...createChannelTestPluginBase({ id: "telegram" }), + messaging: { normalizeTarget: normalizeTelegramMessagingTarget }, + }, + source: "test", + }, + ]), ); }); diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index 1712f6f810e..09f19468776 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -69,6 +69,18 @@ describe("resolveProviderCapabilities", () => { geminiThoughtSignatureModelHints: [], dropThinkingBlockModelHints: ["claude"], }); + expect(resolveProviderCapabilities("anthropic-vertex")).toEqual({ + anthropicToolSchemaMode: "native", + anthropicToolChoiceMode: "native", + providerFamily: "anthropic", + preserveAnthropicThinkingSignatures: true, + openAiCompatTurnValidation: true, + geminiThoughtSignatureSanitization: false, + transcriptToolCallIdMode: "default", + transcriptToolCallIdModelHints: [], + geminiThoughtSignatureModelHints: [], + dropThinkingBlockModelHints: ["claude"], + }); expect(resolveProviderCapabilities("amazon-bedrock")).toEqual({ anthropicToolSchemaMode: "native", anthropicToolChoiceMode: "native", @@ -136,6 +148,7 @@ describe("resolveProviderCapabilities", () => { it("tracks provider families and model-specific transcript quirks in the registry", () => { expect(isOpenAiProviderFamily("openai")).toBe(true); + expect(isAnthropicProviderFamily("anthropic-vertex")).toBe(true); expect(isAnthropicProviderFamily("amazon-bedrock")).toBe(true); expect( shouldDropThinkingBlocksForModel({ @@ -143,6 +156,12 @@ describe("resolveProviderCapabilities", () => { modelId: "claude-opus-4-6", }), ).toBe(true); + expect( + shouldDropThinkingBlocksForModel({ + provider: "anthropic-vertex", + modelId: "claude-sonnet-4-6", + }), + ).toBe(true); expect( shouldDropThinkingBlocksForModel({ provider: "amazon-bedrock", diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 2fe11666766..c52be686387 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -35,6 +35,10 @@ const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { }; const CORE_PROVIDER_CAPABILITIES: Record> = { + "anthropic-vertex": { + providerFamily: "anthropic", + dropThinkingBlockModelHints: ["claude"], + }, "amazon-bedrock": { providerFamily: "anthropic", dropThinkingBlockModelHints: ["claude"], diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index 1060ae8b2bc..36150800fd5 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -16,6 +16,11 @@ import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call- const GUARD_TRUNCATION_SUFFIX = "\n\n⚠️ [Content truncated during persistence β€” original exceeded size limit. " + "Use offset/limit parameters or request specific sections for large content.]"; +const RAW_APPEND_MESSAGE = Symbol("openclaw.session.rawAppendMessage"); + +type SessionManagerWithRawAppend = SessionManager & { + [RAW_APPEND_MESSAGE]?: SessionManager["appendMessage"]; +}; /** * Truncate oversized text content blocks in a tool result message. @@ -68,6 +73,16 @@ function normalizePersistedToolResultName( return toolResult; } +/** + * Return the unguarded appendMessage implementation for a session manager. + */ +export function getRawSessionAppendMessage( + sessionManager: SessionManager, +): SessionManager["appendMessage"] { + const rawAppend = (sessionManager as SessionManagerWithRawAppend)[RAW_APPEND_MESSAGE]; + return rawAppend ?? sessionManager.appendMessage.bind(sessionManager); +} + export function installSessionToolResultGuard( sessionManager: SessionManager, opts?: { @@ -109,7 +124,8 @@ export function installSessionToolResultGuard( clearPendingToolResults: () => void; getPendingIds: () => string[]; } { - const originalAppend = sessionManager.appendMessage.bind(sessionManager); + const originalAppend = getRawSessionAppendMessage(sessionManager); + (sessionManager as SessionManagerWithRawAppend)[RAW_APPEND_MESSAGE] = originalAppend; const pendingState = createPendingToolCallState(); const persistMessage = (message: AgentMessage) => { const transformer = opts?.transformMessageForPersistence; diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index e7ab7db94b3..9455837d930 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -195,6 +195,10 @@ export type ToolCallInputRepairOptions = { allowedToolNames?: Iterable; }; +export type ToolUseResultPairingOptions = { + preserveErroredAssistantResults?: boolean; +}; + export function stripToolResultDetails(messages: AgentMessage[]): AgentMessage[] { let touched = false; const out: AgentMessage[] = []; @@ -327,8 +331,11 @@ export function sanitizeToolCallInputs( return repairToolCallInputs(messages, options).messages; } -export function sanitizeToolUseResultPairing(messages: AgentMessage[]): AgentMessage[] { - return repairToolUseResultPairing(messages).messages; +export function sanitizeToolUseResultPairing( + messages: AgentMessage[], + options?: ToolUseResultPairingOptions, +): AgentMessage[] { + return repairToolUseResultPairing(messages, options).messages; } export type ToolUseRepairReport = { @@ -339,7 +346,10 @@ export type ToolUseRepairReport = { moved: boolean; }; -export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRepairReport { +export function repairToolUseResultPairing( + messages: AgentMessage[], + options?: ToolUseResultPairingOptions, +): ToolUseRepairReport { // Anthropic (and Cloud Code Assist) reject transcripts where assistant tool calls are not // immediately followed by matching tool results. Session files can end up with results // displaced (e.g. after user turns) or duplicated. Repair by: @@ -390,18 +400,6 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep const assistant = msg as Extract; - // Skip tool call extraction for aborted or errored assistant messages. - // When stopReason is "error" or "aborted", the tool_use blocks may be incomplete - // (e.g., partialJson: true) and should not have synthetic tool_results created. - // Creating synthetic results for incomplete tool calls causes API 400 errors: - // "unexpected tool_use_id found in tool_result blocks" - // See: https://github.com/openclaw/openclaw/issues/4597 - const stopReason = (assistant as { stopReason?: string }).stopReason; - if (stopReason === "error" || stopReason === "aborted") { - out.push(msg); - continue; - } - const toolCalls = extractToolCallsFromAssistant(assistant); if (toolCalls.length === 0) { out.push(msg); @@ -459,6 +457,28 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep } } + // Aborted/errored assistant turns should never synthesize missing tool results, but + // the replay sanitizer can still legitimately retain real tool results for surviving + // tool calls in the same turn after malformed siblings are dropped. + const stopReason = (assistant as { stopReason?: string }).stopReason; + if (stopReason === "error" || stopReason === "aborted") { + out.push(msg); + if (options?.preserveErroredAssistantResults) { + for (const toolCall of toolCalls) { + const result = spanResultsById.get(toolCall.id); + if (!result) { + continue; + } + pushToolResult(result); + } + } + for (const rem of remainder) { + out.push(rem); + } + i = j - 1; + continue; + } + out.push(msg); if (spanResultsById.size > 0 && remainder.length > 0) { diff --git a/src/agents/subagent-announce.capture-completion-reply.test.ts b/src/agents/subagent-announce.capture-completion-reply.test.ts index 9511cd9ec8a..a2cbbb1faa5 100644 --- a/src/agents/subagent-announce.capture-completion-reply.test.ts +++ b/src/agents/subagent-announce.capture-completion-reply.test.ts @@ -1,8 +1,5 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const readLatestAssistantReplyMock = vi.fn<(sessionKey: string) => Promise>( - async (_sessionKey: string) => undefined, -); const chatHistoryMock = vi.fn<(sessionKey: string) => Promise<{ messages?: Array }>>( async (_sessionKey: string) => ({ messages: [] }), ); @@ -17,10 +14,6 @@ vi.mock("../gateway/call.js", () => ({ }), })); -vi.mock("./tools/agent-step.js", () => ({ - readLatestAssistantReply: readLatestAssistantReplyMock, -})); - describe("captureSubagentCompletionReply", () => { let previousFastTestEnv: string | undefined; let captureSubagentCompletionReply: (typeof import("./subagent-announce.js"))["captureSubagentCompletionReply"]; @@ -40,23 +33,27 @@ describe("captureSubagentCompletionReply", () => { }); beforeEach(() => { - readLatestAssistantReplyMock.mockReset().mockResolvedValue(undefined); chatHistoryMock.mockReset().mockResolvedValue({ messages: [] }); }); - it("returns immediate assistant output without polling", async () => { - readLatestAssistantReplyMock.mockResolvedValueOnce("Immediate assistant completion"); + it("returns immediate assistant output from history without polling", async () => { + chatHistoryMock.mockResolvedValueOnce({ + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "Immediate assistant completion" }], + }, + ], + }); const result = await captureSubagentCompletionReply("agent:main:subagent:child"); expect(result).toBe("Immediate assistant completion"); - expect(readLatestAssistantReplyMock).toHaveBeenCalledTimes(1); - expect(chatHistoryMock).not.toHaveBeenCalled(); + expect(chatHistoryMock).toHaveBeenCalledTimes(1); }); it("polls briefly and returns late tool output once available", async () => { vi.useFakeTimers(); - readLatestAssistantReplyMock.mockResolvedValue(undefined); chatHistoryMock.mockResolvedValueOnce({ messages: [] }).mockResolvedValueOnce({ messages: [ { @@ -82,7 +79,6 @@ describe("captureSubagentCompletionReply", () => { it("returns undefined when no completion output arrives before retry window closes", async () => { vi.useFakeTimers(); - readLatestAssistantReplyMock.mockResolvedValue(undefined); chatHistoryMock.mockResolvedValue({ messages: [] }); const pending = captureSubagentCompletionReply("agent:main:subagent:child"); @@ -93,4 +89,26 @@ describe("captureSubagentCompletionReply", () => { expect(chatHistoryMock).toHaveBeenCalled(); vi.useRealTimers(); }); + + it("returns partial assistant progress when the latest assistant turn is tool-only", async () => { + chatHistoryMock.mockResolvedValueOnce({ + messages: [ + { + role: "assistant", + content: [ + { type: "text", text: "Mapped the modules." }, + { type: "toolCall", id: "call-1", name: "read", arguments: {} }, + ], + }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call-2", name: "exec", arguments: {} }], + }, + ], + }); + + const result = await captureSubagentCompletionReply("agent:main:subagent:child"); + + expect(result).toBe("Mapped the modules."); + }); }); diff --git a/src/agents/subagent-announce.timeout.test.ts b/src/agents/subagent-announce.timeout.test.ts index 5fae988fe73..52cde0f69b0 100644 --- a/src/agents/subagent-announce.timeout.test.ts +++ b/src/agents/subagent-announce.timeout.test.ts @@ -29,10 +29,14 @@ let fallbackRequesterResolution: { requesterSessionKey: string; requesterOrigin?: { channel?: string; to?: string; accountId?: string }; } | null = null; +let chatHistoryMessages: Array> = []; vi.mock("../gateway/call.js", () => ({ callGateway: vi.fn(async (request: GatewayCall) => { gatewayCalls.push(request); + if (request.method === "chat.history") { + return { messages: chatHistoryMessages }; + } return await callGatewayImpl(request); }), })); @@ -138,6 +142,7 @@ function setupParentSessionFallback(parentSessionKey: string): void { describe("subagent announce timeout config", () => { beforeEach(() => { gatewayCalls.length = 0; + chatHistoryMessages = []; callGatewayImpl = async (request) => { if (request.method === "chat.history") { return { messages: [] }; @@ -270,7 +275,6 @@ describe("subagent announce timeout config", () => { it("regression, routes child announce to parent session instead of grandparent when parent session still exists", async () => { const parentSessionKey = "agent:main:subagent:parent"; setupParentSessionFallback(parentSessionKey); - // No sessionId on purpose: existence in store should still count as alive. sessionStore[parentSessionKey] = { updatedAt: Date.now() }; await runAnnounceFlowForTest("run-parent-route", { @@ -301,4 +305,147 @@ describe("subagent announce timeout config", () => { expect(directAgentCall?.params?.to).toBe("chan-main"); expect(directAgentCall?.params?.accountId).toBe("acct-main"); }); + + it("uses partial progress on timeout when the child only made tool calls", async () => { + chatHistoryMessages = [ + { role: "user", content: "do a complex task" }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call-1", name: "read", arguments: {} }], + }, + { role: "toolResult", toolCallId: "call-1", content: [{ type: "text", text: "data" }] }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call-2", name: "exec", arguments: {} }], + }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call-3", name: "search", arguments: {} }], + }, + ]; + + await runAnnounceFlowForTest("run-timeout-partial-progress", { + outcome: { status: "timeout" }, + roundOneReply: undefined, + }); + + const directAgentCall = findFinalDirectAgentCall(); + const internalEvents = + (directAgentCall?.params?.internalEvents as Array<{ result?: string }>) ?? []; + expect(internalEvents[0]?.result).toContain("3 tool call(s)"); + expect(internalEvents[0]?.result).not.toContain("data"); + }); + + it("preserves NO_REPLY when timeout history ends with silence after earlier progress", async () => { + chatHistoryMessages = [ + { + role: "assistant", + content: [ + { type: "text", text: "Still working through the files." }, + { type: "toolCall", id: "call-1", name: "read", arguments: {} }, + ], + }, + { + role: "assistant", + content: [{ type: "text", text: "NO_REPLY" }], + }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call-2", name: "exec", arguments: {} }], + }, + ]; + + await runAnnounceFlowForTest("run-timeout-no-reply", { + outcome: { status: "timeout" }, + roundOneReply: undefined, + }); + + expect(findFinalDirectAgentCall()).toBeUndefined(); + }); + + it("prefers visible assistant progress over a later raw tool result", async () => { + chatHistoryMessages = [ + { + role: "assistant", + content: [{ type: "text", text: "Read 12 files. Narrowing the search now." }], + }, + { + role: "toolResult", + content: [{ type: "text", text: "grep output" }], + }, + ]; + + await runAnnounceFlowForTest("run-timeout-visible-assistant", { + outcome: { status: "timeout" }, + roundOneReply: undefined, + }); + + const directAgentCall = findFinalDirectAgentCall(); + const internalEvents = + (directAgentCall?.params?.internalEvents as Array<{ result?: string }>) ?? []; + expect(internalEvents[0]?.result).toContain("Read 12 files"); + expect(internalEvents[0]?.result).not.toContain("grep output"); + }); + + it("preserves NO_REPLY when timeout partial-progress history mixes prior text and later silence", async () => { + chatHistoryMessages = [ + { role: "user", content: "do something" }, + { + role: "assistant", + content: [ + { type: "text", text: "Still working through the files." }, + { type: "toolCall", id: "call1", name: "read", arguments: {} }, + ], + }, + { role: "toolResult", toolCallId: "call1", content: [{ type: "text", text: "data" }] }, + { + role: "assistant", + content: [{ type: "text", text: "NO_REPLY" }], + }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call2", name: "exec", arguments: {} }], + }, + ]; + + await runAnnounceFlowForTest("run-timeout-mixed-no-reply", { + outcome: { status: "timeout" }, + roundOneReply: undefined, + }); + + expect( + findGatewayCall((call) => call.method === "agent" && call.expectFinal === true), + ).toBeUndefined(); + }); + + it("prefers NO_REPLY partial progress over a longer latest assistant reply", async () => { + chatHistoryMessages = [ + { role: "user", content: "do something" }, + { + role: "assistant", + content: [ + { type: "text", text: "Still working through the files." }, + { type: "toolCall", id: "call1", name: "read", arguments: {} }, + ], + }, + { role: "toolResult", toolCallId: "call1", content: [{ type: "text", text: "data" }] }, + { + role: "assistant", + content: [{ type: "text", text: "NO_REPLY" }], + }, + { + role: "assistant", + content: [{ type: "text", text: "A longer partial summary that should stay silent." }], + }, + ]; + + await runAnnounceFlowForTest("run-timeout-no-reply-overrides-latest-text", { + outcome: { status: "timeout" }, + roundOneReply: undefined, + }); + + expect( + findGatewayCall((call) => call.method === "agent" && call.expectFinal === true), + ).toBeUndefined(); + }); }); diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index eeef9db6b9b..ab2fbb1140e 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -47,7 +47,6 @@ import { import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import type { SpawnSubagentMode } from "./subagent-spawn.js"; -import { readLatestAssistantReply } from "./tools/agent-step.js"; import { sanitizeTextContent, extractAssistantText } from "./tools/sessions-helpers.js"; import { isAnnounceSkip } from "./tools/sessions-send-helpers.js"; @@ -55,7 +54,6 @@ const FAST_TEST_MODE = process.env.OPENCLAW_TEST_FAST === "1"; const FAST_TEST_RETRY_INTERVAL_MS = 8; const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 90_000; const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000; -const GATEWAY_TIMEOUT_PATTERN = /gateway timeout/i; let subagentRegistryRuntimePromise: Promise< typeof import("./subagent-registry-runtime.js") > | null = null; @@ -74,6 +72,14 @@ type ToolResultMessage = { content?: unknown; }; +type SubagentOutputSnapshot = { + latestAssistantText?: string; + latestSilentText?: string; + latestRawText?: string; + assistantFragments: string[]; + toolCallCount: number; +}; + function resolveSubagentAnnounceTimeoutMs(cfg: ReturnType): number { const configured = cfg.agents?.defaults?.subagents?.announceTimeoutMs; if (typeof configured !== "number" || !Number.isFinite(configured)) { @@ -110,7 +116,7 @@ const TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [ /no active .* listener/i, /gateway not connected/i, /gateway closed \(1006/i, - GATEWAY_TIMEOUT_PATTERN, + /gateway timeout/i, /\b(econnreset|econnrefused|etimedout|enotfound|ehostunreach|network error)\b/i, ]; @@ -136,11 +142,6 @@ function isTransientAnnounceDeliveryError(error: unknown): boolean { return TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message)); } -function isGatewayTimeoutError(error: unknown): boolean { - const message = summarizeDeliveryError(error); - return Boolean(message) && GATEWAY_TIMEOUT_PATTERN.test(message); -} - async function waitForAnnounceRetryDelay(ms: number, signal?: AbortSignal): Promise { if (ms <= 0) { return; @@ -168,7 +169,6 @@ async function waitForAnnounceRetryDelay(ms: number, signal?: AbortSignal): Prom async function runAnnounceDeliveryWithRetry(params: { operation: string; - noRetryOnGatewayTimeout?: boolean; signal?: AbortSignal; run: () => Promise; }): Promise { @@ -180,9 +180,6 @@ async function runAnnounceDeliveryWithRetry(params: { try { return await params.run(); } catch (err) { - if (params.noRetryOnGatewayTimeout && isGatewayTimeoutError(err)) { - throw err; - } const delayMs = DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS[retryIndex]; if (delayMs == null || !isTransientAnnounceDeliveryError(err) || params.signal?.aborted) { throw err; @@ -287,42 +284,126 @@ function extractSubagentOutputText(message: unknown): string { return ""; } -async function readLatestSubagentOutput(sessionKey: string): Promise { - try { - const latestAssistant = await readLatestAssistantReply({ - sessionKey, - limit: 50, - }); - if (latestAssistant?.trim()) { - return latestAssistant; - } - } catch { - // Best-effort: fall back to richer history parsing below. +function countAssistantToolCalls(content: unknown): number { + if (!Array.isArray(content)) { + return 0; } + let count = 0; + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const type = (block as { type?: unknown }).type; + if ( + type === "toolCall" || + type === "tool_use" || + type === "toolUse" || + type === "functionCall" || + type === "function_call" + ) { + count += 1; + } + } + return count; +} + +function summarizeSubagentOutputHistory(messages: Array): SubagentOutputSnapshot { + const snapshot: SubagentOutputSnapshot = { + assistantFragments: [], + toolCallCount: 0, + }; + for (const message of messages) { + if (!message || typeof message !== "object") { + continue; + } + const role = (message as { role?: unknown }).role; + if (role === "assistant") { + snapshot.toolCallCount += countAssistantToolCalls((message as { content?: unknown }).content); + const text = extractSubagentOutputText(message).trim(); + if (!text) { + continue; + } + if (isAnnounceSkip(text) || isSilentReplyText(text, SILENT_REPLY_TOKEN)) { + snapshot.latestSilentText = text; + snapshot.latestAssistantText = undefined; + snapshot.assistantFragments = []; + continue; + } + snapshot.latestSilentText = undefined; + snapshot.latestAssistantText = text; + snapshot.assistantFragments.push(text); + continue; + } + const text = extractSubagentOutputText(message).trim(); + if (text) { + snapshot.latestRawText = text; + } + } + return snapshot; +} + +function formatSubagentPartialProgress( + snapshot: SubagentOutputSnapshot, + outcome?: SubagentRunOutcome, +): string | undefined { + if (snapshot.latestSilentText) { + return undefined; + } + const timedOut = outcome?.status === "timeout"; + if (snapshot.assistantFragments.length === 0 && (!timedOut || snapshot.toolCallCount === 0)) { + return undefined; + } + const parts: string[] = []; + if (timedOut && snapshot.toolCallCount > 0) { + parts.push( + `[Partial progress: ${snapshot.toolCallCount} tool call(s) executed before timeout]`, + ); + } + if (snapshot.assistantFragments.length > 0) { + parts.push(snapshot.assistantFragments.slice(-3).join("\n\n---\n\n")); + } + return parts.join("\n\n") || undefined; +} + +function selectSubagentOutputText( + snapshot: SubagentOutputSnapshot, + outcome?: SubagentRunOutcome, +): string | undefined { + if (snapshot.latestSilentText) { + return snapshot.latestSilentText; + } + if (snapshot.latestAssistantText) { + return snapshot.latestAssistantText; + } + const partialProgress = formatSubagentPartialProgress(snapshot, outcome); + if (partialProgress) { + return partialProgress; + } + return snapshot.latestRawText; +} + +async function readSubagentOutput( + sessionKey: string, + outcome?: SubagentRunOutcome, +): Promise { const history = await callGateway<{ messages?: Array }>({ method: "chat.history", - params: { sessionKey, limit: 50 }, + params: { sessionKey, limit: 100 }, }); const messages = Array.isArray(history?.messages) ? history.messages : []; - for (let i = messages.length - 1; i >= 0; i -= 1) { - const msg = messages[i]; - const text = extractSubagentOutputText(msg); - if (text) { - return text; - } - } - return undefined; + return selectSubagentOutputText(summarizeSubagentOutputHistory(messages), outcome); } async function readLatestSubagentOutputWithRetry(params: { sessionKey: string; maxWaitMs: number; + outcome?: SubagentRunOutcome; }): Promise { const RETRY_INTERVAL_MS = FAST_TEST_MODE ? FAST_TEST_RETRY_INTERVAL_MS : 100; const deadline = Date.now() + Math.max(0, Math.min(params.maxWaitMs, 15_000)); let result: string | undefined; while (Date.now() < deadline) { - result = await readLatestSubagentOutput(params.sessionKey); + result = await readSubagentOutput(params.sessionKey, params.outcome); if (result?.trim()) { return result; } @@ -334,7 +415,7 @@ async function readLatestSubagentOutputWithRetry(params: { export async function captureSubagentCompletionReply( sessionKey: string, ): Promise { - const immediate = await readLatestSubagentOutput(sessionKey); + const immediate = await readSubagentOutput(sessionKey); if (immediate?.trim()) { return immediate; } @@ -811,7 +892,6 @@ async function sendSubagentAnnounceDirectly(params: { operation: params.expectsCompletionMessage ? "completion direct announce agent call" : "direct announce agent call", - noRetryOnGatewayTimeout: params.expectsCompletionMessage && shouldDeliverExternally, signal: params.signal, run: async () => await callGateway({ @@ -1321,13 +1401,14 @@ export async function runSubagentAnnounceFlow(params: { (isAnnounceSkip(fallbackReply) || isSilentReplyText(fallbackReply, SILENT_REPLY_TOKEN)); if (!reply) { - reply = await readLatestSubagentOutput(params.childSessionKey); + reply = await readSubagentOutput(params.childSessionKey, outcome); } if (!reply?.trim()) { reply = await readLatestSubagentOutputWithRetry({ sessionKey: params.childSessionKey, maxWaitMs: params.timeoutMs, + outcome, }); } diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index e87df84b909..0b418806612 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js"; import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js"; -import { createMessageToolButtonsSchema } from "../../plugin-sdk/message-tool-schema.js"; +import { createMessageToolButtonsSchema } from "../../plugin-sdk/channel-actions.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; diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 9f3a6fe017c..5bb2585f3ed 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -341,6 +341,15 @@ describe("web_search grok config resolution", () => { expect(resolveGrokModel({ model: "grok-4-fast" })).toBe("grok-4-fast"); }); + it("normalizes deprecated grok 4.20 beta ids to GA ids", () => { + expect(resolveGrokModel({ model: "grok-4.20-experimental-beta-0304-reasoning" })).toBe( + "grok-4.20-reasoning", + ); + expect(resolveGrokModel({ model: "grok-4.20-experimental-beta-0304-non-reasoning" })).toBe( + "grok-4.20-non-reasoning", + ); + }); + it("falls back to default model", () => { expect(resolveGrokModel({})).toBe("grok-4-1-fast"); }); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 11955d4a9b0..ec7291d7730 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -29,7 +29,6 @@ export function createWebSearchTool(options?: { export const __testing = { SEARCH_CACHE, - resolveSearchProvider: ( - search?: NonNullable["web"]>["search"], - ) => resolveWebSearchProviderId({ search }), + resolveSearchProvider: (search?: Parameters[0]["search"]) => + resolveWebSearchProviderId({ search }), }; diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 05d7fe0139a..94487294500 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -1,7 +1,7 @@ import { isTelegramExecApprovalApprover, isTelegramExecApprovalClientEnabled, -} from "openclaw/plugin-sdk/telegram"; +} from "../../../extensions/telegram/api.js"; import { callGateway } from "../../gateway/call.js"; import { logVerbose } from "../../globals.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index b1a1fcba8da..08574530ed9 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -4,7 +4,7 @@ import { calculateTotalPages, getModelsPageSize, type ProviderInfo, -} from "openclaw/plugin-sdk/telegram"; +} from "../../../extensions/telegram/api.js"; import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts index 8d31fbf8c0d..c0988a72443 100644 --- a/src/auto-reply/reply/commands-session-lifecycle.test.ts +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -1,9 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; const hoisted = vi.hoisted(() => { const getThreadBindingManagerMock = vi.fn(); @@ -233,9 +230,6 @@ function createFakeThreadBindingManager(binding: FakeBinding | null) { describe("/session idle and /session max-age", () => { beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]), - ); hoisted.getThreadBindingManagerMock.mockReset(); hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReset(); hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReset(); diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 5d8d871f9ec..5e79ed7ae9f 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -1,4 +1,4 @@ -import { buildBrowseProvidersButton } from "openclaw/plugin-sdk/telegram"; +import { buildBrowseProvidersButton } from "../../../extensions/telegram/api.js"; import { ensureAuthProfileStore, resolveAuthStorePathForDisplay, diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index e20084ed923..f31df4c0707 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -9,6 +9,8 @@ vi.mock("../../agents/model-catalog.js", () => ({ { provider: "kimi", id: "kimi-code", name: "Kimi Code" }, { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, + { provider: "xai", id: "grok-4", name: "Grok 4" }, + { provider: "xai", id: "grok-4.20-reasoning", name: "Grok 4.20 (Reasoning)" }, ]), })); @@ -263,6 +265,45 @@ describe("createModelSelectionState respects session model override", () => { expect(state.provider).toBe(defaultProvider); expect(state.model).toBe("deepseek-v3-4bit-mlx"); }); + + it("normalizes deprecated xai beta session overrides before allowlist checks", async () => { + const cfg = { + agents: { + defaults: { + model: { + primary: "xai/grok-4", + }, + models: { + "xai/grok-4": {}, + "xai/grok-4.20-experimental-beta-0304-reasoning": {}, + }, + }, + }, + } as OpenClawConfig; + const sessionKey = "agent:main:telegram:group:123:topic:99"; + const sessionEntry = makeEntry({ + providerOverride: "xai", + modelOverride: "grok-4.20-experimental-beta-0304-reasoning", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider: "xai", + defaultModel: "grok-4", + provider: "xai", + model: "grok-4", + hasModelDirective: false, + }); + + expect(state.provider).toBe("xai"); + expect(state.model).toBe("grok-4.20-reasoning"); + expect(state.resetModelOverride).toBe(false); + }); }); describe("createModelSelectionState resolveDefaultReasoningLevel", () => { diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 33132e1f477..26ae8a9b46d 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -6,6 +6,7 @@ import { buildAllowedModelSet, type ModelAliasIndex, modelKey, + normalizeModelRef, normalizeProviderId, resolveModelRefFromString, resolveReasoningDefault, @@ -326,7 +327,8 @@ export async function createModelSelectionState(params: { const overrideProvider = sessionEntry.providerOverride?.trim() || defaultProvider; const overrideModel = sessionEntry.modelOverride?.trim(); if (overrideModel) { - const key = modelKey(overrideProvider, overrideModel); + const normalizedOverride = normalizeModelRef(overrideProvider, overrideModel); + const key = modelKey(normalizedOverride.provider, normalizedOverride.model); if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) { const { updated } = applyModelOverrideToSessionEntry({ entry: sessionEntry, @@ -356,11 +358,14 @@ export async function createModelSelectionState(params: { // the regular session/parent model override behavior. const skipStoredOverride = params.hasResolvedHeartbeatModelOverride === true; if (storedOverride?.model && !skipStoredOverride) { - const candidateProvider = storedOverride.provider || defaultProvider; - const key = modelKey(candidateProvider, storedOverride.model); + const normalizedStoredOverride = normalizeModelRef( + storedOverride.provider || defaultProvider, + storedOverride.model, + ); + const key = modelKey(normalizedStoredOverride.provider, normalizedStoredOverride.model); if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { - provider = candidateProvider; - model = storedOverride.model; + provider = normalizedStoredOverride.provider; + model = normalizedStoredOverride.model; } } diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 515d71726fb..c0eca8d6996 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -1,6 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mattermostPlugin } from "../../../extensions/mattermost/src/channel.js"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; import { discordOutbound, imessageOutbound, @@ -9,7 +7,12 @@ import { telegramOutbound, whatsappOutbound, } from "../../../test/channel-outbounds.js"; -import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js"; +import type { + ChannelMessagingAdapter, + ChannelOutboundAdapter, + ChannelPlugin, + ChannelThreadingAdapter, +} from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { PluginRegistry } from "../../plugins/registry.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; @@ -28,13 +31,22 @@ const mocks = vi.hoisted(() => ({ sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })), sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })), sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1", toJid: "jid" })), - sendMessageMattermost: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })), + sendMessageMattermost: vi.fn(async (..._args: unknown[]) => ({ + messageId: "m1", + channelId: "c1", + })), deliverOutboundPayloads: vi.fn(), })); -vi.mock("../../../extensions/discord/src/send.js", () => ({ - sendMessageDiscord: mocks.sendMessageDiscord, -})); +vi.mock("../../../extensions/discord/src/send.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageDiscord: mocks.sendMessageDiscord, + sendPollDiscord: mocks.sendMessageDiscord, + sendWebhookMessageDiscord: vi.fn(), + }; +}); vi.mock("../../../extensions/imessage/src/send.js", () => ({ sendMessageIMessage: mocks.sendMessageIMessage, })); @@ -44,21 +56,17 @@ vi.mock("../../../extensions/signal/src/send.js", () => ({ vi.mock("../../../extensions/slack/src/send.js", () => ({ sendMessageSlack: mocks.sendMessageSlack, })); -vi.mock("../../../extensions/telegram/src/send.js", () => ({ - sendMessageTelegram: mocks.sendMessageTelegram, -})); -vi.mock("../../../extensions/telegram/src/send.js", () => ({ - sendMessageTelegram: mocks.sendMessageTelegram, -})); +vi.mock("../../../extensions/telegram/src/send.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageTelegram: mocks.sendMessageTelegram, + }; +}); vi.mock("../../../extensions/whatsapp/src/send.js", () => ({ sendMessageWhatsApp: mocks.sendMessageWhatsApp, sendPollWhatsApp: mocks.sendMessageWhatsApp, })); -vi.mock("../../../extensions/discord/src/send.js", () => ({ - sendMessageDiscord: mocks.sendMessageDiscord, - sendPollDiscord: mocks.sendMessageDiscord, - sendWebhookMessageDiscord: vi.fn(), -})); vi.mock("../../../extensions/mattermost/src/mattermost/send.js", () => ({ sendMessageMattermost: mocks.sendMessageMattermost, })); @@ -132,6 +140,47 @@ const createMSTeamsPlugin = (params: { outbound: ChannelOutboundAdapter }): Chan outbound: params.outbound, }); +const slackMessaging: ChannelMessagingAdapter = { + enableInteractiveReplies: ({ cfg }) => + (cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } } | undefined) + ?.capabilities?.interactiveReplies === true, + hasStructuredReplyPayload: ({ payload }) => { + const blocks = (payload.channelData?.slack as { blocks?: unknown } | undefined)?.blocks; + if (typeof blocks === "string") { + return blocks.trim().length > 0; + } + return Array.isArray(blocks) && blocks.length > 0; + }, +}; + +const slackThreading: ChannelThreadingAdapter = { + resolveReplyTransport: ({ threadId, replyToId }) => ({ + replyToId: replyToId ?? (threadId != null && threadId !== "" ? String(threadId) : undefined), + threadId: null, + }), +}; + +const mattermostOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + sendText: async ({ to, text, cfg, accountId, replyToId, threadId }) => { + const result = await mocks.sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + }); + return { channel: "mattermost", ...result }; + }, + sendMedia: async ({ to, text, cfg, accountId, replyToId, threadId, mediaUrl }) => { + const result = await mocks.sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + mediaUrl, + }); + return { channel: "mattermost", ...result }; + }, +}; + async function expectSlackNoSend( payload: Parameters[0]["payload"], overrides: Partial[0]> = {}, @@ -553,8 +602,8 @@ const defaultRegistry = createTestRegistry([ pluginId: "slack", plugin: { ...createOutboundTestPlugin({ id: "slack", outbound: slackOutbound, label: "Slack" }), - messaging: slackPlugin.messaging, - threading: slackPlugin.threading, + messaging: slackMessaging, + threading: slackThreading, }, source: "test", }, @@ -595,7 +644,11 @@ const defaultRegistry = createTestRegistry([ }, { pluginId: "mattermost", - plugin: mattermostPlugin, + plugin: createOutboundTestPlugin({ + id: "mattermost", + outbound: mattermostOutbound, + label: "Mattermost", + }), source: "test", }, ]); diff --git a/src/auto-reply/reply/telegram-context.test.ts b/src/auto-reply/reply/telegram-context.test.ts index b38397a1c01..7b58b780180 100644 --- a/src/auto-reply/reply/telegram-context.test.ts +++ b/src/auto-reply/reply/telegram-context.test.ts @@ -1,15 +1,6 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { describe, expect, it } from "vitest"; import { resolveTelegramConversationId } from "./telegram-context.js"; -beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]), - ); -}); - describe("resolveTelegramConversationId", () => { it("builds canonical topic ids from chat target and message thread id", () => { const conversationId = resolveTelegramConversationId({ diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 4485e2c22ee..601fa6891bf 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -1,4 +1,4 @@ -import type { StickerMetadata } from "openclaw/plugin-sdk/telegram"; +import type { StickerMetadata } from "../../extensions/telegram/api.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { MediaUnderstandingDecision, diff --git a/src/bundled-web-search-registry.ts b/src/bundled-web-search-registry.ts new file mode 100644 index 00000000000..689f0b7d614 --- /dev/null +++ b/src/bundled-web-search-registry.ts @@ -0,0 +1,61 @@ +import bravePlugin from "../extensions/brave/index.js"; +import firecrawlPlugin from "../extensions/firecrawl/index.js"; +import googlePlugin from "../extensions/google/index.js"; +import moonshotPlugin from "../extensions/moonshot/index.js"; +import perplexityPlugin from "../extensions/perplexity/index.js"; +import tavilyPlugin from "../extensions/tavily/index.js"; +import xaiPlugin from "../extensions/xai/index.js"; +import type { OpenClawPluginApi } from "./plugins/types.js"; + +type RegistrablePlugin = { + id: string; + register: (api: OpenClawPluginApi) => void; +}; + +export const bundledWebSearchPluginRegistrations: ReadonlyArray<{ + readonly plugin: RegistrablePlugin; + credentialValue: unknown; +}> = [ + { + get plugin() { + return bravePlugin; + }, + credentialValue: "BSA-test", + }, + { + get plugin() { + return firecrawlPlugin; + }, + credentialValue: "fc-test", + }, + { + get plugin() { + return googlePlugin; + }, + credentialValue: "AIza-test", + }, + { + get plugin() { + return moonshotPlugin; + }, + credentialValue: "sk-test", + }, + { + get plugin() { + return perplexityPlugin; + }, + credentialValue: "pplx-test", + }, + { + get plugin() { + return tavilyPlugin; + }, + credentialValue: "tvly-test", + }, + { + get plugin() { + return xaiPlugin; + }, + credentialValue: "xai-test", + }, +]; diff --git a/src/channels/plugins/contracts/outbound-payload.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.contract.test.ts index 761d1274091..5488b918510 100644 --- a/src/channels/plugins/contracts/outbound-payload.contract.test.ts +++ b/src/channels/plugins/contracts/outbound-payload.contract.test.ts @@ -1,11 +1,14 @@ import { describe, vi } from "vitest"; import { discordOutbound } from "../../../../extensions/discord/src/outbound-adapter.js"; import { whatsappOutbound } from "../../../../extensions/whatsapp/src/outbound-adapter.js"; -import { zaloPlugin } from "../../../../extensions/zalo/src/channel.js"; import { sendMessageZalo } from "../../../../extensions/zalo/src/send.js"; -import { zalouserPlugin } from "../../../../extensions/zalouser/src/channel.js"; -import { setZalouserRuntime } from "../../../../extensions/zalouser/src/runtime.js"; import { sendMessageZalouser } from "../../../../extensions/zalouser/src/send.js"; +import { parseZalouserOutboundTarget } from "../../../../extensions/zalouser/src/session-route.js"; +import { + chunkTextForOutbound as chunkZaloTextForOutbound, + sendPayloadWithChunkedTextAndMedia as sendZaloPayloadWithChunkedTextAndMedia, +} from "../../../../src/plugin-sdk/zalo.js"; +import { sendPayloadWithChunkedTextAndMedia as sendZalouserPayloadWithChunkedTextAndMedia } from "../../../../src/plugin-sdk/zalouser.js"; import { slackOutbound } from "../../../../test/channel-outbounds.js"; import type { ReplyPayload } from "../../../auto-reply/types.js"; import { createDirectTextMediaOutbound } from "../outbound/direct-text-media.js"; @@ -69,6 +72,13 @@ type PayloadHarnessParams = { sendResults?: Array<{ messageId: string }>; }; +function buildChannelSendResult(channel: string, result: Record) { + return { + channel, + messageId: typeof result.messageId === "string" ? result.messageId : "", + }; +} + const mockedSendZalo = vi.mocked(sendMessageZalo); const mockedSendZalouser = vi.mocked(sendMessageZalouser); @@ -160,6 +170,94 @@ function createDirectTextMediaHarness(params: PayloadHarnessParams) { }; } +function createZaloHarness(params: PayloadHarnessParams) { + primeChannelOutboundSendMock(mockedSendZalo, { ok: true, messageId: "zl-1" }, params.sendResults); + const ctx = { + cfg: {}, + to: "123456789", + text: "", + payload: params.payload, + }; + return { + run: async () => + await sendZaloPayloadWithChunkedTextAndMedia({ + ctx, + textChunkLimit: 2000, + chunker: chunkZaloTextForOutbound, + sendText: async (nextCtx) => + buildChannelSendResult( + "zalo", + await mockedSendZalo(nextCtx.to, nextCtx.text, { + accountId: undefined, + cfg: nextCtx.cfg, + }), + ), + sendMedia: async (nextCtx) => + buildChannelSendResult( + "zalo", + await mockedSendZalo(nextCtx.to, nextCtx.text, { + accountId: undefined, + cfg: nextCtx.cfg, + mediaUrl: nextCtx.mediaUrl, + }), + ), + emptyResult: { channel: "zalo", messageId: "" }, + }), + sendMock: mockedSendZalo, + to: ctx.to, + }; +} + +function createZalouserHarness(params: PayloadHarnessParams) { + primeChannelOutboundSendMock( + mockedSendZalouser, + { ok: true, messageId: "zlu-1" }, + params.sendResults, + ); + const ctx = { + cfg: {}, + to: "user:987654321", + text: "", + payload: params.payload, + }; + return { + run: async () => + await sendZalouserPayloadWithChunkedTextAndMedia({ + ctx, + sendText: async (nextCtx) => { + const target = parseZalouserOutboundTarget(nextCtx.to); + return buildChannelSendResult( + "zalouser", + await mockedSendZalouser(target.threadId, nextCtx.text, { + profile: "default", + isGroup: target.isGroup, + textMode: "markdown", + textChunkMode: "length", + textChunkLimit: 1200, + }), + ); + }, + sendMedia: async (nextCtx) => { + const target = parseZalouserOutboundTarget(nextCtx.to); + return buildChannelSendResult( + "zalouser", + await mockedSendZalouser(target.threadId, nextCtx.text, { + profile: "default", + isGroup: target.isGroup, + mediaUrl: nextCtx.mediaUrl, + textMode: "markdown", + textChunkMode: "length", + textChunkLimit: 1200, + }), + ); + }, + emptyResult: { channel: "zalouser", messageId: "" }, + }), + sendMock: mockedSendZalouser, + to: "987654321", + }; +} + describe("channel outbound payload contract", () => { describe("slack", () => { installChannelOutboundPayloadContractSuite({ @@ -189,20 +287,7 @@ describe("channel outbound payload contract", () => { installChannelOutboundPayloadContractSuite({ channel: "zalo", chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, - createHarness: ({ payload, sendResults }) => { - primeChannelOutboundSendMock(mockedSendZalo, { ok: true, messageId: "zl-1" }, sendResults); - return { - run: async () => - await zaloPlugin.outbound!.sendPayload!({ - cfg: {}, - to: "123456789", - text: "", - payload, - }), - sendMock: mockedSendZalo, - to: "123456789", - }; - }, + createHarness: createZaloHarness, }); }); @@ -210,32 +295,7 @@ describe("channel outbound payload contract", () => { installChannelOutboundPayloadContractSuite({ channel: "zalouser", chunking: { mode: "passthrough", longTextLength: 3000 }, - createHarness: ({ payload, sendResults }) => { - setZalouserRuntime({ - channel: { - text: { - resolveChunkMode: vi.fn(() => "length"), - resolveTextChunkLimit: vi.fn(() => 1200), - }, - }, - } as never); - primeChannelOutboundSendMock( - mockedSendZalouser, - { ok: true, messageId: "zlu-1" }, - sendResults, - ); - return { - run: async () => - await zalouserPlugin.outbound!.sendPayload!({ - cfg: {}, - to: "user:987654321", - text: "", - payload, - }), - sendMock: mockedSendZalouser, - to: "987654321", - }; - }, + createHarness: createZalouserHarness, }); }); diff --git a/src/channels/plugins/message-capability-matrix.test.ts b/src/channels/plugins/message-capability-matrix.test.ts index 459193d0792..bbe4c0bb744 100644 --- a/src/channels/plugins/message-capability-matrix.test.ts +++ b/src/channels/plugins/message-capability-matrix.test.ts @@ -5,37 +5,115 @@ import type { ChannelMessageActionAdapter, ChannelPlugin } from "./types.js"; const telegramDescribeMessageToolMock = vi.fn(); const discordDescribeMessageToolMock = vi.fn(); -vi.mock("../../../extensions/telegram/src/runtime.js", () => ({ - getTelegramRuntime: () => ({ - channel: { - telegram: { - messageActions: { - describeMessageTool: telegramDescribeMessageToolMock, - }, - }, - }, - }), -})); +const telegramPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => telegramDescribeMessageToolMock({ cfg }), + supportsAction: () => true, + }, +}; -vi.mock("../../../extensions/discord/src/runtime.js", () => ({ - getDiscordRuntime: () => ({ - channel: { - discord: { - messageActions: { - describeMessageTool: discordDescribeMessageToolMock, - }, - }, - }, - }), -})); +const discordPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => discordDescribeMessageToolMock({ cfg }), + supportsAction: () => true, + }, +}; -const { slackPlugin } = await import("../../../extensions/slack/src/channel.js"); -const { telegramPlugin } = await import("../../../extensions/telegram/src/channel.js"); -const { discordPlugin } = await import("../../../extensions/discord/src/channel.js"); -const { mattermostPlugin } = await import("../../../extensions/mattermost/src/channel.js"); -const { feishuPlugin } = await import("../../../extensions/feishu/src/channel.js"); -const { msteamsPlugin } = await import("../../../extensions/msteams/src/channel.js"); -const { zaloPlugin } = await import("../../../extensions/zalo/src/channel.js"); +// Keep this matrix focused on capability wiring. The extension packages already +// cover their own full channel/plugin boot paths, so local stubs are enough here. +const slackPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.slack; + const enabled = + typeof account?.botToken === "string" && + account.botToken.trim() !== "" && + typeof account?.appToken === "string" && + account.appToken.trim() !== ""; + const capabilities = new Set(); + if (enabled) { + capabilities.add("blocks"); + } + if ( + account?.capabilities && + (account.capabilities as { interactiveReplies?: unknown }).interactiveReplies === true + ) { + capabilities.add("interactive"); + } + return { + actions: enabled ? ["send"] : [], + capabilities: Array.from(capabilities) as Array<"blocks" | "interactive">, + }; + }, + supportsAction: () => true, + }, +}; + +const mattermostPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.mattermost; + const enabled = + account?.enabled !== false && + typeof account?.botToken === "string" && + account.botToken.trim() !== "" && + typeof account?.baseUrl === "string" && + account.baseUrl.trim() !== ""; + return { + actions: enabled ? ["send"] : [], + capabilities: enabled ? (["buttons"] as const) : [], + }; + }, + supportsAction: () => true, + }, +}; + +const feishuPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.feishu; + const enabled = + account?.enabled !== false && + typeof account?.appId === "string" && + account.appId.trim() !== "" && + typeof account?.appSecret === "string" && + account.appSecret.trim() !== ""; + return { + actions: enabled ? ["send"] : [], + capabilities: enabled ? (["cards"] as const) : [], + }; + }, + supportsAction: () => true, + }, +}; + +const msteamsPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.msteams; + const enabled = + account?.enabled !== false && + typeof account?.tenantId === "string" && + account.tenantId.trim() !== "" && + typeof account?.appId === "string" && + account.appId.trim() !== "" && + typeof account?.appPassword === "string" && + account.appPassword.trim() !== ""; + return { + actions: enabled ? ["poll"] : [], + capabilities: enabled ? (["cards"] as const) : [], + }; + }, + supportsAction: () => true, + }, +}; + +const zaloPlugin: Pick = { + actions: { + describeMessageTool: () => ({ actions: [], capabilities: [] }), + supportsAction: () => true, + }, +}; describe("channel action capability matrix", () => { afterEach(() => { diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index c0b4caafeba..80a7178a10e 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -1,4 +1,10 @@ -import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload"; +import { + resolvePayloadMediaUrls, + sendPayloadMediaSequence, + sendPayloadMediaSequenceAndFinalize, + sendPayloadMediaSequenceOrFallback, + sendTextMediaPayload, +} from "openclaw/plugin-sdk/reply-payload"; import { chunkText } from "../../../auto-reply/chunk.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; @@ -21,110 +27,13 @@ type DirectSendFn, TResult extends DirectS text: string, opts: TOpts, ) => Promise; - -type SendPayloadContext = Parameters>[0]; -type SendPayloadResult = Awaited>>; -type SendPayloadAdapter = Pick< - ChannelOutboundAdapter, - "sendMedia" | "sendText" | "chunker" | "textChunkLimit" ->; - -export function resolvePayloadMediaUrls(payload: SendPayloadContext["payload"]): string[] { - return resolveOutboundMediaUrls(payload); -} - -export async function sendPayloadMediaSequence(params: { - text: string; - mediaUrls: readonly string[]; - send: (input: { - text: string; - mediaUrl: string; - index: number; - isFirst: boolean; - }) => Promise; -}): Promise { - let lastResult: TResult | undefined; - for (let i = 0; i < params.mediaUrls.length; i += 1) { - const mediaUrl = params.mediaUrls[i]; - if (!mediaUrl) { - continue; - } - lastResult = await params.send({ - text: i === 0 ? params.text : "", - mediaUrl, - index: i, - isFirst: i === 0, - }); - } - return lastResult; -} - -export async function sendPayloadMediaSequenceOrFallback(params: { - text: string; - mediaUrls: readonly string[]; - send: (input: { - text: string; - mediaUrl: string; - index: number; - isFirst: boolean; - }) => Promise; - fallbackResult: TResult; - sendNoMedia?: () => Promise; -}): Promise { - if (params.mediaUrls.length === 0) { - return params.sendNoMedia ? await params.sendNoMedia() : params.fallbackResult; - } - return (await sendPayloadMediaSequence(params)) ?? params.fallbackResult; -} - -export async function sendPayloadMediaSequenceAndFinalize(params: { - text: string; - mediaUrls: readonly string[]; - send: (input: { - text: string; - mediaUrl: string; - index: number; - isFirst: boolean; - }) => Promise; - finalize: () => Promise; -}): Promise { - if (params.mediaUrls.length > 0) { - await sendPayloadMediaSequence(params); - } - return await params.finalize(); -} - -export async function sendTextMediaPayload(params: { - channel: string; - ctx: SendPayloadContext; - adapter: SendPayloadAdapter; -}): Promise { - const text = params.ctx.payload.text ?? ""; - const urls = resolvePayloadMediaUrls(params.ctx.payload); - if (!text && urls.length === 0) { - return { channel: params.channel, messageId: "" }; - } - if (urls.length > 0) { - const lastResult = await sendPayloadMediaSequence({ - text, - mediaUrls: urls, - send: async ({ text, mediaUrl }) => - await params.adapter.sendMedia!({ - ...params.ctx, - text, - mediaUrl, - }), - }); - return lastResult ?? { channel: params.channel, messageId: "" }; - } - const limit = params.adapter.textChunkLimit; - const chunks = limit && params.adapter.chunker ? params.adapter.chunker(text, limit) : [text]; - let lastResult: Awaited>>; - for (const chunk of chunks) { - lastResult = await params.adapter.sendText!({ ...params.ctx, text: chunk }); - } - return lastResult!; -} +export { + resolvePayloadMediaUrls, + sendPayloadMediaSequence, + sendPayloadMediaSequenceAndFinalize, + sendPayloadMediaSequenceOrFallback, + sendTextMediaPayload, +} from "openclaw/plugin-sdk/reply-payload"; export function resolveScopedChannelMediaMaxBytes(params: { cfg: OpenClawConfig; diff --git a/src/channels/read-only-account-inspect.discord.runtime.ts b/src/channels/read-only-account-inspect.discord.runtime.ts index d52f56ad316..5e3fe8fdafd 100644 --- a/src/channels/read-only-account-inspect.discord.runtime.ts +++ b/src/channels/read-only-account-inspect.discord.runtime.ts @@ -1,8 +1,8 @@ -import { inspectDiscordAccount as inspectDiscordAccountImpl } from "openclaw/plugin-sdk/discord"; +import { inspectDiscordAccount as inspectDiscordAccountImpl } from "../../extensions/discord/api.js"; -export type { InspectedDiscordAccount } from "openclaw/plugin-sdk/discord"; +export type { InspectedDiscordAccount } from "../../extensions/discord/api.js"; -type InspectDiscordAccount = typeof import("openclaw/plugin-sdk/discord").inspectDiscordAccount; +type InspectDiscordAccount = typeof import("../../extensions/discord/api.js").inspectDiscordAccount; export function inspectDiscordAccount( ...args: Parameters diff --git a/src/channels/read-only-account-inspect.slack.runtime.ts b/src/channels/read-only-account-inspect.slack.runtime.ts index 0d3e2c878c1..8e8db46073c 100644 --- a/src/channels/read-only-account-inspect.slack.runtime.ts +++ b/src/channels/read-only-account-inspect.slack.runtime.ts @@ -1,8 +1,8 @@ -import { inspectSlackAccount as inspectSlackAccountImpl } from "openclaw/plugin-sdk/slack"; +import { inspectSlackAccount as inspectSlackAccountImpl } from "../../extensions/slack/api.js"; -export type { InspectedSlackAccount } from "openclaw/plugin-sdk/slack"; +export type { InspectedSlackAccount } from "../../extensions/slack/api.js"; -type InspectSlackAccount = typeof import("openclaw/plugin-sdk/slack").inspectSlackAccount; +type InspectSlackAccount = typeof import("../../extensions/slack/api.js").inspectSlackAccount; export function inspectSlackAccount( ...args: Parameters diff --git a/src/channels/read-only-account-inspect.telegram.runtime.ts b/src/channels/read-only-account-inspect.telegram.runtime.ts index 12158022b2b..661cdd3b9c4 100644 --- a/src/channels/read-only-account-inspect.telegram.runtime.ts +++ b/src/channels/read-only-account-inspect.telegram.runtime.ts @@ -1,8 +1,9 @@ -import { inspectTelegramAccount as inspectTelegramAccountImpl } from "openclaw/plugin-sdk/telegram"; +import { inspectTelegramAccount as inspectTelegramAccountImpl } from "../../extensions/telegram/api.js"; -export type { InspectedTelegramAccount } from "openclaw/plugin-sdk/telegram"; +export type { InspectedTelegramAccount } from "../../extensions/telegram/api.js"; -type InspectTelegramAccount = typeof import("openclaw/plugin-sdk/telegram").inspectTelegramAccount; +type InspectTelegramAccount = + typeof import("../../extensions/telegram/api.js").inspectTelegramAccount; export function inspectTelegramAccount( ...args: Parameters diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 3a0490d996f..1bc8a645719 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -135,24 +135,16 @@ describe("registerQrCli", () => { }; } - function expectLoggedSetupCode( - url: string, - auth?: { - token?: string; - password?: string; - }, - ) { + function expectLoggedSetupCode(url: string) { const expected = encodePairingSetupCode({ url, bootstrapToken: "bootstrap-123", - ...(auth?.token ? { token: auth.token } : {}), - ...(auth?.password ? { password: auth.password } : {}), }); expect(runtime.log).toHaveBeenCalledWith(expected); } - function expectLoggedLocalSetupCode(auth?: { token?: string; password?: string }) { - expectLoggedSetupCode("ws://gateway.local:18789", auth); + function expectLoggedLocalSetupCode() { + expectLoggedSetupCode("ws://gateway.local:18789"); } function mockTailscaleStatusLookup() { @@ -189,7 +181,6 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", bootstrapToken: "bootstrap-123", - token: "tok", }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(qrGenerate).not.toHaveBeenCalled(); @@ -225,7 +216,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only", "--token", "override-token"]); - expectLoggedLocalSetupCode({ token: "override-token" }); + expectLoggedLocalSetupCode(); }); it("skips local password SecretRef resolution when --token override is provided", async () => { @@ -237,7 +228,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only", "--token", "override-token"]); - expectLoggedLocalSetupCode({ token: "override-token" }); + expectLoggedLocalSetupCode(); }); it("resolves local gateway auth password SecretRefs before setup code generation", async () => { @@ -250,7 +241,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only"]); - expectLoggedLocalSetupCode({ password: "local-password-secret" }); + expectLoggedLocalSetupCode(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -264,7 +255,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only"]); - expectLoggedLocalSetupCode({ password: "password-from-env" }); + expectLoggedLocalSetupCode(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -279,7 +270,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only"]); - expectLoggedLocalSetupCode({ token: "token-123" }); + expectLoggedLocalSetupCode(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -293,7 +284,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only"]); - expectLoggedLocalSetupCode({ password: "inferred-password" }); + expectLoggedLocalSetupCode(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -342,7 +333,6 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "wss://remote.example.com:444", bootstrapToken: "bootstrap-123", - token: "remote-tok", }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( @@ -386,7 +376,6 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "wss://remote.example.com:444", bootstrapToken: "bootstrap-123", - token: "remote-tok", }); expect(runtime.log).toHaveBeenCalledWith(expected); }); diff --git a/src/cli/qr-dashboard.integration.test.ts b/src/cli/qr-dashboard.integration.test.ts index 559b9a8fc15..81550c5922a 100644 --- a/src/cli/qr-dashboard.integration.test.ts +++ b/src/cli/qr-dashboard.integration.test.ts @@ -69,8 +69,6 @@ function createGatewayTokenRefFixture() { function decodeSetupCode(setupCode: string): { url?: string; bootstrapToken?: string; - token?: string; - password?: string; } { const padded = setupCode.replace(/-/g, "+").replace(/_/g, "/"); const padLength = (4 - (padded.length % 4)) % 4; @@ -79,8 +77,6 @@ function decodeSetupCode(setupCode: string): { return JSON.parse(json) as { url?: string; bootstrapToken?: string; - token?: string; - password?: string; }; } @@ -119,7 +115,7 @@ describe("cli integration: qr + dashboard token SecretRef", () => { delete process.env.SHARED_GATEWAY_TOKEN; }); - it("uses the same resolved token SecretRef for both qr and dashboard commands", async () => { + it("uses the same resolved token SecretRef for qr auth validation and dashboard commands", async () => { const fixture = createGatewayTokenRefFixture(); process.env.SHARED_GATEWAY_TOKEN = "shared-token-123"; loadConfigMock.mockReturnValue(fixture); @@ -137,7 +133,6 @@ describe("cli integration: qr + dashboard token SecretRef", () => { const payload = decodeSetupCode(setupCode ?? ""); expect(payload.url).toBe("ws://gateway.local:18789"); expect(payload.bootstrapToken).toBeTruthy(); - expect(payload.token).toBe("shared-token-123"); expect(runtimeErrors).toEqual([]); runtimeLogs.length = 0; diff --git a/src/cli/send-runtime/discord.ts b/src/cli/send-runtime/discord.ts index 3c6527a8175..5c15549cfae 100644 --- a/src/cli/send-runtime/discord.ts +++ b/src/cli/send-runtime/discord.ts @@ -1,7 +1,7 @@ -import { sendMessageDiscord as sendMessageDiscordImpl } from "openclaw/plugin-sdk/discord"; +import { sendMessageDiscord as sendMessageDiscordImpl } from "../../../extensions/discord/runtime-api.js"; type RuntimeSend = { - sendMessage: typeof import("openclaw/plugin-sdk/discord").sendMessageDiscord; + sendMessage: typeof import("../../../extensions/discord/runtime-api.js").sendMessageDiscord; }; export const runtimeSend = { diff --git a/src/cli/send-runtime/slack.ts b/src/cli/send-runtime/slack.ts index beec4f55906..e7d50aefe1f 100644 --- a/src/cli/send-runtime/slack.ts +++ b/src/cli/send-runtime/slack.ts @@ -1,7 +1,7 @@ -import { sendMessageSlack as sendMessageSlackImpl } from "openclaw/plugin-sdk/slack"; +import { sendMessageSlack as sendMessageSlackImpl } from "../../../extensions/slack/runtime-api.js"; type RuntimeSend = { - sendMessage: typeof import("openclaw/plugin-sdk/slack").sendMessageSlack; + sendMessage: typeof import("../../../extensions/slack/runtime-api.js").sendMessageSlack; }; export const runtimeSend = { diff --git a/src/cli/send-runtime/telegram.ts b/src/cli/send-runtime/telegram.ts index bfa22643976..e5e04680532 100644 --- a/src/cli/send-runtime/telegram.ts +++ b/src/cli/send-runtime/telegram.ts @@ -1,7 +1,7 @@ -import { sendMessageTelegram as sendMessageTelegramImpl } from "openclaw/plugin-sdk/telegram"; +import { sendMessageTelegram as sendMessageTelegramImpl } from "../../../extensions/telegram/runtime-api.js"; type RuntimeSend = { - sendMessage: typeof import("openclaw/plugin-sdk/telegram").sendMessageTelegram; + sendMessage: typeof import("../../../extensions/telegram/runtime-api.js").sendMessageTelegram; }; export const runtimeSend = { diff --git a/src/commands/agents.bind.matrix.integration.test.ts b/src/commands/agents.bind.matrix.integration.test.ts index 416d9f88250..e9f82a8dc69 100644 --- a/src/commands/agents.bind.matrix.integration.test.ts +++ b/src/commands/agents.bind.matrix.integration.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { matrixPlugin } from "../../extensions/matrix/src/channel.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 { agentsBindCommand } from "./agents.js"; import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js"; import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; @@ -9,6 +8,20 @@ import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-hel const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const writeConfigFileMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const matrixBindingPlugin = { + ...createChannelTestPluginBase({ id: "matrix" }), + setup: { + resolveBindingAccountId: ({ accountId, agentId }: { accountId?: string; agentId?: string }) => { + const explicit = accountId?.trim(); + if (explicit) { + return explicit; + } + const agent = agentId?.trim(); + return agent || "default"; + }, + }, +}; + vi.mock("../config/config.js", async (importOriginal) => ({ ...(await importOriginal()), readConfigFileSnapshot: readConfigFileSnapshotMock, @@ -26,7 +39,7 @@ describe("agents bind matrix integration", () => { runtime.exit.mockClear(); setActivePluginRegistry( - createTestRegistry([{ pluginId: "matrix", plugin: matrixPlugin, source: "test" }]), + createTestRegistry([{ pluginId: "matrix", plugin: matrixBindingPlugin, source: "test" }]), ); }); diff --git a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts index 83ef8718b0a..e2437c8b667 100644 --- a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts +++ b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts @@ -1,10 +1,18 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { signalPlugin } from "../../extensions/signal/src/channel.js"; +import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.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 { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js"; import { formatGatewayChannelsStatusLines } from "./channels/status.js"; +const signalPlugin = { + ...createChannelTestPluginBase({ id: "signal" }), + status: { + collectStatusIssues: (accounts: Parameters[1]) => + collectStatusIssuesFromLastError("signal", accounts), + }, +}; + describe("channels command", () => { beforeEach(() => { setActivePluginRegistry( diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 034a3fdf505..27fc1047103 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.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 mocks = vi.hoisted(() => ({ @@ -7,6 +7,12 @@ const mocks = vi.hoisted(() => ({ clackSelect: vi.fn(), clackText: vi.fn(), clackConfirm: vi.fn(), + applySearchKey: vi.fn(), + applySearchProviderSelection: vi.fn(), + hasExistingKey: vi.fn(), + hasKeyInEnv: vi.fn(), + resolveExistingKey: vi.fn(), + resolveSearchProviderOptions: vi.fn(), readConfigFileSnapshot: vi.fn(), writeConfigFile: vi.fn(), resolveGatewayPort: vi.fn(), @@ -95,10 +101,51 @@ vi.mock("./onboard-channels.js", () => ({ setupChannels: vi.fn(), })); +vi.mock("./onboard-search.js", () => ({ + resolveSearchProviderOptions: mocks.resolveSearchProviderOptions, + SEARCH_PROVIDER_OPTIONS: [ + { + id: "firecrawl", + label: "Firecrawl Search", + hint: "Structured results with optional result scraping", + envVars: ["FIRECRAWL_API_KEY"], + placeholder: "fc-...", + signupUrl: "https://www.firecrawl.dev/", + credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", + }, + ], + resolveExistingKey: mocks.resolveExistingKey, + hasExistingKey: mocks.hasExistingKey, + applySearchKey: mocks.applySearchKey, + applySearchProviderSelection: mocks.applySearchProviderSelection, + hasKeyInEnv: mocks.hasKeyInEnv, +})); + import { WizardCancelledError } from "../wizard/prompts.js"; import { runConfigureWizard } from "./configure.wizard.js"; describe("runConfigureWizard", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true }); + mocks.resolveExistingKey.mockReturnValue(undefined); + mocks.hasExistingKey.mockReturnValue(false); + mocks.hasKeyInEnv.mockReturnValue(false); + mocks.resolveSearchProviderOptions.mockReturnValue([ + { + id: "firecrawl", + label: "Firecrawl Search", + hint: "Structured results with optional result scraping", + envVars: ["FIRECRAWL_API_KEY"], + placeholder: "fc-...", + signupUrl: "https://www.firecrawl.dev/", + credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", + }, + ]); + mocks.applySearchKey.mockReset(); + mocks.applySearchProviderSelection.mockReset(); + }); + it("persists gateway.mode=local when only the run mode is selected", async () => { mocks.readConfigFileSnapshot.mockResolvedValue({ exists: false, @@ -158,4 +205,214 @@ describe("runConfigureWizard", () => { expect(runtime.exit).toHaveBeenCalledWith(1); }); + + it("persists provider-owned web search config changes returned by applySearchKey", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: false, + valid: true, + config: {}, + issues: [], + }); + mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.probeGatewayReachable.mockResolvedValue({ ok: false }); + mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); + mocks.summarizeExistingConfig.mockReturnValue(""); + mocks.createClackPrompter.mockReturnValue({}); + mocks.resolveExistingKey.mockReturnValue(undefined); + mocks.hasExistingKey.mockReturnValue(false); + mocks.hasKeyInEnv.mockReturnValue(false); + mocks.applySearchKey.mockImplementation( + (cfg: OpenClawConfig, provider: string, key: string) => ({ + ...cfg, + tools: { + ...cfg.tools, + web: { + ...cfg.tools?.web, + search: { + provider, + enabled: true, + }, + }, + }, + plugins: { + ...cfg.plugins, + entries: { + ...cfg.plugins?.entries, + firecrawl: { + enabled: true, + config: { webSearch: { apiKey: key } }, + }, + }, + }, + }), + ); + + const selectQueue = ["local", "firecrawl"]; + const confirmQueue = [true, false]; + mocks.clackSelect.mockImplementation(async () => selectQueue.shift()); + mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift()); + mocks.clackText.mockResolvedValue("fc-entered-key"); + mocks.clackIntro.mockResolvedValue(undefined); + mocks.clackOutro.mockResolvedValue(undefined); + + await runConfigureWizard( + { command: "configure", sections: ["web"] }, + { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + ); + + expect(mocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + web: expect.objectContaining({ + search: expect.objectContaining({ + provider: "firecrawl", + enabled: true, + }), + }), + }), + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + firecrawl: expect.objectContaining({ + enabled: true, + config: expect.objectContaining({ + webSearch: expect.objectContaining({ apiKey: "fc-entered-key" }), + }), + }), + }), + }), + }), + ); + }); + + it("applies provider selection side effects when a key already exists via secret ref or env", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: false, + valid: true, + config: {}, + issues: [], + }); + mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.probeGatewayReachable.mockResolvedValue({ ok: false }); + mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); + mocks.summarizeExistingConfig.mockReturnValue(""); + mocks.createClackPrompter.mockReturnValue({}); + mocks.resolveExistingKey.mockReturnValue(undefined); + mocks.hasExistingKey.mockReturnValue(true); + mocks.hasKeyInEnv.mockReturnValue(false); + mocks.applySearchProviderSelection.mockImplementation( + (cfg: OpenClawConfig, provider: string) => ({ + ...cfg, + tools: { + ...cfg.tools, + web: { + ...cfg.tools?.web, + search: { + provider, + enabled: true, + }, + }, + }, + plugins: { + ...cfg.plugins, + entries: { + ...cfg.plugins?.entries, + firecrawl: { + enabled: true, + }, + }, + }, + }), + ); + + const selectQueue = ["local", "firecrawl"]; + const confirmQueue = [true, false]; + mocks.clackSelect.mockImplementation(async () => selectQueue.shift()); + mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift()); + mocks.clackText.mockResolvedValue(""); + mocks.clackIntro.mockResolvedValue(undefined); + mocks.clackOutro.mockResolvedValue(undefined); + + await runConfigureWizard( + { command: "configure", sections: ["web"] }, + { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + ); + + expect(mocks.applySearchProviderSelection).toHaveBeenCalledWith( + expect.objectContaining({ + gateway: expect.objectContaining({ mode: "local" }), + }), + "firecrawl", + ); + expect(mocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + firecrawl: expect.objectContaining({ + enabled: true, + }), + }), + }), + }), + ); + }); + + it("does not crash when web search providers are unavailable under plugin policy", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: false, + valid: true, + config: {}, + issues: [], + }); + mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.probeGatewayReachable.mockResolvedValue({ ok: false }); + mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); + mocks.summarizeExistingConfig.mockReturnValue(""); + mocks.createClackPrompter.mockReturnValue({}); + mocks.resolveSearchProviderOptions.mockReturnValue([]); + + const selectQueue = ["local"]; + const confirmQueue = [true, false]; + mocks.clackSelect.mockImplementation(async () => selectQueue.shift()); + mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift()); + mocks.clackText.mockResolvedValue(""); + mocks.clackIntro.mockResolvedValue(undefined); + mocks.clackOutro.mockResolvedValue(undefined); + + await expect( + runConfigureWizard( + { command: "configure", sections: ["web"] }, + { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + ), + ).resolves.toBeUndefined(); + + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining( + "No web search providers are currently available under this plugin policy.", + ), + "Web search", + ); + expect(mocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + web: expect.objectContaining({ + search: expect.objectContaining({ + enabled: false, + }), + }), + }), + }), + ); + }); }); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index c74909ae14b..b1a5816cfdc 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -167,34 +167,30 @@ async function promptWebToolsConfig( const existingSearch = nextConfig.tools?.web?.search; const existingFetch = nextConfig.tools?.web?.fetch; const { - SEARCH_PROVIDER_OPTIONS, + resolveSearchProviderOptions, resolveExistingKey, hasExistingKey, applySearchKey, + applySearchProviderSelection, hasKeyInEnv, } = await import("./onboard-search.js"); - type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"]; - const defaultProvider = SEARCH_PROVIDER_OPTIONS[0]?.value; - if (!defaultProvider) { - throw new Error("No web search providers are registered."); - } + const searchProviderOptions = resolveSearchProviderOptions(nextConfig); + const defaultProvider = searchProviderOptions[0]?.id; const hasKeyForProvider = (provider: string): boolean => { - const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider); + const entry = searchProviderOptions.find((e) => e.id === provider); if (!entry) { return false; } return hasExistingKey(nextConfig, provider) || hasKeyInEnv(entry); }; - const existingProvider: SP = (() => { + const existingProvider = (() => { const stored = existingSearch?.provider; - if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) { + if (stored && searchProviderOptions.some((e) => e.id === stored)) { return stored; } - return ( - SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? defaultProvider - ); + return searchProviderOptions.find((e) => hasKeyForProvider(e.id))?.id ?? defaultProvider; })(); note( @@ -210,7 +206,7 @@ async function promptWebToolsConfig( await confirm({ message: "Enable web_search?", initialValue: - existingSearch?.enabled ?? SEARCH_PROVIDER_OPTIONS.some((e) => hasKeyForProvider(e.value)), + existingSearch?.enabled ?? searchProviderOptions.some((e) => hasKeyForProvider(e.id)), }), runtime, ); @@ -219,64 +215,82 @@ async function promptWebToolsConfig( ...existingSearch, enabled: enableSearch, }; + let workingConfig = nextConfig; if (enableSearch) { - const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => { - const configured = hasKeyForProvider(entry.value); - return { - value: entry.value, - label: entry.label, - hint: configured ? `${entry.hint} Β· configured` : entry.hint, - }; - }); - - const providerChoice = guardCancel( - await select({ - message: "Choose web search provider", - options: providerOptions, - initialValue: existingProvider, - }), - runtime, - ); - - nextSearch = { ...nextSearch, provider: providerChoice }; - - const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!; - const existingKey = resolveExistingKey(nextConfig, providerChoice); - const keyConfigured = hasExistingKey(nextConfig, providerChoice); - const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); - const envVarNames = entry.envKeys.join(" / "); - - const keyInput = guardCancel( - await text({ - message: keyConfigured - ? envAvailable - ? `${entry.label} API key (leave blank to keep current or use ${envVarNames})` - : `${entry.label} API key (leave blank to keep current)` - : envAvailable - ? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})` - : `${entry.label} API key`, - placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder, - }), - runtime, - ); - const key = String(keyInput ?? "").trim(); - - if (key || existingKey) { - const applied = applySearchKey(nextConfig, providerChoice, (key || existingKey)!); - nextSearch = { ...applied.tools?.web?.search }; - } else if (keyConfigured || envAvailable) { - nextSearch = { ...nextSearch }; - } else { + if (searchProviderOptions.length === 0) { note( [ - "No key stored yet β€” web_search won't work until a key is available.", - `Store a key here or set ${envVarNames} in the Gateway environment.`, - `Get your API key at: ${entry.signupUrl}`, + "No web search providers are currently available under this plugin policy.", + "Enable plugins or remove deny rules, then rerun configure.", "Docs: https://docs.openclaw.ai/tools/web", ].join("\n"), "Web search", ); + nextSearch = { + ...existingSearch, + enabled: false, + }; + } else { + const providerOptions = searchProviderOptions.map((entry) => { + const configured = hasKeyForProvider(entry.id); + return { + value: entry.id, + label: entry.label, + hint: configured ? `${entry.hint} Β· configured` : entry.hint, + }; + }); + + const providerChoice = guardCancel( + await select({ + message: "Choose web search provider", + options: providerOptions, + initialValue: existingProvider, + }), + runtime, + ); + + nextSearch = { ...nextSearch, provider: providerChoice }; + + const entry = searchProviderOptions.find((e) => e.id === providerChoice)!; + const existingKey = resolveExistingKey(nextConfig, providerChoice); + const keyConfigured = hasExistingKey(nextConfig, providerChoice); + const envAvailable = entry.envVars.some((k) => Boolean(process.env[k]?.trim())); + const envVarNames = entry.envVars.join(" / "); + + const keyInput = guardCancel( + await text({ + message: keyConfigured + ? envAvailable + ? `${entry.label} API key (leave blank to keep current or use ${envVarNames})` + : `${entry.label} API key (leave blank to keep current)` + : envAvailable + ? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})` + : `${entry.label} API key`, + placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder, + }), + runtime, + ); + const key = String(keyInput ?? "").trim(); + + if (key || existingKey) { + workingConfig = applySearchKey(workingConfig, providerChoice, (key || existingKey)!); + nextSearch = { ...workingConfig.tools?.web?.search }; + } else if (keyConfigured || envAvailable) { + workingConfig = applySearchProviderSelection(workingConfig, providerChoice); + nextSearch = { ...workingConfig.tools?.web?.search }; + } else { + nextSearch = { ...nextSearch, provider: providerChoice }; + note( + [ + "No key stored yet β€” web_search won't work until a key is available.", + `Store a key here or set ${envVarNames} in the Gateway environment.`, + `Get your API key at: ${entry.signupUrl}`, + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } } } @@ -294,11 +308,11 @@ async function promptWebToolsConfig( }; return { - ...nextConfig, + ...workingConfig, tools: { - ...nextConfig.tools, + ...workingConfig.tools, web: { - ...nextConfig.tools?.web, + ...workingConfig.tools?.web, search: nextSearch, fetch: nextFetch, }, diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 4a461c58267..7590402bde5 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js"; import { withTempHome } from "../../test/helpers/temp-home.js"; +import * as commandSecretGatewayModule from "../cli/command-secret-gateway.js"; import * as noteModule from "../terminal/note.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js"; @@ -516,8 +517,11 @@ describe("doctor config flow", () => { }); it("resolves Telegram @username allowFrom entries to numeric IDs on repair", async () => { - const fetchSpy = vi.fn(async (url: string) => { - const u = String(url); + const globalFetch = vi.fn(async () => { + throw new Error("global fetch should not be called"); + }); + const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { + const u = input instanceof URL ? input.href : typeof input === "string" ? input : input.url; const chatId = new URL(u).searchParams.get("chat_id") ?? ""; const id = chatId.toLowerCase() === "@testuser" @@ -534,7 +538,14 @@ describe("doctor config flow", () => { json: async () => (id != null ? { ok: true, result: { id } } : { ok: false }), } as unknown as Response; }); - vi.stubGlobal("fetch", fetchSpy); + vi.stubGlobal("fetch", globalFetch); + const proxyFetch = vi.fn(); + const telegramFetchModule = await import("../../extensions/telegram/src/fetch.js"); + const telegramProxyModule = await import("../../extensions/telegram/src/proxy.js"); + const resolveTelegramFetch = vi.spyOn(telegramFetchModule, "resolveTelegramFetch"); + const makeProxyFetch = vi.spyOn(telegramProxyModule, "makeProxyFetch"); + makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); + resolveTelegramFetch.mockReturnValue(fetchSpy as unknown as typeof fetch); try { const result = await runDoctorConfigWithInput({ repair: true, @@ -580,6 +591,8 @@ describe("doctor config flow", () => { expect(cfg.channels.telegram.accounts.default.allowFrom).toEqual(["111"]); expect(cfg.channels.telegram.accounts.default.groupAllowFrom).toEqual(["222"]); } finally { + makeProxyFetch.mockRestore(); + resolveTelegramFetch.mockRestore(); vi.unstubAllGlobals(); } }); @@ -632,6 +645,88 @@ describe("doctor config flow", () => { } }); + it("uses account apiRoot when repairing Telegram allowFrom usernames", async () => { + const globalFetch = vi.fn(async () => { + throw new Error("global fetch should not be called"); + }); + const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url; + expect(url).toBe("https://custom.telegram.test/root/bottok/getChat?chat_id=%40testuser"); + return { + ok: true, + json: async () => ({ ok: true, result: { id: 12345 } }), + }; + }); + vi.stubGlobal("fetch", globalFetch); + const proxyFetch = vi.fn(); + const telegramFetchModule = await import("../../extensions/telegram/src/fetch.js"); + const telegramProxyModule = await import("../../extensions/telegram/src/proxy.js"); + const resolveTelegramFetch = vi.spyOn(telegramFetchModule, "resolveTelegramFetch"); + const makeProxyFetch = vi.spyOn(telegramProxyModule, "makeProxyFetch"); + makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); + resolveTelegramFetch.mockReturnValue(fetchSpy as unknown as typeof fetch); + const resolveSecretsSpy = vi + .spyOn(commandSecretGatewayModule, "resolveCommandSecretRefsViaGateway") + .mockResolvedValue({ + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + resolvedConfig: { + channels: { + telegram: { + accounts: { + work: { + botToken: "tok", + apiRoot: "https://custom.telegram.test/root/", + proxy: "http://127.0.0.1:8888", + network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" }, + allowFrom: ["@testuser"], + }, + }, + }, + }, + }, + }); + + try { + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + channels: { + telegram: { + accounts: { + work: { + botToken: "tok", + allowFrom: ["@testuser"], + }, + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + const cfg = result.cfg as { + channels?: { + telegram?: { + accounts?: Record; + }; + }; + }; + expect(cfg.channels?.telegram?.accounts?.work?.allowFrom).toEqual(["12345"]); + expect(makeProxyFetch).toHaveBeenCalledWith("http://127.0.0.1:8888"); + expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { + network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" }, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + } finally { + makeProxyFetch.mockRestore(); + resolveTelegramFetch.mockRestore(); + resolveSecretsSpy.mockRestore(); + vi.unstubAllGlobals(); + } + }); + it("warns and continues when Telegram account inspection hits inactive SecretRef surfaces", async () => { const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); const fetchSpy = vi.fn(); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index e0599eca1bb..628fd656b2d 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,10 +1,10 @@ import { - fetchTelegramChatId, inspectTelegramAccount, isNumericTelegramUserId, listTelegramAccountIds, + lookupTelegramChatId, normalizeTelegramAllowFromEntry, -} from "openclaw/plugin-sdk/telegram"; +} from "../../extensions/telegram/api.js"; import { normalizeChatChannelId } from "../channels/registry.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; @@ -15,6 +15,7 @@ import { CONFIG_PATH, migrateLegacyConfig } from "../config/config.js"; import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; import { formatConfigIssueLines } from "../config/issue-format.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; +import type { TelegramNetworkConfig } from "../config/types.telegram.js"; import { parseToolsBySenderTypedKey } from "../config/types.tools.js"; import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js"; import { @@ -84,6 +85,13 @@ type TelegramAllowFromListRef = { key: "allowFrom" | "groupAllowFrom"; }; +type ResolvedTelegramLookupAccount = { + token: string; + apiRoot?: string; + proxyUrl?: string; + network?: TelegramNetworkConfig; +}; + function asObjectRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; @@ -399,29 +407,34 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi return inspected.enabled && inspected.tokenStatus === "configured_unavailable"; }); const tokenResolutionWarnings: string[] = []; - const tokens = Array.from( - new Set( - listTelegramAccountIds(resolvedConfig) - .map((accountId) => { - try { - return resolveTelegramAccount({ cfg: resolvedConfig, accountId }); - } catch (error) { - tokenResolutionWarnings.push( - `- Telegram account ${accountId}: failed to inspect bot token (${describeUnknownError(error)}).`, - ); - return null; - } - }) - .filter((account): account is NonNullable> => - Boolean(account), - ) - .map((account) => (account.tokenSource === "none" ? "" : account.token)) - .map((token) => token.trim()) - .filter(Boolean), - ), - ); + const lookupAccounts: ResolvedTelegramLookupAccount[] = []; + const seenLookupAccounts = new Set(); + for (const accountId of listTelegramAccountIds(resolvedConfig)) { + let account: NonNullable>; + try { + account = resolveTelegramAccount({ cfg: resolvedConfig, accountId }); + } catch (error) { + tokenResolutionWarnings.push( + `- Telegram account ${accountId}: failed to inspect bot token (${describeUnknownError(error)}).`, + ); + continue; + } + const token = account.tokenSource === "none" ? "" : account.token.trim(); + if (!token) { + continue; + } + const apiRoot = account.config.apiRoot?.trim() || undefined; + const proxyUrl = account.config.proxy?.trim() || undefined; + const network = account.config.network; + const cacheKey = `${token}::${apiRoot ?? ""}::${proxyUrl ?? ""}::${JSON.stringify(network ?? {})}`; + if (seenLookupAccounts.has(cacheKey)) { + continue; + } + seenLookupAccounts.add(cacheKey); + lookupAccounts.push({ token, apiRoot, proxyUrl, network }); + } - if (tokens.length === 0) { + if (lookupAccounts.length === 0) { return { config: cfg, changes: [ @@ -449,14 +462,17 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi return null; } const username = stripped.startsWith("@") ? stripped : `@${stripped}`; - for (const token of tokens) { + for (const account of lookupAccounts) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 4000); try { - const id = await fetchTelegramChatId({ - token, + const id = await lookupTelegramChatId({ + token: account.token, chatId: username, signal: controller.signal, + apiRoot: account.apiRoot, + proxyUrl: account.proxyUrl, + network: account.network, }); if (id) { return id; diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 46212816410..3762afc6d8a 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -567,6 +567,47 @@ describe("gateway-status command", () => { expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true); }); + it("passes the full caller timeout through to local loopback probes", async () => { + const { runtime } = createRuntimeCapture(); + probeGateway.mockClear(); + readBestEffortConfig.mockResolvedValueOnce({ + gateway: { + mode: "local", + auth: { mode: "token", token: "ltok" }, + }, + } as never); + + await runGatewayStatus(runtime, { timeout: "15000", json: true }); + + expect(probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ + url: "ws://127.0.0.1:18789", + timeoutMs: 15_000, + }), + ); + }); + + it("keeps inactive local loopback probes on the short timeout in remote mode", async () => { + const { runtime } = createRuntimeCapture(); + probeGateway.mockClear(); + readBestEffortConfig.mockResolvedValueOnce({ + gateway: { + mode: "remote", + auth: { mode: "token", token: "ltok" }, + remote: {}, + }, + } as never); + + await runGatewayStatus(runtime, { timeout: "15000", json: true }); + + expect(probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ + url: "ws://127.0.0.1:18789", + timeoutMs: 800, + }), + ); + }); + it("skips invalid ssh-auto discovery targets", async () => { const { runtime } = createRuntimeCapture(); await withEnvAsync({ USER: "steipete" }, async () => { diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index ecdeeaa9570..c338d7fe55b 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -176,7 +176,7 @@ export async function gatewayStatusCommand( token: authResolution.token, password: authResolution.password, }; - const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind); + const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target); const probe = await probeGateway({ url: target.url, auth, diff --git a/src/commands/gateway-status/helpers.test.ts b/src/commands/gateway-status/helpers.test.ts index e0c1ecee763..525b99db98c 100644 --- a/src/commands/gateway-status/helpers.test.ts +++ b/src/commands/gateway-status/helpers.test.ts @@ -6,6 +6,7 @@ import { isScopeLimitedProbeFailure, renderProbeSummaryLine, resolveAuthForTarget, + resolveProbeBudgetMs, } from "./helpers.js"; describe("extractConfigSummary", () => { @@ -273,3 +274,21 @@ describe("probe reachability classification", () => { expect(renderProbeSummaryLine(probe, false)).toContain("RPC: failed"); }); }); + +describe("resolveProbeBudgetMs", () => { + it("lets active local loopback probes use the full caller budget", () => { + expect(resolveProbeBudgetMs(15_000, { kind: "localLoopback", active: true })).toBe(15_000); + expect(resolveProbeBudgetMs(3_000, { kind: "localLoopback", active: true })).toBe(3_000); + }); + + it("keeps inactive local loopback probes on the short cap", () => { + expect(resolveProbeBudgetMs(15_000, { kind: "localLoopback", active: false })).toBe(800); + expect(resolveProbeBudgetMs(500, { kind: "localLoopback", active: false })).toBe(500); + }); + + it("keeps non-local probe caps unchanged", () => { + expect(resolveProbeBudgetMs(15_000, { kind: "configRemote", active: true })).toBe(1_500); + expect(resolveProbeBudgetMs(15_000, { kind: "explicit", active: true })).toBe(1_500); + expect(resolveProbeBudgetMs(15_000, { kind: "sshTunnel", active: true })).toBe(2_000); + }); +}); diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index 5f1a5e2f5ee..aec1a6a794d 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -116,14 +116,21 @@ export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): Gatew return targets; } -export function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number { - if (kind === "localLoopback") { - return Math.min(800, overallMs); +export function resolveProbeBudgetMs( + overallMs: number, + target: Pick, +): number { + switch (target.kind) { + case "localLoopback": + // Active loopback probes should honor the caller budget because local shells/containers + // can legitimately take longer to connect. Inactive loopback probes stay bounded so + // remote-mode status checks do not stall on an expected local miss. + return target.active ? overallMs : Math.min(800, overallMs); + case "sshTunnel": + return Math.min(2_000, overallMs); + default: + return Math.min(1_500, overallMs); } - if (kind === "sshTunnel") { - return Math.min(2000, overallMs); - } - return Math.min(1500, overallMs); } export function sanitizeSshTarget(value: unknown): string | null { diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 03055c8eb17..24653eb187c 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -1,10 +1,18 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildTokenChannelStatusSummary, + probeTelegram, + type ChannelPlugin as TelegramChannelPlugin, +} from "../../extensions/telegram/runtime-api.js"; +import { + listTelegramAccountIds, + resolveTelegramAccount, +} from "../../extensions/telegram/src/accounts.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 type { HealthSummary } from "./health.js"; import { getHealthSnapshot } from "./health.js"; @@ -109,20 +117,32 @@ async function runSuccessfulTelegramProbe( return { calls, telegram }; } -let createPluginRuntime: typeof import("../plugins/runtime/index.js").createPluginRuntime; -let setTelegramRuntime: typeof import("../../extensions/telegram/src/runtime.js").setTelegramRuntime; +const telegramHealthPlugin: Pick< + TelegramChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "status" +> = { + ...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }), + config: { + listAccountIds: (cfg) => listTelegramAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), + isConfigured: (account) => Boolean(account.token?.trim()), + }, + status: { + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), + probeAccount: async ({ account, timeoutMs }) => + await probeTelegram(account.token, timeoutMs, { + proxyUrl: account.config.proxy, + network: account.config.network, + accountId: account.accountId, + }), + }, +}; describe("getHealthSnapshot", () => { - beforeAll(async () => { - ({ createPluginRuntime } = await import("../plugins/runtime/index.js")); - ({ setTelegramRuntime } = await import("../../extensions/telegram/src/runtime.js")); - }); - beforeEach(() => { setActivePluginRegistry( - createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]), + createTestRegistry([{ pluginId: "telegram", plugin: telegramHealthPlugin, source: "test" }]), ); - setTelegramRuntime(createPluginRuntime()); }); afterEach(() => { diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 29df194cf2d..daeb4e95893 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -5,6 +5,7 @@ import type { ChannelPlugin, } from "../channels/plugins/types.js"; import type { CliDeps } from "../cli/deps.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { RuntimeEnv } from "../runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { captureEnv } from "../test-utils/env.js"; @@ -69,21 +70,17 @@ vi.mock("../../extensions/whatsapp/runtime-api.js", () => ({ handleWhatsAppAction, })); +import { messageCommand } from "./message.js"; + let envSnapshot: ReturnType; +const EMPTY_TEST_REGISTRY = createTestRegistry([]); -const setRegistry = async (registry: ReturnType) => { - const { setActivePluginRegistry } = await import("../plugins/runtime.js"); - setActivePluginRegistry(registry); -}; - -beforeEach(async () => { - vi.resetModules(); +beforeEach(() => { 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([])); + setActivePluginRegistry(EMPTY_TEST_REGISTRY); callGatewayMock.mockClear(); webAuthExists.mockClear().mockResolvedValue(false); handleDiscordAction.mockClear(); @@ -197,8 +194,6 @@ const createTelegramPollPluginRegistration = () => ({ }), }); -let messageCommand: typeof import("./message.js").messageCommand; - function createTelegramSecretRawConfig() { return { channels: { @@ -247,7 +242,7 @@ async function runTelegramDirectOutboundSend(params: { messageId: "msg-2", chatId: "123456", })); - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { pluginId: "telegram", @@ -288,7 +283,7 @@ describe("messageCommand", () => { rawConfig: rawConfig as unknown as Record, resolvedConfig: resolvedConfig as unknown as Record, }); - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramSendPluginRegistration(), @@ -379,7 +374,7 @@ describe("messageCommand", () => { it("defaults channel when only one configured", async () => { process.env.TELEGRAM_BOT_TOKEN = "token-abc"; - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramSendPluginRegistration(), @@ -401,7 +396,7 @@ describe("messageCommand", () => { it("requires channel when multiple configured", async () => { process.env.TELEGRAM_BOT_TOKEN = "token-abc"; process.env.DISCORD_BOT_TOKEN = "token-discord"; - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramSendPluginRegistration(), @@ -426,7 +421,7 @@ describe("messageCommand", () => { it("sends via gateway for WhatsApp", async () => { callGatewayMock.mockResolvedValueOnce({ messageId: "g1" }); - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { pluginId: "whatsapp", @@ -456,7 +451,7 @@ describe("messageCommand", () => { }); it("routes discord polls through message action", async () => { - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createDiscordPollPluginRegistration(), @@ -485,7 +480,7 @@ describe("messageCommand", () => { }); it("routes telegram polls through message action", async () => { - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramPollPluginRegistration(), diff --git a/src/commands/onboard-search.providers.test.ts b/src/commands/onboard-search.providers.test.ts new file mode 100644 index 00000000000..db57239951b --- /dev/null +++ b/src/commands/onboard-search.providers.test.ts @@ -0,0 +1,210 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; + +const mocks = vi.hoisted(() => ({ + resolvePluginWebSearchProviders: vi.fn< + (params?: { config?: OpenClawConfig }) => PluginWebSearchProviderEntry[] + >(() => []), + listBundledWebSearchProviders: vi.fn<() => PluginWebSearchProviderEntry[]>(() => []), + resolveBundledWebSearchPluginId: vi.fn<(providerId?: string) => string | undefined>( + () => undefined, + ), +})); + +vi.mock("../plugins/web-search-providers.runtime.js", () => ({ + resolvePluginWebSearchProviders: mocks.resolvePluginWebSearchProviders, +})); + +vi.mock("../plugins/bundled-web-search.js", () => ({ + listBundledWebSearchProviders: mocks.listBundledWebSearchProviders, + resolveBundledWebSearchPluginId: mocks.resolveBundledWebSearchPluginId, +})); + +function createCustomProviderEntry(): PluginWebSearchProviderEntry { + return { + id: "custom-search" as never, + pluginId: "custom-plugin", + label: "Custom Search", + hint: "Custom provider", + envVars: ["CUSTOM_SEARCH_API_KEY"], + placeholder: "custom-...", + signupUrl: "https://example.com/custom", + credentialPath: "plugins.entries.custom-plugin.config.webSearch.apiKey", + getCredentialValue: () => undefined, + setCredentialValue: () => {}, + getConfiguredCredentialValue: (config) => + ( + config?.plugins?.entries?.["custom-plugin"]?.config as + | { webSearch?: { apiKey?: unknown } } + | undefined + )?.webSearch?.apiKey, + setConfiguredCredentialValue: (configTarget, value) => { + const entries = ((configTarget.plugins ??= {}).entries ??= {}); + const pluginEntry = (entries["custom-plugin"] ??= {}); + const pluginConfig = ((pluginEntry as Record).config ??= {}) as Record< + string, + unknown + >; + const webSearch = (pluginConfig.webSearch ??= {}) as Record; + webSearch.apiKey = value; + }, + createTool: () => null, + }; +} + +function createBundledFirecrawlEntry(): PluginWebSearchProviderEntry { + return { + id: "firecrawl", + pluginId: "firecrawl", + label: "Firecrawl Search", + hint: "Structured results", + envVars: ["FIRECRAWL_API_KEY"], + placeholder: "fc-...", + signupUrl: "https://example.com/firecrawl", + credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", + getCredentialValue: () => undefined, + setCredentialValue: () => {}, + getConfiguredCredentialValue: (config) => + ( + config?.plugins?.entries?.firecrawl?.config as + | { webSearch?: { apiKey?: unknown } } + | undefined + )?.webSearch?.apiKey, + setConfiguredCredentialValue: () => {}, + createTool: () => null, + }; +} + +describe("onboard-search provider resolution", () => { + afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("uses config-aware non-bundled provider hooks when resolving existing keys", async () => { + const customEntry = createCustomProviderEntry(); + mocks.resolvePluginWebSearchProviders.mockImplementation((params) => + params?.config ? [customEntry] : [], + ); + + const mod = await import("./onboard-search.js"); + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "custom-search" as never, + }, + }, + }, + plugins: { + entries: { + "custom-plugin": { + config: { + webSearch: { + apiKey: "custom-key", + }, + }, + }, + }, + }, + }; + + expect(mod.hasExistingKey(cfg, "custom-search" as never)).toBe(true); + expect(mod.resolveExistingKey(cfg, "custom-search" as never)).toBe("custom-key"); + + const updated = mod.applySearchKey(cfg, "custom-search" as never, "next-key"); + expect( + ( + updated.plugins?.entries?.["custom-plugin"]?.config as + | { webSearch?: { apiKey?: unknown } } + | undefined + )?.webSearch?.apiKey, + ).toBe("next-key"); + }); + + it("uses config-aware non-bundled providers when building secret refs", async () => { + const customEntry = createCustomProviderEntry(); + mocks.resolvePluginWebSearchProviders.mockImplementation((params) => + params?.config ? [customEntry] : [], + ); + + const mod = await import("./onboard-search.js"); + const cfg: OpenClawConfig = { + plugins: { + installs: { + "custom-plugin": { + installPath: "/tmp/custom-plugin", + source: "path", + }, + }, + }, + }; + const notes: Array<{ title?: string; message: string }> = []; + const prompter = { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async (message: string, title?: string) => { + notes.push({ title, message }); + }), + select: vi.fn(async () => "custom-search"), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm: vi.fn(async () => true), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + }; + + const result = await mod.setupSearch(cfg, {} as never, prompter as never, { + secretInputMode: "ref", + }); + + expect(result.tools?.web?.search?.provider).toBe("custom-search"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect( + ( + result.plugins?.entries?.["custom-plugin"]?.config as + | { webSearch?: { apiKey?: unknown } } + | undefined + )?.webSearch?.apiKey, + ).toEqual({ + source: "env", + provider: "default", + id: "CUSTOM_SEARCH_API_KEY", + }); + expect(notes.some((note) => note.message.includes("CUSTOM_SEARCH_API_KEY"))).toBe(true); + }); + + it("does not treat hard-disabled bundled providers as selectable credentials", async () => { + const firecrawlEntry = createBundledFirecrawlEntry(); + mocks.resolvePluginWebSearchProviders.mockReturnValue([]); + mocks.listBundledWebSearchProviders.mockReturnValue([firecrawlEntry]); + mocks.resolveBundledWebSearchPluginId.mockReturnValue("firecrawl"); + + const mod = await import("./onboard-search.js"); + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "firecrawl", + }, + }, + }, + plugins: { + enabled: false, + entries: { + firecrawl: { + config: { + webSearch: { + apiKey: "fc-disabled-key", + }, + }, + }, + }, + }, + }; + + expect(mod.hasExistingKey(cfg, "firecrawl")).toBe(false); + expect(mod.resolveExistingKey(cfg, "firecrawl")).toBeUndefined(); + expect(mod.applySearchProviderSelection(cfg, "firecrawl")).toBe(cfg); + }); +}); diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index c15fdefcf72..ce4ac6be96c 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -57,6 +57,45 @@ function pluginWebSearchApiKey(config: OpenClawConfig, pluginId: string): unknow return entry?.config?.webSearch?.apiKey; } +function createDisabledFirecrawlConfig(apiKey?: string): OpenClawConfig { + return { + tools: { + web: { + search: { + provider: "firecrawl", + }, + }, + }, + plugins: { + entries: { + firecrawl: { + enabled: false, + ...(apiKey + ? { + config: { + webSearch: { + apiKey, + }, + }, + } + : {}), + }, + }, + }, + }; +} + +function readFirecrawlPluginApiKey(config: OpenClawConfig): string | undefined { + const pluginConfig = config.plugins?.entries?.firecrawl?.config as + | { + webSearch?: { + apiKey?: string; + }; + } + | undefined; + return pluginConfig?.webSearch?.apiKey; +} + async function runBlankPerplexityKeyEntry( apiKey: string, enabled?: boolean, @@ -141,6 +180,20 @@ describe("setupSearch", () => { expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true); }); + it("re-enables firecrawl and persists its plugin config when selected from disabled state", async () => { + const cfg = createDisabledFirecrawlConfig(); + const { prompter } = createPrompter({ + selectValue: "firecrawl", + textValue: "fc-disabled-key", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("firecrawl"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.firecrawl?.apiKey).toBeUndefined(); + expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true); + expect(readFirecrawlPluginApiKey(result)).toBe("fc-disabled-key"); + }); + it("sets provider and key for grok", async () => { const cfg: OpenClawConfig = {}; const { prompter } = createPrompter({ @@ -314,6 +367,60 @@ describe("setupSearch", () => { } }); + it("quickstart detects an existing firecrawl key even when the plugin is disabled", async () => { + const cfg = createDisabledFirecrawlConfig("fc-configured-key"); + const { prompter } = createPrompter({ selectValue: "firecrawl" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + expect(prompter.text).not.toHaveBeenCalled(); + expect(result.tools?.web?.search?.provider).toBe("firecrawl"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.firecrawl?.apiKey).toBeUndefined(); + expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true); + expect(readFirecrawlPluginApiKey(result)).toBe("fc-configured-key"); + }); + + it("preserves disabled firecrawl plugin state and allowlist when web search stays disabled", async () => { + const original = process.env.FIRECRAWL_API_KEY; + process.env.FIRECRAWL_API_KEY = "env-firecrawl-key"; // pragma: allowlist secret + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "firecrawl", + enabled: false, + }, + }, + }, + plugins: { + allow: ["google"], + entries: { + firecrawl: { + enabled: false, + }, + }, + }, + }; + try { + const { prompter } = createPrompter({ selectValue: "firecrawl" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + expect(prompter.text).not.toHaveBeenCalled(); + expect(result.tools?.web?.search?.provider).toBe("firecrawl"); + expect(result.tools?.web?.search?.enabled).toBe(false); + expect(result.plugins?.entries?.firecrawl?.enabled).toBe(false); + expect(result.plugins?.allow).toEqual(["google"]); + } finally { + if (original === undefined) { + delete process.env.FIRECRAWL_API_KEY; + } else { + process.env.FIRECRAWL_API_KEY = original; + } + } + }); + it("stores env-backed SecretRef when secretInputMode=ref for perplexity", async () => { const originalPerplexity = process.env.PERPLEXITY_API_KEY; const originalOpenRouter = process.env.OPENROUTER_API_KEY; @@ -430,8 +537,8 @@ describe("setupSearch", () => { }); it("exports all 7 providers in SEARCH_PROVIDER_OPTIONS", () => { + const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.id); expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(7); - const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value); expect(values).toEqual([ "brave", "gemini", diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index 2047328433f..7052260f748 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -6,6 +6,10 @@ import { hasConfiguredSecretInput, normalizeSecretInputString, } from "../config/types.secrets.js"; +import { + listBundledWebSearchProviders, + resolveBundledWebSearchPluginId, +} from "../plugins/bundled-web-search.js"; import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -18,41 +22,77 @@ export type SearchProvider = NonNullable< type SearchConfig = NonNullable["web"]>["search"]>; type MutableSearchConfig = SearchConfig & Record; -type SearchProviderEntry = { - value: SearchProvider; - label: string; - hint: string; - envKeys: string[]; - placeholder: string; - signupUrl: string; - credentialPath: string; - applySelectionConfig?: PluginWebSearchProviderEntry["applySelectionConfig"]; -}; - -export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = +export const SEARCH_PROVIDER_OPTIONS: readonly PluginWebSearchProviderEntry[] = resolvePluginWebSearchProviders({ bundledAllowlistCompat: true, - }).map((provider) => ({ - value: provider.id, - label: provider.label, - hint: provider.hint, - envKeys: provider.envVars, - placeholder: provider.placeholder, - signupUrl: provider.signupUrl, - credentialPath: provider.credentialPath, - applySelectionConfig: provider.applySelectionConfig, - })); + }); -export function hasKeyInEnv(entry: SearchProviderEntry): boolean { - return entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); +function sortSearchProviderOptions( + providers: PluginWebSearchProviderEntry[], +): PluginWebSearchProviderEntry[] { + return providers.toSorted((left, right) => { + const leftOrder = left.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + const rightOrder = right.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + return left.id.localeCompare(right.id); + }); +} + +function canRepairBundledProviderSelection( + config: OpenClawConfig, + provider: Pick, +): boolean { + const pluginId = provider.pluginId ?? resolveBundledWebSearchPluginId(provider.id); + if (!pluginId) { + return false; + } + if (config.plugins?.enabled === false) { + return false; + } + return !config.plugins?.deny?.includes(pluginId); +} + +export function resolveSearchProviderOptions( + config?: OpenClawConfig, +): readonly PluginWebSearchProviderEntry[] { + if (!config) { + return SEARCH_PROVIDER_OPTIONS; + } + + const merged = new Map( + resolvePluginWebSearchProviders({ + config, + bundledAllowlistCompat: true, + env: process.env, + }).map((entry) => [entry.id, entry]), + ); + + for (const entry of listBundledWebSearchProviders()) { + if (merged.has(entry.id) || !canRepairBundledProviderSelection(config, entry)) { + continue; + } + merged.set(entry.id, entry); + } + + return sortSearchProviderOptions([...merged.values()]); +} + +function resolveSearchProviderEntry( + config: OpenClawConfig, + provider: SearchProvider, +): PluginWebSearchProviderEntry | undefined { + return resolveSearchProviderOptions(config).find((entry) => entry.id === provider); +} + +export function hasKeyInEnv(entry: Pick): boolean { + return entry.envVars.some((k) => Boolean(process.env[k]?.trim())); } function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown { const search = config.tools?.web?.search; - const entry = resolvePluginWebSearchProviders({ - config, - bundledAllowlistCompat: true, - }).find((candidate) => candidate.id === provider); + const entry = resolveSearchProviderEntry(config, provider); return ( entry?.getConfiguredCredentialValue?.(config) ?? entry?.getCredentialValue(search as Record | undefined) @@ -73,9 +113,12 @@ export function hasExistingKey(config: OpenClawConfig, provider: SearchProvider) } /** Build an env-backed SecretRef for a search provider. */ -function buildSearchEnvRef(provider: SearchProvider): SecretRef { - const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider); - const envVar = entry?.envKeys.find((k) => Boolean(process.env[k]?.trim())) ?? entry?.envKeys[0]; +function buildSearchEnvRef(config: OpenClawConfig, provider: SearchProvider): SecretRef { + const entry = + resolveSearchProviderEntry(config, provider) ?? + SEARCH_PROVIDER_OPTIONS.find((candidate) => candidate.id === provider) ?? + listBundledWebSearchProviders().find((candidate) => candidate.id === provider); + const envVar = entry?.envVars.find((k) => Boolean(process.env[k]?.trim())) ?? entry?.envVars[0]; if (!envVar) { throw new Error( `No env var mapping for search provider "${provider}" at ${entry?.credentialPath ?? "unknown path"} in secret-input-mode=ref.`, @@ -86,13 +129,14 @@ function buildSearchEnvRef(provider: SearchProvider): SecretRef { /** Resolve a plaintext key into the appropriate SecretInput based on mode. */ function resolveSearchSecretInput( + config: OpenClawConfig, provider: SearchProvider, key: string, secretInputMode?: SecretInputMode, ): SecretInput { const useSecretRefMode = secretInputMode === "ref"; // pragma: allowlist secret if (useSecretRefMode) { - return buildSearchEnvRef(provider); + return buildSearchEnvRef(config, provider); } return key; } @@ -102,12 +146,12 @@ export function applySearchKey( provider: SearchProvider, key: SecretInput, ): OpenClawConfig { - const providerEntry = resolvePluginWebSearchProviders({ - config, - bundledAllowlistCompat: true, - }).find((candidate) => candidate.id === provider); + const providerEntry = resolveSearchProviderEntry(config, provider); + if (!providerEntry) { + return config; + } const search: MutableSearchConfig = { ...config.tools?.web?.search, provider, enabled: true }; - if (providerEntry && !providerEntry.setConfiguredCredentialValue) { + if (!providerEntry.setConfiguredCredentialValue) { providerEntry.setCredentialValue(search, key); } const nextBase: OpenClawConfig = { @@ -117,16 +161,19 @@ export function applySearchKey( web: { ...config.tools?.web, search }, }, }; - const next = providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase; - providerEntry?.setConfiguredCredentialValue?.(next, key); + const next = providerEntry.applySelectionConfig?.(nextBase) ?? nextBase; + providerEntry.setConfiguredCredentialValue?.(next, key); return next; } -function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig { - const providerEntry = resolvePluginWebSearchProviders({ - config, - bundledAllowlistCompat: true, - }).find((candidate) => candidate.id === provider); +export function applySearchProviderSelection( + config: OpenClawConfig, + provider: SearchProvider, +): OpenClawConfig { + const providerEntry = resolveSearchProviderEntry(config, provider); + if (!providerEntry) { + return config; + } const search: MutableSearchConfig = { ...config.tools?.web?.search, provider, @@ -142,20 +189,65 @@ function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): Op }, }, }; - return providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase; + return providerEntry.applySelectionConfig?.(nextBase) ?? nextBase; } function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig): OpenClawConfig { if (original.tools?.web?.search?.enabled !== false) { return result; } - return { + + const next: OpenClawConfig = { ...result, tools: { ...result.tools, web: { ...result.tools?.web, search: { ...result.tools?.web?.search, enabled: false } }, }, }; + + const provider = next.tools?.web?.search?.provider; + if (typeof provider !== "string") { + return next; + } + const providerEntry = resolveSearchProviderEntry(original, provider); + if (!providerEntry?.pluginId) { + return next; + } + + const pluginId = providerEntry.pluginId; + const originalPluginEntry = ( + original.plugins?.entries as Record> | undefined + )?.[pluginId]; + const resultPluginEntry = ( + next.plugins?.entries as Record> | undefined + )?.[pluginId]; + + const nextPlugins = { ...next.plugins } as Record; + + if (Array.isArray(original.plugins?.allow)) { + nextPlugins.allow = [...original.plugins.allow]; + } else { + delete nextPlugins.allow; + } + + if (resultPluginEntry || originalPluginEntry) { + const nextEntries = { + ...(nextPlugins.entries as Record> | undefined), + }; + const patchedEntry = { ...resultPluginEntry }; + if (typeof originalPluginEntry?.enabled === "boolean") { + patchedEntry.enabled = originalPluginEntry.enabled; + } else { + delete patchedEntry.enabled; + } + nextEntries[pluginId] = patchedEntry; + nextPlugins.entries = nextEntries; + } + + return { + ...next, + plugins: nextPlugins as OpenClawConfig["plugins"], + }; } export type SetupSearchOptions = { @@ -169,6 +261,19 @@ export async function setupSearch( prompter: WizardPrompter, opts?: SetupSearchOptions, ): Promise { + const providerOptions = resolveSearchProviderOptions(config); + if (providerOptions.length === 0) { + await prompter.note( + [ + "No web search providers are currently available under this plugin policy.", + "Enable plugins or remove deny rules, then run setup again.", + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + return config; + } + await prompter.note( [ "Web search lets your agent look things up online.", @@ -180,23 +285,21 @@ export async function setupSearch( const existingProvider = config.tools?.web?.search?.provider; - const options = SEARCH_PROVIDER_OPTIONS.map((entry) => { - const configured = hasExistingKey(config, entry.value) || hasKeyInEnv(entry); + const options = providerOptions.map((entry) => { + const configured = hasExistingKey(config, entry.id) || hasKeyInEnv(entry); const hint = configured ? `${entry.hint} Β· configured` : entry.hint; - return { value: entry.value, label: entry.label, hint }; + return { value: entry.id, label: entry.label, hint }; }); const defaultProvider: SearchProvider = (() => { - if (existingProvider && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === existingProvider)) { + if (existingProvider && providerOptions.some((entry) => entry.id === existingProvider)) { return existingProvider; } - const detected = SEARCH_PROVIDER_OPTIONS.find( - (e) => hasExistingKey(config, e.value) || hasKeyInEnv(e), - ); + const detected = providerOptions.find((e) => hasExistingKey(config, e.id) || hasKeyInEnv(e)); if (detected) { - return detected.value; + return detected.id; } - return SEARCH_PROVIDER_OPTIONS[0].value; + return providerOptions[0].id; })(); const choice = await prompter.select({ @@ -216,7 +319,11 @@ export async function setupSearch( return config; } - const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === choice)!; + const entry = + resolveSearchProviderEntry(config, choice) ?? providerOptions.find((e) => e.id === choice); + if (!entry) { + return config; + } const existingKey = resolveExistingKey(config, choice); const keyConfigured = hasExistingKey(config, choice); const envAvailable = hasKeyInEnv(entry); @@ -224,16 +331,16 @@ export async function setupSearch( if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) { const result = existingKey ? applySearchKey(config, choice, existingKey) - : applyProviderOnly(config, choice); + : applySearchProviderSelection(config, choice); return preserveDisabledState(config, result); } const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret if (useSecretRefMode) { if (keyConfigured) { - return preserveDisabledState(config, applyProviderOnly(config, choice)); + return preserveDisabledState(config, applySearchProviderSelection(config, choice)); } - const ref = buildSearchEnvRef(choice); + const ref = buildSearchEnvRef(config, choice); await prompter.note( [ "Secret references enabled β€” OpenClaw will store a reference instead of the API key.", @@ -257,7 +364,7 @@ export async function setupSearch( const key = keyInput?.trim() ?? ""; if (key) { - const secretInput = resolveSearchSecretInput(choice, key, opts?.secretInputMode); + const secretInput = resolveSearchSecretInput(config, choice, key, opts?.secretInputMode); return applySearchKey(config, choice, secretInput); } @@ -266,7 +373,7 @@ export async function setupSearch( } if (keyConfigured || envAvailable) { - return preserveDisabledState(config, applyProviderOnly(config, choice)); + return preserveDisabledState(config, applySearchProviderSelection(config, choice)); } await prompter.note( diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index decb5e68e3b..b0319f219eb 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -136,6 +136,35 @@ function pluginWebSearchApiKey( } describe("web search provider config", () => { + it("does not warn for legacy brave config when bundled web search allowlist compat applies", () => { + const res = validateConfigObjectWithPlugins({ + plugins: { + allow: ["bluebubbles", "memory-core"], + }, + tools: { + web: { + search: { + enabled: true, + apiKey: "test-brave-key", // pragma: allowlist secret + }, + }, + }, + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.warnings).not.toContainEqual( + expect.objectContaining({ + path: "plugins.entries.brave", + message: expect.stringContaining( + "plugin disabled (not in allowlist) but config is present", + ), + }), + ); + }); + it("accepts perplexity provider and config", () => { const res = validateConfigObjectWithPlugins( buildWebSearchProviderConfig({ diff --git a/src/config/doc-baseline.integration.test.ts b/src/config/doc-baseline.integration.test.ts index 1cb81623889..71c95d03f07 100644 --- a/src/config/doc-baseline.integration.test.ts +++ b/src/config/doc-baseline.integration.test.ts @@ -70,6 +70,26 @@ describe("config doc baseline integration", () => { expect(tokenEntry?.tags).toContain("security"); }); + it("uses human-readable channel metadata for top-level channel sections", async () => { + const baseline = await getSharedBaseline(); + const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); + + expect(byPath.get("channels.discord")).toMatchObject({ + label: "Discord", + help: "very well supported right now.", + }); + expect(byPath.get("channels.msteams")).toMatchObject({ + label: "Microsoft Teams", + help: "Bot Framework; enterprise support.", + }); + expect(byPath.get("channels.matrix")).toMatchObject({ + label: "Matrix", + help: "open protocol; install the plugin to enable.", + }); + expect(byPath.get("channels.msteams")?.label).not.toContain("@openclaw/"); + expect(byPath.get("channels.matrix")?.help).not.toContain("homeserver"); + }); + it("matches array help hints that still use [] notation", async () => { const baseline = await getSharedBaseline(); const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); diff --git a/src/config/doc-baseline.ts b/src/config/doc-baseline.ts index 1603fa3dd1b..1aa996fd5b6 100644 --- a/src/config/doc-baseline.ts +++ b/src/config/doc-baseline.ts @@ -269,7 +269,12 @@ function resolveFirstExistingPath(candidates: string[]): string | null { } async function loadBundledConfigSchemaResponse(): Promise { - const [{ loadPluginManifestRegistry }, { buildConfigSchema }] = await Promise.all([ + const [ + { listChannelPluginCatalogEntries }, + { loadPluginManifestRegistry }, + { buildConfigSchema }, + ] = await Promise.all([ + import("../channels/plugins/catalog.js"), import("../plugins/manifest-registry.js"), import("./schema.js"), ]); @@ -286,6 +291,12 @@ async function loadBundledConfigSchemaResponse(): Promise env, config: {}, }); + const channelCatalogById = new Map( + listChannelPluginCatalogEntries({ + workspaceDir: repoRoot, + env, + }).map((entry) => [entry.id, entry.meta] as const), + ); logConfigDocBaselineDebug(`loaded ${manifestRegistry.plugins.length} bundled plugin manifests`); const bundledChannelPlugins = manifestRegistry.plugins.filter( (plugin) => plugin.origin === "bundled" && plugin.channels.length > 0, @@ -295,16 +306,20 @@ async function loadBundledConfigSchemaResponse(): Promise ? await bundledChannelPlugins.reduce>( async (promise, plugin) => { const loaded = await promise; + const catalogMeta = channelCatalogById.get(plugin.id); + const label = catalogMeta?.label ?? plugin.name ?? plugin.id; + const description = catalogMeta?.blurb ?? plugin.description; loaded.push( (await loadChannelSurfaceMetadata( plugin.rootDir, plugin.id, - plugin.name ?? plugin.id, + label, + description, repoRoot, )) ?? { id: plugin.id, - label: plugin.name ?? plugin.id, - description: plugin.description, + label, + description, configSchema: plugin.configSchema, configUiHints: plugin.configUiHints, }, @@ -314,21 +329,26 @@ async function loadBundledConfigSchemaResponse(): Promise Promise.resolve([]), ) : await Promise.all( - bundledChannelPlugins.map( - async (plugin) => + bundledChannelPlugins.map(async (plugin) => { + const catalogMeta = channelCatalogById.get(plugin.id); + const label = catalogMeta?.label ?? plugin.name ?? plugin.id; + const description = catalogMeta?.blurb ?? plugin.description; + return ( (await loadChannelSurfaceMetadata( plugin.rootDir, plugin.id, - plugin.name ?? plugin.id, + label, + description, repoRoot, )) ?? { id: plugin.id, - label: plugin.name ?? plugin.id, - description: plugin.description, + label, + description, configSchema: plugin.configSchema, configUiHints: plugin.configUiHints, - }, - ), + } + ); + }), ); logConfigDocBaselineDebug( `loaded ${channelPlugins.length} bundled channel entries from channel surfaces`, @@ -359,6 +379,7 @@ async function loadChannelSurfaceMetadata( rootDir: string, id: string, label: string, + description: string | undefined, repoRoot: string, ): Promise { logConfigDocBaselineDebug(`resolve channel config surface ${rootDir}`); @@ -386,6 +407,7 @@ async function loadChannelSurfaceMetadata( return { id, label, + description, configSchema: configSurface.schema, configUiHints: configSurface.uiHints as ConfigSchemaResponse["uiHints"] | undefined, }; diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 54fd24b5880..6cf09647cf6 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -1,4 +1,4 @@ -import { hasAnyWhatsAppAuth } from "openclaw/plugin-sdk/whatsapp"; +import { hasAnyWhatsAppAuth } from "../../extensions/whatsapp/api.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { hasMeaningfulChannelConfig } from "../channels/config-presence.js"; import { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index c22d5e15b32..233900305fa 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1,7 +1,7 @@ import { DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, -} from "openclaw/plugin-sdk/discord"; +} from "../../extensions/discord/runtime-api.js"; import { MEDIA_AUDIO_FIELD_HELP } from "./media-audio-field-metadata.js"; import { IRC_FIELD_HELP } from "./schema.irc.js"; import { describeTalkSilenceTimeoutDefaults } from "./talk-defaults.js"; @@ -1532,6 +1532,8 @@ export const FIELD_HELP: Record = { "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", "channels.telegram.silentErrorReplies": "When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.", + "channels.telegram.apiRoot": + "Custom Telegram Bot API root URL. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked.", "channels.telegram.threadBindings.enabled": "Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set.", "channels.telegram.threadBindings.idleHours": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 53317e2fcd2..e762e979c71 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -732,6 +732,7 @@ export const FIELD_LABELS: Record = { "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", "channels.telegram.silentErrorReplies": "Telegram Silent Error Replies", + "channels.telegram.apiRoot": "Telegram API Root URL", "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", "channels.telegram.execApprovals": "Telegram Exec Approvals", "channels.telegram.execApprovals.enabled": "Telegram Exec Approvals Enabled", diff --git a/src/config/sessions/store.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index 3fde5236294..cba88dda8b9 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -10,11 +10,9 @@ vi.mock("../config.js", () => ({ loadConfig: vi.fn().mockReturnValue({}), })); -type StoreModule = typeof import("./store.js"); +import { loadConfig } from "../config.js"; +import { clearSessionStoreCacheForTest, loadSessionStore, saveSessionStore } from "./store.js"; -let clearSessionStoreCacheForTest: StoreModule["clearSessionStoreCacheForTest"]; -let loadSessionStore: StoreModule["loadSessionStore"]; -let saveSessionStore: StoreModule["saveSessionStore"]; let mockLoadConfig: ReturnType; const DAY_MS = 24 * 60 * 60 * 1000; @@ -81,10 +79,6 @@ 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"); diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 71ded650deb..33b090317ca 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -216,6 +216,8 @@ export type TelegramAccountConfig = { * Telegram expects unicode emoji (e.g., "πŸ‘€") rather than shortcodes. */ ackReaction?: string; + /** Custom Telegram Bot API root URL (e.g. "https://my-proxy.example.com" or a local Bot API server). */ + apiRoot?: string; }; export type TelegramTopicConfig = { diff --git a/src/config/validation.ts b/src/config/validation.ts index 0c2bba53aae..98a1fd29fc6 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -1,6 +1,8 @@ import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js"; +import { withBundledPluginAllowlistCompat } from "../plugins/bundled-compat.js"; +import { resolveBundledWebSearchPluginIds } from "../plugins/bundled-web-search.js"; import { normalizePluginsConfig, resolveEffectiveEnableState, @@ -351,15 +353,38 @@ function validateConfigObjectWithPluginsBase( }; let registryInfo: RegistryInfo | null = null; + let compatConfig: OpenClawConfig | null | undefined; + + const ensureCompatConfig = (): OpenClawConfig => { + if (compatConfig !== undefined) { + return compatConfig ?? config; + } + + const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const bundledWebSearchPluginIds = resolveBundledWebSearchPluginIds({ + config, + workspaceDir: workspaceDir ?? undefined, + env: opts.env, + }); + compatConfig = withBundledPluginAllowlistCompat({ + config, + pluginIds: bundledWebSearchPluginIds, + }); + return compatConfig ?? config; + }; const ensureRegistry = (): RegistryInfo => { if (registryInfo) { return registryInfo; } - const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const effectiveConfig = ensureCompatConfig(); + const workspaceDir = resolveAgentWorkspaceDir( + effectiveConfig, + resolveDefaultAgentId(effectiveConfig), + ); const registry = loadPluginManifestRegistry({ - config, + config: effectiveConfig, workspaceDir: workspaceDir ?? undefined, env: opts.env, }); @@ -393,7 +418,7 @@ function validateConfigObjectWithPluginsBase( const ensureNormalizedPlugins = (): ReturnType => { const info = ensureRegistry(); if (!info.normalizedPlugins) { - info.normalizedPlugins = normalizePluginsConfig(config.plugins); + info.normalizedPlugins = normalizePluginsConfig(ensureCompatConfig().plugins); } return info.normalizedPlugins; }; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index e65030d8f38..897accf2878 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -280,6 +280,7 @@ export const TelegramAccountSchemaBase = z silentErrorReplies: z.boolean().optional(), responsePrefix: z.string().optional(), ackReaction: z.string().optional(), + apiRoot: z.string().url().optional(), }) .strict(); diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index cf24bfd7a07..3038eb6cafe 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -20,6 +20,7 @@ import type { ContextEngineInfo, AssembleResult, CompactResult, + ContextEngineMaintenanceResult, IngestResult, } from "./types.js"; @@ -118,6 +119,7 @@ class LegacySessionKeyStrictEngine implements ContextEngine { readonly ingestCalls: Array> = []; readonly assembleCalls: Array> = []; readonly compactCalls: Array> = []; + readonly maintainCalls: Array> = []; readonly ingestedMessages: AgentMessage[] = []; private rejectSessionKey(params: { sessionKey?: string }): void { @@ -143,6 +145,7 @@ class LegacySessionKeyStrictEngine implements ContextEngine { sessionKey?: string; messages: AgentMessage[]; tokenBudget?: number; + prompt?: string; }): Promise { this.assembleCalls.push({ ...params }); this.rejectSessionKey(params); @@ -172,6 +175,21 @@ class LegacySessionKeyStrictEngine implements ContextEngine { }, }; } + + async maintain(params: { + sessionId: string; + sessionKey?: string; + sessionFile: string; + runtimeContext?: Record; + }): Promise { + this.maintainCalls.push({ ...params }); + this.rejectSessionKey(params); + return { + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + }; + } } class SessionKeyRuntimeErrorEngine implements ContextEngine { @@ -217,6 +235,58 @@ class SessionKeyRuntimeErrorEngine implements ContextEngine { } } +class LegacyAssembleStrictEngine implements ContextEngine { + readonly info: ContextEngineInfo = { + id: "legacy-assemble-strict", + name: "Legacy Assemble Strict Engine", + }; + readonly assembleCalls: Array> = []; + + 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; + prompt?: string; + }): Promise { + this.assembleCalls.push({ ...params }); + if (Object.prototype.hasOwnProperty.call(params, "sessionKey")) { + throw new Error("Unrecognized key(s) in object: 'sessionKey'"); + } + if (Object.prototype.hasOwnProperty.call(params, "prompt")) { + throw new Error("Unrecognized key(s) in object: 'prompt'"); + } + return { + messages: params.messages, + estimatedTokens: 3, + }; + } + + 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 // ═══════════════════════════════════════════════════════════════════════════ @@ -463,6 +533,24 @@ describe("Legacy sessionKey compatibility", () => { expect(strictEngine.ingestedMessages).toEqual([firstMessage, secondMessage]); }); + it("retries strict maintain once and memoizes legacy mode there too", async () => { + const engineId = `legacy-sessionkey-maintain-${Date.now().toString(36)}`; + const strictEngine = new LegacySessionKeyStrictEngine(); + registerContextEngine(engineId, () => strictEngine); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + + await engine.maintain?.({ + sessionId: "s1", + sessionKey: "agent:main:test", + sessionFile: "/tmp/session.json", + }); + + expect(strictEngine.maintainCalls).toHaveLength(2); + expect(strictEngine.maintainCalls[0]).toHaveProperty("sessionKey", "agent:main:test"); + expect(strictEngine.maintainCalls[1]).not.toHaveProperty("sessionKey"); + }); + it("does not retry non-compat runtime errors", async () => { const engineId = `sessionkey-runtime-${Date.now().toString(36)}`; const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine(); @@ -605,6 +693,124 @@ describe("LegacyContextEngine parity", () => { }); }); +// ═══════════════════════════════════════════════════════════════════════════ +// 5b. assemble() prompt forwarding +// ═══════════════════════════════════════════════════════════════════════════ + +describe("assemble() prompt forwarding", () => { + it("forwards prompt to the underlying engine", async () => { + const engineId = `prompt-fwd-${Date.now().toString(36)}`; + const calls: Array> = []; + registerContextEngine(engineId, () => ({ + info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" }, + async ingest() { + return { ingested: false }; + }, + async assemble(params) { + calls.push({ ...params }); + return { messages: params.messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + })); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + await engine.assemble({ + sessionId: "s1", + messages: [makeMockMessage("user", "hello")], + prompt: "hello", + }); + + expect(calls).toHaveLength(1); + expect(calls[0]).toHaveProperty("prompt", "hello"); + }); + + it("omits prompt when not provided", async () => { + const engineId = `prompt-omit-${Date.now().toString(36)}`; + const calls: Array> = []; + registerContextEngine(engineId, () => ({ + info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" }, + async ingest() { + return { ingested: false }; + }, + async assemble(params) { + calls.push({ ...params }); + return { messages: params.messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + })); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + await engine.assemble({ + sessionId: "s1", + messages: [makeMockMessage("user", "hello")], + }); + + expect(calls).toHaveLength(1); + expect(calls[0]).not.toHaveProperty("prompt"); + }); + + it("does not leak prompt key when caller spreads undefined", async () => { + // Guards against the pattern `{ prompt: params.prompt }` when params.prompt + // is undefined β€” JavaScript keeps the key present with value undefined, + // which breaks engines that guard with `'prompt' in params`. + const engineId = `prompt-undef-${Date.now().toString(36)}`; + const calls: Array> = []; + registerContextEngine(engineId, () => ({ + info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" }, + async ingest() { + return { ingested: false }; + }, + async assemble(params) { + calls.push({ ...params }); + return { messages: params.messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + })); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + // Simulate the attempt.ts call-site pattern: conditional spread + const callerPrompt: string | undefined = undefined; + await engine.assemble({ + sessionId: "s1", + messages: [makeMockMessage("user", "hello")], + ...(callerPrompt !== undefined ? { prompt: callerPrompt } : {}), + }); + + expect(calls).toHaveLength(1); + expect(calls[0]).not.toHaveProperty("prompt"); + expect(Object.keys(calls[0] as object)).not.toContain("prompt"); + }); + + it("retries strict legacy assemble without sessionKey and prompt", async () => { + const engineId = `prompt-legacy-${Date.now().toString(36)}`; + const strictEngine = new LegacyAssembleStrictEngine(); + registerContextEngine(engineId, () => strictEngine); + + const engine = await resolveContextEngine(configWithSlot(engineId)); + const result = await engine.assemble({ + sessionId: "s1", + sessionKey: "agent:main:test", + messages: [makeMockMessage("user", "hello")], + prompt: "hello", + }); + + expect(result.estimatedTokens).toBe(3); + expect(strictEngine.assembleCalls).toHaveLength(3); + expect(strictEngine.assembleCalls[0]).toHaveProperty("sessionKey", "agent:main:test"); + expect(strictEngine.assembleCalls[0]).toHaveProperty("prompt", "hello"); + expect(strictEngine.assembleCalls[1]).not.toHaveProperty("sessionKey"); + expect(strictEngine.assembleCalls[1]).toHaveProperty("prompt", "hello"); + expect(strictEngine.assembleCalls[2]).not.toHaveProperty("sessionKey"); + expect(strictEngine.assembleCalls[2]).not.toHaveProperty("prompt"); + }); +}); + // ═══════════════════════════════════════════════════════════════════════════ // 6. Initialization guard // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/context-engine/index.ts b/src/context-engine/index.ts index 09cc4c8e94e..fef9105d8be 100644 --- a/src/context-engine/index.ts +++ b/src/context-engine/index.ts @@ -3,7 +3,12 @@ export type { ContextEngineInfo, AssembleResult, CompactResult, + ContextEngineMaintenanceResult, + ContextEngineRuntimeContext, IngestResult, + TranscriptRewriteReplacement, + TranscriptRewriteRequest, + TranscriptRewriteResult, } from "./types.js"; export { diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index 2c5cac439c0..af7d6032f62 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -16,17 +16,31 @@ type RegisterContextEngineForOwnerOptions = { const LEGACY_SESSION_KEY_COMPAT = Symbol.for("openclaw.contextEngine.sessionKeyCompat"); const SESSION_KEY_COMPAT_METHODS = [ "bootstrap", + "maintain", "ingest", "ingestBatch", "afterTurn", "assemble", "compact", ] as const; +const LEGACY_COMPAT_PARAMS = ["sessionKey", "prompt"] as const; +const LEGACY_COMPAT_METHOD_KEYS = { + bootstrap: ["sessionKey"], + maintain: ["sessionKey"], + ingest: ["sessionKey"], + ingestBatch: ["sessionKey"], + afterTurn: ["sessionKey"], + assemble: ["sessionKey", "prompt"], + compact: ["sessionKey"], +} as const; type SessionKeyCompatMethodName = (typeof SESSION_KEY_COMPAT_METHODS)[number]; type SessionKeyCompatParams = { sessionKey?: string; + prompt?: string; }; +type LegacyCompatKey = (typeof LEGACY_COMPAT_PARAMS)[number]; +type LegacyCompatParamMap = Partial>; function isSessionKeyCompatMethodName(value: PropertyKey): value is SessionKeyCompatMethodName { return ( @@ -34,21 +48,29 @@ function isSessionKeyCompatMethodName(value: PropertyKey): value is SessionKeyCo ); } -function hasOwnSessionKey(params: unknown): params is SessionKeyCompatParams { +function hasOwnLegacyCompatKey( + params: unknown, + key: K, +): params is SessionKeyCompatParams & Required> { return ( params !== null && typeof params === "object" && - Object.prototype.hasOwnProperty.call(params, "sessionKey") + Object.prototype.hasOwnProperty.call(params, key) ); } -function withoutSessionKey(params: T): T { +function withoutLegacyCompatKeys( + params: T, + keys: Iterable, +): T { const legacyParams = { ...params }; - delete legacyParams.sessionKey; + for (const key of keys) { + delete legacyParams[key]; + } return legacyParams; } -function issueRejectsSessionKeyStrictly(issue: unknown): boolean { +function issueRejectsLegacyCompatKeyStrictly(issue: unknown, key: LegacyCompatKey): boolean { if (!issue || typeof issue !== "object") { return false; } @@ -61,12 +83,12 @@ function issueRejectsSessionKeyStrictly(issue: unknown): boolean { if ( issueRecord.code === "unrecognized_keys" && Array.isArray(issueRecord.keys) && - issueRecord.keys.some((key) => key === "sessionKey") + issueRecord.keys.some((issueKey) => issueKey === key) ) { return true; } - return isSessionKeyCompatibilityError(issueRecord.message); + return isLegacyCompatErrorForKey(issueRecord.message, key); } function* iterateErrorChain(error: unknown) { @@ -82,31 +104,45 @@ function* iterateErrorChain(error: unknown) { } } -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; +const LEGACY_UNKNOWN_FIELD_PATTERNS: Record = { + sessionKey: [ + /\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, + ], + prompt: [ + /\bunrecognized key(?:\(s\)|s)? in object:.*['"`]prompt['"`]/i, + /\badditional propert(?:y|ies)\b.*['"`]prompt['"`]/i, + /\bmust not have additional propert(?:y|ies)\b.*['"`]prompt['"`]/i, + /\b(?:unexpected|extraneous)\s+(?:property|properties|field|fields|key|keys)\b.*['"`]prompt['"`]/i, + /\b(?:unknown|invalid)\s+(?:property|properties|field|fields|key|keys)\b.*['"`]prompt['"`]/i, + /['"`]prompt['"`].*\b(?:was|is)\s+not allowed\b/i, + /"code"\s*:\s*"unrecognized_keys"[^]*"prompt"/i, + ], +} as const; -function isSessionKeyUnknownFieldValidationMessage(message: string): boolean { - return SESSION_KEY_UNKNOWN_FIELD_PATTERNS.some((pattern) => pattern.test(message)); +function isLegacyCompatUnknownFieldValidationMessage( + message: string, + key: LegacyCompatKey, +): boolean { + return LEGACY_UNKNOWN_FIELD_PATTERNS[key].some((pattern) => pattern.test(message)); } -function isSessionKeyCompatibilityError(error: unknown): boolean { +function isLegacyCompatErrorForKey(error: unknown, key: LegacyCompatKey): boolean { for (const candidate of iterateErrorChain(error)) { if (Array.isArray(candidate)) { - if (candidate.some((entry) => issueRejectsSessionKeyStrictly(entry))) { + if (candidate.some((entry) => issueRejectsLegacyCompatKeyStrictly(entry, key))) { return true; } continue; } if (typeof candidate === "string") { - if (isSessionKeyUnknownFieldValidationMessage(candidate)) { + if (isLegacyCompatUnknownFieldValidationMessage(candidate, key)) { return true; } continue; @@ -124,21 +160,21 @@ function isSessionKeyCompatibilityError(error: unknown): boolean { if ( Array.isArray(issueContainer.issues) && - issueContainer.issues.some((issue) => issueRejectsSessionKeyStrictly(issue)) + issueContainer.issues.some((issue) => issueRejectsLegacyCompatKeyStrictly(issue, key)) ) { return true; } if ( Array.isArray(issueContainer.errors) && - issueContainer.errors.some((issue) => issueRejectsSessionKeyStrictly(issue)) + issueContainer.errors.some((issue) => issueRejectsLegacyCompatKeyStrictly(issue, key)) ) { return true; } if ( typeof issueContainer.message === "string" && - isSessionKeyUnknownFieldValidationMessage(issueContainer.message) + isLegacyCompatUnknownFieldValidationMessage(issueContainer.message, key) ) { return true; } @@ -147,25 +183,66 @@ function isSessionKeyCompatibilityError(error: unknown): boolean { return false; } -async function invokeWithLegacySessionKeyCompat( +function detectRejectedLegacyCompatKeys( + error: unknown, + allowedKeys: readonly LegacyCompatKey[], +): Set { + const rejectedKeys = new Set(); + for (const key of allowedKeys) { + if (isLegacyCompatErrorForKey(error, key)) { + rejectedKeys.add(key); + } + } + return rejectedKeys; +} + +async function invokeWithLegacyCompat( method: (params: TParams) => Promise | TResult, params: TParams, + allowedKeys: readonly LegacyCompatKey[], opts?: { onLegacyModeDetected?: () => void; + onLegacyKeysDetected?: (keys: Set) => void; + rejectedKeys?: ReadonlySet; }, ): Promise { - if (!hasOwnSessionKey(params)) { + const activeRejectedKeys = new Set(opts?.rejectedKeys ?? []); + const availableKeys = allowedKeys.filter((key) => hasOwnLegacyCompatKey(params, key)); + if (availableKeys.length === 0) { return await method(params); } + let currentParams = + activeRejectedKeys.size > 0 ? withoutLegacyCompatKeys(params, activeRejectedKeys) : params; + try { - return await method(params); + return await method(currentParams); } catch (error) { - if (!isSessionKeyCompatibilityError(error)) { - throw error; + let currentError = error; + while (true) { + const rejectedKeys = detectRejectedLegacyCompatKeys(currentError, availableKeys); + let learnedNewKey = false; + for (const key of rejectedKeys) { + if (!activeRejectedKeys.has(key)) { + activeRejectedKeys.add(key); + learnedNewKey = true; + } + } + + if (!learnedNewKey) { + throw currentError; + } + + opts?.onLegacyModeDetected?.(); + opts?.onLegacyKeysDetected?.(rejectedKeys); + currentParams = withoutLegacyCompatKeys(params, activeRejectedKeys); + + try { + return await method(currentParams); + } catch (retryError) { + currentError = retryError; + } } - opts?.onLegacyModeDetected?.(); - return await method(withoutSessionKey(params)); } } @@ -178,6 +255,7 @@ function wrapContextEngineWithSessionKeyCompat(engine: ContextEngine): ContextEn } let isLegacy = false; + const rejectedKeys = new Set(); const proxy: ContextEngine = new Proxy(engine, { get(target, property, receiver) { if (property === LEGACY_SESSION_KEY_COMPAT) { @@ -195,13 +273,23 @@ function wrapContextEngineWithSessionKeyCompat(engine: ContextEngine): ContextEn return (params: SessionKeyCompatParams) => { const method = value.bind(target) as (params: SessionKeyCompatParams) => unknown; - if (isLegacy && hasOwnSessionKey(params)) { - return method(withoutSessionKey(params)); + const allowedKeys = LEGACY_COMPAT_METHOD_KEYS[property]; + if ( + isLegacy && + allowedKeys.some((key) => rejectedKeys.has(key) && hasOwnLegacyCompatKey(params, key)) + ) { + return method(withoutLegacyCompatKeys(params, rejectedKeys)); } - return invokeWithLegacySessionKeyCompat(method, params, { + return invokeWithLegacyCompat(method, params, allowedKeys, { onLegacyModeDetected: () => { isLegacy = true; }, + onLegacyKeysDetected: (keys) => { + for (const key of keys) { + rejectedKeys.add(key); + } + }, + rejectedKeys, }); }; }, diff --git a/src/context-engine/types.ts b/src/context-engine/types.ts index 438ae625d2d..03401fdf3f2 100644 --- a/src/context-engine/types.ts +++ b/src/context-engine/types.ts @@ -57,7 +57,43 @@ export type SubagentSpawnPreparation = { }; export type SubagentEndReason = "deleted" | "completed" | "swept" | "released"; -export type ContextEngineRuntimeContext = Record; + +export type TranscriptRewriteReplacement = { + /** Existing transcript entry id to replace on the active branch. */ + entryId: string; + /** Replacement message content for that entry. */ + message: AgentMessage; +}; + +export type TranscriptRewriteRequest = { + /** Message entry replacements to apply in one branch-and-reappend pass. */ + replacements: TranscriptRewriteReplacement[]; +}; + +export type TranscriptRewriteResult = { + /** Whether the active branch changed. */ + changed: boolean; + /** Estimated bytes removed from the active branch message payloads. */ + bytesFreed: number; + /** Number of transcript message entries rewritten. */ + rewrittenEntries: number; + /** Optional reason when no rewrite occurred. */ + reason?: string; +}; + +export type ContextEngineMaintenanceResult = TranscriptRewriteResult; + +export type ContextEngineRuntimeContext = Record & { + /** + * Safe transcript rewrite helper implemented by the runtime. + * + * Engines decide what is safe to rewrite; the runtime owns how the session + * DAG is updated on disk. + */ + rewriteTranscriptEntries?: ( + request: TranscriptRewriteRequest, + ) => Promise; +}; /** * ContextEngine defines the pluggable contract for context management. @@ -78,6 +114,19 @@ export interface ContextEngine { sessionFile: string; }): Promise; + /** + * Run transcript maintenance after bootstrap, successful turns, or compaction. + * + * Engines can use runtimeContext.rewriteTranscriptEntries() to request safe + * branch-and-reappend transcript rewrites without depending on Pi internals. + */ + maintain?(params: { + sessionId: string; + sessionKey?: string; + sessionFile: string; + runtimeContext?: ContextEngineRuntimeContext; + }): Promise; + /** * Ingest a single message into the engine's store. */ @@ -134,6 +183,8 @@ export interface ContextEngine { /** Current model identifier (e.g. "claude-opus-4", "gpt-4o", "qwen2.5-7b"). * Allows context engine plugins to adapt formatting per model. */ model?: string; + /** The incoming user prompt for this turn (useful for retrieval-oriented engines). */ + prompt?: string; }): Promise; /** diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index 85966c3e07c..538ebdca273 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -1,4 +1,4 @@ -import { resolveWhatsAppAccount } from "openclaw/plugin-sdk/whatsapp"; +import { resolveWhatsAppAccount } from "../../../extensions/whatsapp/api.js"; import type { ChannelId } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { 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 d953185c369..cadde9700a4 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, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { clearFastTestEnv, loadRunCronIsolatedAgentTurn, @@ -8,11 +8,7 @@ import { runWithModelFallbackMock, } from "./run.test-harness.js"; -type RunModule = typeof import("./run.js"); -type SandboxConfigModule = typeof import("../../agents/sandbox/config.js"); - -let runCronIsolatedAgentTurn: RunModule["runCronIsolatedAgentTurn"]; -let resolveSandboxConfigForAgent: SandboxConfigModule["resolveSandboxConfigForAgent"]; +const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); function makeJob(overrides?: Record) { return { @@ -85,10 +81,7 @@ function expectDefaultSandboxPreserved( describe("runCronIsolatedAgentTurn sandbox config preserved", () => { let previousFastTestEnv: string | undefined; - beforeEach(async () => { - vi.resetModules(); - runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); - ({ resolveSandboxConfigForAgent } = await import("../../agents/sandbox/config.js")); + beforeEach(() => { previousFastTestEnv = clearFastTestEnv(); resetRunCronIsolatedAgentTurnHarness(); }); @@ -132,6 +125,7 @@ describe("runCronIsolatedAgentTurn sandbox config preserved", () => { expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1); const runCfg = runWithModelFallbackMock.mock.calls[0]?.[0]?.cfg; + const { resolveSandboxConfigForAgent } = await import("../../agents/sandbox/config.js"); const resolvedSandbox = resolveSandboxConfigForAgent(runCfg, "specialist"); expectDefaultSandboxPreserved(runCfg); diff --git a/src/extensionAPI.test.ts b/src/extensionAPI.test.ts new file mode 100644 index 00000000000..d2d9bf657a0 --- /dev/null +++ b/src/extensionAPI.test.ts @@ -0,0 +1,21 @@ +import * as extensionApi from "openclaw/extension-api"; +import { describe, expect, it } from "vitest"; + +describe("extension-api compat surface", () => { + it("keeps legacy agent helpers importable", () => { + expect(typeof extensionApi.runEmbeddedPiAgent).toBe("function"); + expect(typeof extensionApi.resolveAgentDir).toBe("function"); + expect(typeof extensionApi.resolveAgentWorkspaceDir).toBe("function"); + expect(typeof extensionApi.resolveAgentTimeoutMs).toBe("function"); + expect(typeof extensionApi.ensureAgentWorkspace).toBe("function"); + }); + + it("keeps legacy defaults and session helpers importable", () => { + expect(typeof extensionApi.DEFAULT_MODEL).toBe("string"); + expect(typeof extensionApi.DEFAULT_PROVIDER).toBe("string"); + expect(typeof extensionApi.resolveStorePath).toBe("function"); + expect(typeof extensionApi.loadSessionStore).toBe("function"); + expect(typeof extensionApi.saveSessionStore).toBe("function"); + expect(typeof extensionApi.resolveSessionFilePath).toBe("function"); + }); +}); diff --git a/src/extensionAPI.ts b/src/extensionAPI.ts new file mode 100644 index 00000000000..267ba27ab3c --- /dev/null +++ b/src/extensionAPI.ts @@ -0,0 +1,32 @@ +// Legacy compat surface for plugins that still import openclaw/extension-api. +// Keep this file intentionally narrow and forward-only. + +const shouldWarnExtensionApiImport = + process.env.VITEST !== "true" && + process.env.NODE_ENV !== "test" && + process.env.OPENCLAW_SUPPRESS_EXTENSION_API_WARNING !== "1"; + +if (shouldWarnExtensionApiImport) { + process.emitWarning( + "openclaw/extension-api is deprecated. Migrate to api.runtime.agent.* or focused openclaw/plugin-sdk/ imports. See https://docs.openclaw.ai/plugins/sdk-migration", + { + code: "OPENCLAW_EXTENSION_API_DEPRECATED", + detail: + "This compatibility bridge is temporary. Bundled plugins should use the injected plugin runtime instead of importing host-side agent helpers directly. Migration guide: https://docs.openclaw.ai/plugins/sdk-migration", + }, + ); +} + +export { resolveAgentDir, resolveAgentWorkspaceDir } from "./agents/agent-scope.js"; +export { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./agents/defaults.js"; +export { resolveAgentIdentity } from "./agents/identity.js"; +export { resolveThinkingDefault } from "./agents/model-selection.js"; +export { runEmbeddedPiAgent } from "./agents/pi-embedded.js"; +export { resolveAgentTimeoutMs } from "./agents/timeout.js"; +export { ensureAgentWorkspace } from "./agents/workspace.js"; +export { + resolveStorePath, + loadSessionStore, + saveSessionStore, + resolveSessionFilePath, +} from "./config/sessions.js"; diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts index 8ce128d4938..159211f7e8e 100644 --- a/src/gateway/model-pricing-cache.test.ts +++ b/src/gateway/model-pricing-cache.test.ts @@ -101,7 +101,7 @@ describe("model-pricing-cache", () => { ], }, hooks: { - mappings: [{ model: "xai/grok-4" }], + mappings: [{ model: "xai/grok-4.20-experimental-beta-0304-reasoning" }], }, tools: { subagents: { model: { primary: "zai/glm-5" } }, @@ -130,7 +130,7 @@ describe("model-pricing-cache", () => { }, }, { - id: "x-ai/grok-4", + id: "x-ai/grok-4.20-experimental-beta-0304-reasoning", pricing: { prompt: "0.000002", completion: "0.00001", @@ -172,12 +172,25 @@ describe("model-pricing-cache", () => { cacheRead: 0.3, cacheWrite: 0, }); - expect(getCachedGatewayModelPricing({ provider: "xai", model: "grok-4" })).toEqual({ + expect( + getCachedGatewayModelPricing({ + provider: "xai", + model: "grok-4.20-experimental-beta-0304-reasoning", + }), + ).toEqual({ input: 2, output: 10, cacheRead: 0, cacheWrite: 0, }); + expect(getCachedGatewayModelPricing({ provider: "xai", model: "grok-4.20-reasoning" })).toEqual( + { + input: 2, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + ); expect(getCachedGatewayModelPricing({ provider: "zai", model: "glm-5" })).toEqual({ input: 1, output: 4, diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index 8a2e250f53f..ef05628d234 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -7,7 +7,7 @@ import { resolveModelRefFromString, type ModelRef, } from "../agents/model-selection.js"; -import { normalizeGoogleModelId } from "../agents/models-config.providers.js"; +import { normalizeGoogleModelId, normalizeXaiModelId } from "../agents/models-config.providers.js"; import type { OpenClawConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -155,6 +155,9 @@ function canonicalizeOpenRouterLookupId(id: string): string { if (provider === "google") { model = normalizeGoogleModelId(model); } + if (provider === "x-ai") { + model = normalizeXaiModelId(model); + } return `${provider}/${model}`; } diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index 4a2374e17cb..01c69be5199 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -40,9 +40,15 @@ vi.mock("./client.js", () => ({ GatewayClient: MockGatewayClient, })); -const { probeGateway } = await import("./probe.js"); +const { clampProbeTimeoutMs, probeGateway } = await import("./probe.js"); describe("probeGateway", () => { + it("clamps probe timeout to timer-safe bounds", () => { + expect(clampProbeTimeoutMs(1)).toBe(250); + expect(clampProbeTimeoutMs(2_000)).toBe(2_000); + expect(clampProbeTimeoutMs(3_000_000_000)).toBe(2_147_483_647); + }); + it("connects with operator.read scope", async () => { const result = await probeGateway({ url: "ws://127.0.0.1:18789", diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index bbd36639b78..b285c395c3d 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -29,6 +29,13 @@ export type GatewayProbeResult = { configSnapshot: unknown; }; +export const MIN_PROBE_TIMEOUT_MS = 250; +export const MAX_TIMER_DELAY_MS = 2_147_483_647; + +export function clampProbeTimeoutMs(timeoutMs: number): number { + return Math.min(MAX_TIMER_DELAY_MS, Math.max(MIN_PROBE_TIMEOUT_MS, timeoutMs)); +} + export async function probeGateway(opts: { url: string; auth?: GatewayProbeAuth; @@ -144,21 +151,18 @@ export async function probeGateway(opts: { }, }); - const timer = setTimeout( - () => { - settle({ - ok: false, - connectLatencyMs, - error: connectError ? `connect failed: ${connectError}` : "timeout", - close, - health: null, - status: null, - presence: null, - configSnapshot: null, - }); - }, - Math.max(250, opts.timeoutMs), - ); + const timer = setTimeout(() => { + settle({ + ok: false, + connectLatencyMs, + error: connectError ? `connect failed: ${connectError}` : "timeout", + close, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + }, clampProbeTimeoutMs(opts.timeoutMs)); client.start(); }); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index ebf81bea62c..dd5a659dbc9 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -7,8 +7,8 @@ import { } from "node:http"; import { createServer as createHttpsServer } from "node:https"; import type { TlsOptions } from "node:tls"; -import { handleSlackHttpRequest } from "openclaw/plugin-sdk/slack"; import type { WebSocketServer } from "ws"; +import { handleSlackHttpRequest } from "../../extensions/slack/api.js"; import { resolveAgentAvatar } from "../agents/identity-avatar.js"; import { CANVAS_WS_PATH, handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index 61fff855a8f..a5ffeae9a21 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -3,9 +3,9 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; -import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js"; +import { createChannelTestPluginBase } from "../test-utils/channel-plugins.js"; import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js"; import { createRegistry } from "./server.e2e-registry-helpers.js"; import { @@ -58,12 +58,31 @@ const createMSTeamsPlugin = (params?: { aliases?: string[] }): ChannelPlugin => }, }); +const createStubChannelPlugin = (params: { + id: ChannelPlugin["id"]; + label: string; +}): ChannelPlugin => ({ + ...createChannelTestPluginBase({ + id: params.id, + label: params.label, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + }), + outbound: { + deliveryMode: "direct", + sendText: async () => ({ channel: params.id, messageId: "msg-test" }), + sendMedia: async () => ({ channel: params.id, messageId: "msg-test" }), + }, +}); + const emptyRegistry = createRegistry([]); const defaultRegistry = createRegistry([ { pluginId: "whatsapp", source: "test", - plugin: whatsappPlugin, + plugin: createStubChannelPlugin({ id: "whatsapp", label: "WhatsApp" }), }, ]); @@ -181,7 +200,7 @@ describe("gateway server agent", () => { expect(vi.mocked(agentCommand)).not.toHaveBeenCalled(); }); - test("agent accepts channel aliases (imsg/teams)", async () => { + test("agent accepts built-in channel alias (imsg)", async () => { const registry = createRegistry([ { pluginId: "msteams", @@ -204,6 +223,19 @@ describe("gateway server agent", () => { }); expect(resIMessage.ok).toBe(true); + expectAgentRoutingCall({ channel: "imessage", deliver: true, fromEnd: 1 }); + }); + + test("agent accepts plugin channel alias (teams)", async () => { + const registry = createRegistry([ + { + pluginId: "msteams", + source: "test", + plugin: createMSTeamsPlugin({ aliases: ["teams"] }), + }, + ]); + setRegistry(registry); + const resTeams = await rpcReq(ws, "agent", { message: "hi", sessionKey: "main", @@ -213,8 +245,6 @@ describe("gateway server agent", () => { idempotencyKey: "idem-agent-teams", }); expect(resTeams.ok).toBe(true); - - expectAgentRoutingCall({ channel: "imessage", deliver: true, fromEnd: 2 }); expectAgentRoutingCall({ channel: "msteams", deliver: false, diff --git a/src/gateway/server.agent.gateway-server-agent.mocks.ts b/src/gateway/server.agent.gateway-server-agent.mocks.ts index f6b29fe041a..a450fcddde2 100644 --- a/src/gateway/server.agent.gateway-server-agent.mocks.ts +++ b/src/gateway/server.agent.gateway-server-agent.mocks.ts @@ -1,6 +1,7 @@ import { vi } from "vitest"; import { createEmptyPluginRegistry, type PluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { setTestPluginRegistry } from "./test-helpers.mocks.js"; export const registryState: { registry: PluginRegistry } = { registry: createEmptyPluginRegistry(), @@ -8,6 +9,7 @@ export const registryState: { registry: PluginRegistry } = { export function setRegistry(registry: PluginRegistry) { registryState.registry = registry; + setTestPluginRegistry(registry); setActivePluginRegistry(registry); } diff --git a/src/image-generation/providers/openai.ts b/src/image-generation/providers/openai.ts index 7bce3854ab3..0913025102a 100644 --- a/src/image-generation/providers/openai.ts +++ b/src/image-generation/providers/openai.ts @@ -58,6 +58,13 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProviderPlu throw new Error("OpenAI API key missing"); } + const controller = new AbortController(); + const timeoutMs = req.timeoutMs; + const timeout = + typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? setTimeout(() => controller.abort(), timeoutMs) + : undefined; + const response = await fetch(`${resolveOpenAIBaseUrl(req.cfg)}/images/generations`, { method: "POST", headers: { @@ -70,6 +77,9 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProviderPlu n: req.count ?? 1, size: req.size ?? DEFAULT_SIZE, }), + signal: controller.signal, + }).finally(() => { + clearTimeout(timeout); }); if (!response.ok) { diff --git a/src/image-generation/types.ts b/src/image-generation/types.ts index 123d5d98e6c..8e1a8fa0136 100644 --- a/src/image-generation/types.ts +++ b/src/image-generation/types.ts @@ -25,6 +25,7 @@ export type ImageGenerationRequest = { cfg: OpenClawConfig; agentDir?: string; authStore?: AuthProfileStore; + timeoutMs?: number; count?: number; size?: string; aspectRatio?: string; diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index 2dfc1c97dbd..4e1f0b003e2 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -1,9 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { discordPlugin } from "../../extensions/discord/src/channel.js"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { isDiscordExecApprovalClientEnabled } from "../../extensions/discord/src/exec-approvals.js"; +import { buildTelegramExecApprovalButtons } from "../../extensions/telegram/src/approval-buttons.js"; +import { isTelegramExecApprovalClientEnabled } from "../../extensions/telegram/src/exec-approvals.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; +import { buildExecApprovalPendingReplyPayload } from "../infra/exec-approval-reply.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 { createExecApprovalForwarder } from "./exec-approval-forwarder.js"; const baseRequest = { @@ -23,15 +26,65 @@ afterEach(() => { }); const emptyRegistry = createTestRegistry([]); +const telegramApprovalPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "execApprovals" +> = { + ...createChannelTestPluginBase({ id: "telegram" }), + execApprovals: { + shouldSuppressForwardingFallback: ({ cfg, target, request }) => { + if (target.channel !== "telegram" || request.request.turnSourceChannel !== "telegram") { + return false; + } + const accountId = target.accountId?.trim() || request.request.turnSourceAccountId?.trim(); + return isTelegramExecApprovalClientEnabled({ cfg, accountId }); + }, + buildPendingPayload: ({ request, nowMs }) => { + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: request.id, + approvalSlug: request.id.slice(0, 8), + approvalCommandId: request.id, + command: request.request.command, + cwd: request.request.cwd ?? undefined, + host: request.request.host === "node" ? "node" : "gateway", + nodeId: request.request.nodeId ?? undefined, + expiresAtMs: request.expiresAtMs, + nowMs, + }); + const buttons = buildTelegramExecApprovalButtons(request.id); + if (!buttons) { + return payload; + } + return { + ...payload, + channelData: { + ...payload.channelData, + telegram: { buttons }, + }, + }; + }, + }, +}; +const discordApprovalPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "execApprovals" +> = { + ...createChannelTestPluginBase({ id: "discord" }), + execApprovals: { + shouldSuppressForwardingFallback: ({ cfg, target }) => + target.channel === "discord" && + isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId }), + }, +}; const defaultRegistry = createTestRegistry([ { pluginId: "telegram", - plugin: telegramPlugin, + plugin: telegramApprovalPlugin, source: "test", }, { pluginId: "discord", - plugin: discordPlugin, + plugin: discordApprovalPlugin, source: "test", }, ]); diff --git a/src/infra/heartbeat-runner.model-override.test.ts b/src/infra/heartbeat-runner.model-override.test.ts index 92c89e0b026..0026297c56e 100644 --- a/src/infra/heartbeat-runner.model-override.test.ts +++ b/src/infra/heartbeat-runner.model-override.test.ts @@ -1,19 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -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 * as replyModule from "../auto-reply/reply.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentMainSessionKey, resolveMainSessionKey } from "../config/sessions.js"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createPluginRuntime } from "../plugins/runtime/index.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { runHeartbeatOnce } from "./heartbeat-runner.js"; import { seedSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js"; // Avoid pulling optional runtime deps during isolated runs. vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); +vi.mock("./outbound/deliver.js", () => ({ + deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined), +})); type SeedSessionInput = { lastChannel: string; @@ -44,17 +40,7 @@ async function withHeartbeatFixture( ); } -beforeEach(() => { - const runtime = createPluginRuntime(); - setTelegramRuntime(runtime); - setWhatsAppRuntime(runtime); - setActivePluginRegistry( - createTestRegistry([ - { pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" }, - { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, - ]), - ); -}); +beforeEach(() => {}); afterEach(() => { vi.restoreAllMocks(); diff --git a/src/infra/host-env-security-policy.json b/src/infra/host-env-security-policy.json index 785b8e37049..2f6cd25bde6 100644 --- a/src/infra/host-env-security-policy.json +++ b/src/infra/host-env-security-policy.json @@ -56,7 +56,23 @@ "OPENSSL_ENGINES", "PYTHONSTARTUP", "WGETRC", - "CURL_HOME" + "CURL_HOME", + "CLASSPATH", + "CGO_CFLAGS", + "CGO_LDFLAGS", + "GOFLAGS", + "CORECLR_PROFILER_PATH", + "PHPRC", + "PHP_INI_SCAN_DIR", + "DENO_DIR", + "BUN_CONFIG_REGISTRY", + "LUA_PATH", + "LUA_CPATH", + "GEM_HOME", + "GEM_PATH", + "BUNDLE_GEMFILE", + "COMPOSER_HOME", + "XDG_CONFIG_HOME" ], "blockedOverridePrefixes": ["GIT_CONFIG_", "NPM_CONFIG_"], "blockedPrefixes": ["DYLD_", "LD_", "BASH_FUNC_"] diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index cd3edb3e06b..f326a0c75ed 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -8,6 +8,7 @@ import { isDangerousHostEnvVarName, normalizeEnvVarKey, sanitizeHostExecEnv, + sanitizeHostExecEnvWithDiagnostics, sanitizeSystemRunEnvOverrides, } from "./host-env-security.js"; import { OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js"; @@ -114,6 +115,10 @@ describe("sanitizeHostExecEnv", () => { GIT_CONFIG_GLOBAL: "/tmp/gitconfig", SHELLOPTS: "xtrace", PS4: "$(touch /tmp/pwned)", + CLASSPATH: "/tmp/evil-classpath", + GOFLAGS: "-mod=mod", + PHPRC: "/tmp/evil-php.ini", + XDG_CONFIG_HOME: "/tmp/evil-config", SAFE: "ok", }, }); @@ -128,6 +133,10 @@ describe("sanitizeHostExecEnv", () => { expect(env.GIT_CONFIG_GLOBAL).toBeUndefined(); expect(env.SHELLOPTS).toBeUndefined(); expect(env.PS4).toBeUndefined(); + expect(env.CLASSPATH).toBeUndefined(); + expect(env.GOFLAGS).toBeUndefined(); + expect(env.PHPRC).toBeUndefined(); + expect(env.XDG_CONFIG_HOME).toBeUndefined(); expect(env.SAFE).toBe("ok"); expect(env.HOME).toBe("/tmp/trusted-home"); expect(env.ZDOTDIR).toBe("/tmp/trusted-zdotdir"); @@ -183,7 +192,7 @@ describe("sanitizeHostExecEnv", () => { expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); }); - it("drops non-string inherited values and non-portable inherited keys", () => { + it("drops non-string inherited values while preserving non-portable inherited keys", () => { const env = sanitizeHostExecEnv({ baseEnv: { PATH: "/usr/bin:/bin", @@ -191,6 +200,7 @@ describe("sanitizeHostExecEnv", () => { // oxlint-disable-next-line typescript/no-explicit-any BAD_NUMBER: 1 as any, "NOT-PORTABLE": "x", + "ProgramFiles(x86)": "C:\\Program Files (x86)", }, }); @@ -198,6 +208,8 @@ describe("sanitizeHostExecEnv", () => { OPENCLAW_CLI: OPENCLAW_CLI_ENV_VALUE, PATH: "/usr/bin:/bin", GOOD: "1", + "NOT-PORTABLE": "x", + "ProgramFiles(x86)": "C:\\Program Files (x86)", }); }); }); @@ -212,11 +224,58 @@ describe("isDangerousHostEnvOverrideVarName", () => { expect(isDangerousHostEnvOverrideVarName("git_config_global")).toBe(true); expect(isDangerousHostEnvOverrideVarName("GRADLE_USER_HOME")).toBe(true); expect(isDangerousHostEnvOverrideVarName("gradle_user_home")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("CLASSPATH")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("classpath")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("GOFLAGS")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("goflags")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("CORECLR_PROFILER_PATH")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("coreclr_profiler_path")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("XDG_CONFIG_HOME")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("xdg_config_home")).toBe(true); expect(isDangerousHostEnvOverrideVarName("BASH_ENV")).toBe(false); expect(isDangerousHostEnvOverrideVarName("FOO")).toBe(false); }); }); +describe("sanitizeHostExecEnvWithDiagnostics", () => { + it("reports blocked and invalid requested overrides", () => { + const result = sanitizeHostExecEnvWithDiagnostics({ + baseEnv: { + PATH: "/usr/bin:/bin", + }, + overrides: { + PATH: "/tmp/evil", + CLASSPATH: "/tmp/evil-classpath", + SAFE_KEY: "ok", + "BAD-KEY": "bad", + }, + }); + + expect(result.rejectedOverrideBlockedKeys).toEqual(["CLASSPATH", "PATH"]); + expect(result.rejectedOverrideInvalidKeys).toEqual(["BAD-KEY"]); + expect(result.env.SAFE_KEY).toBe("ok"); + expect(result.env.PATH).toBe("/usr/bin:/bin"); + expect(result.env.CLASSPATH).toBeUndefined(); + }); + + it("allows Windows-style override names while still rejecting invalid keys", () => { + const result = sanitizeHostExecEnvWithDiagnostics({ + baseEnv: { + PATH: "/usr/bin:/bin", + "ProgramFiles(x86)": "C:\\Program Files (x86)", + }, + overrides: { + "ProgramFiles(x86)": "D:\\SDKs", + "BAD-KEY": "bad", + }, + }); + + expect(result.rejectedOverrideBlockedKeys).toEqual([]); + expect(result.rejectedOverrideInvalidKeys).toEqual(["BAD-KEY"]); + expect(result.env["ProgramFiles(x86)"]).toBe("D:\\SDKs"); + }); +}); + describe("normalizeEnvVarKey", () => { it("normalizes and validates keys", () => { expect(normalizeEnvVarKey(" OPENROUTER_API_KEY ")).toBe("OPENROUTER_API_KEY"); diff --git a/src/infra/host-env-security.ts b/src/infra/host-env-security.ts index 11d6b8e9f3c..c6ac3dded61 100644 --- a/src/infra/host-env-security.ts +++ b/src/infra/host-env-security.ts @@ -2,6 +2,7 @@ import HOST_ENV_SECURITY_POLICY_JSON from "./host-env-security-policy.json" with import { markOpenClawExecEnv } from "./openclaw-exec-env.js"; const PORTABLE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; +const WINDOWS_COMPAT_OVERRIDE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_()]*$/; type HostEnvSecurityPolicy = { blockedKeys: string[]; @@ -42,6 +43,17 @@ export const HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS = new Set( HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEY_VALUES, ); +export type HostExecEnvSanitizationResult = { + env: Record; + rejectedOverrideBlockedKeys: string[]; + rejectedOverrideInvalidKeys: string[]; +}; + +export type HostExecEnvOverrideDiagnostics = { + rejectedOverrideBlockedKeys: string[]; + rejectedOverrideInvalidKeys: string[]; +}; + export function normalizeEnvVarKey( rawKey: string, options?: { portable?: boolean }, @@ -56,6 +68,17 @@ export function normalizeEnvVarKey( return key; } +function normalizeHostOverrideEnvVarKey(rawKey: string): string | null { + const key = normalizeEnvVarKey(rawKey); + if (!key) { + return null; + } + if (PORTABLE_ENV_VAR_KEY.test(key) || WINDOWS_COMPAT_OVERRIDE_ENV_VAR_KEY.test(key)) { + return key; + } + return null; +} + export function isDangerousHostEnvVarName(rawKey: string): boolean { const key = normalizeEnvVarKey(rawKey); if (!key) { @@ -80,15 +103,16 @@ export function isDangerousHostEnvOverrideVarName(rawKey: string): boolean { return HOST_DANGEROUS_OVERRIDE_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix)); } -function listNormalizedPortableEnvEntries( +function listNormalizedEnvEntries( source: Record, + options?: { portable?: boolean }, ): Array<[string, string]> { const entries: Array<[string, string]> = []; for (const [rawKey, value] of Object.entries(source)) { if (typeof value !== "string") { continue; } - const key = normalizeEnvVarKey(rawKey, { portable: true }); + const key = normalizeEnvVarKey(rawKey, options); if (!key) { continue; } @@ -97,41 +121,112 @@ function listNormalizedPortableEnvEntries( return entries; } -export function sanitizeHostExecEnv(params?: { +function sortUnique(values: Iterable): string[] { + return Array.from(new Set(values)).toSorted((a, b) => a.localeCompare(b)); +} + +function sanitizeHostEnvOverridesWithDiagnostics(params?: { + overrides?: Record | null; + blockPathOverrides?: boolean; +}): { + acceptedOverrides?: Record; + rejectedOverrideBlockedKeys: string[]; + rejectedOverrideInvalidKeys: string[]; +} { + const overrides = params?.overrides ?? undefined; + if (!overrides) { + return { + acceptedOverrides: undefined, + rejectedOverrideBlockedKeys: [], + rejectedOverrideInvalidKeys: [], + }; + } + + const blockPathOverrides = params?.blockPathOverrides ?? true; + const acceptedOverrides: Record = {}; + const rejectedBlocked: string[] = []; + const rejectedInvalid: string[] = []; + + for (const [rawKey, value] of Object.entries(overrides)) { + if (typeof value !== "string") { + continue; + } + const normalized = normalizeHostOverrideEnvVarKey(rawKey); + if (!normalized) { + const candidate = rawKey.trim(); + rejectedInvalid.push(candidate || rawKey); + continue; + } + const upper = normalized.toUpperCase(); + // PATH is part of the security boundary (command resolution + safe-bin checks). Never allow + // request-scoped PATH overrides from agents/gateways. + if (blockPathOverrides && upper === "PATH") { + rejectedBlocked.push(upper); + continue; + } + if (isDangerousHostEnvVarName(upper) || isDangerousHostEnvOverrideVarName(upper)) { + rejectedBlocked.push(upper); + continue; + } + acceptedOverrides[normalized] = value; + } + + return { + acceptedOverrides, + rejectedOverrideBlockedKeys: sortUnique(rejectedBlocked), + rejectedOverrideInvalidKeys: sortUnique(rejectedInvalid), + }; +} + +export function sanitizeHostExecEnvWithDiagnostics(params?: { baseEnv?: Record; overrides?: Record | null; blockPathOverrides?: boolean; -}): Record { +}): HostExecEnvSanitizationResult { const baseEnv = params?.baseEnv ?? process.env; - const overrides = params?.overrides ?? undefined; - const blockPathOverrides = params?.blockPathOverrides ?? true; const merged: Record = {}; - for (const [key, value] of listNormalizedPortableEnvEntries(baseEnv)) { + for (const [key, value] of listNormalizedEnvEntries(baseEnv)) { if (isDangerousHostEnvVarName(key)) { continue; } merged[key] = value; } - if (!overrides) { - return markOpenClawExecEnv(merged); + const overrideResult = sanitizeHostEnvOverridesWithDiagnostics({ + overrides: params?.overrides ?? undefined, + blockPathOverrides: params?.blockPathOverrides ?? true, + }); + if (overrideResult.acceptedOverrides) { + for (const [key, value] of Object.entries(overrideResult.acceptedOverrides)) { + merged[key] = value; + } } - for (const [key, value] of listNormalizedPortableEnvEntries(overrides)) { - const upper = key.toUpperCase(); - // PATH is part of the security boundary (command resolution + safe-bin checks). Never allow - // request-scoped PATH overrides from agents/gateways. - if (blockPathOverrides && upper === "PATH") { - continue; - } - if (isDangerousHostEnvVarName(upper) || isDangerousHostEnvOverrideVarName(upper)) { - continue; - } - merged[key] = value; - } + return { + env: markOpenClawExecEnv(merged), + rejectedOverrideBlockedKeys: overrideResult.rejectedOverrideBlockedKeys, + rejectedOverrideInvalidKeys: overrideResult.rejectedOverrideInvalidKeys, + }; +} - return markOpenClawExecEnv(merged); +export function inspectHostExecEnvOverrides(params?: { + overrides?: Record | null; + blockPathOverrides?: boolean; +}): HostExecEnvOverrideDiagnostics { + const result = sanitizeHostEnvOverridesWithDiagnostics(params); + return { + rejectedOverrideBlockedKeys: result.rejectedOverrideBlockedKeys, + rejectedOverrideInvalidKeys: result.rejectedOverrideInvalidKeys, + }; +} + +export function sanitizeHostExecEnv(params?: { + baseEnv?: Record; + overrides?: Record | null; + blockPathOverrides?: boolean; +}): Record { + return sanitizeHostExecEnvWithDiagnostics(params).env; } export function sanitizeSystemRunEnvOverrides(params?: { @@ -146,7 +241,7 @@ export function sanitizeSystemRunEnvOverrides(params?: { return overrides; } const filtered: Record = {}; - for (const [key, value] of listNormalizedPortableEnvEntries(overrides)) { + for (const [key, value] of listNormalizedEnvEntries(overrides, { portable: true })) { if (!HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS.has(key.toUpperCase())) { continue; } diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index f90df5271f1..dc57971af4b 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -278,6 +278,40 @@ describe("fetchWithSsrFGuard hardening", () => { }); }); + it("blocks URLs that use credentials to obscure a private host", async () => { + const fetchImpl = vi.fn(); + // http://attacker.com@127.0.0.1:8080/ β€” URL parser extracts hostname as 127.0.0.1 + await expect( + fetchWithSsrFGuard({ + url: "http://attacker.com@127.0.0.1:8080/internal", + fetchImpl, + }), + ).rejects.toThrow(/private|internal|blocked/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("blocks private IPv6 addresses embedded in URLs with credentials", async () => { + const fetchImpl = vi.fn(); + await expect( + fetchWithSsrFGuard({ + url: "http://user:pass@[::1]:8080/internal", + fetchImpl, + }), + ).rejects.toThrow(/private|internal|blocked/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("blocks redirect to a URL using credentials to obscure a private host", async () => { + const lookupFn = createPublicLookup(); + const fetchImpl = await expectRedirectFailure({ + url: "https://public.example/start", + responses: [redirectResponse("http://public@127.0.0.1:6379/")], + expectedError: /private|internal|blocked/i, + lookupFn, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + it("ignores env proxy by default to preserve DNS-pinned destination binding", async () => { await runProxyModeDispatcherTest({ mode: GUARDED_FETCH_MODE.STRICT, diff --git a/src/infra/outbound/channel-adapters.test.ts b/src/infra/outbound/channel-adapters.test.ts index ca39b403226..7656c879b3b 100644 --- a/src/infra/outbound/channel-adapters.test.ts +++ b/src/infra/outbound/channel-adapters.test.ts @@ -1,15 +1,42 @@ -import { Separator, TextDisplay } from "@buape/carbon"; +import { Container, Separator, TextDisplay } from "@buape/carbon"; import { beforeEach, describe, expect, it } from "vitest"; -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; -import { DiscordUiContainer } from "../../../extensions/discord/src/ui.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.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 { getChannelMessageAdapter } from "./channel-adapters.js"; +class TestDiscordUiContainer extends Container {} + +const discordCrossContextPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "messaging" +> = { + ...createChannelTestPluginBase({ id: "discord" }), + messaging: { + buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => { + const trimmed = message.trim(); + const components: Array = []; + if (trimmed) { + components.push(new TextDisplay(message)); + components.push(new Separator({ divider: true, spacing: "small" })); + } + components.push(new TextDisplay(`*From ${originLabel}*`)); + void cfg; + void accountId; + return [new TestDiscordUiContainer(components)]; + }, + }, +}; + describe("getChannelMessageAdapter", () => { beforeEach(() => { setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), + createTestRegistry([ + { pluginId: "discord", plugin: discordCrossContextPlugin, source: "test" }, + ]), ); }); @@ -31,10 +58,10 @@ describe("getChannelMessageAdapter", () => { cfg: {} as never, accountId: "primary", }); - const container = components?.[0] as DiscordUiContainer | undefined; + const container = components?.[0] as TestDiscordUiContainer | undefined; expect(components).toHaveLength(1); - expect(container).toBeInstanceOf(DiscordUiContainer); + expect(container).toBeInstanceOf(TestDiscordUiContainer); expect(container?.components).toEqual([ expect.any(TextDisplay), expect.any(Separator), @@ -49,7 +76,7 @@ describe("getChannelMessageAdapter", () => { message: " ", cfg: {} as never, }); - const container = components?.[0] as DiscordUiContainer | undefined; + const container = components?.[0] as TestDiscordUiContainer | undefined; expect(components).toHaveLength(1); expect(container?.components).toEqual([expect.any(TextDisplay)]); diff --git a/src/infra/outbound/message-action-params.test.ts b/src/infra/outbound/message-action-params.test.ts index 3442711eab4..309a237af52 100644 --- a/src/infra/outbound/message-action-params.test.ts +++ b/src/infra/outbound/message-action-params.test.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; +import { parseSlackTarget } from "../../../extensions/slack/src/targets.js"; +import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js"; import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -27,28 +27,67 @@ function createToolContext( }; } +function resolveSlackAutoThreadId(params: { + to: string; + toolContext?: { + currentChannelId?: string; + currentThreadTs?: string; + replyToMode?: "off" | "first" | "all"; + hasRepliedRef?: { value: boolean }; + }; +}): string | undefined { + const context = params.toolContext; + if (!context?.currentThreadTs || !context.currentChannelId) { + return undefined; + } + if (context.replyToMode !== "all" && context.replyToMode !== "first") { + return undefined; + } + const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" }); + if (!parsedTarget || parsedTarget.kind !== "channel") { + return undefined; + } + if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) { + return undefined; + } + if (context.replyToMode === "first" && context.hasRepliedRef?.value) { + return undefined; + } + return context.currentThreadTs; +} + +function resolveTelegramAutoThreadId(params: { + to: string; + toolContext?: { currentThreadTs?: string; currentChannelId?: string }; +}): string | undefined { + const context = params.toolContext; + if (!context?.currentThreadTs || !context.currentChannelId) { + return undefined; + } + const parsedTo = parseTelegramTarget(params.to); + const parsedChannel = parseTelegramTarget(context.currentChannelId); + if (parsedTo.chatId.toLowerCase() !== parsedChannel.chatId.toLowerCase()) { + return undefined; + } + return context.currentThreadTs; +} + describe("message action threading helpers", () => { it("resolves Slack auto-thread ids only for matching active channels", () => { expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "#c123", toolContext: createToolContext(), }), ).toBe("thread-1"); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "channel:C999", toolContext: createToolContext(), }), ).toBeUndefined(); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "user:U123", toolContext: createToolContext(), }), @@ -57,9 +96,7 @@ describe("message action threading helpers", () => { it("skips Slack auto-thread ids when reply mode or context blocks them", () => { expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "C123", toolContext: createToolContext({ replyToMode: "first", @@ -68,17 +105,13 @@ describe("message action threading helpers", () => { }), ).toBeUndefined(); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "C123", toolContext: createToolContext({ replyToMode: "off" }), }), ).toBeUndefined(); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "C123", toolContext: createToolContext({ currentThreadTs: undefined }), }), @@ -87,9 +120,7 @@ describe("message action threading helpers", () => { it("resolves Telegram auto-thread ids for matching chats across target formats", () => { expect( - telegramPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveTelegramAutoThreadId({ to: "telegram:group:-100123:topic:77", toolContext: createToolContext({ currentChannelId: "tg:group:-100123", @@ -97,9 +128,7 @@ describe("message action threading helpers", () => { }), ).toBe("thread-1"); expect( - telegramPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveTelegramAutoThreadId({ to: "-100999:77", toolContext: createToolContext({ currentChannelId: "-100123", @@ -107,9 +136,7 @@ describe("message action threading helpers", () => { }), ).toBeUndefined(); expect( - telegramPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveTelegramAutoThreadId({ to: "-100123", toolContext: createToolContext({ currentChannelId: undefined }), }), diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index 89ab0cd6c2c..9665e44f558 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -6,7 +6,10 @@ 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 { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; vi.mock("../../media/web-media.js", async () => { @@ -78,28 +81,45 @@ async function expectSandboxMediaRewrite(params: { type MessageActionRunnerModule = typeof import("./message-action-runner.js"); type WebMediaModule = typeof import("../../media/web-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: WebMediaModule["loadWebMedia"]; -let slackPlugin: SlackChannelModule["slackPlugin"]; -let createPluginRuntime: RuntimeIndexModule["createPluginRuntime"]; -let setSlackRuntime: SlackRuntimeModule["setSlackRuntime"]; -function installSlackRuntime() { - const runtime = createPluginRuntime(); - setSlackRuntime(runtime); -} +const slackPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "slack", + label: "Slack", + config: { + listAccountIds: () => ["default"], + resolveAccount: (cfg) => cfg.channels?.slack ?? {}, + isConfigured: async (account) => + typeof (account as { botToken?: unknown }).botToken === "string" && + (account as { botToken?: string }).botToken!.trim() !== "" && + typeof (account as { appToken?: unknown }).appToken === "string" && + (account as { appToken?: string }).appToken!.trim() !== "", + }, + }), + outbound: { + deliveryMode: "direct", + resolveTarget: ({ to }) => { + const trimmed = to?.trim() ?? ""; + if (!trimmed) { + return { + ok: false, + error: new Error("missing target for slack"), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async () => ({ channel: "slack", messageId: "msg-test" }), + sendMedia: async () => ({ channel: "slack", messageId: "msg-test" }), + }, +}; describe("runMessageAction media behavior", () => { beforeAll(async () => { ({ runMessageAction } = await import("./message-action-runner.js")); ({ loadWebMedia } = await import("../../media/web-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")); }); beforeEach(() => { @@ -304,7 +324,6 @@ describe("runMessageAction media behavior", () => { describe("sandboxed media validation", () => { beforeEach(() => { - installSlackRuntime(); setActivePluginRegistry( createTestRegistry([ { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 318699c1042..d8d40cbe28c 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -318,14 +318,16 @@ async function handleBroadcastAction( throw new Error("Broadcast requires at least one target in --targets."); } const channelHint = readStringParam(params, "channel"); - const configured = await listConfiguredMessageChannels(input.cfg); - if (configured.length === 0) { - throw new Error("Broadcast requires at least one configured channel."); - } const targetChannels = channelHint && channelHint.trim().toLowerCase() !== "all" ? [await resolveChannel(input.cfg, { channel: channelHint }, input.toolContext)] - : configured; + : await (async () => { + const configured = await listConfiguredMessageChannels(input.cfg); + if (configured.length === 0) { + throw new Error("Broadcast requires at least one configured channel."); + } + return configured; + })(); const results: Array<{ channel: ChannelId; to: string; diff --git a/src/infra/outbound/outbound-policy.test.ts b/src/infra/outbound/outbound-policy.test.ts index 43e71afb923..72abac24d58 100644 --- a/src/infra/outbound/outbound-policy.test.ts +++ b/src/infra/outbound/outbound-policy.test.ts @@ -1,8 +1,12 @@ +import { Container, Separator, TextDisplay } from "@buape/carbon"; import { beforeEach, describe, expect, it } from "vitest"; -import { discordPlugin } from "../../../extensions/discord/src/channel.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 { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; import { applyCrossContextDecoration, buildCrossContextDecoration, @@ -10,6 +14,29 @@ import { shouldApplyCrossContextMarker, } from "./outbound-policy.js"; +class TestDiscordUiContainer extends Container {} + +const discordCrossContextPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "messaging" +> = { + ...createChannelTestPluginBase({ id: "discord" }), + messaging: { + buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => { + const trimmed = message.trim(); + const components: Array = []; + if (trimmed) { + components.push(new TextDisplay(message)); + components.push(new Separator({ divider: true, spacing: "small" })); + } + components.push(new TextDisplay(`*From ${originLabel}*`)); + void cfg; + void accountId; + return [new TestDiscordUiContainer(components)]; + }, + }, +}; + const slackConfig = { channels: { slack: { @@ -28,7 +55,9 @@ const discordConfig = { describe("outbound policy helpers", () => { beforeEach(() => { setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), + createTestRegistry([ + { pluginId: "discord", plugin: discordCrossContextPlugin, source: "test" }, + ]), ); }); diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index f90fc7f221e..006a160e6ab 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -44,9 +43,7 @@ import { import { runResolveOutboundTargetCoreTests } from "./targets.shared-test.js"; beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), - ); + setActivePluginRegistry(createTestRegistry([])); }); describe("delivery-queue", () => { diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 8c8dd821df6..a5b5bc9111f 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { listTelegramAccountIds } from "openclaw/plugin-sdk/telegram"; +import { listTelegramAccountIds } from "../../extensions/telegram/api.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { diff --git a/src/line/webhook.test.ts b/src/line/webhook.test.ts index 9b3b9c0539a..5c38c58f3ce 100644 --- a/src/line/webhook.test.ts +++ b/src/line/webhook.test.ts @@ -138,6 +138,92 @@ describe("createLineWebhookMiddleware", () => { expect(onEvents).not.toHaveBeenCalled(); }); + it("uses the signed raw body instead of a pre-parsed req.body object", async () => { + const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); + const rawBody = JSON.stringify({ + events: [{ type: "message", source: { userId: "signed-user" } }], + }); + const reqBody = { + events: [{ type: "message", source: { userId: "tampered-user" } }], + }; + const middleware = createLineWebhookMiddleware({ + channelSecret: SECRET, + onEvents, + }); + + const req = { + headers: { "x-line-signature": sign(rawBody, SECRET) }, + rawBody, + body: reqBody, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const res = createRes(); + + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(200); + expect(onEvents).toHaveBeenCalledTimes(1); + const processedBody = onEvents.mock.calls[0]?.[0] as WebhookRequestBody | undefined; + expect(processedBody?.events?.[0]?.source?.userId).toBe("signed-user"); + expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user"); + }); + + it("uses signed raw buffer body instead of a pre-parsed req.body object", async () => { + const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); + const rawBodyText = JSON.stringify({ + events: [{ type: "message", source: { userId: "signed-buffer-user" } }], + }); + const reqBody = { + events: [{ type: "message", source: { userId: "tampered-user" } }], + }; + const middleware = createLineWebhookMiddleware({ + channelSecret: SECRET, + onEvents, + }); + + const req = { + headers: { "x-line-signature": sign(rawBodyText, SECRET) }, + rawBody: Buffer.from(rawBodyText, "utf-8"), + body: reqBody, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const res = createRes(); + + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(200); + expect(onEvents).toHaveBeenCalledTimes(1); + const processedBody = onEvents.mock.calls[0]?.[0] as WebhookRequestBody | undefined; + expect(processedBody?.events?.[0]?.source?.userId).toBe("signed-buffer-user"); + expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user"); + }); + + it("rejects invalid signed raw JSON even when req.body is a valid object", async () => { + const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); + const rawBody = "not-json"; + const middleware = createLineWebhookMiddleware({ + channelSecret: SECRET, + onEvents, + }); + + const req = { + headers: { "x-line-signature": sign(rawBody, SECRET) }, + rawBody, + body: { events: [{ type: "message" }] }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const res = createRes(); + + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Invalid webhook payload" }); + expect(onEvents).not.toHaveBeenCalled(); + }); + it("returns 500 when event processing fails and does not acknowledge with 200", async () => { const onEvents = vi.fn(async () => { throw new Error("boom"); diff --git a/src/line/webhook.ts b/src/line/webhook.ts index 99c338db2f9..879972d0490 100644 --- a/src/line/webhook.ts +++ b/src/line/webhook.ts @@ -23,10 +23,7 @@ function readRawBody(req: Request): string | null { return Buffer.isBuffer(rawBody) ? rawBody.toString("utf-8") : rawBody; } -function parseWebhookBody(req: Request, rawBody?: string | null): WebhookRequestBody | null { - if (req.body && typeof req.body === "object" && !Buffer.isBuffer(req.body)) { - return req.body as WebhookRequestBody; - } +function parseWebhookBody(rawBody?: string | null): WebhookRequestBody | null { if (!rawBody) { return null; } @@ -64,7 +61,8 @@ export function createLineWebhookMiddleware( return; } - const body = parseWebhookBody(req, rawBody); + // Keep processing tied to the exact bytes that passed signature verification. + const body = parseWebhookBody(rawBody); if (!body) { res.status(400).json({ error: "Invalid webhook payload" }); diff --git a/src/media-understanding/providers/image.test.ts b/src/media-understanding/providers/image.test.ts index 9044d8ba83d..7427cc84d34 100644 --- a/src/media-understanding/providers/image.test.ts +++ b/src/media-understanding/providers/image.test.ts @@ -1,58 +1,72 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const completeMock = vi.fn(); -const minimaxUnderstandImageMock = vi.fn(); -const ensureOpenClawModelsJsonMock = vi.fn(async () => {}); -const getApiKeyForModelMock = vi.fn(async () => ({ - apiKey: "oauth-test", // pragma: allowlist secret - source: "test", - mode: "oauth", +const hoisted = vi.hoisted(() => ({ + completeMock: vi.fn(), + minimaxUnderstandImageMock: vi.fn(), + ensureOpenClawModelsJsonMock: vi.fn(async () => {}), + getApiKeyForModelMock: vi.fn(async () => ({ + apiKey: "oauth-test", // pragma: allowlist secret + source: "test", + mode: "oauth", + })), + resolveApiKeyForProviderMock: vi.fn(async () => ({ + apiKey: "oauth-test", // pragma: allowlist secret + source: "test", + mode: "oauth", + })), + requireApiKeyMock: vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""), + setRuntimeApiKeyMock: vi.fn(), + discoverModelsMock: vi.fn(), })); -const resolveApiKeyForProviderMock = vi.fn(async () => ({ - apiKey: "oauth-test", // pragma: allowlist secret - source: "test", - mode: "oauth", -})); -const requireApiKeyMock = vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""); -const setRuntimeApiKeyMock = vi.fn(); -const discoverModelsMock = vi.fn(); -type ImageModule = typeof import("./image.js"); +const { + completeMock, + minimaxUnderstandImageMock, + ensureOpenClawModelsJsonMock, + getApiKeyForModelMock, + resolveApiKeyForProviderMock, + requireApiKeyMock, + setRuntimeApiKeyMock, + discoverModelsMock, +} = hoisted; -let describeImageWithModel: ImageModule["describeImageWithModel"]; +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, +})); + +const { describeImageWithModel } = await import("./image.js"); describe("describeImageWithModel", () => { - beforeEach(async () => { - vi.resetModules(); + beforeEach(() => { 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(() => ({ diff --git a/src/media-understanding/providers/image.ts b/src/media-understanding/providers/image.ts index 9d7dc67949b..3702f0f20f0 100644 --- a/src/media-understanding/providers/image.ts +++ b/src/media-understanding/providers/image.ts @@ -188,9 +188,19 @@ export async function describeImagesWithModel( } const context = buildImageContext(prompt, params.images); + const controller = new AbortController(); + const timeout = + typeof params.timeoutMs === "number" && + Number.isFinite(params.timeoutMs) && + params.timeoutMs > 0 + ? setTimeout(() => controller.abort(), params.timeoutMs) + : undefined; const message = await complete(model, context, { apiKey, maxTokens: resolveImageToolMaxTokens(model.maxTokens, params.maxTokens ?? 512), + signal: controller.signal, + }).finally(() => { + clearTimeout(timeout); }); const text = coerceImageAssistantText({ message, diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 045897a5fc4..02457b98b4d 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -336,6 +336,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { preferMacAppExecHost: boolean; runViaResponse?: ExecHostResponse | null; command?: string[]; + env?: Record; rawCommand?: string | null; systemRunPlan?: SystemRunApprovalPlan | null; cwd?: string; @@ -391,6 +392,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { client: {} as never, params: { command: params.command ?? ["echo", "ok"], + env: params.env, rawCommand: params.rawCommand, systemRunPlan: params.systemRunPlan, cwd: params.cwd, @@ -1106,6 +1108,65 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult }); }); + it("rejects blocked environment overrides before execution", async () => { + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + security: "full", + ask: "off", + env: { CLASSPATH: "/tmp/evil-classpath" }, + }); + + expect(runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: environment override rejected", + }); + expectInvokeErrorMessage(sendInvokeResult, { + message: "CLASSPATH", + }); + }); + + it("rejects blocked environment overrides for shell-wrapper commands", async () => { + const shellCommand = + process.platform === "win32" + ? ["cmd.exe", "/d", "/s", "/c", "echo ok"] + : ["/bin/sh", "-lc", "echo ok"]; + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + security: "full", + ask: "off", + command: shellCommand, + env: { + CLASSPATH: "/tmp/evil-classpath", + LANG: "C", + }, + }); + + expect(runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: environment override rejected", + }); + expectInvokeErrorMessage(sendInvokeResult, { + message: "CLASSPATH", + }); + }); + + it("rejects invalid non-portable environment override keys before execution", async () => { + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + security: "full", + ask: "off", + env: { "BAD-KEY": "x" }, + }); + + expect(runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: environment override rejected", + }); + expectInvokeErrorMessage(sendInvokeResult, { + message: "BAD-KEY", + }); + }); + async function expectNestedEnvShellDenied(params: { depth: number; markerName: string; diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index c38094dc683..b530b980840 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -14,7 +14,10 @@ import { } from "../infra/exec-approvals.js"; import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js"; import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; -import { sanitizeSystemRunEnvOverrides } from "../infra/host-env-security.js"; +import { + inspectHostExecEnvOverrides, + sanitizeSystemRunEnvOverrides, +} from "../infra/host-env-security.js"; import { normalizeSystemRunApprovalPlan } from "../infra/system-run-approval-binding.js"; import { resolveSystemRunCommandRequest } from "../infra/system-run-command.js"; import { logWarn } from "../logger.js"; @@ -244,6 +247,34 @@ async function parseSystemRunPhase( const sessionKey = opts.params.sessionKey?.trim() || "node"; const runId = opts.params.runId?.trim() || crypto.randomUUID(); const suppressNotifyOnExit = opts.params.suppressNotifyOnExit === true; + const envOverrideDiagnostics = inspectHostExecEnvOverrides({ + overrides: opts.params.env ?? undefined, + blockPathOverrides: true, + }); + if ( + envOverrideDiagnostics.rejectedOverrideBlockedKeys.length > 0 || + envOverrideDiagnostics.rejectedOverrideInvalidKeys.length > 0 + ) { + const details: string[] = []; + if (envOverrideDiagnostics.rejectedOverrideBlockedKeys.length > 0) { + details.push( + `blocked override keys: ${envOverrideDiagnostics.rejectedOverrideBlockedKeys.join(", ")}`, + ); + } + if (envOverrideDiagnostics.rejectedOverrideInvalidKeys.length > 0) { + details.push( + `invalid non-portable override keys: ${envOverrideDiagnostics.rejectedOverrideInvalidKeys.join(", ")}`, + ); + } + await opts.sendInvokeResult({ + ok: false, + error: { + code: "INVALID_REQUEST", + message: `SYSTEM_RUN_DENIED: environment override rejected (${details.join("; ")})`, + }, + }); + return null; + } const envOverrides = sanitizeSystemRunEnvOverrides({ overrides: opts.params.env ?? undefined, shellWrapper: shellPayload !== null, diff --git a/src/node-host/invoke.sanitize-env.test.ts b/src/node-host/invoke.sanitize-env.test.ts index aa55a24047e..de299a2cc6a 100644 --- a/src/node-host/invoke.sanitize-env.test.ts +++ b/src/node-host/invoke.sanitize-env.test.ts @@ -3,6 +3,19 @@ import { withEnv } from "../test-utils/env.js"; import { decodeCapturedOutputBuffer, parseWindowsCodePage, sanitizeEnv } from "./invoke.js"; import { buildNodeInvokeResultParams } from "./runner.js"; +function getEnvValueCaseInsensitive( + env: Record, + expectedKey: string, +): string | undefined { + const direct = env[expectedKey]; + if (direct !== undefined) { + return direct; + } + const upper = expectedKey.toUpperCase(); + const actualKey = Object.keys(env).find((key) => key.toUpperCase() === upper); + return actualKey ? env[actualKey] : undefined; +} + describe("node-host sanitizeEnv", () => { it("ignores PATH overrides", () => { withEnv({ PATH: "/usr/bin" }, () => { @@ -51,6 +64,13 @@ describe("node-host sanitizeEnv", () => { expect(env.BASH_ENV).toBeUndefined(); }); }); + + it("preserves inherited non-portable Windows-style env keys", () => { + withEnv({ "ProgramFiles(x86)": "C:\\Program Files (x86)" }, () => { + const env = sanitizeEnv(undefined); + expect(getEnvValueCaseInsensitive(env, "ProgramFiles(x86)")).toBe("C:\\Program Files (x86)"); + }); + }); }); describe("node-host output decoding", () => { diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index b1d80a5e50d..6622f6c010f 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -45,8 +45,6 @@ describe("pairing setup code", () => { authLabel: string; url?: string; urlSource?: string; - token?: string; - password?: string; }, ) { expect(resolved.ok).toBe(true); @@ -55,8 +53,6 @@ describe("pairing setup code", () => { } expect(resolved.authLabel).toBe(params.authLabel); expect(resolved.payload.bootstrapToken).toBe("bootstrap-123"); - expect(resolved.payload.token).toBe(params.token); - expect(resolved.payload.password).toBe(params.password); if (params.url) { expect(resolved.payload.url).toBe(params.url); } @@ -117,7 +113,6 @@ describe("pairing setup code", () => { payload: { url: "ws://gateway.local:19001", bootstrapToken: "bootstrap-123", - token: "tok_123", }, authLabel: "token", urlSource: "gateway.bind=custom", @@ -144,7 +139,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "password", password: "resolved-password" }); + expectResolvedSetupOk(resolved, { authLabel: "password" }); }); it("uses OPENCLAW_GATEWAY_PASSWORD without resolving configured password SecretRef", async () => { @@ -167,7 +162,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "password", password: "password-from-env" }); + expectResolvedSetupOk(resolved, { authLabel: "password" }); }); it("does not resolve gateway.auth.password SecretRef in token mode", async () => { @@ -189,7 +184,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "token", token: "tok_123" }); + expectResolvedSetupOk(resolved, { authLabel: "token" }); }); it("resolves gateway.auth.token SecretRef for pairing payload", async () => { @@ -212,7 +207,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "token", token: "resolved-token" }); + expectResolvedSetupOk(resolved, { authLabel: "token" }); }); it("errors when gateway.auth.token SecretRef is unresolved in token mode", async () => { @@ -261,13 +256,13 @@ describe("pairing setup code", () => { id: "MISSING_GW_TOKEN", }); - expectResolvedSetupOk(resolved, { authLabel: "password", password: "password-from-env" }); + expectResolvedSetupOk(resolved, { authLabel: "password" }); }); it("does not treat env-template token as plaintext in inferred mode", async () => { const resolved = await resolveInferredModeWithPasswordEnv("${MISSING_GW_TOKEN}"); - expectResolvedSetupOk(resolved, { authLabel: "password", password: "password-from-env" }); + expectResolvedSetupOk(resolved, { authLabel: "password" }); }); it("requires explicit auth mode when token and password are both configured", async () => { @@ -333,7 +328,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "token", token: "new-token" }); + expectResolvedSetupOk(resolved, { authLabel: "token" }); }); it("errors when gateway is loopback only", async () => { @@ -367,7 +362,6 @@ describe("pairing setup code", () => { payload: { url: "wss://mb-server.tailnet.ts.net", bootstrapToken: "bootstrap-123", - password: "secret", }, authLabel: "password", urlSource: "gateway.tailscale.mode=serve", @@ -396,7 +390,6 @@ describe("pairing setup code", () => { payload: { url: "wss://remote.example.com:444", bootstrapToken: "bootstrap-123", - token: "tok_123", }, authLabel: "token", urlSource: "gateway.remote.url", diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index c64ae36077e..6a2c5dd0b39 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -16,8 +16,6 @@ import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; export type PairingSetupPayload = { url: string; bootstrapToken: string; - token?: string; - password?: string; }; export type PairingSetupCommandResult = { @@ -64,11 +62,6 @@ type ResolveAuthLabelResult = { error?: string; }; -type ResolveSharedAuthResult = { - token?: string; - password?: string; -}; - function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null { const trimmed = raw.trim(); if (!trimmed) { @@ -213,41 +206,6 @@ function resolvePairingSetupAuthLabel( return { error: "Gateway auth is not configured (no token or password)." }; } -function resolvePairingSetupSharedAuth( - cfg: OpenClawConfig, - env: NodeJS.ProcessEnv, -): ResolveSharedAuthResult { - const defaults = cfg.secrets?.defaults; - const tokenRef = resolveSecretInputRef({ - value: cfg.gateway?.auth?.token, - defaults, - }).ref; - const passwordRef = resolveSecretInputRef({ - value: cfg.gateway?.auth?.password, - defaults, - }).ref; - const token = - resolveGatewayTokenFromEnv(env) || - (tokenRef ? undefined : normalizeSecretInputString(cfg.gateway?.auth?.token)); - const password = - resolveGatewayPasswordFromEnv(env) || - (passwordRef ? undefined : normalizeSecretInputString(cfg.gateway?.auth?.password)); - const mode = cfg.gateway?.auth?.mode; - if (mode === "token") { - return { token }; - } - if (mode === "password") { - return { password }; - } - if (token) { - return { token }; - } - if (password) { - return { password }; - } - return {}; -} - async function resolveGatewayTokenSecretRef( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, @@ -417,8 +375,6 @@ export async function resolvePairingSetupFromConfig( if (authLabel.error) { return { ok: false, error: authLabel.error }; } - const sharedAuth = resolvePairingSetupSharedAuth(cfgForAuth, env); - const urlResult = await resolveGatewayUrl(cfgForAuth, { env, publicUrl: options.publicUrl, @@ -445,8 +401,6 @@ export async function resolvePairingSetupFromConfig( baseDir: options.pairingBaseDir, }) ).token, - ...(sharedAuth.token ? { token: sharedAuth.token } : {}), - ...(sharedAuth.password ? { password: sharedAuth.password } : {}), }, authLabel: authLabel.label, urlSource: urlResult.source ?? "unknown", diff --git a/src/plugin-sdk/allow-from.ts b/src/plugin-sdk/allow-from.ts index f03f2427558..5b15896c917 100644 --- a/src/plugin-sdk/allow-from.ts +++ b/src/plugin-sdk/allow-from.ts @@ -1,3 +1,32 @@ +export type { + AllowlistMatch, + AllowlistMatchSource, + CompiledAllowlist, +} from "../channels/allowlist-match.js"; +export type { AllowlistUserResolutionLike } from "../channels/allowlists/resolve-utils.js"; +export { + compileAllowlist, + formatAllowlistMatchMeta, + resolveAllowlistCandidates, + resolveAllowlistMatchByCandidates, + resolveAllowlistMatchSimple, + resolveCompiledAllowlistMatch, +} from "../channels/allowlist-match.js"; +export { + firstDefined, + isSenderIdAllowed, + mergeDmAllowFromSources, + resolveGroupAllowFromSources, +} from "../channels/allow-from.js"; +export { + addAllowlistUserEntriesFromConfigEntry, + buildAllowlistResolutionSummary, + canonicalizeAllowlistWithResolvedIds, + mergeAllowlist, + patchAllowlistUsersInConfigEntries, + summarizeMapping, +} from "../channels/allowlists/resolve-utils.js"; + /** Lowercase and optionally strip prefixes from allowlist entries before sender comparisons. */ export function formatAllowFromLowercase(params: { allowFrom: Array; @@ -96,3 +125,36 @@ export function isAllowedParsedChatSender } return false; } + +export type BasicAllowlistResolutionEntry = { + input: string; + resolved: boolean; + id?: string; + name?: string; + note?: string; +}; + +/** Clone allowlist resolution entries into a plain serializable shape for UI and docs output. */ +export function mapBasicAllowlistResolutionEntries( + entries: BasicAllowlistResolutionEntry[], +): BasicAllowlistResolutionEntry[] { + return entries.map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id, + name: entry.name, + note: entry.note, + })); +} + +/** Map allowlist inputs sequentially so resolver side effects stay ordered and predictable. */ +export async function mapAllowlistResolutionInputs(params: { + inputs: string[]; + mapInput: (input: string) => Promise | T; +}): Promise { + const results: T[] = []; + for (const input of params.inputs) { + results.push(await params.mapInput(input)); + } + return results; +} diff --git a/src/plugin-sdk/allowlist-resolution.test.ts b/src/plugin-sdk/allowlist-resolution.test.ts index 5b606cfbe9f..12619308269 100644 --- a/src/plugin-sdk/allowlist-resolution.test.ts +++ b/src/plugin-sdk/allowlist-resolution.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { mapAllowlistResolutionInputs } from "./allowlist-resolution.js"; +import { mapAllowlistResolutionInputs } from "./allow-from.js"; describe("mapAllowlistResolutionInputs", () => { it("maps inputs sequentially and preserves order", async () => { diff --git a/src/plugin-sdk/allowlist-resolution.ts b/src/plugin-sdk/allowlist-resolution.ts deleted file mode 100644 index 1acf87f4d1c..00000000000 --- a/src/plugin-sdk/allowlist-resolution.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type BasicAllowlistResolutionEntry = { - input: string; - resolved: boolean; - id?: string; - name?: string; - note?: string; -}; - -/** Clone allowlist resolution entries into a plain serializable shape for UI and docs output. */ -export function mapBasicAllowlistResolutionEntries( - entries: BasicAllowlistResolutionEntry[], -): BasicAllowlistResolutionEntry[] { - return entries.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.id, - name: entry.name, - note: entry.note, - })); -} - -/** Map allowlist inputs sequentially so resolver side effects stay ordered and predictable. */ -export async function mapAllowlistResolutionInputs(params: { - inputs: string[]; - mapInput: (input: string) => Promise | T; -}): Promise { - const results: T[] = []; - for (const input of params.inputs) { - results.push(await params.mapInput(input)); - } - return results; -} diff --git a/src/plugin-sdk/message-tool-schema.ts b/src/plugin-sdk/channel-actions.ts similarity index 78% rename from src/plugin-sdk/message-tool-schema.ts rename to src/plugin-sdk/channel-actions.ts index 889812fdbe4..2f6f5748461 100644 --- a/src/plugin-sdk/message-tool-schema.ts +++ b/src/plugin-sdk/channel-actions.ts @@ -1,3 +1,8 @@ +export { + createUnionActionGate, + listTokenSourcedAccounts, +} from "../channels/plugins/actions/shared.js"; +export { resolveReactionMessageId } from "../channels/plugins/actions/reaction-message-id.js"; import { Type } from "@sinclair/typebox"; import type { TSchema } from "@sinclair/typebox"; import { stringEnum } from "../agents/schema/typebox.js"; diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index d9a229657dd..18fb609de31 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -2,6 +2,15 @@ import { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; +import { + authorizeConfigWrite, + canBypassConfigWritePolicy, + formatConfigWriteDeniedMessage, + resolveChannelConfigWrites, + type ConfigWriteAuthorizationResult, + type ConfigWriteScope, + type ConfigWriteTarget, +} from "../channels/plugins/config-writes.js"; import { collectAllowlistProviderGroupPolicyWarnings, collectAllowlistProviderRestrictSendersWarnings, @@ -17,6 +26,14 @@ import type { OpenClawConfig } from "../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; +export { + authorizeConfigWrite, + canBypassConfigWritePolicy, + formatConfigWriteDeniedMessage, + resolveChannelConfigWrites, +}; +export type { ConfigWriteAuthorizationResult, ConfigWriteScope, ConfigWriteTarget }; + /** Coerce mixed allowlist config values into plain strings without trimming or deduping. */ export function mapAllowFromEntries( allowFrom: Array | null | undefined, diff --git a/src/plugin-sdk/channel-contract.ts b/src/plugin-sdk/channel-contract.ts new file mode 100644 index 00000000000..507166d87f0 --- /dev/null +++ b/src/plugin-sdk/channel-contract.ts @@ -0,0 +1,16 @@ +export type { + BaseProbeResult, + BaseTokenResolution, + ChannelAgentTool, + ChannelAccountSnapshot, + ChannelGroupContext, + ChannelMessageActionAdapter, + ChannelMessageActionContext, + ChannelMessageActionDiscoveryContext, + ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelMessageToolSchemaContribution, + ChannelStatusIssue, + ChannelThreadingContext, + ChannelThreadingToolContext, +} from "../channels/plugins/types.js"; diff --git a/src/plugin-sdk/channel-feedback.ts b/src/plugin-sdk/channel-feedback.ts new file mode 100644 index 00000000000..f9f03011ee0 --- /dev/null +++ b/src/plugin-sdk/channel-feedback.ts @@ -0,0 +1,21 @@ +export { + removeAckReactionAfterReply, + shouldAckReaction, + shouldAckReactionForWhatsApp, + type AckReactionGateParams, + type AckReactionScope, + type WhatsAppAckReactionMode, +} from "../channels/ack-reactions.js"; +export { logAckFailure, logTypingFailure, type LogFn } from "../channels/logging.js"; +export { + CODING_TOOL_TOKENS, + createStatusReactionController, + DEFAULT_EMOJIS, + DEFAULT_TIMING, + resolveToolEmoji, + WEB_TOOL_TOKENS, + type StatusReactionAdapter, + type StatusReactionController, + type StatusReactionEmojis, + type StatusReactionTiming, +} from "../channels/status-reactions.js"; diff --git a/src/plugin-sdk/channel-inbound.ts b/src/plugin-sdk/channel-inbound.ts new file mode 100644 index 00000000000..3f2f2708564 --- /dev/null +++ b/src/plugin-sdk/channel-inbound.ts @@ -0,0 +1,34 @@ +export { + createInboundDebouncer, + resolveInboundDebounceMs, +} from "../auto-reply/inbound-debounce.js"; +export { + formatInboundEnvelope, + formatInboundFromLabel, + resolveEnvelopeFormatOptions, +} from "../auto-reply/envelope.js"; +export type { EnvelopeFormatOptions } from "../auto-reply/envelope.js"; +export { + buildMentionRegexes, + matchesMentionPatterns, + matchesMentionWithExplicit, + normalizeMentionText, +} from "../auto-reply/reply/mentions.js"; +export { + createChannelInboundDebouncer, + shouldDebounceTextInbound, +} from "../channels/inbound-debounce-policy.js"; +export type { + MentionGateParams, + MentionGateResult, + MentionGateWithBypassParams, + MentionGateWithBypassResult, +} from "../channels/mention-gating.js"; +export { + resolveMentionGating, + resolveMentionGatingWithBypass, +} from "../channels/mention-gating.js"; +export type { NormalizedLocation } from "../channels/location.js"; +export { formatLocationText, toLocationContext } from "../channels/location.js"; +export { logInboundDrop } from "../channels/logging.js"; +export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js"; diff --git a/src/plugin-sdk/channel-lifecycle.ts b/src/plugin-sdk/channel-lifecycle.ts index 28045aeb058..96a031ce5b7 100644 --- a/src/plugin-sdk/channel-lifecycle.ts +++ b/src/plugin-sdk/channel-lifecycle.ts @@ -1,4 +1,12 @@ import type { ChannelAccountSnapshot } from "../channels/plugins/types.core.js"; +export * from "../channels/draft-stream-controls.js"; +export * from "../channels/draft-stream-loop.js"; +export { createRunStateMachine } from "../channels/run-state-machine.js"; +export { + createArmableStallWatchdog, + type ArmableStallWatchdog, + type StallWatchdogTimeoutMeta, +} from "../channels/transport/stall-watchdog.js"; type CloseAwareServer = { once: (event: "close", listener: () => void) => unknown; diff --git a/src/plugin-sdk/channel-pairing.ts b/src/plugin-sdk/channel-pairing.ts index 749c18bf86c..e085dc4e381 100644 --- a/src/plugin-sdk/channel-pairing.ts +++ b/src/plugin-sdk/channel-pairing.ts @@ -1,4 +1,9 @@ import type { ChannelId } from "../channels/plugins/types.js"; +export { + createLoggedPairingApprovalNotifier, + createPairingPrefixStripper, + createTextPairingAdapter, +} from "../channels/plugins/pairing-adapters.js"; import { issuePairingChallenge } from "../pairing/pairing-challenge.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import { createScopedPairingAccess } from "./pairing-access.js"; diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index b45315a6757..377c7269613 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -1,73 +1,18 @@ -// Shared channel/runtime helpers for plugins. Channel plugins should use this -// surface instead of reaching into src/channels or adjacent infra modules. +// Legacy compatibility shim for older channel helpers. Prefer the dedicated +// plugin-sdk subpaths instead of adding new imports here. -export * from "../channels/ack-reactions.js"; -export * from "../channels/allow-from.js"; -export * from "../channels/allowlists/resolve-utils.js"; -export * from "../channels/allowlist-match.js"; -export * from "../channels/channel-config.js"; export * from "../channels/chat-type.js"; -export * from "../channels/command-gating.js"; -export * from "../channels/conversation-label.js"; -export * from "../channels/draft-stream-controls.js"; -export * from "../channels/draft-stream-loop.js"; -export * from "../channels/inbound-debounce-policy.js"; -export * from "../channels/location.js"; -export * from "../channels/logging.js"; -export * from "../channels/mention-gating.js"; -export * from "../channels/native-command-session-targets.js"; export * from "../channels/reply-prefix.js"; -export * from "../channels/run-state-machine.js"; -export * from "../channels/session.js"; -export * from "../channels/session-envelope.js"; -export * from "../channels/session-meta.js"; -export * from "../channels/status-reactions.js"; -export * from "../channels/targets.js"; -export * from "../channels/thread-binding-id.js"; -export * from "../channels/thread-bindings-messages.js"; -export * from "../channels/thread-bindings-policy.js"; -export * from "../channels/transport/stall-watchdog.js"; export * from "../channels/typing.js"; -export * from "../channels/plugins/actions/reaction-message-id.js"; -export * from "../channels/plugins/actions/shared.js"; export type * from "../channels/plugins/types.js"; -export * from "../channels/plugins/config-writes.js"; -export * from "../channels/plugins/directory-adapters.js"; -export * from "../channels/plugins/media-payload.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; -export * from "./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"; export * from "../channels/plugins/outbound/interactive.js"; -export * from "../channels/plugins/pairing-adapters.js"; -export * from "../channels/plugins/runtime-forwarders.js"; -export * from "../channels/plugins/target-resolvers.js"; -export * from "../channels/plugins/threading-helpers.js"; -export * from "../channels/plugins/status-issues/shared.js"; export * from "../channels/plugins/whatsapp-heartbeat.js"; -export { - buildComputedAccountStatusSnapshot, - buildTokenChannelStatusSummary, -} from "./status-helpers.js"; -export { - projectCredentialSnapshotFields, - resolveConfiguredFromCredentialStatuses, -} from "../channels/account-snapshot-fields.js"; -export * from "../infra/outbound/send-deps.js"; export * from "../polls.js"; -export * from "../utils/message-channel.js"; export * from "../whatsapp/normalize.js"; -export { createActionGate, jsonResult, readStringParam } from "../agents/tools/common.js"; -export * from "./channel-send-result.js"; -export * from "./channel-lifecycle.js"; -export * from "./directory-runtime.js"; -export type { - InteractiveButtonStyle, - InteractiveReplyButton, - InteractiveReply, -} from "../interactive/payload.js"; export { - normalizeInteractiveReply, - resolveInteractiveTextFallback, -} from "../interactive/payload.js"; + createAccountStatusSink, + keepHttpServerTaskAlive, + waitUntilAbort, +} from "./channel-lifecycle.js"; diff --git a/src/plugin-sdk/channel-send-result.ts b/src/plugin-sdk/channel-send-result.ts index 12e74741264..07c0099500f 100644 --- a/src/plugin-sdk/channel-send-result.ts +++ b/src/plugin-sdk/channel-send-result.ts @@ -1,6 +1,8 @@ import type { ChannelOutboundAdapter, ChannelPollResult } from "../channels/plugins/types.js"; import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; +export type { ChannelOutboundAdapter } from "../channels/plugins/types.js"; + export type ChannelSendRawResult = { ok: boolean; messageId?: string | null; diff --git a/src/plugin-sdk/channel-setup.ts b/src/plugin-sdk/channel-setup.ts index 6488bd1a770..c12027f2944 100644 --- a/src/plugin-sdk/channel-setup.ts +++ b/src/plugin-sdk/channel-setup.ts @@ -1,11 +1,13 @@ import type { ChannelSetupWizard } from "../channels/plugins/setup-wizard.js"; import type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; +import type { ChannelSetupInput } from "../channels/plugins/types.core.js"; import { createOptionalChannelSetupAdapter, createOptionalChannelSetupWizard, } from "./optional-channel-setup.js"; export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; +export type { ChannelSetupInput } from "../channels/plugins/types.core.js"; export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "./setup.js"; export { DEFAULT_ACCOUNT_ID, diff --git a/src/plugin-sdk/channel-targets.ts b/src/plugin-sdk/channel-targets.ts new file mode 100644 index 00000000000..c24bc9b3046 --- /dev/null +++ b/src/plugin-sdk/channel-targets.ts @@ -0,0 +1,29 @@ +export { + applyChannelMatchMeta, + buildChannelKeyCandidates, + normalizeChannelSlug, + resolveChannelEntryMatch, + resolveChannelEntryMatchWithFallback, + resolveChannelMatchConfig, + resolveNestedAllowlistDecision, + type ChannelEntryMatch, + type ChannelMatchSource, +} from "../channels/channel-config.js"; +export { + buildMessagingTarget, + ensureTargetId, + normalizeTargetId, + parseAtUserTarget, + parseMentionPrefixOrAtUserTarget, + parseTargetMention, + parseTargetPrefix, + parseTargetPrefixes, + requireTargetKind, + type MessagingTarget, + type MessagingTargetKind, + type MessagingTargetParseOptions, +} from "../channels/targets.js"; +export { + buildUnresolvedTargetResults, + resolveTargetsWithOptionalToken, +} from "../channels/plugins/target-resolvers.js"; diff --git a/src/plugin-sdk/command-auth.ts b/src/plugin-sdk/command-auth.ts index 0a09e0c1dcd..4d4324ce891 100644 --- a/src/plugin-sdk/command-auth.ts +++ b/src/plugin-sdk/command-auth.ts @@ -1,6 +1,83 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js"; +export { + hasControlCommand, + hasInlineCommandTokens, + isControlCommandMessage, + shouldComputeCommandAuthorized, +} from "../auto-reply/command-detection.js"; +export { + buildCommandText, + buildCommandTextFromArgs, + findCommandByNativeName, + getCommandDetection, + isCommandEnabled, + isCommandMessage, + isNativeCommandSurface, + listChatCommands, + listChatCommandsForConfig, + listNativeCommandSpecs, + listNativeCommandSpecsForConfig, + maybeResolveTextAlias, + normalizeCommandBody, + parseCommandArgs, + resolveCommandArgChoices, + resolveCommandArgMenu, + resolveTextCommand, + serializeCommandArgs, + shouldHandleTextCommands, +} from "../auto-reply/commands-registry.js"; +export type { + ChatCommandDefinition, + CommandArgChoiceContext, + CommandArgDefinition, + CommandArgMenuSpec, + CommandArgValues, + CommandArgs, + CommandDetection, + CommandNormalizeOptions, + CommandScope, + NativeCommandSpec, + ResolvedCommandArgChoice, + ShouldHandleTextCommandsParams, +} from "../auto-reply/commands-registry.js"; +export { + resolveCommandAuthorizedFromAuthorizers, + resolveControlCommandGate, + resolveDualTextControlCommandGate, + type CommandAuthorizer, + type CommandGatingModeWhenAccessGroupsOff, +} from "../channels/command-gating.js"; +export { + resolveNativeCommandSessionTargets, + type ResolveNativeCommandSessionTargetsParams, +} from "../channels/native-command-session-targets.js"; +export { + resolveCommandAuthorization, + type CommandAuthorization, +} from "../auto-reply/command-auth.js"; +export { + listReservedChatSlashCommandNames, + listSkillCommandsForAgents, + listSkillCommandsForWorkspace, + resolveSkillCommandInvocation, +} from "../auto-reply/skill-commands.js"; +export { buildCommandsPaginationKeyboard } from "../auto-reply/reply/commands-info.js"; +export { + buildModelsProviderData, + formatModelsAvailableHeader, + resolveModelsCommandReply, +} from "../auto-reply/reply/commands-models.js"; +export type { ModelsProviderData } from "../auto-reply/reply/commands-models.js"; +export { resolveStoredModelOverride } from "../auto-reply/reply/model-selection.js"; +export type { StoredModelOverride } from "../auto-reply/reply/model-selection.js"; +export { + buildCommandsMessage, + buildCommandsMessagePaginated, + buildHelpMessage, +} from "../auto-reply/status.js"; + export type ResolveSenderCommandAuthorizationParams = { cfg: OpenClawConfig; rawBody: string; diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index 643557f0960..eb85c062c71 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -43,7 +43,7 @@ export * from "./channel-config-schema.js"; export * from "./channel-policy.js"; export * from "./reply-history.js"; export * from "./directory-runtime.js"; -export { mapAllowlistResolutionInputs } from "./allowlist-resolution.js"; +export { mapAllowlistResolutionInputs } from "./allow-from.js"; export { resolveBlueBubblesGroupRequireMention, diff --git a/src/plugin-sdk/config-runtime.ts b/src/plugin-sdk/config-runtime.ts index 67b2ec82fee..3836f15508d 100644 --- a/src/plugin-sdk/config-runtime.ts +++ b/src/plugin-sdk/config-runtime.ts @@ -1,19 +1,78 @@ // Shared config/runtime boundary for plugins that need config loading, // config writes, or session-store helpers without importing src internals. -export * from "../config/config.js"; -export * from "../config/markdown-tables.js"; -export * from "../config/group-policy.js"; -export * from "../config/runtime-group-policy.js"; -export * from "../config/commands.js"; -export * from "../config/discord-preview-streaming.js"; -export * from "../config/io.js"; -export * from "../config/telegram-custom-commands.js"; -export * from "../config/talk.js"; -export * from "../config/agent-limits.js"; -export * from "../cron/store.js"; -export * from "../sessions/model-overrides.js"; -export type * from "../config/types.slack.js"; +export { + getRuntimeConfigSnapshot, + loadConfig, + readConfigFileSnapshotForWrite, + writeConfigFile, +} from "../config/io.js"; +export { resolveMarkdownTableMode } from "../config/markdown-tables.js"; +export { + resolveChannelGroupPolicy, + resolveChannelGroupRequireMention, + type ChannelGroupPolicy, +} from "../config/group-policy.js"; +export { + GROUP_POLICY_BLOCKED_LABEL, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../config/runtime-group-policy.js"; +export { + isNativeCommandsExplicitlyDisabled, + resolveNativeCommandsEnabled, + resolveNativeSkillsEnabled, +} from "../config/commands.js"; +export { + TELEGRAM_COMMAND_NAME_PATTERN, + normalizeTelegramCommandName, + resolveTelegramCustomCommands, +} from "../config/telegram-custom-commands.js"; +export { + mapStreamingModeToSlackLegacyDraftStreamMode, + resolveDiscordPreviewStreamMode, + resolveSlackNativeStreaming, + resolveSlackStreamingMode, + resolveTelegramPreviewStreamMode, + type SlackLegacyDraftStreamMode, + type StreamingMode, +} from "../config/discord-preview-streaming.js"; +export { resolveActiveTalkProviderConfig } from "../config/talk.js"; +export { resolveAgentMaxConcurrent } from "../config/agent-limits.js"; +export { loadCronStore, resolveCronStorePath, saveCronStore } from "../cron/store.js"; +export { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; +export { coerceSecretRef } from "../config/types.secrets.js"; +export type { + DiscordAccountConfig, + DiscordActionConfig, + DiscordAutoPresenceConfig, + DiscordExecApprovalConfig, + DiscordGuildChannelConfig, + DiscordGuildEntry, + DiscordIntentsConfig, + DiscordSlashCommandConfig, + DmPolicy, + GroupPolicy, + MarkdownTableMode, + OpenClawConfig, + ReplyToMode, + SignalReactionNotificationMode, + SlackAccountConfig, + SlackChannelConfig, + SlackReactionNotificationMode, + SlackSlashCommandConfig, + TelegramAccountConfig, + TelegramActionConfig, + TelegramDirectConfig, + TelegramExecApprovalConfig, + TelegramGroupConfig, + TelegramInlineButtonsScope, + TelegramNetworkConfig, + TelegramTopicConfig, + TtsConfig, +} from "../config/types.js"; export { loadSessionStore, readSessionUpdatedAt, @@ -35,8 +94,3 @@ export { } from "../config/sessions/reset.js"; export { resolveSessionStoreEntry } from "../config/sessions/store.js"; export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; -export { - hasConfiguredSecretInput, - normalizeResolvedSecretInputString, - normalizeSecretInputString, -} from "../config/types.secrets.js"; diff --git a/src/plugin-sdk/conversation-runtime.ts b/src/plugin-sdk/conversation-runtime.ts index 66b7e3b938f..6a9546bf0f2 100644 --- a/src/plugin-sdk/conversation-runtime.ts +++ b/src/plugin-sdk/conversation-runtime.ts @@ -26,6 +26,36 @@ export { ensureConfiguredBindingTargetSession, resetConfiguredBindingTargetInPlace, } from "../channels/plugins/binding-targets.js"; +export { resolveConversationLabel } from "../channels/conversation-label.js"; +export { recordInboundSession } from "../channels/session.js"; +export { recordInboundSessionMetaSafe } from "../channels/session-meta.js"; +export { resolveThreadBindingConversationIdFromBindingId } from "../channels/thread-binding-id.js"; +export { + createScopedAccountReplyToModeResolver, + createStaticReplyToModeResolver, + createTopLevelChannelReplyToModeResolver, +} from "../channels/plugins/threading-helpers.js"; +export { + formatThreadBindingDurationLabel, + resolveThreadBindingFarewellText, + resolveThreadBindingIntroText, + resolveThreadBindingThreadName, +} from "../channels/thread-bindings-messages.js"; +export { + DISCORD_THREAD_BINDING_CHANNEL, + MATRIX_THREAD_BINDING_CHANNEL, + formatThreadBindingDisabledError, + resolveThreadBindingEffectiveExpiresAt, + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingLifecycle, + resolveThreadBindingMaxAgeMs, + resolveThreadBindingMaxAgeMsForChannel, + resolveThreadBindingsEnabled, + resolveThreadBindingSpawnPolicy, + type ThreadBindingSpawnKind, + type ThreadBindingSpawnPolicy, +} from "../channels/thread-bindings-policy.js"; export type { ConfiguredBindingConversation, ConfiguredBindingResolution, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index c8c7980fbd2..24f99bb3dad 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -85,6 +85,7 @@ export { migrateBaseNameToDefaultAccount, } from "../channels/plugins/setup-helpers.js"; export { + clearAccountEntryFields, deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; @@ -121,8 +122,6 @@ export { type RoutePeer, type RoutePeerKind, } from "../routing/resolve-route.js"; -export { buildOutboundBaseSessionKey } from "../infra/outbound/base-session-key.js"; -export { normalizeOutboundThreadId } from "../infra/outbound/thread-id.js"; export { resolveThreadSessionKeys } from "../routing/session-key.js"; export type ChannelOutboundSessionRouteParams = Parameters< diff --git a/src/plugin-sdk/directory-runtime.ts b/src/plugin-sdk/directory-runtime.ts index caa21657810..31209a89561 100644 --- a/src/plugin-sdk/directory-runtime.ts +++ b/src/plugin-sdk/directory-runtime.ts @@ -1,6 +1,16 @@ /** Shared directory listing helpers for plugins that derive users/groups from config maps. */ export type { DirectoryConfigParams } from "../channels/plugins/directory-types.js"; +export type { + ChannelDirectoryEntry, + ChannelDirectoryEntryKind, +} from "../channels/plugins/types.js"; export type { ReadOnlyInspectedAccount } from "../channels/read-only-account-inspect.js"; +export { + createChannelDirectoryAdapter, + createEmptyChannelDirectoryAdapter, + emptyChannelDirectoryList, + nullChannelDirectorySelf, +} from "../channels/plugins/directory-adapters.js"; export { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, @@ -15,4 +25,5 @@ export { listDirectoryUserEntriesFromAllowFromAndMapKeys, toDirectoryEntries, } from "../channels/plugins/directory-config-helpers.js"; +export { createRuntimeDirectoryLiveAdapter } from "../channels/plugins/runtime-forwarders.js"; export { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; diff --git a/src/plugin-sdk/extension-shared.ts b/src/plugin-sdk/extension-shared.ts index 43c11f7c09d..a0c5a12faa1 100644 --- a/src/plugin-sdk/extension-shared.ts +++ b/src/plugin-sdk/extension-shared.ts @@ -1,5 +1,5 @@ import type { z } from "zod"; -import { runPassiveAccountLifecycle } from "./channel-runtime.js"; +import { runPassiveAccountLifecycle } from "./channel-lifecycle.js"; import { createLoggerBackedRuntime } from "./runtime.js"; type PassiveChannelStatusSnapshot = { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 20f8a34672a..c80dbc37eaf 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -65,6 +65,15 @@ export type { ReplyPayload } from "../auto-reply/types.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export type { ContextEngineFactory } from "../context-engine/registry.js"; export type { DiagnosticEventPayload } from "../infra/diagnostic-events.js"; +export type { + ContextEngine, + ContextEngineInfo, + ContextEngineMaintenanceResult, + ContextEngineRuntimeContext, + TranscriptRewriteReplacement, + TranscriptRewriteRequest, + TranscriptRewriteResult, +} from "../context-engine/types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { registerContextEngine } from "../context-engine/registry.js"; diff --git a/src/plugin-sdk/infra-runtime.ts b/src/plugin-sdk/infra-runtime.ts index 0339ca1f307..dfc21eb753b 100644 --- a/src/plugin-sdk/infra-runtime.ts +++ b/src/plugin-sdk/infra-runtime.ts @@ -27,6 +27,7 @@ export * from "../infra/net/proxy-env.js"; export * from "../infra/net/proxy-fetch.js"; export * from "../infra/net/ssrf.js"; export * from "../infra/outbound/identity.js"; +export * from "../infra/outbound/send-deps.js"; export * from "../infra/retry.js"; export * from "../infra/retry-policy.js"; export * from "../infra/scp-host.ts"; @@ -37,4 +38,5 @@ export * from "../infra/system-message.ts"; export * from "../infra/tmp-openclaw-dir.js"; export * from "../infra/transport-ready.js"; export * from "../infra/wsl.ts"; +export { createRuntimeOutboundDelegates } from "../channels/plugins/runtime-forwarders.js"; export * from "./ssrf-policy.js"; diff --git a/src/plugin-sdk/line-core.ts b/src/plugin-sdk/line-core.ts index 596593fc8f4..083e9ddcbe0 100644 --- a/src/plugin-sdk/line-core.ts +++ b/src/plugin-sdk/line-core.ts @@ -1,5 +1,5 @@ export type { OpenClawConfig } from "../config/config.js"; -export type { LineConfig } from "../line/types.js"; +export type { LineChannelData, LineConfig } from "../line/types.js"; export { createTopLevelChannelDmPolicy, DEFAULT_ACCOUNT_ID, @@ -18,3 +18,13 @@ export { export { resolveExactLineGroupConfigKey } from "../line/group-keys.js"; export type { ResolvedLineAccount } from "../line/types.js"; export { LineConfigSchema } from "../line/config-schema.js"; +export { + createActionCard, + createImageCard, + createInfoCard, + createListCard, + createReceiptCard, + type CardAction, + type ListItem, +} from "../line/flex-templates.js"; +export { processLineMessage } from "../line/markdown-to-line.js"; diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index 16a6c235ac3..e2196996397 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -32,7 +32,6 @@ export { resolveDefaultLineAccountId, resolveLineAccount, } from "../line/accounts.js"; -export { lineSetupAdapter, lineSetupWizard } from "../../extensions/line/setup-api.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js"; export { diff --git a/src/plugin-sdk/matrix-runtime-heavy.ts b/src/plugin-sdk/matrix-runtime-heavy.ts new file mode 100644 index 00000000000..cc153f83e4b --- /dev/null +++ b/src/plugin-sdk/matrix-runtime-heavy.ts @@ -0,0 +1,7 @@ +// Matrix runtime helpers that are needed internally by the bundled extension +// but are too heavy for the light external runtime-api surface. + +export { ensureConfiguredAcpBindingReady } from "../acp/persistent-bindings.lifecycle.js"; +export { resolveConfiguredAcpBindingRecord } from "../acp/persistent-bindings.resolve.js"; +export { maybeCreateMatrixMigrationSnapshot } from "../infra/matrix-migration-snapshot.js"; +export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js"; diff --git a/src/plugin-sdk/matrix-runtime-shared.ts b/src/plugin-sdk/matrix-runtime-shared.ts new file mode 100644 index 00000000000..862a1445dea --- /dev/null +++ b/src/plugin-sdk/matrix-runtime-shared.ts @@ -0,0 +1,11 @@ +// Narrow shared Matrix runtime exports for light runtime-api consumers. + +export type { + ChannelDirectoryEntry, + ChannelMessageActionContext, +} from "../channels/plugins/types.js"; +export type { OpenClawConfig } from "../config/config.js"; +export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js"; +export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; +export type { RuntimeEnv } from "../runtime.js"; +export type { WizardPrompter } from "../wizard/prompts.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 3d6ff402d59..012dc4e6b10 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -1 +1,174 @@ -export * from "../plugins/runtime/runtime-matrix-contract.js"; +// Private helper surface for the bundled matrix plugin. +// Keep this list additive and scoped to symbols used under extensions/matrix. + +import { createOptionalChannelSetupSurface } from "./channel-setup.js"; + +export { + createActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringArrayParam, + readStringParam, +} from "../agents/tools/common.js"; +export type { ReplyPayload } from "../auto-reply/types.js"; +export { resolveAckReaction } from "../agents/identity.js"; +export { + compileAllowlist, + resolveCompiledAllowlistMatch, + resolveAllowlistCandidates, + resolveAllowlistMatchByCandidates, +} from "../channels/allowlist-match.js"; +export { + addAllowlistUserEntriesFromConfigEntry, + buildAllowlistResolutionSummary, + canonicalizeAllowlistWithResolvedIds, + mergeAllowlist, + patchAllowlistUsersInConfigEntries, + summarizeMapping, +} from "../channels/allowlists/resolve-utils.js"; +export { resolveControlCommandGate } from "../channels/command-gating.js"; +export type { NormalizedLocation } from "../channels/location.js"; +export { formatLocationText, toLocationContext } from "../channels/location.js"; +export { logInboundDrop, logTypingFailure } from "../channels/logging.js"; +export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js"; +export { formatAllowlistMatchMeta } from "../channels/plugins/allowlist-match.js"; +export { + buildChannelKeyCandidates, + resolveChannelEntryMatch, +} from "../channels/plugins/channel-config.js"; +export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; +export { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "../channels/plugins/config-helpers.js"; +export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; +export { + buildSingleChannelSecretPromptState, + addWildcardAllowFrom, + mergeAllowFromEntries, + promptAccountId, + promptSingleChannelSecretInput, + setTopLevelChannelGroupPolicy, +} from "../channels/plugins/setup-wizard-helpers.js"; +export { promptChannelAccessConfig } from "../channels/plugins/setup-group-access.js"; +export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; +export { + applyAccountNameToChannelSection, + moveSingleAccountChannelSectionToDefaultAccount, +} from "../channels/plugins/setup-helpers.js"; +export type { + BaseProbeResult, + ChannelDirectoryEntry, + ChannelGroupContext, + ChannelMessageActionAdapter, + ChannelMessageActionContext, + ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelMessageToolSchemaContribution, + ChannelOutboundAdapter, + ChannelResolveKind, + ChannelResolveResult, + ChannelSetupInput, + ChannelToolSend, +} from "../channels/plugins/types.js"; +export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +export { createReplyPrefixOptions } from "../channels/reply-prefix.js"; +export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js"; +export { + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingMaxAgeMsForChannel, +} from "../channels/thread-bindings-policy.js"; +export { + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +} from "../../extensions/matrix/thread-bindings-runtime.js"; +export { createTypingCallbacks } from "../channels/typing.js"; +export { createChannelReplyPipeline } from "./channel-reply-pipeline.js"; +export type { OpenClawConfig } from "../config/config.js"; +export { + GROUP_POLICY_BLOCKED_LABEL, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../config/runtime-group-policy.js"; +export type { + DmPolicy, + GroupPolicy, + GroupToolPolicyConfig, + MarkdownTableMode, +} from "../config/types.js"; +export type { SecretInput } from "./secret-input.js"; +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +} from "./secret-input.js"; +export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; +export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; +export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js"; +export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; +export { + getSessionBindingService, + registerSessionBindingAdapter, + unregisterSessionBindingAdapter, +} from "../infra/outbound/session-binding-service.js"; +export { resolveOutboundSendDep } from "../infra/outbound/send-deps.js"; +export type { + BindingTargetKind, + SessionBindingRecord, +} from "../infra/outbound/session-binding-service.js"; +export { isPrivateOrLoopbackHost } from "../gateway/net.js"; +export { getAgentScopedMediaLocalRoots } from "../media/local-roots.js"; +export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; +export type { OpenClawPluginApi } from "../plugins/types.js"; +export type { PollInput } from "../polls.js"; +export { normalizePollInput } from "../polls.js"; +export { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, + resolveAgentIdFromSessionKey, +} from "../routing/session-key.js"; +export type { RuntimeEnv } from "../runtime.js"; +export { normalizeStringEntries } from "../shared/string-normalization.js"; +export { formatDocsLink } from "../terminal/links.js"; +export { redactSensitiveText } from "../logging/redact.js"; +export type { WizardPrompter } from "../wizard/prompts.js"; +export { + evaluateGroupRouteAccessForPolicy, + resolveSenderScopedGroupPolicy, +} from "./group-access.js"; +export { createChannelPairingController } from "./channel-pairing.js"; +export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; +export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; +export { runPluginCommandWithTimeout } from "./run-command.js"; +export { createLoggerBackedRuntime, resolveRuntimeEnv } from "./runtime.js"; +export { + buildProbeChannelStatusSummary, + collectStatusIssuesFromLastError, +} from "./status-helpers.js"; +export { + resolveMatrixAccountStorageRoot, + resolveMatrixCredentialsDir, + resolveMatrixCredentialsPath, + resolveMatrixLegacyFlatStoragePaths, +} from "../../extensions/matrix/helper-api.js"; +export { getMatrixScopedEnvVarNames } from "../../extensions/matrix/helper-api.js"; +export { + requiresExplicitMatrixDefaultAccount, + resolveMatrixDefaultOrOnlyAccountId, +} from "../../extensions/matrix/helper-api.js"; + +const matrixSetup = createOptionalChannelSetupSurface({ + channel: "matrix", + label: "Matrix", + npmSpec: "@openclaw/matrix", + docsPath: "/channels/matrix", +}); + +export const matrixSetupWizard = matrixSetup.setupWizard; +export const matrixSetupAdapter = matrixSetup.setupAdapter; diff --git a/src/plugin-sdk/media-runtime.ts b/src/plugin-sdk/media-runtime.ts index f824246ed51..8563c4513a6 100644 --- a/src/plugin-sdk/media-runtime.ts +++ b/src/plugin-sdk/media-runtime.ts @@ -14,9 +14,15 @@ export * from "../media/outbound-attachment.js"; export * from "../media/png-encode.ts"; export * from "../media/store.js"; export * from "../media/temp-files.js"; +export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export * from "./agent-media-payload.js"; export * from "../media-understanding/audio-preflight.ts"; export * from "../media-understanding/defaults.js"; export * from "../media-understanding/providers/image-runtime.ts"; export * from "../media-understanding/runner.js"; export * from "../polls.js"; +export { + createDirectTextMediaOutbound, + createScopedChannelMediaMaxBytesResolver, + resolveScopedChannelMediaMaxBytes, +} from "../channels/plugins/outbound/direct-text-media.js"; diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index bdc73f50793..b5de7026f0e 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -47,3 +47,5 @@ export { listKnownProviderAuthEnvVarNames, omitEnvKeysCaseInsensitive, } from "../secrets/provider-env-vars.js"; +export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; +export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index 7103147e91d..e38c02138bb 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -24,6 +24,7 @@ export { XAI_TOOL_SCHEMA_PROFILE, } from "../agents/model-compat.js"; export { normalizeProviderId } from "../agents/provider-id.js"; +export { normalizeXaiModelId } from "../agents/model-id-normalization.js"; export { cloneFirstTemplateModel } from "../plugins/provider-model-helpers.js"; export { @@ -40,6 +41,7 @@ export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, resolveCloudflareAiGatewayBaseUrl, } from "../agents/cloudflare-ai-gateway.js"; +export { resolveAnthropicVertexRegion } from "../agents/anthropic-vertex-provider.js"; export { discoverHuggingfaceModels, HUGGINGFACE_BASE_URL, diff --git a/src/plugin-sdk/provider-oauth.ts b/src/plugin-sdk/provider-oauth.ts deleted file mode 100644 index 8e183c55954..00000000000 --- a/src/plugin-sdk/provider-oauth.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Focused OAuth helpers for provider plugins. - -export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; -export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index 52cc878c83d..98df862d748 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -1,3 +1,8 @@ +import type { ChannelOutboundAdapter } from "../channels/plugins/types.js"; + +export type { MediaPayload, MediaPayloadInput } from "../channels/plugins/media-payload.js"; +export { buildMediaPayload } from "../channels/plugins/media-payload.js"; + export type OutboundReplyPayload = { text?: string; mediaUrls?: string[]; @@ -15,6 +20,13 @@ export type SendableOutboundReplyParts = { hasContent: boolean; }; +type SendPayloadContext = Parameters>[0]; +type SendPayloadResult = Awaited>>; +type SendPayloadAdapter = Pick< + ChannelOutboundAdapter, + "sendMedia" | "sendText" | "chunker" | "textChunkLimit" +>; + /** Extract the supported outbound reply fields from loose tool or agent payload objects. */ export function normalizeOutboundReplyPayload( payload: Record, @@ -62,6 +74,11 @@ export function resolveOutboundMediaUrls(payload: { return []; } +/** Resolve media URLs from a channel sendPayload context after legacy fallback normalization. */ +export function resolvePayloadMediaUrls(payload: SendPayloadContext["payload"]): string[] { + return resolveOutboundMediaUrls(payload); +} + /** Count outbound media items after legacy single-media fallback normalization. */ export function countOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): number { return resolveOutboundMediaUrls(payload).length; @@ -163,6 +180,99 @@ export async function sendPayloadWithChunkedTextAndMedia< return lastResult!; } +export async function sendPayloadMediaSequence(params: { + text: string; + mediaUrls: readonly string[]; + send: (input: { + text: string; + mediaUrl: string; + index: number; + isFirst: boolean; + }) => Promise; +}): Promise { + let lastResult: TResult | undefined; + for (let i = 0; i < params.mediaUrls.length; i += 1) { + const mediaUrl = params.mediaUrls[i]; + if (!mediaUrl) { + continue; + } + lastResult = await params.send({ + text: i === 0 ? params.text : "", + mediaUrl, + index: i, + isFirst: i === 0, + }); + } + return lastResult; +} + +export async function sendPayloadMediaSequenceOrFallback(params: { + text: string; + mediaUrls: readonly string[]; + send: (input: { + text: string; + mediaUrl: string; + index: number; + isFirst: boolean; + }) => Promise; + fallbackResult: TResult; + sendNoMedia?: () => Promise; +}): Promise { + if (params.mediaUrls.length === 0) { + return params.sendNoMedia ? await params.sendNoMedia() : params.fallbackResult; + } + return (await sendPayloadMediaSequence(params)) ?? params.fallbackResult; +} + +export async function sendPayloadMediaSequenceAndFinalize(params: { + text: string; + mediaUrls: readonly string[]; + send: (input: { + text: string; + mediaUrl: string; + index: number; + isFirst: boolean; + }) => Promise; + finalize: () => Promise; +}): Promise { + if (params.mediaUrls.length > 0) { + await sendPayloadMediaSequence(params); + } + return await params.finalize(); +} + +export async function sendTextMediaPayload(params: { + channel: string; + ctx: SendPayloadContext; + adapter: SendPayloadAdapter; +}): Promise { + const text = params.ctx.payload.text ?? ""; + const urls = resolvePayloadMediaUrls(params.ctx.payload); + if (!text && urls.length === 0) { + return { channel: params.channel, messageId: "" }; + } + if (urls.length > 0) { + const lastResult = await sendPayloadMediaSequence({ + text, + mediaUrls: urls, + send: async ({ text, mediaUrl }) => + await params.adapter.sendMedia!({ + ...params.ctx, + text, + mediaUrl, + }), + }); + return lastResult ?? { channel: params.channel, messageId: "" }; + } + const limit = params.adapter.textChunkLimit; + const chunks = limit && params.adapter.chunker ? params.adapter.chunker(text, limit) : [text]; + let lastResult: Awaited>>; + for (const chunk of chunks) { + lastResult = await params.adapter.sendText!({ ...params.ctx, text: chunk }); + } + return lastResult!; +} + /** Detect numeric-looking target ids for channels that distinguish ids from handles. */ export function isNumericTargetId(raw: string): boolean { const trimmed = raw.trim(); diff --git a/src/plugin-sdk/reply-runtime.ts b/src/plugin-sdk/reply-runtime.ts index 689cf4cdba7..386ecae10ad 100644 --- a/src/plugin-sdk/reply-runtime.ts +++ b/src/plugin-sdk/reply-runtime.ts @@ -1,31 +1,49 @@ // Shared agent/reply runtime helpers for channel plugins. Keep channel plugins // off direct src/auto-reply imports by routing common reply primitives here. -export * from "../auto-reply/chunk.js"; -export * from "../auto-reply/command-auth.js"; -export * from "../auto-reply/command-detection.js"; -export * from "../auto-reply/commands-registry.js"; -export * from "../auto-reply/dispatch.js"; -export * from "../auto-reply/group-activation.js"; -export * from "../auto-reply/heartbeat.js"; -export * from "../auto-reply/heartbeat-reply-payload.js"; -export * from "../auto-reply/inbound-debounce.js"; -export * from "../auto-reply/reply.js"; -export * from "../auto-reply/tokens.js"; -export * from "../auto-reply/envelope.js"; -export * from "../auto-reply/reply/history.js"; -export * from "../auto-reply/reply/abort.js"; -export * from "../auto-reply/reply/btw-command.js"; -export * from "../auto-reply/reply/commands-models.js"; -export * from "../auto-reply/reply/inbound-dedupe.js"; -export * from "../auto-reply/reply/inbound-context.js"; -export * from "../auto-reply/reply/mentions.js"; -export * from "../auto-reply/reply/reply-dispatcher.js"; -export * from "../auto-reply/reply/reply-reference.js"; -export * from "../auto-reply/reply/provider-dispatcher.js"; -export * from "../auto-reply/reply/model-selection.js"; -export * from "../auto-reply/reply/commands-info.js"; -export * from "../auto-reply/skill-commands.js"; -export * from "../auto-reply/status.js"; -export type { ReplyPayload } from "../auto-reply/types.js"; +export { + chunkMarkdownTextWithMode, + chunkText, + chunkTextWithMode, + resolveChunkMode, + resolveTextChunkLimit, +} from "../auto-reply/chunk.js"; +export type { ChunkMode } from "../auto-reply/chunk.js"; +export { + dispatchInboundMessage, + dispatchInboundMessageWithBufferedDispatcher, + dispatchInboundMessageWithDispatcher, +} from "../auto-reply/dispatch.js"; +export { + normalizeGroupActivation, + parseActivationCommand, +} from "../auto-reply/group-activation.js"; +export { + HEARTBEAT_PROMPT, + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + resolveHeartbeatPrompt, + stripHeartbeatToken, +} from "../auto-reply/heartbeat.js"; +export { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js"; +export { getReplyFromConfig } from "../auto-reply/reply.js"; +export { HEARTBEAT_TOKEN, isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +export { isAbortRequestText } from "../auto-reply/reply/abort.js"; +export { isBtwRequestText } from "../auto-reply/reply/btw-command.js"; +export { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +export { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; +export { + dispatchReplyWithBufferedBlockDispatcher, + dispatchReplyWithDispatcher, +} from "../auto-reply/reply/provider-dispatcher.js"; +export { + createReplyDispatcher, + createReplyDispatcherWithTyping, +} from "../auto-reply/reply/reply-dispatcher.js"; +export type { + ReplyDispatcher, + ReplyDispatcherOptions, + ReplyDispatcherWithTypingOptions, +} from "../auto-reply/reply/reply-dispatcher.js"; +export { createReplyReferencePlanner } from "../auto-reply/reply/reply-reference.js"; +export type { GetReplyOptions, ReplyPayload } from "../auto-reply/types.js"; export type { FinalizedMsgContext, MsgContext } from "../auto-reply/templating.js"; diff --git a/src/plugin-sdk/routing.ts b/src/plugin-sdk/routing.ts index 144304a607c..6bf7917170b 100644 --- a/src/plugin-sdk/routing.ts +++ b/src/plugin-sdk/routing.ts @@ -29,3 +29,6 @@ export { formatSetExplicitDefaultInstruction, formatSetExplicitDefaultToConfiguredInstruction, } from "../routing/default-account-warnings.js"; +export { buildOutboundBaseSessionKey } from "../infra/outbound/base-session-key.js"; +export { normalizeOutboundThreadId } from "../infra/outbound/thread-id.js"; +export { normalizeMessageChannel, resolveGatewayMessageChannel } from "../utils/message-channel.js"; diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index afa32af0b7f..f9e4c411e6a 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -27,21 +27,25 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export * from "./src/send.js";', ], "extensions/imessage/runtime-api.ts": [ - 'export { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, buildChannelConfigSchema, collectStatusIssuesFromLastError, formatTrimmedAllowFromEntries, getChatChannelMeta, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, resolveChannelMediaMaxBytes, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo, IMessageConfigSchema, type ChannelPlugin, type IMessageAccountConfig } from "openclaw/plugin-sdk/imessage";', + 'export { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, buildChannelConfigSchema, collectStatusIssuesFromLastError, formatTrimmedAllowFromEntries, getChatChannelMeta, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, resolveChannelMediaMaxBytes, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo, IMessageConfigSchema, type ChannelPlugin, type IMessageAccountConfig } from "../../src/plugin-sdk/imessage.js";', 'export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy } from "./src/group-policy.js";', 'export { monitorIMessageProvider } from "./src/monitor.js";', 'export type { MonitorIMessageOpts } from "./src/monitor.js";', 'export { probeIMessage } from "./src/probe.js";', 'export { sendMessageIMessage } from "./src/send.js";', ], - "extensions/googlechat/runtime-api.ts": ['export * from "openclaw/plugin-sdk/googlechat";'], + "extensions/googlechat/runtime-api.ts": ['export * from "../../src/plugin-sdk/googlechat.js";'], "extensions/matrix/runtime-api.ts": [ 'export * from "./src/auth-precedence.js";', 'export * from "./helper-api.js";', - 'export * from "./thread-bindings-runtime.js";', + 'export { assertHttpUrlTargetsPrivateNetwork, closeDispatcher, createPinnedDispatcher, resolvePinnedHostnameWithPolicy, ssrfPolicyFromAllowPrivateNetwork, type LookupFn, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";', + 'export { setMatrixThreadBindingIdleTimeoutBySessionKey, setMatrixThreadBindingMaxAgeBySessionKey } from "./thread-bindings-runtime.js";', + 'export { writeJsonFileAtomically } from "../../src/plugin-sdk/json-store.js";', + 'export type { ChannelDirectoryEntry, ChannelMessageActionContext, OpenClawConfig, PluginRuntime, RuntimeLogger, RuntimeEnv, WizardPrompter } from "../../src/plugin-sdk/matrix.js";', + 'export { formatZonedTimestamp } from "../../src/plugin-sdk/matrix.js";', ], "extensions/nextcloud-talk/runtime-api.ts": [ - 'export * from "openclaw/plugin-sdk/nextcloud-talk";', + 'export * from "../../src/plugin-sdk/nextcloud-talk.js";', ], "extensions/signal/runtime-api.ts": ['export * from "./src/runtime-api.js";'], "extensions/slack/runtime-api.ts": [ @@ -52,12 +56,12 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export * from "./src/resolve-users.js";', ], "extensions/telegram/runtime-api.ts": [ - 'export type { ChannelMessageActionAdapter, ChannelPlugin, OpenClawConfig, OpenClawPluginApi, PluginRuntime, TelegramAccountConfig, TelegramActionConfig, TelegramNetworkConfig } from "openclaw/plugin-sdk/telegram";', + 'export type { ChannelMessageActionAdapter, ChannelPlugin, OpenClawConfig, OpenClawPluginApi, PluginRuntime, TelegramAccountConfig, TelegramActionConfig, TelegramNetworkConfig } from "../../src/plugin-sdk/telegram.js";', 'export type { OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger } from "openclaw/plugin-sdk/core";', 'export type { AcpRuntime, AcpRuntimeCapabilities, AcpRuntimeDoctorReport, AcpRuntimeEnsureInput, AcpRuntimeEvent, AcpRuntimeHandle, AcpRuntimeStatus, AcpRuntimeTurnInput, AcpRuntimeErrorCode, AcpSessionUpdateTag } from "openclaw/plugin-sdk/acp-runtime";', 'export { AcpRuntimeError } from "openclaw/plugin-sdk/acp-runtime";', - 'export { buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, normalizeAccountId, PAIRING_APPROVED_MESSAGE, parseTelegramTopicConversation, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, resolveTelegramPollVisibility } from "openclaw/plugin-sdk/telegram";', - 'export { buildChannelConfigSchema, getChatChannelMeta, jsonResult, readNumberParam, readReactionParams, readStringArrayParam, readStringOrNumberParam, readStringParam, resolvePollMaxSelections, TelegramConfigSchema } from "openclaw/plugin-sdk/telegram-core";', + 'export { buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, normalizeAccountId, PAIRING_APPROVED_MESSAGE, parseTelegramTopicConversation, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, resolveTelegramPollVisibility } from "../../src/plugin-sdk/telegram.js";', + 'export { buildChannelConfigSchema, getChatChannelMeta, jsonResult, readNumberParam, readReactionParams, readStringArrayParam, readStringOrNumberParam, readStringParam, resolvePollMaxSelections, TelegramConfigSchema } from "../../src/plugin-sdk/telegram-core.js";', 'export type { TelegramProbe } from "./src/probe.js";', 'export { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./src/audit.js";', 'export { telegramMessageActions } from "./src/channel-actions.js";', diff --git a/src/plugin-sdk/ssrf-runtime.ts b/src/plugin-sdk/ssrf-runtime.ts new file mode 100644 index 00000000000..a05c7e8ad89 --- /dev/null +++ b/src/plugin-sdk/ssrf-runtime.ts @@ -0,0 +1,14 @@ +// Narrow SSRF helpers for extensions that need pinned-dispatcher and policy +// utilities without loading the full infra-runtime surface. + +export { + closeDispatcher, + createPinnedDispatcher, + resolvePinnedHostnameWithPolicy, + type LookupFn, + type SsrFPolicy, +} from "../infra/net/ssrf.js"; +export { + assertHttpUrlTargetsPrivateNetwork, + ssrfPolicyFromAllowPrivateNetwork, +} from "./ssrf-policy.js"; diff --git a/src/plugin-sdk/status-helpers.ts b/src/plugin-sdk/status-helpers.ts index 231c438b8ef..7ae74b14ed6 100644 --- a/src/plugin-sdk/status-helpers.ts +++ b/src/plugin-sdk/status-helpers.ts @@ -1,4 +1,12 @@ import type { ChannelStatusIssue } from "../channels/plugins/types.js"; +export { isRecord } from "../channels/plugins/status-issues/shared.js"; +export { + appendMatchMetadata, + asString, + collectIssuesForEnabledAccounts, + formatMatchMetadata, + resolveEnabledConfiguredAccountId, +} from "../channels/plugins/status-issues/shared.js"; type RuntimeLifecycleSnapshot = { running?: boolean | null; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 566dc6645e1..b6e3abcd647 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,9 +1,32 @@ -import * as bluebubblesSdk from "openclaw/plugin-sdk/bluebubbles"; +import * as allowFromSdk from "openclaw/plugin-sdk/allow-from"; +import * as channelActionsSdk from "openclaw/plugin-sdk/channel-actions"; +import * as channelConfigHelpersSdk from "openclaw/plugin-sdk/channel-config-helpers"; +import type { + BaseProbeResult as ContractBaseProbeResult, + BaseTokenResolution as ContractBaseTokenResolution, + ChannelAgentTool as ContractChannelAgentTool, + ChannelAccountSnapshot as ContractChannelAccountSnapshot, + ChannelGroupContext as ContractChannelGroupContext, + ChannelMessageActionAdapter as ContractChannelMessageActionAdapter, + ChannelMessageActionContext as ContractChannelMessageActionContext, + ChannelMessageActionName as ContractChannelMessageActionName, + ChannelMessageToolDiscovery as ContractChannelMessageToolDiscovery, + ChannelStatusIssue as ContractChannelStatusIssue, + ChannelThreadingContext as ContractChannelThreadingContext, + ChannelThreadingToolContext as ContractChannelThreadingToolContext, +} from "openclaw/plugin-sdk/channel-contract"; +import * as channelFeedbackSdk from "openclaw/plugin-sdk/channel-feedback"; +import * as channelInboundSdk from "openclaw/plugin-sdk/channel-inbound"; +import * as channelLifecycleSdk from "openclaw/plugin-sdk/channel-lifecycle"; import * as channelPairingSdk from "openclaw/plugin-sdk/channel-pairing"; import * as channelReplyPipelineSdk from "openclaw/plugin-sdk/channel-reply-pipeline"; import * as channelRuntimeSdk from "openclaw/plugin-sdk/channel-runtime"; import * as channelSendResultSdk from "openclaw/plugin-sdk/channel-send-result"; import * as channelSetupSdk from "openclaw/plugin-sdk/channel-setup"; +import * as channelTargetsSdk from "openclaw/plugin-sdk/channel-targets"; +import * as commandAuthSdk from "openclaw/plugin-sdk/command-auth"; +import * as configRuntimeSdk from "openclaw/plugin-sdk/config-runtime"; +import * as conversationRuntimeSdk from "openclaw/plugin-sdk/conversation-runtime"; import * as coreSdk from "openclaw/plugin-sdk/core"; import type { ChannelMessageActionContext as CoreChannelMessageActionContext, @@ -11,31 +34,42 @@ import type { PluginRuntime as CorePluginRuntime, } from "openclaw/plugin-sdk/core"; import * as directoryRuntimeSdk from "openclaw/plugin-sdk/directory-runtime"; -import * as discordSdk from "openclaw/plugin-sdk/discord"; -import * as imessageSdk from "openclaw/plugin-sdk/imessage"; -import * as imessageCoreSdk from "openclaw/plugin-sdk/imessage-core"; +import * as infraRuntimeSdk from "openclaw/plugin-sdk/infra-runtime"; import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime"; +import * as matrixRuntimeSharedSdk from "openclaw/plugin-sdk/matrix-runtime-shared"; +import * as mediaRuntimeSdk from "openclaw/plugin-sdk/media-runtime"; import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup"; import * as providerAuthSdk from "openclaw/plugin-sdk/provider-auth"; import * as providerModelsSdk from "openclaw/plugin-sdk/provider-models"; -import * as providerOauthSdk from "openclaw/plugin-sdk/provider-oauth"; import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup"; +import * as replyHistorySdk from "openclaw/plugin-sdk/reply-history"; import * as replyPayloadSdk from "openclaw/plugin-sdk/reply-payload"; +import * as replyRuntimeSdk from "openclaw/plugin-sdk/reply-runtime"; import * as routingSdk from "openclaw/plugin-sdk/routing"; import * as runtimeSdk from "openclaw/plugin-sdk/runtime"; import * as sandboxSdk from "openclaw/plugin-sdk/sandbox"; import * as secretInputSdk from "openclaw/plugin-sdk/secret-input"; import * as selfHostedProviderSetupSdk from "openclaw/plugin-sdk/self-hosted-provider-setup"; import * as setupSdk from "openclaw/plugin-sdk/setup"; -import * as slackSdk from "openclaw/plugin-sdk/slack"; -import * as telegramSdk from "openclaw/plugin-sdk/telegram"; +import * as ssrfRuntimeSdk from "openclaw/plugin-sdk/ssrf-runtime"; import * as testingSdk from "openclaw/plugin-sdk/testing"; +import * as threadBindingsRuntimeSdk from "openclaw/plugin-sdk/thread-bindings-runtime"; import * as webhookIngressSdk from "openclaw/plugin-sdk/webhook-ingress"; -import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; -import * as whatsappActionRuntimeSdk from "openclaw/plugin-sdk/whatsapp-action-runtime"; -import * as whatsappLoginQrSdk from "openclaw/plugin-sdk/whatsapp-login-qr"; import { describe, expect, expectTypeOf, it } from "vitest"; import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; +import type { + BaseProbeResult, + BaseTokenResolution, + ChannelAgentTool, + ChannelAccountSnapshot, + ChannelGroupContext, + ChannelMessageActionAdapter, + ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelStatusIssue, + ChannelThreadingContext, + ChannelThreadingToolContext, +} from "../channels/plugins/types.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { OpenClawPluginApi } from "../plugins/types.js"; import type { @@ -55,21 +89,51 @@ const bundledExtensionSubpathLoaders = pluginSdkSubpaths.map((id: string) => ({ const asExports = (mod: object) => mod as Record; const accountHelpersSdk = await import("openclaw/plugin-sdk/account-helpers"); const allowlistEditSdk = await import("openclaw/plugin-sdk/allowlist-config-edit"); +const statusHelpersSdk = await import("openclaw/plugin-sdk/status-helpers"); describe("plugin-sdk subpath exports", () => { it("keeps the curated public list free of internal implementation subpaths", () => { expect(pluginSdkSubpaths).not.toContain("acpx"); + expect(pluginSdkSubpaths).not.toContain("bluebubbles"); expect(pluginSdkSubpaths).not.toContain("compat"); expect(pluginSdkSubpaths).not.toContain("device-pair"); + expect(pluginSdkSubpaths).not.toContain("discord"); + expect(pluginSdkSubpaths).not.toContain("feishu"); expect(pluginSdkSubpaths).not.toContain("google"); + expect(pluginSdkSubpaths).not.toContain("googlechat"); + expect(pluginSdkSubpaths).not.toContain("imessage"); + expect(pluginSdkSubpaths).not.toContain("irc"); + expect(pluginSdkSubpaths).not.toContain("imessage-core"); + expect(pluginSdkSubpaths).not.toContain("line"); + expect(pluginSdkSubpaths).not.toContain("line-core"); expect(pluginSdkSubpaths).not.toContain("lobster"); + expect(pluginSdkSubpaths).not.toContain("mattermost"); + expect(pluginSdkSubpaths).not.toContain("matrix"); + expect(pluginSdkSubpaths).not.toContain("msteams"); + expect(pluginSdkSubpaths).not.toContain("nextcloud-talk"); + expect(pluginSdkSubpaths).not.toContain("nostr"); expect(pluginSdkSubpaths).not.toContain("pairing-access"); expect(pluginSdkSubpaths).not.toContain("qwen-portal-auth"); expect(pluginSdkSubpaths).not.toContain("reply-prefix"); expect(pluginSdkSubpaths).not.toContain("signal-core"); + expect(pluginSdkSubpaths).not.toContain("slack"); expect(pluginSdkSubpaths).not.toContain("synology-chat"); + expect(pluginSdkSubpaths).not.toContain("telegram"); + expect(pluginSdkSubpaths).not.toContain("telegram-core"); + expect(pluginSdkSubpaths).not.toContain("tlon"); + expect(pluginSdkSubpaths).not.toContain("twitch"); expect(pluginSdkSubpaths).not.toContain("typing"); + expect(pluginSdkSubpaths).not.toContain("voice-call"); + expect(pluginSdkSubpaths).not.toContain("whatsapp"); + expect(pluginSdkSubpaths).not.toContain("whatsapp-action-runtime"); + expect(pluginSdkSubpaths).not.toContain("whatsapp-core"); + expect(pluginSdkSubpaths).not.toContain("whatsapp-login-qr"); + expect(pluginSdkSubpaths).not.toContain("whatsapp-shared"); + expect(pluginSdkSubpaths).not.toContain("secret-input-runtime"); + expect(pluginSdkSubpaths).not.toContain("secret-input-schema"); expect(pluginSdkSubpaths).not.toContain("zai"); + expect(pluginSdkSubpaths).not.toContain("discord-core"); + expect(pluginSdkSubpaths).not.toContain("slack-core"); expect(pluginSdkSubpaths).not.toContain("provider-model-definitions"); }); @@ -92,11 +156,31 @@ describe("plugin-sdk subpath exports", () => { }); it("exports reply payload helpers from the dedicated subpath", () => { + expect(typeof replyPayloadSdk.buildMediaPayload).toBe("function"); expect(typeof replyPayloadSdk.deliverTextOrMediaReply).toBe("function"); expect(typeof replyPayloadSdk.resolveOutboundMediaUrls).toBe("function"); + expect(typeof replyPayloadSdk.resolvePayloadMediaUrls).toBe("function"); + expect(typeof replyPayloadSdk.sendPayloadMediaSequenceAndFinalize).toBe("function"); + expect(typeof replyPayloadSdk.sendPayloadMediaSequenceOrFallback).toBe("function"); + expect(typeof replyPayloadSdk.sendTextMediaPayload).toBe("function"); expect(typeof replyPayloadSdk.sendPayloadWithChunkedTextAndMedia).toBe("function"); }); + it("exports media runtime helpers from the dedicated subpath", () => { + expect(typeof mediaRuntimeSdk.createDirectTextMediaOutbound).toBe("function"); + expect(typeof mediaRuntimeSdk.createScopedChannelMediaMaxBytesResolver).toBe("function"); + }); + + it("exports reply history helpers from the dedicated subpath", () => { + expect(typeof replyHistorySdk.buildPendingHistoryContextFromMap).toBe("function"); + expect(typeof replyHistorySdk.clearHistoryEntriesIfEnabled).toBe("function"); + expect(typeof replyHistorySdk.recordPendingHistoryEntryIfEnabled).toBe("function"); + expect("buildPendingHistoryContextFromMap" in asExports(replyRuntimeSdk)).toBe(false); + expect("clearHistoryEntriesIfEnabled" in asExports(replyRuntimeSdk)).toBe(false); + expect("recordPendingHistoryEntryIfEnabled" in asExports(replyRuntimeSdk)).toBe(false); + expect("DEFAULT_GROUP_HISTORY_LIMIT" in asExports(replyRuntimeSdk)).toBe(false); + }); + it("exports account helper builders from the dedicated subpath", () => { expect(typeof accountHelpersSdk.createAccountListHelpers).toBe("function"); }); @@ -113,19 +197,181 @@ describe("plugin-sdk subpath exports", () => { expect(typeof allowlistEditSdk.createNestedAllowlistOverrideResolver).toBe("function"); }); + it("exports allowlist resolution helpers from the dedicated subpath", () => { + expect(typeof allowFromSdk.addAllowlistUserEntriesFromConfigEntry).toBe("function"); + expect(typeof allowFromSdk.buildAllowlistResolutionSummary).toBe("function"); + expect(typeof allowFromSdk.canonicalizeAllowlistWithResolvedIds).toBe("function"); + expect(typeof allowFromSdk.mapAllowlistResolutionInputs).toBe("function"); + expect(typeof allowFromSdk.mergeAllowlist).toBe("function"); + expect(typeof allowFromSdk.patchAllowlistUsersInConfigEntries).toBe("function"); + expect(typeof allowFromSdk.summarizeMapping).toBe("function"); + }); + + it("exports allow-from matching helpers from the dedicated subpath", () => { + expect(typeof allowFromSdk.compileAllowlist).toBe("function"); + expect(typeof allowFromSdk.firstDefined).toBe("function"); + expect(typeof allowFromSdk.formatAllowlistMatchMeta).toBe("function"); + expect(typeof allowFromSdk.isSenderIdAllowed).toBe("function"); + expect(typeof allowFromSdk.mergeDmAllowFromSources).toBe("function"); + expect(typeof allowFromSdk.resolveAllowlistMatchSimple).toBe("function"); + }); + it("exports runtime helpers from the dedicated subpath", () => { expect(typeof runtimeSdk.createLoggerBackedRuntime).toBe("function"); }); + it("exports channel identity and session helpers from stronger existing homes", () => { + expect(typeof routingSdk.normalizeMessageChannel).toBe("function"); + expect(typeof routingSdk.resolveGatewayMessageChannel).toBe("function"); + expect(typeof conversationRuntimeSdk.recordInboundSession).toBe("function"); + expect(typeof conversationRuntimeSdk.recordInboundSessionMetaSafe).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveConversationLabel).toBe("function"); + }); + it("exports directory runtime helpers from the dedicated subpath", () => { + expect(typeof directoryRuntimeSdk.createChannelDirectoryAdapter).toBe("function"); + expect(typeof directoryRuntimeSdk.createRuntimeDirectoryLiveAdapter).toBe("function"); expect(typeof directoryRuntimeSdk.listDirectoryEntriesFromSources).toBe("function"); expect(typeof directoryRuntimeSdk.listResolvedDirectoryEntriesFromSources).toBe("function"); }); + it("exports infra runtime helpers from the dedicated subpath", () => { + expect(typeof infraRuntimeSdk.createRuntimeOutboundDelegates).toBe("function"); + expect(typeof infraRuntimeSdk.resolveOutboundSendDep).toBe("function"); + }); + it("exports channel runtime helpers from the dedicated subpath", () => { - expect(typeof channelRuntimeSdk.createChannelDirectoryAdapter).toBe("function"); - expect(typeof channelRuntimeSdk.createRuntimeOutboundDelegates).toBe("function"); - expect(typeof channelRuntimeSdk.sendPayloadMediaSequenceOrFallback).toBe("function"); + expect("applyChannelMatchMeta" in asExports(channelRuntimeSdk)).toBe(false); + expect("createChannelDirectoryAdapter" in asExports(channelRuntimeSdk)).toBe(false); + expect("createEmptyChannelDirectoryAdapter" in asExports(channelRuntimeSdk)).toBe(false); + expect("createArmableStallWatchdog" in asExports(channelRuntimeSdk)).toBe(false); + expect("createDraftStreamLoop" in asExports(channelRuntimeSdk)).toBe(false); + expect("createLoggedPairingApprovalNotifier" in asExports(channelRuntimeSdk)).toBe(false); + expect("createPairingPrefixStripper" in asExports(channelRuntimeSdk)).toBe(false); + expect("createRunStateMachine" in asExports(channelRuntimeSdk)).toBe(false); + expect("createRuntimeDirectoryLiveAdapter" in asExports(channelRuntimeSdk)).toBe(false); + expect("createRuntimeOutboundDelegates" in asExports(channelRuntimeSdk)).toBe(false); + expect("createStatusReactionController" in asExports(channelRuntimeSdk)).toBe(false); + expect("createTextPairingAdapter" in asExports(channelRuntimeSdk)).toBe(false); + expect("createFinalizableDraftLifecycle" in asExports(channelRuntimeSdk)).toBe(false); + expect("DEFAULT_EMOJIS" in asExports(channelRuntimeSdk)).toBe(false); + expect("logAckFailure" in asExports(channelRuntimeSdk)).toBe(false); + expect("logTypingFailure" in asExports(channelRuntimeSdk)).toBe(false); + expect("logInboundDrop" in asExports(channelRuntimeSdk)).toBe(false); + expect("normalizeMessageChannel" in asExports(channelRuntimeSdk)).toBe(false); + expect("removeAckReactionAfterReply" in asExports(channelRuntimeSdk)).toBe(false); + expect("recordInboundSession" in asExports(channelRuntimeSdk)).toBe(false); + expect("recordInboundSessionMetaSafe" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveInboundSessionEnvelopeContext" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveMentionGating" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveMentionGatingWithBypass" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveOutboundSendDep" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveConversationLabel" in asExports(channelRuntimeSdk)).toBe(false); + expect("shouldDebounceTextInbound" in asExports(channelRuntimeSdk)).toBe(false); + expect("shouldAckReaction" in asExports(channelRuntimeSdk)).toBe(false); + expect("shouldAckReactionForWhatsApp" in asExports(channelRuntimeSdk)).toBe(false); + expect("toLocationContext" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingConversationIdFromBindingId" in asExports(channelRuntimeSdk)).toBe( + false, + ); + expect("resolveThreadBindingEffectiveExpiresAt" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingFarewellText" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingIdleTimeoutMs" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingIdleTimeoutMsForChannel" in asExports(channelRuntimeSdk)).toBe( + false, + ); + expect("resolveThreadBindingIntroText" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingLifecycle" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingMaxAgeMs" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingMaxAgeMsForChannel" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingSpawnPolicy" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingThreadName" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveThreadBindingsEnabled" in asExports(channelRuntimeSdk)).toBe(false); + expect("formatThreadBindingDisabledError" in asExports(channelRuntimeSdk)).toBe(false); + expect("DISCORD_THREAD_BINDING_CHANNEL" in asExports(channelRuntimeSdk)).toBe(false); + expect("MATRIX_THREAD_BINDING_CHANNEL" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveControlCommandGate" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveCommandAuthorizedFromAuthorizers" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveDualTextControlCommandGate" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveNativeCommandSessionTargets" in asExports(channelRuntimeSdk)).toBe(false); + expect("attachChannelToResult" in asExports(channelRuntimeSdk)).toBe(false); + expect("buildComputedAccountStatusSnapshot" in asExports(channelRuntimeSdk)).toBe(false); + expect("buildMediaPayload" in asExports(channelRuntimeSdk)).toBe(false); + expect("createActionGate" in asExports(channelRuntimeSdk)).toBe(false); + expect("jsonResult" in asExports(channelRuntimeSdk)).toBe(false); + expect("normalizeInteractiveReply" in asExports(channelRuntimeSdk)).toBe(false); + expect("PAIRING_APPROVED_MESSAGE" in asExports(channelRuntimeSdk)).toBe(false); + expect("projectCredentialSnapshotFields" in asExports(channelRuntimeSdk)).toBe(false); + expect("readStringParam" in asExports(channelRuntimeSdk)).toBe(false); + expect("compileAllowlist" in asExports(channelRuntimeSdk)).toBe(false); + expect("formatAllowlistMatchMeta" in asExports(channelRuntimeSdk)).toBe(false); + expect("firstDefined" in asExports(channelRuntimeSdk)).toBe(false); + expect("isSenderIdAllowed" in asExports(channelRuntimeSdk)).toBe(false); + expect("mergeDmAllowFromSources" in asExports(channelRuntimeSdk)).toBe(false); + expect("addAllowlistUserEntriesFromConfigEntry" in asExports(channelRuntimeSdk)).toBe(false); + expect("buildAllowlistResolutionSummary" in asExports(channelRuntimeSdk)).toBe(false); + expect("canonicalizeAllowlistWithResolvedIds" in asExports(channelRuntimeSdk)).toBe(false); + expect("mergeAllowlist" in asExports(channelRuntimeSdk)).toBe(false); + expect("patchAllowlistUsersInConfigEntries" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveChannelConfigWrites" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolvePayloadMediaUrls" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveScopedChannelMediaMaxBytes" in asExports(channelRuntimeSdk)).toBe(false); + expect("sendPayloadMediaSequenceAndFinalize" in asExports(channelRuntimeSdk)).toBe(false); + expect("sendPayloadMediaSequenceOrFallback" in asExports(channelRuntimeSdk)).toBe(false); + expect("sendTextMediaPayload" in asExports(channelRuntimeSdk)).toBe(false); + expect("createScopedChannelMediaMaxBytesResolver" in asExports(channelRuntimeSdk)).toBe(false); + expect("runPassiveAccountLifecycle" in asExports(channelRuntimeSdk)).toBe(false); + expect("buildChannelKeyCandidates" in asExports(channelRuntimeSdk)).toBe(false); + expect("buildMessagingTarget" in asExports(channelRuntimeSdk)).toBe(false); + expect("createDirectTextMediaOutbound" in asExports(channelRuntimeSdk)).toBe(false); + expect("createMessageToolButtonsSchema" in asExports(channelRuntimeSdk)).toBe(false); + expect("createMessageToolCardSchema" in asExports(channelRuntimeSdk)).toBe(false); + expect("createScopedAccountReplyToModeResolver" in asExports(channelRuntimeSdk)).toBe(false); + expect("createStaticReplyToModeResolver" in asExports(channelRuntimeSdk)).toBe(false); + expect("createTopLevelChannelReplyToModeResolver" in asExports(channelRuntimeSdk)).toBe(false); + expect("createUnionActionGate" in asExports(channelRuntimeSdk)).toBe(false); + expect("ensureTargetId" in asExports(channelRuntimeSdk)).toBe(false); + expect("listTokenSourcedAccounts" in asExports(channelRuntimeSdk)).toBe(false); + expect("parseMentionPrefixOrAtUserTarget" in asExports(channelRuntimeSdk)).toBe(false); + expect("requireTargetKind" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveChannelEntryMatchWithFallback" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveChannelMatchConfig" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveReactionMessageId" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveTargetsWithOptionalToken" in asExports(channelRuntimeSdk)).toBe(false); + expect("appendMatchMetadata" in asExports(channelRuntimeSdk)).toBe(false); + expect("asString" in asExports(channelRuntimeSdk)).toBe(false); + expect("collectIssuesForEnabledAccounts" in asExports(channelRuntimeSdk)).toBe(false); + expect("isRecord" in asExports(channelRuntimeSdk)).toBe(false); + expect("resolveEnabledConfiguredAccountId" in asExports(channelRuntimeSdk)).toBe(false); + }); + + it("exports inbound channel helpers from the dedicated subpath", () => { + expect(typeof channelInboundSdk.buildMentionRegexes).toBe("function"); + expect(typeof channelInboundSdk.createChannelInboundDebouncer).toBe("function"); + expect(typeof channelInboundSdk.createInboundDebouncer).toBe("function"); + expect(typeof channelInboundSdk.formatInboundEnvelope).toBe("function"); + expect(typeof channelInboundSdk.formatInboundFromLabel).toBe("function"); + expect(typeof channelInboundSdk.formatLocationText).toBe("function"); + expect(typeof channelInboundSdk.logInboundDrop).toBe("function"); + expect(typeof channelInboundSdk.matchesMentionPatterns).toBe("function"); + expect(typeof channelInboundSdk.matchesMentionWithExplicit).toBe("function"); + expect(typeof channelInboundSdk.normalizeMentionText).toBe("function"); + expect(typeof channelInboundSdk.resolveInboundDebounceMs).toBe("function"); + expect(typeof channelInboundSdk.resolveEnvelopeFormatOptions).toBe("function"); + expect(typeof channelInboundSdk.resolveInboundSessionEnvelopeContext).toBe("function"); + expect(typeof channelInboundSdk.resolveMentionGating).toBe("function"); + expect(typeof channelInboundSdk.resolveMentionGatingWithBypass).toBe("function"); + expect(typeof channelInboundSdk.shouldDebounceTextInbound).toBe("function"); + expect(typeof channelInboundSdk.toLocationContext).toBe("function"); + expect("buildMentionRegexes" in asExports(replyRuntimeSdk)).toBe(false); + expect("createInboundDebouncer" in asExports(replyRuntimeSdk)).toBe(false); + expect("formatInboundEnvelope" in asExports(replyRuntimeSdk)).toBe(false); + expect("formatInboundFromLabel" in asExports(replyRuntimeSdk)).toBe(false); + expect("matchesMentionPatterns" in asExports(replyRuntimeSdk)).toBe(false); + expect("matchesMentionWithExplicit" in asExports(replyRuntimeSdk)).toBe(false); + expect("normalizeMentionText" in asExports(replyRuntimeSdk)).toBe(false); + expect("resolveEnvelopeFormatOptions" in asExports(replyRuntimeSdk)).toBe(false); + expect("resolveInboundDebounceMs" in asExports(replyRuntimeSdk)).toBe(false); }); it("exports channel setup helpers from the dedicated subpath", () => { @@ -133,9 +379,83 @@ describe("plugin-sdk subpath exports", () => { expect(typeof channelSetupSdk.createTopLevelChannelDmPolicy).toBe("function"); }); + it("exports channel action helpers from the dedicated subpath", () => { + expect(typeof channelActionsSdk.createUnionActionGate).toBe("function"); + expect(typeof channelActionsSdk.listTokenSourcedAccounts).toBe("function"); + expect(typeof channelActionsSdk.resolveReactionMessageId).toBe("function"); + }); + + it("exports channel target helpers from the dedicated subpath", () => { + expect(typeof channelTargetsSdk.applyChannelMatchMeta).toBe("function"); + expect(typeof channelTargetsSdk.buildChannelKeyCandidates).toBe("function"); + expect(typeof channelTargetsSdk.buildMessagingTarget).toBe("function"); + expect(typeof channelTargetsSdk.ensureTargetId).toBe("function"); + expect(typeof channelTargetsSdk.parseMentionPrefixOrAtUserTarget).toBe("function"); + expect(typeof channelTargetsSdk.requireTargetKind).toBe("function"); + expect(typeof channelTargetsSdk.resolveChannelEntryMatchWithFallback).toBe("function"); + expect(typeof channelTargetsSdk.resolveChannelMatchConfig).toBe("function"); + expect(typeof channelTargetsSdk.resolveTargetsWithOptionalToken).toBe("function"); + }); + + it("exports channel config write helpers from the dedicated subpath", () => { + expect(typeof channelConfigHelpersSdk.authorizeConfigWrite).toBe("function"); + expect(typeof channelConfigHelpersSdk.canBypassConfigWritePolicy).toBe("function"); + expect(typeof channelConfigHelpersSdk.formatConfigWriteDeniedMessage).toBe("function"); + expect(typeof channelConfigHelpersSdk.resolveChannelConfigWrites).toBe("function"); + }); + + it("keeps channel contract types on the dedicated subpath", () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + }); + + it("exports channel lifecycle helpers from the dedicated subpath", () => { + expect(typeof channelLifecycleSdk.createDraftStreamLoop).toBe("function"); + expect(typeof channelLifecycleSdk.createFinalizableDraftLifecycle).toBe("function"); + expect(typeof channelLifecycleSdk.runPassiveAccountLifecycle).toBe("function"); + expect(typeof channelLifecycleSdk.createRunStateMachine).toBe("function"); + expect(typeof channelLifecycleSdk.createArmableStallWatchdog).toBe("function"); + }); + + it("exports channel feedback helpers from the dedicated subpath", () => { + expect(typeof channelFeedbackSdk.createStatusReactionController).toBe("function"); + expect(typeof channelFeedbackSdk.logAckFailure).toBe("function"); + expect(typeof channelFeedbackSdk.logTypingFailure).toBe("function"); + expect(typeof channelFeedbackSdk.removeAckReactionAfterReply).toBe("function"); + expect(typeof channelFeedbackSdk.shouldAckReaction).toBe("function"); + expect(typeof channelFeedbackSdk.shouldAckReactionForWhatsApp).toBe("function"); + expect(typeof channelFeedbackSdk.DEFAULT_EMOJIS).toBe("object"); + }); + + it("exports status helper utilities from the dedicated subpath", () => { + expect(typeof statusHelpersSdk.appendMatchMetadata).toBe("function"); + expect(typeof statusHelpersSdk.asString).toBe("function"); + expect(typeof statusHelpersSdk.collectIssuesForEnabledAccounts).toBe("function"); + expect(typeof statusHelpersSdk.isRecord).toBe("function"); + expect(typeof statusHelpersSdk.resolveEnabledConfiguredAccountId).toBe("function"); + }); + + it("exports message tool schema helpers from the dedicated subpath", () => { + expect(typeof channelActionsSdk.createMessageToolButtonsSchema).toBe("function"); + expect(typeof channelActionsSdk.createMessageToolCardSchema).toBe("function"); + }); + it("exports channel pairing helpers from the dedicated subpath", () => { expect(typeof channelPairingSdk.createChannelPairingController).toBe("function"); expect(typeof channelPairingSdk.createChannelPairingChallengeIssuer).toBe("function"); + expect(typeof channelPairingSdk.createLoggedPairingApprovalNotifier).toBe("function"); + expect(typeof channelPairingSdk.createPairingPrefixStripper).toBe("function"); + expect(typeof channelPairingSdk.createTextPairingAdapter).toBe("function"); expect("createScopedPairingAccess" in asExports(channelPairingSdk)).toBe(false); }); @@ -146,22 +466,92 @@ describe("plugin-sdk subpath exports", () => { expect("createReplyPrefixOptions" in asExports(channelReplyPipelineSdk)).toBe(false); }); + it("exports command auth helpers from the dedicated subpath", () => { + expect(typeof commandAuthSdk.buildCommandTextFromArgs).toBe("function"); + expect(typeof commandAuthSdk.buildCommandsPaginationKeyboard).toBe("function"); + expect(typeof commandAuthSdk.buildModelsProviderData).toBe("function"); + expect(typeof commandAuthSdk.hasControlCommand).toBe("function"); + expect(typeof commandAuthSdk.listNativeCommandSpecsForConfig).toBe("function"); + expect(typeof commandAuthSdk.listSkillCommandsForAgents).toBe("function"); + expect(typeof commandAuthSdk.normalizeCommandBody).toBe("function"); + expect(typeof commandAuthSdk.resolveCommandAuthorization).toBe("function"); + expect(typeof commandAuthSdk.resolveCommandAuthorizedFromAuthorizers).toBe("function"); + expect(typeof commandAuthSdk.resolveControlCommandGate).toBe("function"); + expect(typeof commandAuthSdk.resolveDualTextControlCommandGate).toBe("function"); + expect(typeof commandAuthSdk.resolveNativeCommandSessionTargets).toBe("function"); + expect(typeof commandAuthSdk.resolveStoredModelOverride).toBe("function"); + expect(typeof commandAuthSdk.shouldComputeCommandAuthorized).toBe("function"); + expect(typeof commandAuthSdk.shouldHandleTextCommands).toBe("function"); + expect("hasControlCommand" in asExports(replyRuntimeSdk)).toBe(false); + expect("buildCommandTextFromArgs" in asExports(replyRuntimeSdk)).toBe(false); + expect("buildCommandsPaginationKeyboard" in asExports(replyRuntimeSdk)).toBe(false); + expect("buildModelsProviderData" in asExports(replyRuntimeSdk)).toBe(false); + expect("listNativeCommandSpecsForConfig" in asExports(replyRuntimeSdk)).toBe(false); + expect("listSkillCommandsForAgents" in asExports(replyRuntimeSdk)).toBe(false); + expect("normalizeCommandBody" in asExports(replyRuntimeSdk)).toBe(false); + expect("resolveCommandAuthorization" in asExports(replyRuntimeSdk)).toBe(false); + expect("resolveStoredModelOverride" in asExports(replyRuntimeSdk)).toBe(false); + expect("shouldComputeCommandAuthorized" in asExports(replyRuntimeSdk)).toBe(false); + expect("shouldHandleTextCommands" in asExports(replyRuntimeSdk)).toBe(false); + }); + it("exports channel send-result helpers from the dedicated subpath", () => { expect(typeof channelSendResultSdk.attachChannelToResult).toBe("function"); expect(typeof channelSendResultSdk.buildChannelSendResult).toBe("function"); }); + it("exports binding lifecycle helpers from the conversation-runtime subpath", () => { + expect(typeof conversationRuntimeSdk.DISCORD_THREAD_BINDING_CHANNEL).toBe("string"); + expect(typeof conversationRuntimeSdk.MATRIX_THREAD_BINDING_CHANNEL).toBe("string"); + expect(typeof conversationRuntimeSdk.formatThreadBindingDisabledError).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingFarewellText).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingConversationIdFromBindingId).toBe( + "function", + ); + expect(typeof conversationRuntimeSdk.resolveThreadBindingEffectiveExpiresAt).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingIdleTimeoutMs).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingIdleTimeoutMsForChannel).toBe( + "function", + ); + expect(typeof conversationRuntimeSdk.resolveThreadBindingIntroText).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingLifecycle).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingMaxAgeMs).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingMaxAgeMsForChannel).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingSpawnPolicy).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingThreadName).toBe("function"); + expect(typeof conversationRuntimeSdk.resolveThreadBindingsEnabled).toBe("function"); + expect(typeof conversationRuntimeSdk.formatThreadBindingDurationLabel).toBe("function"); + expect(typeof conversationRuntimeSdk.createScopedAccountReplyToModeResolver).toBe("function"); + expect(typeof conversationRuntimeSdk.createStaticReplyToModeResolver).toBe("function"); + expect(typeof conversationRuntimeSdk.createTopLevelChannelReplyToModeResolver).toBe("function"); + }); + + it("exports narrow binding lifecycle helpers from the dedicated subpath", () => { + expect(typeof threadBindingsRuntimeSdk.resolveThreadBindingLifecycle).toBe("function"); + }); + + it("exports narrow matrix runtime helpers from the dedicated subpath", () => { + expect(typeof matrixRuntimeSharedSdk.formatZonedTimestamp).toBe("function"); + }); + + it("exports narrow ssrf helpers from the dedicated subpath", () => { + expect(typeof ssrfRuntimeSdk.closeDispatcher).toBe("function"); + expect(typeof ssrfRuntimeSdk.createPinnedDispatcher).toBe("function"); + expect(typeof ssrfRuntimeSdk.resolvePinnedHostnameWithPolicy).toBe("function"); + expect(typeof ssrfRuntimeSdk.assertHttpUrlTargetsPrivateNetwork).toBe("function"); + expect(typeof ssrfRuntimeSdk.ssrfPolicyFromAllowPrivateNetwork).toBe("function"); + }); + it("exports provider setup helpers from the dedicated subpath", () => { expect(typeof providerSetupSdk.buildVllmProvider).toBe("function"); expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function"); }); - it("exports oauth helpers from the dedicated provider oauth subpath", () => { - expect(typeof providerOauthSdk.buildOauthProviderAuthResult).toBe("function"); - expect(typeof providerOauthSdk.generatePkceVerifierChallenge).toBe("function"); - expect(typeof providerOauthSdk.toFormUrlEncoded).toBe("function"); + it("exports oauth helpers from provider-auth", () => { + expect(typeof providerAuthSdk.buildOauthProviderAuthResult).toBe("function"); + expect(typeof providerAuthSdk.generatePkceVerifierChallenge).toBe("function"); + expect(typeof providerAuthSdk.toFormUrlEncoded).toBe("function"); expect("buildOauthProviderAuthResult" in asExports(coreSdk)).toBe(false); - expect("buildOauthProviderAuthResult" in asExports(providerAuthSdk)).toBe(false); }); it("keeps provider models focused on shared provider primitives", () => { @@ -209,6 +599,9 @@ describe("plugin-sdk subpath exports", () => { expect(typeof secretInputSdk.buildSecretInputSchema).toBe("function"); expect(typeof secretInputSdk.buildOptionalSecretInputSchema).toBe("function"); expect(typeof secretInputSdk.normalizeSecretInputString).toBe("function"); + expect("hasConfiguredSecretInput" in asExports(configRuntimeSdk)).toBe(false); + expect("normalizeResolvedSecretInputString" in asExports(configRuntimeSdk)).toBe(false); + expect("normalizeSecretInputString" in asExports(configRuntimeSdk)).toBe(false); }); it("exports webhook ingress helpers from the dedicated subpath", () => { @@ -220,7 +613,7 @@ describe("plugin-sdk subpath exports", () => { expect(typeof webhookIngressSdk.withResolvedWebhookRequestPipeline).toBe("function"); }); - it("exports shared core types used by bundled channels", () => { + it("exports shared core types used by bundled extensions", () => { expectTypeOf().toMatchTypeOf(); expectTypeOf().toMatchTypeOf(); expectTypeOf().toMatchTypeOf(); @@ -237,62 +630,6 @@ describe("plugin-sdk subpath exports", () => { expectTypeOf().toMatchTypeOf(); }); - it("exports Discord helpers", () => { - expect(typeof discordSdk.buildChannelConfigSchema).toBe("function"); - expect(typeof discordSdk.DiscordConfigSchema).toBe("object"); - expect(typeof discordSdk.projectCredentialSnapshotFields).toBe("function"); - expect("resolveDiscordAccount" in asExports(discordSdk)).toBe(false); - }); - - it("exports Slack helpers", () => { - expect(typeof slackSdk.buildChannelConfigSchema).toBe("function"); - expect(typeof slackSdk.SlackConfigSchema).toBe("object"); - expect(typeof slackSdk.looksLikeSlackTargetId).toBe("function"); - expect("resolveSlackAccount" in asExports(slackSdk)).toBe(false); - }); - - it("exports Telegram helpers", () => { - expect(typeof telegramSdk.buildChannelConfigSchema).toBe("function"); - expect(typeof telegramSdk.TelegramConfigSchema).toBe("object"); - expect(typeof telegramSdk.projectCredentialSnapshotFields).toBe("function"); - expect("resolveTelegramAccount" in asExports(telegramSdk)).toBe(false); - }); - - it("exports iMessage helpers", () => { - expect(typeof imessageSdk.IMessageConfigSchema).toBe("object"); - expect(typeof imessageSdk.resolveIMessageConfigAllowFrom).toBe("function"); - expect(typeof imessageSdk.looksLikeIMessageTargetId).toBe("function"); - expect("resolveIMessageAccount" in asExports(imessageSdk)).toBe(false); - }); - - it("exports iMessage core helpers", () => { - expect(typeof imessageCoreSdk.buildChannelConfigSchema).toBe("function"); - expect(typeof imessageCoreSdk.parseChatTargetPrefixesOrThrow).toBe("function"); - expect(typeof imessageCoreSdk.resolveServicePrefixedTarget).toBe("function"); - expect(typeof imessageCoreSdk.IMessageConfigSchema).toBe("object"); - }); - - it("exports WhatsApp helpers", () => { - expect(typeof whatsappSdk.WhatsAppConfigSchema).toBe("object"); - expect(typeof whatsappSdk.resolveWhatsAppOutboundTarget).toBe("function"); - expect(typeof whatsappSdk.resolveWhatsAppMentionStripRegexes).toBe("function"); - expect(typeof whatsappSdk.sendMessageWhatsApp).toBe("function"); - expect(typeof whatsappSdk.loadWebMedia).toBe("function"); - }); - - it("exports WhatsApp QR login helpers from the dedicated subpath", () => { - expect(typeof whatsappLoginQrSdk.startWebLoginWithQr).toBe("function"); - expect(typeof whatsappLoginQrSdk.waitForWebLogin).toBe("function"); - }); - - it("exports WhatsApp action runtime helpers from the dedicated subpath", () => { - expect(typeof whatsappActionRuntimeSdk.handleWhatsAppAction).toBe("function"); - }); - - it("keeps the remaining bundled helper surface narrow", () => { - expect(typeof bluebubblesSdk.parseFiniteNumber).toBe("function"); - }); - it("resolves every curated public subpath", async () => { for (const { id, load } of bundledExtensionSubpathLoaders) { const mod = await load(); diff --git a/src/plugin-sdk/thread-bindings-runtime.ts b/src/plugin-sdk/thread-bindings-runtime.ts new file mode 100644 index 00000000000..007c46465be --- /dev/null +++ b/src/plugin-sdk/thread-bindings-runtime.ts @@ -0,0 +1,9 @@ +// Narrow thread-binding lifecycle helpers for extensions that need binding +// expiry and session-binding record types without loading the full +// conversation-runtime surface. + +export { resolveThreadBindingLifecycle } from "../channels/thread-bindings-policy.js"; +export type { + BindingTargetKind, + SessionBindingRecord, +} from "../infra/outbound/session-binding-service.js"; diff --git a/src/plugin-sdk/tool-send.ts b/src/plugin-sdk/tool-send.ts index 61ee56fa9ac..7bd3c91acc1 100644 --- a/src/plugin-sdk/tool-send.ts +++ b/src/plugin-sdk/tool-send.ts @@ -1,3 +1,5 @@ +export type { ChannelToolSend } from "../channels/plugins/types.js"; + /** Extract the canonical send target fields from tool arguments when the action matches. */ export function extractToolSend( args: Record, diff --git a/src/plugin-sdk/whatsapp-shared.ts b/src/plugin-sdk/whatsapp-shared.ts index d1794898bc3..b55cf4304d1 100644 --- a/src/plugin-sdk/whatsapp-shared.ts +++ b/src/plugin-sdk/whatsapp-shared.ts @@ -5,5 +5,10 @@ export { resolveWhatsAppGroupIntroHint, resolveWhatsAppMentionStripRegexes, } from "../channels/plugins/whatsapp-shared.js"; +export { + looksLikeWhatsAppTargetId, + normalizeWhatsAppAllowFromEntries, + normalizeWhatsAppMessagingTarget, +} from "../channels/plugins/normalize/whatsapp.js"; export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp-heartbeat.js"; export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; diff --git a/src/plugins/bundled-web-search.ts b/src/plugins/bundled-web-search.ts index 4b9594caaf8..3aa01274da6 100644 --- a/src/plugins/bundled-web-search.ts +++ b/src/plugins/bundled-web-search.ts @@ -1,264 +1,64 @@ -import { - getScopedCredentialValue, - getTopLevelCredentialValue, - resolveProviderWebSearchPluginConfig, - setProviderWebSearchPluginConfigValue, - setScopedCredentialValue, - setTopLevelCredentialValue, -} from "../agents/tools/web-search-provider-config.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; -import { enablePluginInConfig } from "./enable.js"; +import { bundledWebSearchPluginRegistrations } from "../bundled-web-search-registry.js"; +import { capturePluginRegistration } from "./captured-registration.js"; import type { PluginLoadOptions } from "./loader.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; -import type { PluginWebSearchProviderEntry, WebSearchRuntimeMetadataContext } from "./types.js"; +import type { PluginWebSearchProviderEntry } from "./types.js"; -const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; -const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; -const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; -const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; +type BundledWebSearchProviderEntry = PluginWebSearchProviderEntry & { pluginId: string }; +type BundledWebSearchPluginRegistration = (typeof bundledWebSearchPluginRegistrations)[number]; -type BundledWebSearchProviderDescriptor = { - pluginId: string; - id: string; - label: string; - hint: string; - envVars: string[]; - placeholder: string; - signupUrl: string; - docsUrl?: string; - autoDetectOrder: number; - credentialPath: string; - inactiveSecretPaths: string[]; - credentialScope: - | { kind: "top-level" } - | { - kind: "scoped"; - key: string; - }; - supportsConfiguredCredentialValue?: boolean; - applySelectionConfig?: (config: OpenClawConfig) => OpenClawConfig; - resolveRuntimeMetadata?: ( - ctx: WebSearchRuntimeMetadataContext, - ) => Partial; -}; +let bundledWebSearchProvidersCache: BundledWebSearchProviderEntry[] | null = null; +let bundledWebSearchPluginIdsCache: string[] | null = null; -function inferPerplexityBaseUrlFromApiKey(apiKey?: string): "direct" | "openrouter" | undefined { - if (!apiKey) { - return undefined; - } - const normalized = apiKey.toLowerCase(); - if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return "direct"; - } - if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return "openrouter"; - } - return undefined; -} - -function isDirectPerplexityBaseUrl(baseUrl: string): boolean { +function resolveBundledWebSearchPlugin( + entry: BundledWebSearchPluginRegistration, +): BundledWebSearchPluginRegistration["plugin"] | null { try { - return new URL(baseUrl.trim()).hostname.toLowerCase() === "api.perplexity.ai"; + return entry.plugin; } catch { - return false; + return null; } } -function resolvePerplexityRuntimeMetadata( - ctx: WebSearchRuntimeMetadataContext, -): Partial { - const perplexity = ctx.searchConfig?.perplexity; - const scoped = - perplexity && typeof perplexity === "object" && !Array.isArray(perplexity) - ? (perplexity as { baseUrl?: string; model?: string }) - : undefined; - const configuredBaseUrl = typeof scoped?.baseUrl === "string" ? scoped.baseUrl.trim() : ""; - const configuredModel = typeof scoped?.model === "string" ? scoped.model.trim() : ""; - const keySource = ctx.resolvedCredential?.source ?? "missing"; - const baseUrl = (() => { - if (configuredBaseUrl) { - return configuredBaseUrl; - } - if (keySource === "env") { - if (ctx.resolvedCredential?.fallbackEnvVar === "PERPLEXITY_API_KEY") { - return PERPLEXITY_DIRECT_BASE_URL; - } - if (ctx.resolvedCredential?.fallbackEnvVar === "OPENROUTER_API_KEY") { - return DEFAULT_PERPLEXITY_BASE_URL; - } - } - if ((keySource === "config" || keySource === "secretRef") && ctx.resolvedCredential?.value) { - return inferPerplexityBaseUrlFromApiKey(ctx.resolvedCredential.value) === "openrouter" - ? DEFAULT_PERPLEXITY_BASE_URL - : PERPLEXITY_DIRECT_BASE_URL; - } - return DEFAULT_PERPLEXITY_BASE_URL; - })(); - return { - perplexityTransport: - configuredBaseUrl || configuredModel || !isDirectPerplexityBaseUrl(baseUrl) - ? "chat_completions" - : "search_api", - }; +function listBundledWebSearchPluginRegistrations() { + return bundledWebSearchPluginRegistrations + .map((entry) => { + const plugin = resolveBundledWebSearchPlugin(entry); + return plugin ? { ...entry, plugin } : null; + }) + .filter( + ( + entry, + ): entry is BundledWebSearchPluginRegistration & { + plugin: BundledWebSearchPluginRegistration["plugin"]; + } => Boolean(entry), + ); } -const BUNDLED_WEB_SEARCH_PROVIDER_DESCRIPTORS = [ - { - pluginId: "brave", - id: "brave", - label: "Brave Search", - hint: "Structured results Β· country/language/time filters", - envVars: ["BRAVE_API_KEY"], - placeholder: "BSA...", - signupUrl: "https://brave.com/search/api/", - docsUrl: "https://docs.openclaw.ai/brave-search", - autoDetectOrder: 10, - credentialPath: "plugins.entries.brave.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"], - credentialScope: { kind: "top-level" }, - }, - { - pluginId: "google", - id: "gemini", - label: "Gemini (Google Search)", - hint: "Google Search grounding Β· AI-synthesized", - envVars: ["GEMINI_API_KEY"], - placeholder: "AIza...", - signupUrl: "https://aistudio.google.com/apikey", - docsUrl: "https://docs.openclaw.ai/tools/web", - autoDetectOrder: 20, - credentialPath: "plugins.entries.google.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"], - credentialScope: { kind: "scoped", key: "gemini" }, - }, - { - pluginId: "xai", - id: "grok", - label: "Grok (xAI)", - hint: "xAI web-grounded responses", - envVars: ["XAI_API_KEY"], - placeholder: "xai-...", - signupUrl: "https://console.x.ai/", - docsUrl: "https://docs.openclaw.ai/tools/web", - autoDetectOrder: 30, - credentialPath: "plugins.entries.xai.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.xai.config.webSearch.apiKey"], - credentialScope: { kind: "scoped", key: "grok" }, - supportsConfiguredCredentialValue: false, - }, - { - pluginId: "moonshot", - id: "kimi", - label: "Kimi (Moonshot)", - hint: "Moonshot web search", - envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], - placeholder: "sk-...", - signupUrl: "https://platform.moonshot.cn/", - docsUrl: "https://docs.openclaw.ai/tools/web", - autoDetectOrder: 40, - credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.moonshot.config.webSearch.apiKey"], - credentialScope: { kind: "scoped", key: "kimi" }, - }, - { - pluginId: "perplexity", - id: "perplexity", - label: "Perplexity Search", - hint: "Structured results Β· domain/country/language/time filters", - envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"], - placeholder: "pplx-...", - signupUrl: "https://www.perplexity.ai/settings/api", - docsUrl: "https://docs.openclaw.ai/perplexity", - autoDetectOrder: 50, - credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.perplexity.config.webSearch.apiKey"], - credentialScope: { kind: "scoped", key: "perplexity" }, - resolveRuntimeMetadata: resolvePerplexityRuntimeMetadata, - }, - { - pluginId: "firecrawl", - id: "firecrawl", - label: "Firecrawl Search", - hint: "Structured results with optional result scraping", - envVars: ["FIRECRAWL_API_KEY"], - placeholder: "fc-...", - signupUrl: "https://www.firecrawl.dev/", - docsUrl: "https://docs.openclaw.ai/tools/firecrawl", - autoDetectOrder: 60, - credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.firecrawl.config.webSearch.apiKey"], - credentialScope: { kind: "scoped", key: "firecrawl" }, - applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config, - }, - { - pluginId: "tavily", - id: "tavily", - label: "Tavily Search", - hint: "Structured results with domain filters and AI answer summaries", - envVars: ["TAVILY_API_KEY"], - placeholder: "tvly-...", - signupUrl: "https://tavily.com/", - docsUrl: "https://docs.openclaw.ai/tools/tavily", - autoDetectOrder: 70, - credentialPath: "plugins.entries.tavily.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.tavily.config.webSearch.apiKey"], - credentialScope: { kind: "scoped", key: "tavily" }, - applySelectionConfig: (config) => enablePluginInConfig(config, "tavily").config, - }, -] as const satisfies ReadonlyArray; +function loadBundledWebSearchPluginIds(): string[] { + if (!bundledWebSearchPluginIdsCache) { + bundledWebSearchPluginIdsCache = listBundledWebSearchPluginRegistrations() + .map(({ plugin }) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); + } + return bundledWebSearchPluginIdsCache; +} -export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = [ - ...new Set(BUNDLED_WEB_SEARCH_PROVIDER_DESCRIPTORS.map((descriptor) => descriptor.pluginId)), -] as ReadonlyArray; +export function listBundledWebSearchPluginIds(): string[] { + return loadBundledWebSearchPluginIds(); +} -const bundledWebSearchPluginIdSet = new Set(BUNDLED_WEB_SEARCH_PLUGIN_IDS); - -function buildBundledWebSearchProviderEntry( - descriptor: BundledWebSearchProviderDescriptor, -): PluginWebSearchProviderEntry { - const scopedKey = - descriptor.credentialScope.kind === "scoped" ? descriptor.credentialScope.key : undefined; - return { - pluginId: descriptor.pluginId, - id: descriptor.id, - label: descriptor.label, - hint: descriptor.hint, - envVars: [...descriptor.envVars], - placeholder: descriptor.placeholder, - signupUrl: descriptor.signupUrl, - docsUrl: descriptor.docsUrl, - autoDetectOrder: descriptor.autoDetectOrder, - credentialPath: descriptor.credentialPath, - inactiveSecretPaths: [...descriptor.inactiveSecretPaths], - getCredentialValue: - descriptor.credentialScope.kind === "top-level" - ? getTopLevelCredentialValue - : (searchConfig) => getScopedCredentialValue(searchConfig, scopedKey!), - setCredentialValue: - descriptor.credentialScope.kind === "top-level" - ? setTopLevelCredentialValue - : (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, scopedKey!, value), - getConfiguredCredentialValue: - descriptor.supportsConfiguredCredentialValue === false - ? undefined - : (config) => resolveProviderWebSearchPluginConfig(config, descriptor.pluginId)?.apiKey, - setConfiguredCredentialValue: - descriptor.supportsConfiguredCredentialValue === false - ? undefined - : (configTarget, value) => { - setProviderWebSearchPluginConfigValue( - configTarget, - descriptor.pluginId, - "apiKey", - value, - ); - }, - applySelectionConfig: descriptor.applySelectionConfig, - resolveRuntimeMetadata: descriptor.resolveRuntimeMetadata, - createTool: () => null, - }; +function loadBundledWebSearchProviders(): BundledWebSearchProviderEntry[] { + if (!bundledWebSearchProvidersCache) { + bundledWebSearchProvidersCache = listBundledWebSearchPluginRegistrations().flatMap( + ({ plugin }) => + capturePluginRegistration(plugin).webSearchProviders.map((provider) => ({ + ...provider, + pluginId: plugin.id, + })), + ); + } + return bundledWebSearchProvidersCache; } export function resolveBundledWebSearchPluginIds(params: { @@ -271,6 +71,7 @@ export function resolveBundledWebSearchPluginIds(params: { workspaceDir: params.workspaceDir, env: params.env, }); + const bundledWebSearchPluginIdSet = new Set(loadBundledWebSearchPluginIds()); return registry.plugins .filter((plugin) => plugin.origin === "bundled" && bundledWebSearchPluginIdSet.has(plugin.id)) .map((plugin) => plugin.id) @@ -278,9 +79,7 @@ export function resolveBundledWebSearchPluginIds(params: { } export function listBundledWebSearchProviders(): PluginWebSearchProviderEntry[] { - return BUNDLED_WEB_SEARCH_PROVIDER_DESCRIPTORS.map((descriptor) => - buildBundledWebSearchProviderEntry(descriptor), - ); + return loadBundledWebSearchProviders(); } export function resolveBundledWebSearchPluginId( @@ -289,6 +88,5 @@ export function resolveBundledWebSearchPluginId( if (!providerId) { return undefined; } - return BUNDLED_WEB_SEARCH_PROVIDER_DESCRIPTORS.find((descriptor) => descriptor.id === providerId) - ?.pluginId; + return loadBundledWebSearchProviders().find((provider) => provider.id === providerId)?.pluginId; } diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 9f10ae7fe81..51997a53fff 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { discordPlugin } from "../../extensions/discord/src/channel.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { __testing, @@ -21,9 +20,7 @@ async function importCommandsModule(cacheBust: string): Promise } beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]), - ); + setActivePluginRegistry(createTestRegistry([])); }); afterEach(() => { diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index cde5b8e8e2d..0a419efebe1 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -1,13 +1,11 @@ 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 chutesPlugin from "../../../extensions/chutes/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 falPlugin from "../../../extensions/fal/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"; @@ -24,12 +22,10 @@ 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 qwenPortalAuthPlugin from "../../../extensions/qwen-portal-auth/index.js"; import sglangPlugin from "../../../extensions/sglang/index.js"; import syntheticPlugin from "../../../extensions/synthetic/index.js"; -import tavilyPlugin from "../../../extensions/tavily/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"; @@ -38,6 +34,7 @@ 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 { bundledWebSearchPluginRegistrations } from "../../bundled-web-search-registry.js"; import { createCapturedPluginRegistration } from "../captured-registration.js"; import { resolvePluginProviders } from "../providers.js"; import type { @@ -79,15 +76,11 @@ type PluginRegistrationContractEntry = { toolNames: string[]; }; -const bundledWebSearchPlugins: Array = [ - { ...bravePlugin, credentialValue: "BSA-test" }, - { ...firecrawlPlugin, credentialValue: "fc-test" }, - { ...googlePlugin, credentialValue: "AIza-test" }, - { ...moonshotPlugin, credentialValue: "sk-test" }, - { ...perplexityPlugin, credentialValue: "pplx-test" }, - { ...tavilyPlugin, credentialValue: "tvly-test" }, - { ...xaiPlugin, credentialValue: "xai-test" }, -]; +const bundledWebSearchPlugins: Array = + bundledWebSearchPluginRegistrations.map(({ plugin, credentialValue }) => ({ + ...plugin, + credentialValue, + })); const bundledSpeechPlugins: RegistrablePlugin[] = [elevenLabsPlugin, microsoftPlugin, openAIPlugin]; const bundledMediaUnderstandingPlugins: RegistrablePlugin[] = [ diff --git a/src/plugins/loader.git-path-regression.test.ts b/src/plugins/loader.git-path-regression.test.ts index fac5e22657c..6f74b508c3d 100644 --- a/src/plugins/loader.git-path-regression.test.ts +++ b/src/plugins/loader.git-path-regression.test.ts @@ -23,7 +23,7 @@ afterEach(() => { }); describe("plugin loader git path regression", () => { - it("loads git-style package extension entries when they import plugin-sdk channel-runtime (#49806)", async () => { + it("loads git-style package extension entries when they import plugin-sdk infra-runtime (#49806)", async () => { const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "imessage"); const copiedSourceDir = path.join(copiedExtensionRoot, "src"); const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk"); @@ -33,7 +33,7 @@ describe("plugin loader git path regression", () => { fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8"); fs.writeFileSync( path.join(copiedSourceDir, "channel.runtime.ts"), - `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; + `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; import { PAIRING_APPROVED_MESSAGE } from "../runtime-api.js"; export const copiedRuntimeMarker = { @@ -49,7 +49,7 @@ export const copiedRuntimeMarker = { `, "utf-8", ); - const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "channel-runtime.ts"); + const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "infra-runtime.ts"); fs.writeFileSync( copiedChannelRuntimeShim, `export function resolveOutboundSendDep() { @@ -77,7 +77,7 @@ export const copiedRuntimeMarker = { tryNative: false, extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], alias: { - "openclaw/plugin-sdk/channel-runtime": ${JSON.stringify(copiedChannelRuntimeShim)}, + "openclaw/plugin-sdk/infra-runtime": ${JSON.stringify(copiedChannelRuntimeShim)}, }, }); const mod = withAlias(${JSON.stringify(copiedChannelRuntime)}); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index a4bf12fad15..8af6cf927d4 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { emitDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js"; import { withEnv } from "../test-utils/env.js"; type CreateJiti = typeof import("jiti").createJiti; @@ -358,6 +359,23 @@ function createPluginSdkAliasFixture(params?: { return { root, srcFile, distFile }; } +function createExtensionApiAliasFixture(params?: { srcBody?: string; distBody?: string }) { + const root = makeTempDir(); + const srcFile = path.join(root, "src", "extensionAPI.ts"); + const distFile = path.join(root, "dist", "extensionAPI.js"); + mkdirSafe(path.dirname(srcFile)); + mkdirSafe(path.dirname(distFile)); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ name: "openclaw", type: "module" }, null, 2), + "utf-8", + ); + fs.writeFileSync(path.join(root, "openclaw.mjs"), "export {};\n", "utf-8"); + fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8"); + fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8"); + return { root, srcFile, distFile }; +} + function createPluginRuntimeAliasFixture(params?: { srcBody?: string; distBody?: string }) { const root = makeTempDir(); const srcFile = path.join(root, "src", "plugins", "runtime", "index.ts"); @@ -685,6 +703,7 @@ function resolvePluginRuntimeModule(params: { afterEach(() => { clearPluginLoaderCache(); + resetDiagnosticEventsForTest(); if (prevBundledDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; } else { @@ -3271,6 +3290,70 @@ module.exports = { expect(record?.status).toBe("loaded"); }); + it("supports legacy plugins subscribing to diagnostic events from the root sdk", async () => { + useNoBundledPlugins(); + const seenKey = "__openclawLegacyRootDiagnosticSeen"; + delete (globalThis as Record)[seenKey]; + + const plugin = writePlugin({ + id: "legacy-root-diagnostic-listener", + filename: "legacy-root-diagnostic-listener.cjs", + body: `module.exports = { + id: "legacy-root-diagnostic-listener", + configSchema: (require("openclaw/plugin-sdk").emptyPluginConfigSchema)(), + register() { + const { onDiagnosticEvent } = require("openclaw/plugin-sdk"); + if (typeof onDiagnosticEvent !== "function") { + throw new Error("missing onDiagnosticEvent root export"); + } + globalThis.${seenKey} = []; + onDiagnosticEvent((event) => { + globalThis.${seenKey}.push({ + type: event.type, + sessionKey: event.sessionKey, + }); + }); + }, +};`, + }); + + try { + const registry = withEnv( + { OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins" }, + () => + loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["legacy-root-diagnostic-listener"], + }, + }, + }), + ); + const record = registry.plugins.find( + (entry) => entry.id === "legacy-root-diagnostic-listener", + ); + expect(record?.status).toBe("loaded"); + + emitDiagnosticEvent({ + type: "model.usage", + sessionKey: "agent:main:test:dm:peer", + usage: { total: 1 }, + }); + + expect((globalThis as Record)[seenKey]).toEqual([ + { + type: "model.usage", + sessionKey: "agent:main:test:dm:peer", + }, + ]); + } finally { + delete (globalThis as Record)[seenKey]; + } + }); + it.each([ { name: "prefers dist plugin-sdk alias when loader runs from dist", @@ -3354,6 +3437,36 @@ module.exports = { expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile); }); + it.each([ + { + name: "prefers dist extension-api alias when loader runs from dist", + modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"), + expected: "dist" as const, + }, + { + name: "prefers src extension-api alias when loader runs from src in non-production", + modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"), + env: { NODE_ENV: undefined }, + expected: "src" as const, + }, + { + name: "resolves extension-api alias 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 = createExtensionApiAliasFixture(); + const resolved = withEnv(env ?? {}, () => + __testing.resolveExtensionApiAlias({ + modulePath: modulePath(fixture.root), + argv1: argv1?.(fixture.root), + }), + ); + expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile); + }); + it.each([ { name: "prefers dist candidates first for production src runtime", @@ -3570,7 +3683,7 @@ module.exports = { fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8"); fs.writeFileSync( path.join(copiedSourceDir, "channel.runtime.ts"), - `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; + `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; export const syntheticRuntimeMarker = { resolveOutboundSendDep, @@ -3578,7 +3691,7 @@ export const syntheticRuntimeMarker = { `, "utf-8", ); - const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "channel-runtime.ts"); + const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "infra-runtime.ts"); fs.writeFileSync( copiedChannelRuntimeShim, `export function resolveOutboundSendDep() { @@ -3601,7 +3714,7 @@ export const syntheticRuntimeMarker = { const withAlias = createJiti(jitiBaseUrl, { ...__testing.buildPluginLoaderJitiOptions({ - "openclaw/plugin-sdk/channel-runtime": copiedChannelRuntimeShim, + "openclaw/plugin-sdk/infra-runtime": copiedChannelRuntimeShim, }), tryNative: false, }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 03a1b0810ff..6f5900f8334 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -130,12 +130,42 @@ const resolvePluginSdkAlias = (params: LoaderModuleResolveParams = {}): string | function buildPluginLoaderAliasMap(modulePath: string): Record { const pluginSdkAlias = resolvePluginSdkAlias({ modulePath }); + const extensionApiAlias = resolveExtensionApiAlias({ modulePath }); return { + ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), ...resolvePluginSdkScopedAliasMap({ modulePath }), }; } +const resolveExtensionApiAlias = (params: LoaderModuleResolveParams = {}): string | null => { + try { + const modulePath = resolveLoaderModulePath(params); + const packageRoot = resolveLoaderPackageRoot({ ...params, modulePath }); + if (!packageRoot) { + return null; + } + + const orderedKinds = resolvePluginSdkAliasCandidateOrder({ + modulePath, + isProduction: process.env.NODE_ENV === "production", + }); + const candidateMap = { + src: path.join(packageRoot, "src", "extensionAPI.ts"), + dist: path.join(packageRoot, "dist", "extensionAPI.js"), + } as const; + for (const kind of orderedKinds) { + const candidate = candidateMap[kind]; + if (fs.existsSync(candidate)) { + return candidate; + } + } + } catch { + // ignore + } + return null; +}; + function resolvePluginRuntimeModulePath(params: LoaderModuleResolveParams = {}): string | null { try { const modulePath = resolveLoaderModulePath(params); @@ -170,6 +200,7 @@ export const __testing = { buildPluginLoaderAliasMap, listPluginSdkAliasCandidates, listPluginSdkExportedSubpaths, + resolveExtensionApiAlias, resolvePluginSdkScopedAliasMap, resolvePluginSdkAliasCandidateOrder, resolvePluginSdkAliasFile, diff --git a/src/plugins/runtime/runtime-discord-ops.runtime.ts b/src/plugins/runtime/runtime-discord-ops.runtime.ts index 02a4cc22eb0..3e96771094a 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 "openclaw/plugin-sdk/discord"; import { + auditDiscordChannelPermissions as auditDiscordChannelPermissionsImpl, listDiscordDirectoryGroupsLive as listDiscordDirectoryGroupsLiveImpl, listDiscordDirectoryPeersLive as listDiscordDirectoryPeersLiveImpl, -} from "openclaw/plugin-sdk/discord"; -import { monitorDiscordProvider as monitorDiscordProviderImpl } from "openclaw/plugin-sdk/discord"; -import { probeDiscord as probeDiscordImpl } from "openclaw/plugin-sdk/discord"; -import { resolveDiscordChannelAllowlist as resolveDiscordChannelAllowlistImpl } from "openclaw/plugin-sdk/discord"; -import { resolveDiscordUserAllowlist as resolveDiscordUserAllowlistImpl } from "openclaw/plugin-sdk/discord"; + monitorDiscordProvider as monitorDiscordProviderImpl, + probeDiscord as probeDiscordImpl, + resolveDiscordChannelAllowlist as resolveDiscordChannelAllowlistImpl, + 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 "openclaw/plugin-sdk/discord"; +} from "../../../extensions/discord/runtime-api.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeDiscordOps = Pick< diff --git a/src/plugins/runtime/runtime-discord.ts b/src/plugins/runtime/runtime-discord.ts index 354d205a66d..27535bf602c 100644 --- a/src/plugins/runtime/runtime-discord.ts +++ b/src/plugins/runtime/runtime-discord.ts @@ -1,5 +1,5 @@ -import { discordMessageActions } from "openclaw/plugin-sdk/discord"; import { + discordMessageActions, getThreadBindingManager, resolveThreadBindingIdleTimeoutMs, resolveThreadBindingInactivityExpiresAt, @@ -8,7 +8,7 @@ import { setThreadBindingIdleTimeoutBySessionKey, setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, -} from "openclaw/plugin-sdk/discord"; +} from "../../../extensions/discord/runtime-api.js"; import { createLazyRuntimeMethodBinder, createLazyRuntimeSurface, diff --git a/src/plugins/runtime/runtime-imessage.ts b/src/plugins/runtime/runtime-imessage.ts index 7740b6bdfa3..56136197626 100644 --- a/src/plugins/runtime/runtime-imessage.ts +++ b/src/plugins/runtime/runtime-imessage.ts @@ -2,7 +2,7 @@ import { monitorIMessageProvider, probeIMessage, sendMessageIMessage, -} from "openclaw/plugin-sdk/imessage"; +} 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-matrix-boundary.ts b/src/plugins/runtime/runtime-matrix-boundary.ts new file mode 100644 index 00000000000..a122e613c1f --- /dev/null +++ b/src/plugins/runtime/runtime-matrix-boundary.ts @@ -0,0 +1,129 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createJiti } from "jiti"; +import { loadConfig } from "../../config/config.js"; +import { loadPluginManifestRegistry } from "../manifest-registry.js"; +import { + buildPluginLoaderJitiOptions, + resolvePluginSdkAliasFile, + resolvePluginSdkScopedAliasMap, + shouldPreferNativeJiti, +} from "../sdk-alias.js"; + +const MATRIX_PLUGIN_ID = "matrix"; + +type MatrixModule = typeof import("../../../extensions/matrix/runtime-api.js"); + +type MatrixPluginRecord = { + rootDir?: string; + source: string; +}; + +let cachedModulePath: string | null = null; +let cachedModule: MatrixModule | null = null; + +const jitiLoaders = new Map>(); + +function readConfigSafely() { + try { + return loadConfig(); + } catch { + return {}; + } +} + +function resolveMatrixPluginRecord(): MatrixPluginRecord | null { + const manifestRegistry = loadPluginManifestRegistry({ + config: readConfigSafely(), + cache: true, + }); + const record = manifestRegistry.plugins.find((plugin) => plugin.id === MATRIX_PLUGIN_ID); + if (!record?.source) { + return null; + } + return { + rootDir: record.rootDir, + source: record.source, + }; +} + +function resolveMatrixRuntimeModulePath(record: MatrixPluginRecord): string | null { + const candidates = [ + path.join(path.dirname(record.source), "runtime-api.js"), + path.join(path.dirname(record.source), "runtime-api.ts"), + ...(record.rootDir + ? [path.join(record.rootDir, "runtime-api.js"), path.join(record.rootDir, "runtime-api.ts")] + : []), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + +function getJiti(modulePath: string) { + const tryNative = shouldPreferNativeJiti(modulePath); + const cached = jitiLoaders.get(tryNative); + if (cached) { + return cached; + } + const pluginSdkAlias = resolvePluginSdkAliasFile({ + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + modulePath, + }); + const aliasMap = { + ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), + ...resolvePluginSdkScopedAliasMap({ modulePath }), + }; + const loader = createJiti(import.meta.url, { + ...buildPluginLoaderJitiOptions(aliasMap), + tryNative, + }); + jitiLoaders.set(tryNative, loader); + return loader; +} + +function loadWithJiti(modulePath: string): TModule { + return getJiti(modulePath)(modulePath) as TModule; +} + +function loadMatrixModule(): MatrixModule | null { + const record = resolveMatrixPluginRecord(); + if (!record) { + return null; + } + const modulePath = resolveMatrixRuntimeModulePath(record); + if (!modulePath) { + return null; + } + if (cachedModule && cachedModulePath === modulePath) { + return cachedModule; + } + const loaded = loadWithJiti(modulePath); + cachedModulePath = modulePath; + cachedModule = loaded; + return loaded; +} + +export function setMatrixThreadBindingIdleTimeoutBySessionKey( + ...args: Parameters +): ReturnType { + const fn = loadMatrixModule()?.setMatrixThreadBindingIdleTimeoutBySessionKey; + if (typeof fn !== "function") { + return []; + } + return fn(...args); +} + +export function setMatrixThreadBindingMaxAgeBySessionKey( + ...args: Parameters +): ReturnType { + const fn = loadMatrixModule()?.setMatrixThreadBindingMaxAgeBySessionKey; + if (typeof fn !== "function") { + return []; + } + return fn(...args); +} diff --git a/src/plugins/runtime/runtime-matrix.ts b/src/plugins/runtime/runtime-matrix.ts index d97734397c0..ac72161f69f 100644 --- a/src/plugins/runtime/runtime-matrix.ts +++ b/src/plugins/runtime/runtime-matrix.ts @@ -1,7 +1,7 @@ import { setMatrixThreadBindingIdleTimeoutBySessionKey, setMatrixThreadBindingMaxAgeBySessionKey, -} from "openclaw/plugin-sdk/matrix"; +} from "./runtime-matrix-boundary.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; export function createRuntimeMatrix(): PluginRuntimeChannel["matrix"] { diff --git a/src/plugins/runtime/runtime-slack-ops.runtime.ts b/src/plugins/runtime/runtime-slack-ops.runtime.ts index 89411fafc00..ec534c0b224 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 "openclaw/plugin-sdk/slack"; -import { monitorSlackProvider as monitorSlackProviderImpl } from "openclaw/plugin-sdk/slack"; -import { probeSlack as probeSlackImpl } from "openclaw/plugin-sdk/slack"; -import { resolveSlackChannelAllowlist as resolveSlackChannelAllowlistImpl } from "openclaw/plugin-sdk/slack"; -import { resolveSlackUserAllowlist as resolveSlackUserAllowlistImpl } from "openclaw/plugin-sdk/slack"; -import { sendMessageSlack as sendMessageSlackImpl } from "openclaw/plugin-sdk/slack"; -import { handleSlackAction as handleSlackActionImpl } from "openclaw/plugin-sdk/slack"; + monitorSlackProvider as monitorSlackProviderImpl, + probeSlack as probeSlackImpl, + resolveSlackChannelAllowlist as resolveSlackChannelAllowlistImpl, + resolveSlackUserAllowlist as resolveSlackUserAllowlistImpl, + sendMessageSlack as sendMessageSlackImpl, + 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-telegram-contract.ts b/src/plugins/runtime/runtime-telegram-contract.ts index 6700ae25429..fc0b680f51d 100644 --- a/src/plugins/runtime/runtime-telegram-contract.ts +++ b/src/plugins/runtime/runtime-telegram-contract.ts @@ -82,7 +82,7 @@ export { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, } from "../../../extensions/telegram/api.js"; -export { fetchTelegramChatId } from "../../../extensions/telegram/api.js"; +export { fetchTelegramChatId, lookupTelegramChatId } from "../../../extensions/telegram/api.js"; export { resolveTelegramInlineButtonsScope, resolveTelegramTargetChatType, diff --git a/src/plugins/runtime/runtime-telegram-ops.runtime.ts b/src/plugins/runtime/runtime-telegram-ops.runtime.ts index 5b49e854651..8f236d5e2b6 100644 --- a/src/plugins/runtime/runtime-telegram-ops.runtime.ts +++ b/src/plugins/runtime/runtime-telegram-ops.runtime.ts @@ -1,6 +1,8 @@ -import { auditTelegramGroupMembership as auditTelegramGroupMembershipImpl } from "openclaw/plugin-sdk/telegram"; -import { monitorTelegramProvider as monitorTelegramProviderImpl } from "openclaw/plugin-sdk/telegram"; -import { probeTelegram as probeTelegramImpl } from "openclaw/plugin-sdk/telegram"; +import { + auditTelegramGroupMembership as auditTelegramGroupMembershipImpl, + monitorTelegramProvider as monitorTelegramProviderImpl, + probeTelegram as probeTelegramImpl, +} from "../../../extensions/telegram/runtime-api.js"; import { deleteMessageTelegram as deleteMessageTelegramImpl, editMessageReplyMarkupTelegram as editMessageReplyMarkupTelegramImpl, @@ -11,7 +13,7 @@ import { sendPollTelegram as sendPollTelegramImpl, sendTypingTelegram as sendTypingTelegramImpl, unpinMessageTelegram as unpinMessageTelegramImpl, -} from "openclaw/plugin-sdk/telegram"; +} from "../../../extensions/telegram/runtime-api.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeTelegramOps = Pick< diff --git a/src/plugins/runtime/runtime-telegram.ts b/src/plugins/runtime/runtime-telegram.ts index fd01f964f2a..5754066cd8a 100644 --- a/src/plugins/runtime/runtime-telegram.ts +++ b/src/plugins/runtime/runtime-telegram.ts @@ -1,10 +1,10 @@ -import { collectTelegramUnmentionedGroupIds } from "openclaw/plugin-sdk/telegram"; -import { telegramMessageActions } from "openclaw/plugin-sdk/telegram"; import { + collectTelegramUnmentionedGroupIds, + resolveTelegramToken, setTelegramThreadBindingIdleTimeoutBySessionKey, setTelegramThreadBindingMaxAgeBySessionKey, -} from "openclaw/plugin-sdk/telegram"; -import { resolveTelegramToken } from "openclaw/plugin-sdk/telegram"; + telegramMessageActions, +} from "../../../extensions/telegram/runtime-api.js"; import { createLazyRuntimeMethodBinder, createLazyRuntimeSurface, diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 8794567f98b..45f94f235dd 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; import { - BUNDLED_WEB_SEARCH_PLUGIN_IDS, + listBundledWebSearchPluginIds, resolveBundledWebSearchPluginId, } from "../plugins/bundled-web-search.js"; import type { @@ -82,7 +82,7 @@ function hasCustomWebSearchPluginRisk(config: OpenClawConfig): boolean { return true; } - const bundledPluginIds = new Set(BUNDLED_WEB_SEARCH_PLUGIN_IDS); + const bundledPluginIds = new Set(listBundledWebSearchPluginIds()); const hasNonBundledPluginId = (pluginId: string) => !bundledPluginIds.has(pluginId.trim()); if (Array.isArray(plugins.allow) && plugins.allow.some(hasNonBundledPluginId)) { return true; diff --git a/src/security/audit-channel.runtime.ts b/src/security/audit-channel.runtime.ts index e53c1c19391..d19be6bf441 100644 --- a/src/security/audit-channel.runtime.ts +++ b/src/security/audit-channel.runtime.ts @@ -1,7 +1,7 @@ import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, -} from "openclaw/plugin-sdk/telegram"; +} from "../../extensions/telegram/api.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { isDiscordMutableAllowEntry, diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 6a8e72f6f2e..449fe82045c 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1100,29 +1100,29 @@ description: test skill }, ] as const; - for (const testCase of cases) { - if (!testCase.supported) { - continue; - } + await Promise.all( + cases + .filter((testCase) => testCase.supported) + .map(async (testCase) => { + 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 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, + }); - const res = await runSecurityAudit({ - config: { agents: { defaults: { workspace: fixture.workspaceDir } } }, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir: fixture.stateDir, - configPath, - execDockerRawFn: execDockerRawUnavailable, - }); - - testCase.assert(res, fixture); - } + testCase.assert(res, fixture); + }), + ); }); it("scores small-model risk by tool/sandbox exposure", async () => { @@ -1554,20 +1554,24 @@ description: test skill }, ] as const; - 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)]), + await Promise.all( + cases.map(async (testCase) => { + 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", ); - } - 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); - } - } + 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.each([ @@ -3116,17 +3120,19 @@ description: test skill }, ] as const; - 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); - } - const expectedAbsent = "expectedAbsent" in testCase ? testCase.expectedAbsent : []; - for (const checkId of expectedAbsent) { - expect(hasFinding(res, checkId), `${testCase.name}:${checkId}`).toBe(false); - } - } + await Promise.all( + cases.map(async (testCase) => { + 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); + } + const expectedAbsent = "expectedAbsent" in testCase ? testCase.expectedAbsent : []; + for (const checkId of expectedAbsent) { + expect(hasFinding(res, checkId), `${testCase.name}:${checkId}`).toBe(false); + } + }), + ); }); it("evaluates extension tool reachability findings", async () => { @@ -3339,9 +3345,17 @@ description: test skill }, ] as const; - for (const testCase of cases) { - const result = await testCase.run(); - testCase.assert(result as never); + await Promise.all( + cases.slice(0, -1).map(async (testCase) => { + const result = await testCase.run(); + testCase.assert(result as never); + }), + ); + + const scanFailureCase = cases.at(-1); + if (scanFailureCase) { + const result = await scanFailureCase.run(); + scanFailureCase.assert(result as never); } }); diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index e19ba5d6a6e..273bfd8c8db 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -6,6 +6,7 @@ import type { WebSearchProviderToolDefinition, } from "../plugins/types.js"; import { resolveBundledPluginWebSearchProviders } from "../plugins/web-search-providers.js"; +import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js"; import { resolveRuntimeWebSearchProviders } from "../plugins/web-search-providers.runtime.js"; import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; @@ -88,6 +89,15 @@ export function listWebSearchProviders(params?: { }); } +export function listConfiguredWebSearchProviders(params?: { + config?: OpenClawConfig; +}): PluginWebSearchProviderEntry[] { + return resolvePluginWebSearchProviders({ + config: params?.config, + bundledAllowlistCompat: true, + }); +} + export function resolveWebSearchProviderId(params: { search?: WebSearchConfig; config?: OpenClawConfig; diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 1fee8c154f4..7ceeab37c23 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; const runTui = vi.hoisted(() => vi.fn(async () => {})); @@ -34,6 +36,18 @@ const readSystemdUserLingerStatus = vi.hoisted(() => const resolveSetupSecretInputString = vi.hoisted(() => vi.fn<() => Promise>(async () => undefined), ); +const resolveExistingKey = vi.hoisted(() => + vi.fn<(config: OpenClawConfig, provider: string) => string | undefined>(() => undefined), +); +const hasExistingKey = vi.hoisted(() => + vi.fn<(config: OpenClawConfig, provider: string) => boolean>(() => false), +); +const hasKeyInEnv = vi.hoisted(() => + vi.fn<(entry: Pick) => boolean>(() => false), +); +const listConfiguredWebSearchProviders = vi.hoisted(() => + vi.fn<(params?: { config?: OpenClawConfig }) => PluginWebSearchProviderEntry[]>(() => []), +); vi.mock("../commands/onboard-helpers.js", () => ({ detectBrowserOpenSupport: vi.fn(async () => ({ ok: false })), @@ -71,9 +85,14 @@ vi.mock("../commands/health.js", () => ({ vi.mock("../commands/onboard-search.js", () => ({ SEARCH_PROVIDER_OPTIONS: [], - hasExistingKey: vi.fn(() => false), - hasKeyInEnv: vi.fn(() => false), - resolveExistingKey: vi.fn(() => undefined), + resolveSearchProviderOptions: () => [], + hasExistingKey, + hasKeyInEnv, + resolveExistingKey, +})); + +vi.mock("../web-search/runtime.js", () => ({ + listConfiguredWebSearchProviders, })); vi.mock("../daemon/service.js", () => ({ @@ -135,6 +154,21 @@ function createRuntime(): RuntimeEnv { }; } +function createWebSearchProviderEntry( + provider: Pick< + PluginWebSearchProviderEntry, + "id" | "label" | "hint" | "envVars" | "placeholder" | "signupUrl" | "credentialPath" + >, +): PluginWebSearchProviderEntry { + return { + pluginId: `plugin-${provider.id}`, + getCredentialValue: () => undefined, + setCredentialValue: () => {}, + createTool: () => null, + ...provider, + }; +} + function expectFirstOnboardingInstallPlanCallOmitsToken() { const [firstArg] = (buildGatewayInstallPlan.mock.calls.at(0) as [Record] | undefined) ?? []; @@ -161,6 +195,14 @@ describe("finalizeSetupWizard", () => { readSystemdUserLingerStatus.mockResolvedValue({ user: "test-user", linger: "yes" }); resolveSetupSecretInputString.mockReset(); resolveSetupSecretInputString.mockResolvedValue(undefined); + resolveExistingKey.mockReset(); + resolveExistingKey.mockReturnValue(undefined); + hasExistingKey.mockReset(); + hasExistingKey.mockReturnValue(false); + hasKeyInEnv.mockReset(); + hasKeyInEnv.mockReturnValue(false); + listConfiguredWebSearchProviders.mockReset(); + listConfiguredWebSearchProviders.mockReturnValue([]); }); it("resolves gateway password SecretRef for probe and TUI", async () => { @@ -337,4 +379,160 @@ describe("finalizeSetupWizard", () => { expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…"); expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled."); }); + + it("reports selected providers blocked by plugin policy as unavailable", async () => { + const prompter = buildWizardPrompter({ + select: vi.fn(async () => "later") as never, + confirm: vi.fn(async () => false), + }); + + await finalizeSetupWizard({ + flow: "advanced", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: false, + skipHealth: true, + skipUi: true, + }, + baseConfig: {}, + nextConfig: { + tools: { + web: { + search: { + provider: "firecrawl", + enabled: true, + }, + }, + }, + }, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: undefined, + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }); + + expect(prompter.note).toHaveBeenCalledWith( + expect.stringContaining("selected but unavailable under the current plugin policy"), + "Web search", + ); + expect(resolveExistingKey).not.toHaveBeenCalled(); + expect(hasExistingKey).not.toHaveBeenCalled(); + }); + + it("only reports legacy auto-detect for runtime-visible providers", async () => { + listConfiguredWebSearchProviders.mockReturnValue([ + createWebSearchProviderEntry({ + id: "perplexity", + label: "Perplexity Search", + hint: "Fast web answers", + envVars: ["PERPLEXITY_API_KEY"], + placeholder: "pplx-...", + signupUrl: "https://www.perplexity.ai/", + credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey", + }), + ]); + hasExistingKey.mockImplementation((_config, provider) => provider === "perplexity"); + + const prompter = buildWizardPrompter({ + select: vi.fn(async () => "later") as never, + confirm: vi.fn(async () => false), + }); + + await finalizeSetupWizard({ + flow: "advanced", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: false, + skipHealth: true, + skipUi: true, + }, + baseConfig: {}, + nextConfig: {}, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: undefined, + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }); + + expect(prompter.note).toHaveBeenCalledWith( + expect.stringContaining("Web search is available via Perplexity Search (auto-detected)."), + "Web search", + ); + }); + + it("uses configured provider resolution instead of the active runtime registry", async () => { + listConfiguredWebSearchProviders.mockReturnValue([ + createWebSearchProviderEntry({ + id: "firecrawl", + label: "Firecrawl Search", + hint: "Structured results", + envVars: ["FIRECRAWL_API_KEY"], + placeholder: "fc-...", + signupUrl: "https://www.firecrawl.dev/", + credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", + }), + ]); + hasExistingKey.mockImplementation((_config, provider) => provider === "firecrawl"); + + const prompter = buildWizardPrompter({ + select: vi.fn(async () => "later") as never, + confirm: vi.fn(async () => false), + }); + + await finalizeSetupWizard({ + flow: "advanced", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: false, + skipHealth: true, + skipUi: true, + }, + baseConfig: {}, + nextConfig: { + tools: { + web: { + search: { + provider: "firecrawl", + enabled: true, + }, + }, + }, + }, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: undefined, + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }); + + expect(prompter.note).toHaveBeenCalledWith( + expect.stringContaining( + "Web search is enabled, so your agent can look things up online when needed.", + ), + "Web search", + ); + }); }); diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index 74738facd63..a3879d985ff 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -30,6 +30,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { restoreTerminalState } from "../terminal/restore.js"; import { runTui } from "../tui/tui.js"; import { resolveUserPath } from "../utils.js"; +import { listConfiguredWebSearchProviders } from "../web-search/runtime.js"; import type { WizardPrompter } from "./prompts.js"; import { setupWizardShellCompletion } from "./setup.completion.js"; import { resolveSetupSecretInputString } from "./setup.secret-input.js"; @@ -483,13 +484,14 @@ export async function finalizeSetupWizard( const webSearchProvider = nextConfig.tools?.web?.search?.provider; const webSearchEnabled = nextConfig.tools?.web?.search?.enabled; + const configuredSearchProviders = listConfiguredWebSearchProviders({ config: nextConfig }); if (webSearchProvider) { - const { SEARCH_PROVIDER_OPTIONS, resolveExistingKey, hasExistingKey, hasKeyInEnv } = + const { resolveExistingKey, hasExistingKey, hasKeyInEnv } = await import("../commands/onboard-search.js"); - const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === webSearchProvider); + const entry = configuredSearchProviders.find((e) => e.id === webSearchProvider); const label = entry?.label ?? webSearchProvider; - const storedKey = resolveExistingKey(nextConfig, webSearchProvider); - const keyConfigured = hasExistingKey(nextConfig, webSearchProvider); + const storedKey = entry ? resolveExistingKey(nextConfig, webSearchProvider) : undefined; + const keyConfigured = entry ? hasExistingKey(nextConfig, webSearchProvider) : false; const envAvailable = entry ? hasKeyInEnv(entry) : false; const hasKey = keyConfigured || envAvailable; const keySource = storedKey @@ -497,9 +499,20 @@ export async function finalizeSetupWizard( : keyConfigured ? "API key: configured via secret reference." : envAvailable - ? `API key: provided via ${entry?.envKeys.join(" / ")} env var.` + ? `API key: provided via ${entry?.envVars.join(" / ")} env var.` : undefined; - if (webSearchEnabled !== false && hasKey) { + if (!entry) { + await prompter.note( + [ + `Web search provider ${label} is selected but unavailable under the current plugin policy.`, + "web_search will not work until the provider is re-enabled or a different provider is selected.", + ` ${formatCliCommand("openclaw configure --section web")}`, + "", + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } else if (webSearchEnabled !== false && hasKey) { await prompter.note( [ "Web search is enabled, so your agent can look things up online when needed.", @@ -536,10 +549,9 @@ export async function finalizeSetupWizard( } else { // Legacy configs may have a working key (e.g. apiKey or BRAVE_API_KEY) without // an explicit provider. Runtime auto-detects these, so avoid saying "skipped". - const { SEARCH_PROVIDER_OPTIONS, hasExistingKey, hasKeyInEnv } = - await import("../commands/onboard-search.js"); - const legacyDetected = SEARCH_PROVIDER_OPTIONS.find( - (e) => hasExistingKey(nextConfig, e.value) || hasKeyInEnv(e), + const { hasExistingKey, hasKeyInEnv } = await import("../commands/onboard-search.js"); + const legacyDetected = configuredSearchProviders.find( + (e) => hasExistingKey(nextConfig, e.id) || hasKeyInEnv(e), ); if (legacyDetected) { await prompter.note( diff --git a/test/fixtures/extension-relative-outside-package-inventory.json b/test/fixtures/extension-relative-outside-package-inventory.json index fe51488c706..7768054d4cc 100644 --- a/test/fixtures/extension-relative-outside-package-inventory.json +++ b/test/fixtures/extension-relative-outside-package-inventory.json @@ -1 +1,266 @@ -[] +[ + { + "file": "extensions/bluebubbles/src/runtime-api.ts", + "line": 1, + "kind": "export", + "specifier": "../../../src/plugin-sdk/bluebubbles.js", + "resolvedPath": "src/plugin-sdk/bluebubbles.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/bluebubbles/src/targets.ts", + "line": 8, + "kind": "import", + "specifier": "../../imessage/api.js", + "resolvedPath": "extensions/imessage/api.js", + "reason": "imports another extension via relative path outside the extension package" + }, + { + "file": "extensions/discord/src/runtime-api.ts", + "line": 7, + "kind": "export", + "specifier": "../../../src/plugin-sdk/discord.js", + "resolvedPath": "src/plugin-sdk/discord.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/discord/src/runtime-api.ts", + "line": 22, + "kind": "export", + "specifier": "../../../src/plugin-sdk/discord-core.js", + "resolvedPath": "src/plugin-sdk/discord-core.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/discord/src/runtime-api.ts", + "line": 23, + "kind": "export", + "specifier": "../../../src/plugin-sdk/discord-core.js", + "resolvedPath": "src/plugin-sdk/discord-core.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/discord/src/runtime-api.ts", + "line": 30, + "kind": "export", + "specifier": "../../../src/plugin-sdk/discord-core.js", + "resolvedPath": "src/plugin-sdk/discord-core.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/feishu/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/feishu.js", + "resolvedPath": "src/plugin-sdk/feishu.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/googlechat/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/googlechat.js", + "resolvedPath": "src/plugin-sdk/googlechat.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/imessage/runtime-api.ts", + "line": 16, + "kind": "export", + "specifier": "../../src/plugin-sdk/imessage.js", + "resolvedPath": "src/plugin-sdk/imessage.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/irc/src/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../../src/plugin-sdk/irc.js", + "resolvedPath": "src/plugin-sdk/irc.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/line/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/line.js", + "resolvedPath": "src/plugin-sdk/line.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/line/runtime-api.ts", + "line": 13, + "kind": "export", + "specifier": "../../src/plugin-sdk/line-core.js", + "resolvedPath": "src/plugin-sdk/line-core.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/matrix/runtime-api.ts", + "line": 19, + "kind": "export", + "specifier": "../../src/plugin-sdk/json-store.js", + "resolvedPath": "src/plugin-sdk/json-store.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/matrix/runtime-api.ts", + "line": 28, + "kind": "export", + "specifier": "../../src/plugin-sdk/matrix.js", + "resolvedPath": "src/plugin-sdk/matrix.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/matrix/runtime-api.ts", + "line": 29, + "kind": "export", + "specifier": "../../src/plugin-sdk/matrix.js", + "resolvedPath": "src/plugin-sdk/matrix.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/matrix/src/runtime-api.ts", + "line": 1, + "kind": "export", + "specifier": "../../../src/plugin-sdk/matrix.js", + "resolvedPath": "src/plugin-sdk/matrix.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/mattermost/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/mattermost.js", + "resolvedPath": "src/plugin-sdk/mattermost.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/msteams/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/msteams.js", + "resolvedPath": "src/plugin-sdk/msteams.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/nextcloud-talk/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/nextcloud-talk.js", + "resolvedPath": "src/plugin-sdk/nextcloud-talk.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/nostr/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/nostr.js", + "resolvedPath": "src/plugin-sdk/nostr.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/signal/src/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../../src/plugin-sdk/signal.js", + "resolvedPath": "src/plugin-sdk/signal.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/slack/src/runtime-api.ts", + "line": 12, + "kind": "export", + "specifier": "../../../src/plugin-sdk/slack.js", + "resolvedPath": "src/plugin-sdk/slack.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/slack/src/runtime-api.ts", + "line": 28, + "kind": "export", + "specifier": "../../../src/plugin-sdk/slack-core.js", + "resolvedPath": "src/plugin-sdk/slack-core.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/telegram/runtime-api.ts", + "line": 10, + "kind": "export", + "specifier": "../../src/plugin-sdk/telegram.js", + "resolvedPath": "src/plugin-sdk/telegram.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/telegram/runtime-api.ts", + "line": 40, + "kind": "export", + "specifier": "../../src/plugin-sdk/telegram.js", + "resolvedPath": "src/plugin-sdk/telegram.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/telegram/runtime-api.ts", + "line": 52, + "kind": "export", + "specifier": "../../src/plugin-sdk/telegram-core.js", + "resolvedPath": "src/plugin-sdk/telegram-core.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/tlon/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/tlon.js", + "resolvedPath": "src/plugin-sdk/tlon.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/twitch/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/twitch.js", + "resolvedPath": "src/plugin-sdk/twitch.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/voice-call/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/voice-call.js", + "resolvedPath": "src/plugin-sdk/voice-call.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/whatsapp/src/runtime-api.ts", + "line": 19, + "kind": "export", + "specifier": "../../../src/plugin-sdk/whatsapp-core.js", + "resolvedPath": "src/plugin-sdk/whatsapp-core.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/whatsapp/src/runtime-api.ts", + "line": 34, + "kind": "export", + "specifier": "../../../src/plugin-sdk/whatsapp-shared.js", + "resolvedPath": "src/plugin-sdk/whatsapp-shared.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/zalo/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/zalo.js", + "resolvedPath": "src/plugin-sdk/zalo.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + }, + { + "file": "extensions/zalouser/runtime-api.ts", + "line": 4, + "kind": "export", + "specifier": "../../src/plugin-sdk/zalouser.js", + "resolvedPath": "src/plugin-sdk/zalouser.js", + "reason": "re-exports plugin-sdk via relative path; use openclaw/plugin-sdk/" + } +] diff --git a/test/fixtures/plugin-extension-import-boundary-inventory.json b/test/fixtures/plugin-extension-import-boundary-inventory.json index fe51488c706..ead171321f9 100644 --- a/test/fixtures/plugin-extension-import-boundary-inventory.json +++ b/test/fixtures/plugin-extension-import-boundary-inventory.json @@ -1 +1,66 @@ -[] +[ + { + "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", + "line": 9, + "kind": "import", + "specifier": "../../../extensions/discord/runtime-api.js", + "resolvedPath": "extensions/discord/runtime-api.js", + "reason": "imports extension-owned file from src/plugins" + }, + { + "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", + "line": 21, + "kind": "import", + "specifier": "../../../extensions/discord/runtime-api.js", + "resolvedPath": "extensions/discord/runtime-api.js", + "reason": "imports extension-owned file from src/plugins" + }, + { + "file": "src/plugins/runtime/runtime-discord.ts", + "line": 11, + "kind": "import", + "specifier": "../../../extensions/discord/runtime-api.js", + "resolvedPath": "extensions/discord/runtime-api.js", + "reason": "imports extension-owned file from src/plugins" + }, + { + "file": "src/plugins/runtime/runtime-imessage.ts", + "line": 5, + "kind": "import", + "specifier": "../../../extensions/imessage/runtime-api.js", + "resolvedPath": "extensions/imessage/runtime-api.js", + "reason": "imports extension-owned file from src/plugins" + }, + { + "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", + "line": 10, + "kind": "import", + "specifier": "../../../extensions/slack/runtime-api.js", + "resolvedPath": "extensions/slack/runtime-api.js", + "reason": "imports extension-owned file from src/plugins" + }, + { + "file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts", + "line": 5, + "kind": "import", + "specifier": "../../../extensions/telegram/runtime-api.js", + "resolvedPath": "extensions/telegram/runtime-api.js", + "reason": "imports extension-owned file from src/plugins" + }, + { + "file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts", + "line": 16, + "kind": "import", + "specifier": "../../../extensions/telegram/runtime-api.js", + "resolvedPath": "extensions/telegram/runtime-api.js", + "reason": "imports extension-owned file from src/plugins" + }, + { + "file": "src/plugins/runtime/runtime-telegram.ts", + "line": 7, + "kind": "import", + "specifier": "../../../extensions/telegram/runtime-api.js", + "resolvedPath": "extensions/telegram/runtime-api.js", + "reason": "imports extension-owned file from src/plugins" + } +] diff --git a/test/fixtures/test-parallel.behavior.json b/test/fixtures/test-parallel.behavior.json index 954b5f87557..f1ec0643026 100644 --- a/test/fixtures/test-parallel.behavior.json +++ b/test/fixtures/test-parallel.behavior.json @@ -333,6 +333,10 @@ "file": "src/infra/outbound/message-action-runner.poll.test.ts", "reason": "Terminates cleanly under threads, but not process forks on this host." }, + { + "file": "src/infra/outbound/message-action-runner.context.test.ts", + "reason": "Terminates cleanly under threads, but not process forks on this host." + }, { "file": "src/tts/tts.test.ts", "reason": "Terminates cleanly under threads, but not process forks on this host." diff --git a/test/fixtures/test-timings.unit.json b/test/fixtures/test-timings.unit.json index cdb2505d881..bea7e0d1178 100644 --- a/test/fixtures/test-timings.unit.json +++ b/test/fixtures/test-timings.unit.json @@ -1,227 +1,1031 @@ { "config": "vitest.unit.config.ts", - "generatedAt": "2026-03-18T17:10:00.000Z", + "generatedAt": "2026-03-20T22:27:39.886Z", "defaultDurationMs": 250, "files": { - "src/security/audit.test.ts": { - "durationMs": 6200, - "testCount": 380 - }, "src/plugins/loader.test.ts": { - "durationMs": 6100, - "testCount": 260 + "durationMs": 9585.06884765625, + "testCount": 77 }, - "src/cli/update-cli.test.ts": { - "durationMs": 5400, - "testCount": 210 + "src/plugin-sdk/index.bundle.test.ts": { + "durationMs": 8950.05517578125, + "testCount": 1 }, - "src/agents/pi-embedded-runner.test.ts": { - "durationMs": 5200, - "testCount": 140 + "src/cron/isolated-agent/run.sandbox-config-preserved.test.ts": { + "durationMs": 8918.584228515625, + "testCount": 2 }, - "src/process/supervisor/supervisor.test.ts": { - "durationMs": 5000, - "testCount": 120 + "src/memory/manager.readonly-recovery.test.ts": { + "durationMs": 8524.26123046875, + "testCount": 4 }, - "src/agents/bash-tools.test.ts": { - "durationMs": 4700, - "testCount": 150 + "src/context-engine/context-engine.test.ts": { + "durationMs": 8457.03515625, + "testCount": 27 }, - "src/cli/program.smoke.test.ts": { - "durationMs": 4500, - "testCount": 95 + "src/channels/plugins/setup-wizard-helpers.test.ts": { + "durationMs": 8405.74267578125, + "testCount": 83 }, - "src/hooks/install.test.ts": { - "durationMs": 4300, - "testCount": 95 + "test/extension-plugin-sdk-boundary.test.ts": { + "durationMs": 7965.701171875, + "testCount": 7 }, - "src/agents/skills.test.ts": { - "durationMs": 4200, - "testCount": 135 + "src/config/doc-baseline.integration.test.ts": { + "durationMs": 6192.561767578125, + "testCount": 7 }, - "src/config/schema.test.ts": { - "durationMs": 4000, - "testCount": 110 + "src/daemon/schtasks.stop.test.ts": { + "durationMs": 5804.337158203125, + "testCount": 4 }, - "src/media/store.test.ts": { - "durationMs": 3900, - "testCount": 120 + "src/media/fetch.telegram-network.test.ts": { + "durationMs": 5003.539306640625, + "testCount": 5 }, - "src/commands/agent.test.ts": { - "durationMs": 3700, - "testCount": 110 + "src/infra/restart.test.ts": { + "durationMs": 4300.315673828125, + "testCount": 5 }, - "extensions/telegram/src/bot.create-telegram-bot.test.ts": { - "durationMs": 3600, - "testCount": 80 + "src/channels/plugins/contracts/registry.contract.test.ts": { + "durationMs": 3514.9697265625, + "testCount": 10 }, - "extensions/telegram/src/bot.test.ts": { - "durationMs": 3400, - "testCount": 95 + "src/media-understanding/providers/image.test.ts": { + "durationMs": 3185.248779296875, + "testCount": 4 }, - "src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts": { - "durationMs": 3300, - "testCount": 85 + "test/web-search-provider-boundary.test.ts": { + "durationMs": 2782.843505859375, + "testCount": 4 }, - "src/infra/archive.test.ts": { - "durationMs": 3200, - "testCount": 75 + "src/infra/outbound/message.test.ts": { + "durationMs": 2701.229736328125, + "testCount": 3 }, - "src/auto-reply/reply.block-streaming.test.ts": { - "durationMs": 3100, - "testCount": 60 + "src/tts/edge-tts-validation.test.ts": { + "durationMs": 2662.32421875, + "testCount": 2 }, - "src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts": { - "durationMs": 3000, - "testCount": 55 + "src/media-understanding/runner.vision-skip.test.ts": { + "durationMs": 2446.17724609375, + "testCount": 1 }, - "src/agents/skills.buildworkspaceskillsnapshot.test.ts": { - "durationMs": 2900, - "testCount": 70 + "src/infra/outbound/agent-delivery.test.ts": { + "durationMs": 2414.775390625, + "testCount": 6 }, - "src/docker-setup.test.ts": { - "durationMs": 2800, - "testCount": 65 + "src/memory/manager.read-file.test.ts": { + "durationMs": 2413.658203125, + "testCount": 4 }, - "src/agents/skills-install.download.test.ts": { - "durationMs": 2700, - "testCount": 60 + "src/memory/manager.sync-errors-do-not-crash.test.ts": { + "durationMs": 2389.0439453125, + "testCount": 1 }, - "src/config/schema.tags.test.ts": { - "durationMs": 2600, - "testCount": 70 + "src/acp/runtime/session-meta.test.ts": { + "durationMs": 2388.85302734375, + "testCount": 1 }, - "src/cli/daemon-cli.coverage.test.ts": { - "durationMs": 2500, - "testCount": 50 + "src/infra/provider-usage.auth.plugin.test.ts": { + "durationMs": 2376.7861328125, + "testCount": 1 }, - "extensions/slack/src/monitor/slash.test.ts": { - "durationMs": 2400, - "testCount": 55 + "src/infra/provider-usage.load.plugin.test.ts": { + "durationMs": 2347.157470703125, + "testCount": 1 }, - "test/git-hooks-pre-commit.test.ts": { - "durationMs": 2300, - "testCount": 20 + "src/index.test.ts": { + "durationMs": 2344.759521484375, + "testCount": 2 }, - "src/commands/doctor.warns-state-directory-is-missing.test.ts": { - "durationMs": 2200, - "testCount": 35 - }, - "src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts": { - "durationMs": 2100, - "testCount": 30 - }, - "src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts": { - "durationMs": 2000, - "testCount": 28 - }, - "src/browser/server.agent-contract-snapshot-endpoints.test.ts": { - "durationMs": 1900, - "testCount": 45 - }, - "src/browser/server.agent-contract-form-layout-act-commands.test.ts": { - "durationMs": 1800, - "testCount": 40 - }, - "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts": { - "durationMs": 1700, - "testCount": 25 - }, - "src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts": { - "durationMs": 1600, - "testCount": 22 - }, - "src/plugins/tools.optional.test.ts": { - "durationMs": 1590, - "testCount": 18 - }, - "src/security/fix.test.ts": { - "durationMs": 1580, - "testCount": 24 - }, - "src/utils.test.ts": { - "durationMs": 1570, + "src/plugins/install.test.ts": { + "durationMs": 1894.49658203125, "testCount": 34 }, - "src/auto-reply/tool-meta.test.ts": { - "durationMs": 1560, - "testCount": 26 + "src/config/plugin-auto-enable.test.ts": { + "durationMs": 1378.89013671875, + "testCount": 25 }, - "src/auto-reply/envelope.test.ts": { - "durationMs": 1550, - "testCount": 20 + "src/plugin-sdk/channel-import-guardrails.test.ts": { + "durationMs": 1158.282470703125, + "testCount": 9 }, - "src/commands/auth-choice.test.ts": { - "durationMs": 1540, - "testCount": 18 + "src/hooks/bundled/session-memory/handler.test.ts": { + "durationMs": 1136.251953125, + "testCount": 17 }, - "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts": { - "durationMs": 1530, - "testCount": 14 - }, - "src/media/store.header-ext.test.ts": { - "durationMs": 1520, - "testCount": 16 - }, - "extensions/whatsapp/src/media.test.ts": { - "durationMs": 1510, - "testCount": 16 - }, - "extensions/whatsapp/src/auto-reply.web-auto-reply.falls-back-text-media-send-fails.test.ts": { - "durationMs": 1500, - "testCount": 10 - }, - "src/browser/server.covers-additional-endpoint-branches.test.ts": { - "durationMs": 1490, - "testCount": 18 - }, - "src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts": { - "durationMs": 1480, - "testCount": 12 - }, - "src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts": { - "durationMs": 1470, - "testCount": 10 - }, - "src/browser/server.auth-token-gates-http.test.ts": { - "durationMs": 1460, + "src/hooks/install.test.ts": { + "durationMs": 978.206298828125, "testCount": 15 }, - "extensions/acpx/src/runtime.test.ts": { - "durationMs": 1450, - "testCount": 12 + "test/plugin-extension-import-boundary.test.ts": { + "durationMs": 975.744873046875, + "testCount": 5 }, - "test/scripts/ios-team-id.test.ts": { - "durationMs": 1440, - "testCount": 12 + "test/architecture-smells.test.ts": { + "durationMs": 741.625732421875, + "testCount": 2 }, - "src/agents/bash-tools.exec.background-abort.test.ts": { - "durationMs": 1430, - "testCount": 10 - }, - "src/agents/subagent-announce.format.test.ts": { - "durationMs": 1420, - "testCount": 12 - }, - "src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts": { - "durationMs": 1410, + "src/hooks/loader.test.ts": { + "durationMs": 735.1630859375, "testCount": 14 }, - "src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts": { - "durationMs": 1400, - "testCount": 10 + "src/infra/fs-safe.test.ts": { + "durationMs": 729.53564453125, + "testCount": 27 }, - "src/auto-reply/reply.triggers.group-intro-prompts.test.ts": { - "durationMs": 1390, + "test/scripts/committer.test.ts": { + "durationMs": 626.26806640625, + "testCount": 3 + }, + "src/cron/isolated-agent.model-formatting.test.ts": { + "durationMs": 593.440185546875, + "testCount": 22 + }, + "src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts": { + "durationMs": 571.946533203125, + "testCount": 18 + }, + "src/config/config.plugin-validation.test.ts": { + "durationMs": 565.86474609375, + "testCount": 14 + }, + "src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts": { + "durationMs": 530.2373046875, + "testCount": 15 + }, + "src/infra/provider-usage.test.ts": { + "durationMs": 524.179443359375, + "testCount": 11 + }, + "src/cron/service.issue-regressions.test.ts": { + "durationMs": 457.494873046875, + "testCount": 38 + }, + "src/infra/provider-usage.auth.normalizes-keys.test.ts": { + "durationMs": 450.132568359375, + "testCount": 19 + }, + "src/infra/fs-pinned-write-helper.test.ts": { + "durationMs": 338.172119140625, + "testCount": 3 + }, + "src/infra/archive.test.ts": { + "durationMs": 329.4638671875, + "testCount": 15 + }, + "src/memory/manager.get-concurrency.test.ts": { + "durationMs": 276.911376953125, + "testCount": 2 + }, + "src/cli/program/preaction.test.ts": { + "durationMs": 266.180908203125, + "testCount": 7 + }, + "src/memory/index.test.ts": { + "durationMs": 263.556884765625, + "testCount": 21 + }, + "src/security/temp-path-guard.test.ts": { + "durationMs": 262.98779296875, + "testCount": 3 + }, + "src/security/audit.test.ts": { + "durationMs": 258.43408203125, + "testCount": 65 + }, + "src/memory/embeddings.test.ts": { + "durationMs": 243.285888671875, + "testCount": 19 + }, + "src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts": { + "durationMs": 239.01611328125, + "testCount": 6 + }, + "src/memory/qmd-manager.test.ts": { + "durationMs": 238.613525390625, + "testCount": 57 + }, + "src/infra/archive-staging.test.ts": { + "durationMs": 228.458740234375, + "testCount": 7 + }, + "src/secrets/audit.test.ts": { + "durationMs": 226.931396484375, + "testCount": 18 + }, + "test/scripts/test-extension.test.ts": { + "durationMs": 224.01171875, + "testCount": 8 + }, + "src/infra/git-commit.test.ts": { + "durationMs": 214.883056640625, + "testCount": 13 + }, + "src/tui/gateway-chat.test.ts": { + "durationMs": 210.46240234375, + "testCount": 14 + }, + "src/secrets/runtime.integration.test.ts": { + "durationMs": 210.15087890625, + "testCount": 5 + }, + "src/secrets/apply.test.ts": { + "durationMs": 208.744140625, + "testCount": 15 + }, + "src/entry.version-fast-path.test.ts": { + "durationMs": 192.80029296875, + "testCount": 2 + }, + "src/acp/control-plane/manager.test.ts": { + "durationMs": 183.112548828125, + "testCount": 33 + }, + "src/install-sh-version.test.ts": { + "durationMs": 180.623291015625, + "testCount": 3 + }, + "src/infra/host-env-security.test.ts": { + "durationMs": 180.501220703125, + "testCount": 18 + }, + "src/plugins/loader.git-path-regression.test.ts": { + "durationMs": 178.922119140625, + "testCount": 1 + }, + "src/hooks/plugin-hooks.test.ts": { + "durationMs": 177.90771484375, + "testCount": 4 + }, + "src/cli/daemon-cli/install.integration.test.ts": { + "durationMs": 174.057861328125, + "testCount": 2 + }, + "src/plugins/bundle-mcp.test.ts": { + "durationMs": 169.723876953125, + "testCount": 3 + }, + "src/acp/server.startup.test.ts": { + "durationMs": 161.5439453125, + "testCount": 4 + }, + "src/media-understanding/apply.test.ts": { + "durationMs": 150.961181640625, + "testCount": 32 + }, + "src/cron/isolated-agent.direct-delivery-core-channels.test.ts": { + "durationMs": 148.2373046875, + "testCount": 4 + }, + "src/daemon/schtasks.startup-fallback.test.ts": { + "durationMs": 144.08154296875, + "testCount": 6 + }, + "src/cron/isolated-agent.subagent-model.test.ts": { + "durationMs": 142.85693359375, + "testCount": 4 + }, + "src/channels/plugins/plugins-core.test.ts": { + "durationMs": 142.499755859375, + "testCount": 39 + }, + "src/infra/heartbeat-runner.returns-default-unset.test.ts": { + "durationMs": 135.578369140625, + "testCount": 25 + }, + "src/plugins/manifest-registry.test.ts": { + "durationMs": 133.34912109375, + "testCount": 21 + }, + "src/plugin-sdk/subpaths.test.ts": { + "durationMs": 132.722900390625, + "testCount": 45 + }, + "src/node-host/invoke-system-run-plan.test.ts": { + "durationMs": 128.076171875, + "testCount": 41 + }, + "test/scripts/ios-team-id.test.ts": { + "durationMs": 124.882568359375, + "testCount": 3 + }, + "src/config/schema.hints.test.ts": { + "durationMs": 124.705810546875, + "testCount": 7 + }, + "src/infra/system-presence.version.test.ts": { + "durationMs": 124.248046875, + "testCount": 5 + }, + "src/config/config.nix-integration-u3-u5-u9.test.ts": { + "durationMs": 123.738037109375, + "testCount": 19 + }, + "src/infra/run-node.test.ts": { + "durationMs": 122.07763671875, "testCount": 12 }, - "src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts": { - "durationMs": 1380, + "src/secrets/resolve.test.ts": { + "durationMs": 121.808837890625, + "testCount": 17 + }, + "ui/src/ui/views/chat.test.ts": { + "durationMs": 121.7890625, + "testCount": 26 + }, + "src/media/store.outside-workspace.test.ts": { + "durationMs": 117.4501953125, + "testCount": 1 + }, + "src/plugins/marketplace.test.ts": { + "durationMs": 117.027587890625, + "testCount": 3 + }, + "src/config/sessions/sessions.test.ts": { + "durationMs": 116.381591796875, + "testCount": 23 + }, + "src/memory/manager.batch.test.ts": { + "durationMs": 113.201416015625, + "testCount": 3 + }, + "src/cron/isolated-agent.lane.test.ts": { + "durationMs": 109.29296875, + "testCount": 3 + }, + "src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts": { + "durationMs": 107.308349609375, + "testCount": 12 + }, + "src/cron/isolated-agent/run.owner-auth.test.ts": { + "durationMs": 106.2158203125, + "testCount": 1 + }, + "src/media/read-response-with-limit.test.ts": { + "durationMs": 103.88232421875, + "testCount": 5 + }, + "src/cli/config-cli.integration.test.ts": { + "durationMs": 101.070068359375, + "testCount": 4 + }, + "src/config/io.write-config.test.ts": { + "durationMs": 97.5205078125, + "testCount": 16 + }, + "src/infra/gateway-lock.test.ts": { + "durationMs": 97.258056640625, + "testCount": 9 + }, + "src/infra/outbound/outbound.test.ts": { + "durationMs": 97.128662109375, + "testCount": 65 + }, + "src/security/windows-acl.test.ts": { + "durationMs": 95.044921875, + "testCount": 48 + }, + "src/cron/isolated-agent.direct-delivery-forum-topics.test.ts": { + "durationMs": 93.414306640625, + "testCount": 2 + }, + "src/media-understanding/apply.echo-transcript.test.ts": { + "durationMs": 90.539306640625, "testCount": 10 }, - "extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts": { - "durationMs": 1370, + "test/git-hooks-pre-commit.test.ts": { + "durationMs": 89.74560546875, + "testCount": 1 + }, + "src/plugins/contracts/auth-choice.contract.test.ts": { + "durationMs": 87.48828125, + "testCount": 3 + }, + "src/infra/device-pairing.test.ts": { + "durationMs": 87.477294921875, + "testCount": 19 + }, + "src/pairing/pairing-store.test.ts": { + "durationMs": 86.443115234375, + "testCount": 17 + }, + "src/pairing/setup-code.test.ts": { + "durationMs": 86.40185546875, + "testCount": 15 + }, + "src/media-understanding/runner.skip-tiny-audio.test.ts": { + "durationMs": 85.822265625, + "testCount": 3 + }, + "src/hooks/hooks-install.test.ts": { + "durationMs": 85.01025390625, + "testCount": 1 + }, + "src/media/input-files.fetch-guard.test.ts": { + "durationMs": 83.118408203125, "testCount": 10 + }, + "src/media-understanding/runner.proxy.test.ts": { + "durationMs": 82.6806640625, + "testCount": 3 + }, + "src/plugin-sdk/channel-lifecycle.test.ts": { + "durationMs": 82.321533203125, + "testCount": 6 + }, + "src/media-understanding/runner.deepgram.test.ts": { + "durationMs": 82.171875, + "testCount": 1 + }, + "src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts": { + "durationMs": 78.29052734375, + "testCount": 15 + }, + "src/media-understanding/runner.auto-audio.test.ts": { + "durationMs": 77.9013671875, + "testCount": 4 + }, + "src/config/sessions.test.ts": { + "durationMs": 76.888916015625, + "testCount": 37 + }, + "src/process/command-queue.test.ts": { + "durationMs": 75.699951171875, + "testCount": 17 + }, + "src/node-host/invoke-system-run.test.ts": { + "durationMs": 75.633544921875, + "testCount": 37 + }, + "src/cli/program.smoke.test.ts": { + "durationMs": 74.6591796875, + "testCount": 4 + }, + "src/plugins/stage-bundled-plugin-runtime.test.ts": { + "durationMs": 74.08447265625, + "testCount": 7 + }, + "src/infra/matrix-legacy-crypto.test.ts": { + "durationMs": 72.4951171875, + "testCount": 8 + }, + "src/plugins/discovery.test.ts": { + "durationMs": 71.763671875, + "testCount": 24 + }, + "src/plugins/status.test.ts": { + "durationMs": 71.670654296875, + "testCount": 9 + }, + "src/wizard/setup.gateway-config.test.ts": { + "durationMs": 71.062255859375, + "testCount": 7 + }, + "src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts": { + "durationMs": 69.632568359375, + "testCount": 30 + }, + "src/config/sessions/targets.test.ts": { + "durationMs": 69.172607421875, + "testCount": 13 + }, + "src/media/store.test.ts": { + "durationMs": 67.70458984375, + "testCount": 24 + }, + "src/canvas-host/server.test.ts": { + "durationMs": 67.617431640625, + "testCount": 6 + }, + "src/tts/tts.test.ts": { + "durationMs": 67.5400390625, + "testCount": 27 + }, + "src/infra/heartbeat-runner.ghost-reminder.test.ts": { + "durationMs": 66.83935546875, + "testCount": 6 + }, + "src/cli/pairing-cli.test.ts": { + "durationMs": 65.74462890625, + "testCount": 12 + }, + "src/media-understanding/runtime.test.ts": { + "durationMs": 65.732177734375, + "testCount": 2 + }, + "src/infra/update-runner.test.ts": { + "durationMs": 64.987060546875, + "testCount": 20 + }, + "src/cron/service.failure-alert.test.ts": { + "durationMs": 63.978271484375, + "testCount": 4 + }, + "src/secrets/runtime.test.ts": { + "durationMs": 63.96337890625, + "testCount": 55 + }, + "src/infra/outbound/delivery-queue.test.ts": { + "durationMs": 63.504150390625, + "testCount": 36 + }, + "src/config/config.web-search-provider.test.ts": { + "durationMs": 62.205322265625, + "testCount": 23 + }, + "src/memory/manager.embedding-batches.test.ts": { + "durationMs": 61.173583984375, + "testCount": 5 + }, + "src/cron/service.persists-delivered-status.test.ts": { + "durationMs": 60.770263671875, + "testCount": 6 + }, + "src/cron/isolated-agent.auth-profile-propagation.test.ts": { + "durationMs": 60.474365234375, + "testCount": 1 + }, + "src/infra/jsonl-socket.test.ts": { + "durationMs": 59.739013671875, + "testCount": 2 + }, + "src/infra/session-maintenance-warning.test.ts": { + "durationMs": 58.515869140625, + "testCount": 5 + }, + "src/cron/service.restart-catchup.test.ts": { + "durationMs": 57.26123046875, + "testCount": 8 + }, + "src/config/schema.test.ts": { + "durationMs": 57.260986328125, + "testCount": 22 + }, + "src/plugins/bundled-web-search.test.ts": { + "durationMs": 56.5693359375, + "testCount": 2 + }, + "src/plugin-sdk/keyed-async-queue.test.ts": { + "durationMs": 56.42333984375, + "testCount": 4 + }, + "src/plugins/contracts/registry.contract.test.ts": { + "durationMs": 56.16650390625, + "testCount": 19 + }, + "src/plugins/tools.optional.test.ts": { + "durationMs": 55.7021484375, + "testCount": 8 + }, + "src/plugins/conversation-binding.test.ts": { + "durationMs": 55.24609375, + "testCount": 15 + }, + "src/plugins/copy-bundled-plugin-metadata.test.ts": { + "durationMs": 54.4267578125, + "testCount": 8 + }, + "src/infra/install-package-dir.test.ts": { + "durationMs": 54.185546875, + "testCount": 5 + }, + "src/infra/boundary-path.test.ts": { + "durationMs": 53.643310546875, + "testCount": 5 + }, + "src/infra/heartbeat-runner.model-override.test.ts": { + "durationMs": 52.62109375, + "testCount": 8 + }, + "src/infra/outbound/outbound-send-service.test.ts": { + "durationMs": 52.319091796875, + "testCount": 9 + }, + "src/media-understanding/providers/index.test.ts": { + "durationMs": 52.12060546875, + "testCount": 3 + }, + "src/security/fix.test.ts": { + "durationMs": 51.84716796875, + "testCount": 5 + }, + "src/channels/plugins/acp-bindings.test.ts": { + "durationMs": 51.03369140625, + "testCount": 6 + }, + "src/config/sessions/store.pruning.integration.test.ts": { + "durationMs": 50.060546875, + "testCount": 10 + }, + "src/config/io.runtime-snapshot-write.test.ts": { + "durationMs": 49.54736328125, + "testCount": 6 + }, + "src/cli/route.test.ts": { + "durationMs": 49.52734375, + "testCount": 3 + }, + "src/plugins/web-search-providers.test.ts": { + "durationMs": 49.430908203125, + "testCount": 7 + }, + "src/infra/matrix-legacy-state.test.ts": { + "durationMs": 49.007080078125, + "testCount": 6 + }, + "src/config/config.pruning-defaults.test.ts": { + "durationMs": 47.780029296875, + "testCount": 7 + }, + "src/memory/embeddings-voyage.test.ts": { + "durationMs": 47.3974609375, + "testCount": 4 + }, + "src/infra/ports.test.ts": { + "durationMs": 46.749267578125, + "testCount": 5 + }, + "src/routing/resolve-route.test.ts": { + "durationMs": 46.55078125, + "testCount": 41 + }, + "src/plugins/providers.test.ts": { + "durationMs": 46.517333984375, + "testCount": 7 + }, + "src/cli/plugin-registry.test.ts": { + "durationMs": 45.814697265625, + "testCount": 2 + }, + "src/infra/matrix-plugin-helper.test.ts": { + "durationMs": 44.94140625, + "testCount": 4 + }, + "src/cron/service.store.migration.test.ts": { + "durationMs": 44.7314453125, + "testCount": 7 + }, + "src/logging/log-file-size-cap.test.ts": { + "durationMs": 44.7001953125, + "testCount": 3 + }, + "src/process/supervisor/supervisor.pty-command.test.ts": { + "durationMs": 44.529052734375, + "testCount": 2 + }, + "src/plugins/hook-runner-global.test.ts": { + "durationMs": 44.1142578125, + "testCount": 2 + }, + "src/cron/service.every-jobs-fire.test.ts": { + "durationMs": 43.72607421875, + "testCount": 3 + }, + "src/channels/plugins/whatsapp-heartbeat.test.ts": { + "durationMs": 43.144775390625, + "testCount": 8 + }, + "src/infra/update-startup.test.ts": { + "durationMs": 42.65380859375, + "testCount": 10 + }, + "src/infra/matrix-migration-snapshot.test.ts": { + "durationMs": 42.47119140625, + "testCount": 7 + }, + "src/config/schema.help.quality.test.ts": { + "durationMs": 42.340087890625, + "testCount": 20 + }, + "src/memory/internal.test.ts": { + "durationMs": 42.137939453125, + "testCount": 18 + }, + "src/cron/service.store-migration.test.ts": { + "durationMs": 42.07421875, + "testCount": 5 + }, + "src/cron/run-log.test.ts": { + "durationMs": 42.0673828125, + "testCount": 11 + }, + "src/config/env-preserve-io.test.ts": { + "durationMs": 41.8037109375, + "testCount": 4 + }, + "src/plugins/web-search-providers.runtime.test.ts": { + "durationMs": 41.41015625, + "testCount": 2 + }, + "src/cron/service.issue-16156-list-skips-cron.test.ts": { + "durationMs": 39.339599609375, + "testCount": 3 + }, + "src/cron/service.runs-one-shot-main-job-disables-it.test.ts": { + "durationMs": 39.2939453125, + "testCount": 11 + }, + "src/memory/batch-gemini.test.ts": { + "durationMs": 38.654052734375, + "testCount": 1 + }, + "src/media/fetch.test.ts": { + "durationMs": 38.048583984375, + "testCount": 6 + }, + "src/process/exec.windows.test.ts": { + "durationMs": 37.954833984375, + "testCount": 2 + }, + "src/acp/persistent-bindings.lifecycle.test.ts": { + "durationMs": 37.9296875, + "testCount": 1 + }, + "src/config/config.identity-defaults.test.ts": { + "durationMs": 37.58984375, + "testCount": 7 + }, + "src/cron/isolated-agent/run.skill-filter.test.ts": { + "durationMs": 37.4345703125, + "testCount": 13 + }, + "src/process/exec.no-output-timer.test.ts": { + "durationMs": 37.43212890625, + "testCount": 1 + }, + "src/infra/net/proxy-fetch.test.ts": { + "durationMs": 37.217041015625, + "testCount": 10 + }, + "src/config/mcp-config.test.ts": { + "durationMs": 36.172607421875, + "testCount": 2 + }, + "src/infra/device-bootstrap.test.ts": { + "durationMs": 36.03564453125, + "testCount": 7 + }, + "src/memory/manager.atomic-reindex.test.ts": { + "durationMs": 35.837890625, + "testCount": 1 + }, + "src/infra/state-migrations.test.ts": { + "durationMs": 35.7705078125, + "testCount": 2 + }, + "src/infra/json-files.test.ts": { + "durationMs": 35.27685546875, + "testCount": 5 + }, + "src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts": { + "durationMs": 35.118408203125, + "testCount": 1 + }, + "src/infra/outbound/targets.channel-resolution.test.ts": { + "durationMs": 34.466796875, + "testCount": 2 + }, + "src/infra/provider-usage.fetch.claude.test.ts": { + "durationMs": 34.194091796875, + "testCount": 13 + }, + "src/cli/program/config-guard.test.ts": { + "durationMs": 34.148193359375, + "testCount": 8 + }, + "src/media/server.test.ts": { + "durationMs": 34.0576171875, + "testCount": 9 + }, + "src/cron/service.read-ops-nonblocking.test.ts": { + "durationMs": 33.436279296875, + "testCount": 3 + }, + "src/cli/daemon-cli/restart-health.test.ts": { + "durationMs": 33.208740234375, + "testCount": 10 + }, + "src/infra/exec-approvals-store.test.ts": { + "durationMs": 33.11865234375, + "testCount": 8 + }, + "src/infra/transport-ready.test.ts": { + "durationMs": 32.873046875, + "testCount": 6 + }, + "src/infra/matrix-migration-config.test.ts": { + "durationMs": 32.8076171875, + "testCount": 7 + }, + "src/config/plugins-runtime-boundary.test.ts": { + "durationMs": 32.71142578125, + "testCount": 3 + }, + "src/config/config.backup-rotation.test.ts": { + "durationMs": 32.4921875, + "testCount": 4 + }, + "src/plugins/schema-validator.test.ts": { + "durationMs": 32.45654296875, + "testCount": 7 + }, + "src/infra/outbound/channel-resolution.test.ts": { + "durationMs": 32.39794921875, + "testCount": 6 + }, + "src/memory/manager.async-search.test.ts": { + "durationMs": 32.262451171875, + "testCount": 2 + }, + "src/cli/config-cli.test.ts": { + "durationMs": 32.14404296875, + "testCount": 48 + }, + "src/cron/isolated-agent/run.cron-model-override.test.ts": { + "durationMs": 31.62060546875, + "testCount": 6 + }, + "src/cron/service.session-reaper-in-finally.test.ts": { + "durationMs": 31.336181640625, + "testCount": 3 + }, + "src/config/config.talk-validation.test.ts": { + "durationMs": 31.318359375, + "testCount": 5 + }, + "src/cron/isolated-agent/run.message-tool-policy.test.ts": { + "durationMs": 30.740966796875, + "testCount": 3 + }, + "src/cron/isolated-agent/run.payload-fallbacks.test.ts": { + "durationMs": 30.61376953125, + "testCount": 3 + }, + "src/security/skill-scanner.test.ts": { + "durationMs": 30.46142578125, + "testCount": 27 + }, + "src/infra/push-apns.store.test.ts": { + "durationMs": 30.40869140625, + "testCount": 7 + }, + "src/infra/provider-usage.fetch.shared.test.ts": { + "durationMs": 30.35546875, + "testCount": 9 + }, + "src/config/config.compaction-settings.test.ts": { + "durationMs": 30.129638671875, + "testCount": 5 + }, + "src/infra/heartbeat-runner.transcript-prune.test.ts": { + "durationMs": 30.003173828125, + "testCount": 2 + }, + "src/infra/install-source-utils.test.ts": { + "durationMs": 29.82958984375, + "testCount": 16 + }, + "src/infra/outbound/message-action-runner.media.test.ts": { + "durationMs": 29.814697265625, + "testCount": 7 + }, + "src/infra/infra-runtime.test.ts": { + "durationMs": 29.746337890625, + "testCount": 11 + }, + "src/config/io.compat.test.ts": { + "durationMs": 29.640625, + "testCount": 7 + }, + "src/cron/isolated-agent/run.interim-retry.test.ts": { + "durationMs": 29.623779296875, + "testCount": 3 + }, + "src/infra/provider-usage.fetch.zai.test.ts": { + "durationMs": 29.40576171875, + "testCount": 5 + }, + "src/config/sessions.cache.test.ts": { + "durationMs": 28.903076171875, + "testCount": 9 + }, + "src/config/config-misc.test.ts": { + "durationMs": 28.822509765625, + "testCount": 38 + }, + "src/cron/isolated-agent/run.fast-mode.test.ts": { + "durationMs": 28.714111328125, + "testCount": 3 + }, + "src/cli/daemon-cli.coverage.test.ts": { + "durationMs": 28.397705078125, + "testCount": 5 + }, + "src/infra/outbound/deliver.test.ts": { + "durationMs": 28.26123046875, + "testCount": 43 + }, + "src/infra/session-cost-usage.test.ts": { + "durationMs": 28.23828125, + "testCount": 9 + }, + "src/infra/provider-usage.fetch.minimax.test.ts": { + "durationMs": 28.202880859375, + "testCount": 10 + }, + "src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts": { + "durationMs": 28.074951171875, + "testCount": 28 + }, + "src/cron/session-reaper.test.ts": { + "durationMs": 27.93896484375, + "testCount": 16 + }, + "src/infra/fetch.test.ts": { + "durationMs": 27.3388671875, + "testCount": 16 + }, + "src/infra/provider-usage.fetch.codex.test.ts": { + "durationMs": 27.201904296875, + "testCount": 8 + }, + "src/daemon/service-audit.test.ts": { + "durationMs": 27.169921875, + "testCount": 16 + }, + "src/plugin-sdk/persistent-dedupe.test.ts": { + "durationMs": 26.39892578125, + "testCount": 6 + }, + "src/plugin-sdk/fetch-auth.test.ts": { + "durationMs": 26.328857421875, + "testCount": 5 + }, + "src/cron/store.test.ts": { + "durationMs": 26.302978515625, + "testCount": 11 + }, + "src/infra/provider-usage.fetch.gemini.test.ts": { + "durationMs": 26.23388671875, + "testCount": 4 + }, + "src/plugins/uninstall.test.ts": { + "durationMs": 26.126220703125, + "testCount": 23 + }, + "src/memory/post-json.test.ts": { + "durationMs": 25.935302734375, + "testCount": 2 + }, + "src/config/logging.test.ts": { + "durationMs": 25.74267578125, + "testCount": 2 + }, + "src/infra/outbound/message-action-runner.plugin-dispatch.test.ts": { + "durationMs": 25.54638671875, + "testCount": 10 + }, + "src/infra/secret-file.test.ts": { + "durationMs": 25.1318359375, + "testCount": 11 + }, + "src/cli/mcp-cli.test.ts": { + "durationMs": 25.1083984375, + "testCount": 2 + }, + "src/plugins/bundle-manifest.test.ts": { + "durationMs": 25.0634765625, + "testCount": 8 + }, + "src/cli/prompt.test.ts": { + "durationMs": 24.9404296875, + "testCount": 2 + }, + "src/node-host/invoke-browser.test.ts": { + "durationMs": 24.701171875, + "testCount": 4 + }, + "src/memory/manager.mistral-provider.test.ts": { + "durationMs": 24.597412109375, + "testCount": 3 + }, + "src/cli/memory-cli.test.ts": { + "durationMs": 24.389404296875, + "testCount": 24 + }, + "src/infra/system-presence.test.ts": { + "durationMs": 24.2265625, + "testCount": 5 + }, + "src/channels/plugins/contracts/session-binding.contract.test.ts": { + "durationMs": 24.113037109375, + "testCount": 16 + }, + "src/cron/service.delivery-plan.test.ts": { + "durationMs": 23.473876953125, + "testCount": 3 } } } diff --git a/test/helpers/extensions/discord-provider.test-support.ts b/test/helpers/extensions/discord-provider.test-support.ts index 3c66b4d6743..538e00ae9fa 100644 --- a/test/helpers/extensions/discord-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 "../../../extensions/discord/src/runtime-api.js"; export type NativeCommandSpecMock = { name: string; @@ -319,6 +319,16 @@ vi.mock("openclaw/plugin-sdk/acp-runtime", async () => { }; }); +vi.mock("openclaw/plugin-sdk/command-auth", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/command-auth", + ); + return { + ...actual, + listNativeCommandSpecsForConfig: listNativeCommandSpecsForConfigMock, + listSkillCommandsForAgents: listSkillCommandsForAgentsMock, + }; +}); vi.mock("openclaw/plugin-sdk/reply-runtime", async () => { const actual = await vi.importActual( "openclaw/plugin-sdk/reply-runtime", @@ -326,8 +336,6 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async () => { return { ...actual, resolveTextChunkLimit: () => 2000, - listNativeCommandSpecsForConfig: listNativeCommandSpecsForConfigMock, - listSkillCommandsForAgents: listSkillCommandsForAgentsMock, }; }); diff --git a/test/plugin-extension-import-boundary.test.ts b/test/plugin-extension-import-boundary.test.ts index c2bd07b5e00..bef7bb57838 100644 --- a/test/plugin-extension-import-boundary.test.ts +++ b/test/plugin-extension-import-boundary.test.ts @@ -21,12 +21,15 @@ function readBaseline() { } describe("plugin extension import boundary inventory", () => { - it("keeps web-search-providers out of the remaining inventory", async () => { + it("keeps dedicated web-search registry shims out of the remaining inventory", async () => { const inventory = await collectPluginExtensionImportBoundaryInventory(); expect(inventory.some((entry) => entry.file === "src/plugins/web-search-providers.ts")).toBe( false, ); + expect( + inventory.some((entry) => entry.file === "src/plugins/bundled-web-search-registry.ts"), + ).toBe(false); }); it("ignores boundary shims by scope", async () => { @@ -36,9 +39,6 @@ describe("plugin extension import boundary inventory", () => { expect(inventory.some((entry) => entry.file.startsWith("src/plugin-sdk-internal/"))).toBe( false, ); - expect(inventory.some((entry) => entry.file.startsWith("src/plugins/runtime/runtime-"))).toBe( - false, - ); }); it("produces stable sorted output", async () => { diff --git a/test/setup.ts b/test/setup.ts index f0e1bdc4549..1d4429d48d7 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -29,24 +29,48 @@ import type { } from "../src/channels/plugins/types.js"; import type { OpenClawConfig } from "../src/config/config.js"; import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js"; +import { installProcessWarningFilter } from "../src/infra/warning-filter.js"; +import type { PluginRegistry } from "../src/plugins/registry.js"; import { withIsolatedTestHome } from "./test-env.js"; // Set HOME/state isolation before importing any runtime OpenClaw modules. const testEnv = withIsolatedTestHome(); afterAll(() => testEnv.cleanup()); -const [ - { installProcessWarningFilter }, - { getActivePluginRegistry, setActivePluginRegistry }, - { createTestRegistry }, -] = await Promise.all([ - import("../src/infra/warning-filter.js"), - import("../src/plugins/runtime.js"), - import("../src/test-utils/channel-plugins.js"), -]); - installProcessWarningFilter(); +const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState"); + +type RegistryState = { + registry: PluginRegistry | null; + httpRouteRegistry: PluginRegistry | null; + httpRouteRegistryPinned: boolean; + key: string | null; + version: number; +}; + +type TestChannelRegistration = { + pluginId: string; + plugin: unknown; + source: string; +}; + +const globalRegistryState = (() => { + const globalState = globalThis as typeof globalThis & { + [REGISTRY_STATE]?: RegistryState; + }; + if (!globalState[REGISTRY_STATE]) { + globalState[REGISTRY_STATE] = { + registry: null, + httpRouteRegistry: null, + httpRouteRegistryPinned: false, + key: null, + version: 0, + }; + } + return globalState[REGISTRY_STATE]; +})(); + const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { return deps?.[id] as ((...args: unknown[]) => Promise) | undefined; }; @@ -127,6 +151,32 @@ const createStubPlugin = (params: { outbound: createStubOutbound(params.id, params.deliveryMode), }); +const createTestRegistry = (channels: TestChannelRegistration[] = []): PluginRegistry => ({ + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: channels as unknown as PluginRegistry["channels"], + channelSetups: channels.map((entry) => ({ + pluginId: entry.pluginId, + plugin: entry.plugin as PluginRegistry["channelSetups"][number]["plugin"], + source: entry.source, + enabled: true, + })), + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + gatewayHandlers: {}, + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + conversationBindingResolvedHandlers: [], + diagnostics: [], +}); + const createDefaultRegistry = () => createTestRegistry([ { @@ -174,17 +224,55 @@ const createDefaultRegistry = () => }, ]); -// Creating a fresh registry before every test is measurable overhead. -// The registry is immutable by default; tests that override it are restored in afterEach. -const DEFAULT_PLUGIN_REGISTRY = createDefaultRegistry(); +let materializedDefaultPluginRegistry: PluginRegistry | null = null; + +function getDefaultPluginRegistry(): PluginRegistry { + materializedDefaultPluginRegistry ??= createDefaultRegistry(); + return materializedDefaultPluginRegistry; +} + +// Most unit suites never touch the plugin registry. Keep the default test registry +// behind a lazy proxy so those files avoid allocating channel fixtures up front. +const DEFAULT_PLUGIN_REGISTRY = new Proxy({} as PluginRegistry, { + defineProperty(_target, property, attributes) { + return Reflect.defineProperty(getDefaultPluginRegistry() as object, property, attributes); + }, + deleteProperty(_target, property) { + return Reflect.deleteProperty(getDefaultPluginRegistry() as object, property); + }, + get(_target, property, receiver) { + return Reflect.get(getDefaultPluginRegistry() as object, property, receiver); + }, + getOwnPropertyDescriptor(_target, property) { + return Reflect.getOwnPropertyDescriptor(getDefaultPluginRegistry() as object, property); + }, + has(_target, property) { + return Reflect.has(getDefaultPluginRegistry() as object, property); + }, + ownKeys() { + return Reflect.ownKeys(getDefaultPluginRegistry() as object); + }, + set(_target, property, value, receiver) { + return Reflect.set(getDefaultPluginRegistry() as object, property, value, receiver); + }, +}); + +function installDefaultPluginRegistry(): void { + globalRegistryState.registry = DEFAULT_PLUGIN_REGISTRY; + if (!globalRegistryState.httpRouteRegistryPinned) { + globalRegistryState.httpRouteRegistry = DEFAULT_PLUGIN_REGISTRY; + } +} beforeAll(() => { - setActivePluginRegistry(DEFAULT_PLUGIN_REGISTRY); + installDefaultPluginRegistry(); }); afterEach(() => { - if (getActivePluginRegistry() !== DEFAULT_PLUGIN_REGISTRY) { - setActivePluginRegistry(DEFAULT_PLUGIN_REGISTRY); + if (globalRegistryState.registry !== DEFAULT_PLUGIN_REGISTRY) { + installDefaultPluginRegistry(); + globalRegistryState.key = null; + globalRegistryState.version += 1; } // Guard against leaked fake timers across test files/workers. if (vi.isFakeTimers()) { diff --git a/tsconfig.json b/tsconfig.json index bc6439e921f..e2f9e4ff97e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "target": "es2023", "useDefineForClassFields": false, "paths": { + "openclaw/extension-api": ["./src/extensionAPI.ts"], "openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"], "openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"], "openclaw/plugin-sdk/account-id": ["./src/plugin-sdk/account-id.ts"] diff --git a/tsdown.config.ts b/tsdown.config.ts index 98dd9e3d341..304f781d91d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -169,6 +169,7 @@ function buildCoreDistEntries(): Record { entry: "src/entry.ts", // Ensure this module is bundled as an entry so legacy CLI shims can resolve its exports. "cli/daemon-cli": "src/cli/daemon-cli.ts", + extensionAPI: "src/extensionAPI.ts", "infra/warning-filter": "src/infra/warning-filter.ts", "telegram/audit": "extensions/telegram/src/audit.ts", "telegram/token": "extensions/telegram/src/token.ts", diff --git a/vitest.config.ts b/vitest.config.ts index f254bcdf0a7..568f5dd03e6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,10 @@ export default defineConfig({ resolve: { // Keep this ordered: the base `openclaw/plugin-sdk` alias is a prefix match. alias: [ + { + find: "openclaw/extension-api", + replacement: path.join(repoRoot, "src", "extensionAPI.ts"), + }, ...pluginSdkSubpaths.map((subpath) => ({ find: `openclaw/plugin-sdk/${subpath}`, replacement: path.join(repoRoot, "src", "plugin-sdk", `${subpath}.ts`),