Compare commits

..

4 Commits

Author SHA1 Message Date
Josh Lehman
53ac244ec6 Merge branch 'main' into codex/plugin-command-scope-auth 2026-03-26 16:15:50 -07:00
Josh Lehman
c94f10b915 Plugins: harden dynamic scope resolution
Catch dynamic gateway-scope resolver failures in the dispatcher, narrow
forwarded gateway scope strings with an explicit operator-scope guard, add
regression coverage for admin bypass and resolver-throw behavior, and
refresh bundled plugin metadata after main-branch drift.

Regeneration-Prompt: |
  Follow up on review feedback for the centralized plugin command auth
  change. Keep the scope tightly limited to the three review items:
  catch exceptions from `resolveRequiredGatewayScopes`, replace the raw
  `GatewayClientScopes` cast with explicit operator-scope narrowing, and
  add dispatcher-level tests for the `operator.admin` bypass plus the safe
  failure path when dynamic scope resolution throws.

  While landing that patch, the repo hook may report stale bundled plugin
  metadata generated files because main advanced. Regenerate those standard
  outputs with the repo generator so the branch is consistent enough to
  rebase, but do not chase unrelated CI or Discord test failures here.
2026-03-26 16:11:09 -07:00
Josh Lehman
70b43319ff Plugin SDK: refresh API baselines for auth context change
Update the generated Plugin SDK API baseline files after extending plugin
command types for centralized owner and gateway-scope authorization.

Regeneration-Prompt: |
  The prior commit intentionally changed exported plugin SDK types in
  `src/plugins/types.ts` by adding richer plugin command auth context and
  declarative command requirement fields. CI reported plugin SDK API drift,
  which means the generated baseline files under `docs/.generated/` no
  longer matched the exported surface.

  Regenerate only the plugin SDK API baseline artifacts with the repo's
  standard generator, verify `pnpm plugin-sdk:api:check` passes, and keep
  this follow-up scoped to those generated files. Do not fold in unrelated
  failing tests from untouched surfaces.
2026-03-26 16:11:09 -07:00
Josh Lehman
487f752754 Plugins: centralize plugin command auth requirements
Move plugin command authorization toward the GHSA's long-term model by
preserving richer auth context, supporting declarative owner and gateway
scope requirements, and enforcing them in the shared dispatcher. Convert
`/pair approve` to use the centralized requirement path and add regression
coverage for dispatcher-level auth behavior.

Regeneration-Prompt: |
  This follow-up hardening is for the plugin command auth gap described in
  GHSA-9gwp-pxfh-w6r5. The immediate exploit path was already fixed by
  plumbing gateway scopes into the device-pair plugin and checking `/pair
  approve` inline, but the longer-term goal is to stop relying on lossy,
  plugin-specific auth checks.

  Preserve the existing plugin command flow and keep the change additive.
  Carry richer authorization context into plugin execution, including owner
  status and command surface, and let commands declare owner or internal
  gateway-scope requirements that the central dispatcher enforces. Internal
  callers should fail closed when required scopes are missing, with admin
  scope still satisfying narrower operator requirements, while non-internal
  chat surfaces should keep their current auth behavior.

  Because `/pair` mixes low-risk actions like `qr` and `status` with the
  privileged `approve` action, use a context-sensitive requirement instead
  of making the whole command require pairing scope. Add focused regression
  tests around dispatcher enforcement and update any command-context test
  helpers that now need the richer fields.
2026-03-26 16:10:38 -07:00
776 changed files with 22569 additions and 42757 deletions

View File

@@ -59,7 +59,6 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Multi-word `openclaw agent --message ...` checks should call `& $openclaw ...` inside PowerShell, not `Start-Process ... -ArgumentList` against `openclaw.cmd`, or Commander can see split argv and throw `too many arguments for 'agent'`.
- Windows installer/tgz phases now retry once after guest-ready recheck; keep new Windows smoke steps idempotent so a transport-flake retry is safe.
- Windows global `npm install -g` phases can stay quiet for a minute or more even when healthy; inspect the phase log before calling it hung, and only treat it as a regression once the retry wrapper or timeout trips.
- Fresh Windows ref-mode onboard should use the same background PowerShell runner plus done-file/log-drain pattern as the npm-update helper, including startup materialization checks, host-side timeouts on short poll `prlctl exec` calls, and retry-on-poll-failure behavior for transient transport flakes.
- Keep onboarding and status output ASCII-clean in logs; fancy punctuation becomes mojibake in current capture paths.
- If you hit an older run with `rc=255` plus an empty `fresh.install-main.log` or `upgrade.install-main.log`, treat it as a likely `prlctl exec` transport drop after guest start-up, not immediate proof of an npm/package failure.

View File

@@ -7,13 +7,11 @@ Docs: https://docs.openclaw.ai
### Breaking
- Providers/Qwen: remove the deprecated `qwen-portal-auth` OAuth integration for `portal.qwen.ai`; migrate to Model Studio with `openclaw onboard --auth-choice modelstudio-api-key`. (#52709) Thanks @pomelo-nwu.
- Config/Doctor: drop automatic config migrations older than two months; very old legacy keys now fail validation instead of being rewritten on load or by `openclaw doctor`.
### Changes
- MiniMax: add image generation provider for `image-01` model, supporting generate and image-to-image editing with aspect ratio control. (#54487) Thanks @liyuan97.
- Slack/tool actions: add an explicit `upload-file` Slack action that routes file uploads through the existing Slack upload transport, with optional filename/title/comment overrides for channels and DMs.
- Message actions/files: start unifying file-first sends on the canonical `upload-file` action by adding explicit support for Microsoft Teams and Google Chat, and by exposing BlueBubbles file sends through `upload-file` while keeping the legacy `sendAttachment` alias.
- Plugins/Matrix TTS: send auto-TTS replies as native Matrix voice bubbles instead of generic audio attachments. (#37080) thanks @Matthew19990919.
- Memory/plugins: move the pre-compaction memory flush plan behind the active memory plugin contract so `memory-core` owns flush prompts and target-path policy instead of hardcoded core logic.
- MiniMax: trim model catalog to M2.7 only, removing legacy M2, M2.1, M2.5, and VL-01 models. (#54487) Thanks @liyuan97.
@@ -23,13 +21,9 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual `/compact` no-op cases as skipped instead of failed. (#51072) Thanks @afurm.
- Plugins/CLI backends: move bundled Claude CLI, Codex CLI, and Gemini CLI inference defaults onto the plugin surface, add bundled Gemini CLI backend support, and replace `gateway run --claude-cli-logs` with generic `--cli-backend-logs` while keeping the old flag as a compatibility alias.
- Plugins/startup: auto-load bundled provider and CLI-backend plugins from explicit config refs, so bundled Claude CLI, Codex CLI, and Gemini CLI message-provider setups no longer need manual `plugins.allow` entries.
- Config/TTS: auto-migrate legacy speech config on normal reads and secret resolution, keep legacy diagnostics for Doctor, and remove regular-mode runtime fallback for old bundled `tts.<provider>` API-key shapes.
- OpenAI/apply_patch: enable `apply_patch` by default for OpenAI and OpenAI Codex models, and align its sandbox policy access with `write` permissions.
### Fixes
- ACP/ACPX agent registry: align OpenClaw's ACPX built-in agent mirror with the latest `openclaw/acpx` command defaults and built-in aliases, pin versioned `npx` built-ins to exact versions, and stop unknown ACP agent ids from falling through to raw `--agent` command execution on the MCP-proxy path. (#28321) Thanks @m0nkmaster and @vincentkoc.
- Control UI/config: keep sensitive raw config hidden by default, replace the blank blocked editor with an explicit reveal-to-edit state, and restore raw JSON editing without auto-exposing secrets. Fixes #55322.
- WhatsApp: fix infinite echo loop in self-chat DM mode where the bot's own outbound replies were re-processed as new inbound user messages. (#54570) Thanks @joelnishanth
- OpenAI Codex/image tools: register Codex for media understanding and route image prompts through Codex instructions so image analysis no longer fails on missing provider registration or missing `instructions`. (#54829) Thanks @neeravmakwana.
- Agents/image tool: restore the generic image-runtime fallback when no provider-specific media-understanding provider is registered, so image analysis works again for providers like `openrouter` and `minimax-portal`. (#54858) Thanks @MonkeyLeeT.
@@ -42,11 +36,7 @@ Docs: https://docs.openclaw.ai
- CLI/message send: write manual `openclaw message send` deliveries into the resolved agent session transcript again by always threading the default CLI agent through outbound mirroring. (#54187) Thanks @KevInTheCloud5617.
- CLI/onboarding: show the Kimi Code API key option again in the Moonshot setup menu so the interactive picker includes all Kimi setup paths together. Fixes #54412 Thanks @sparkyrider
- Agents/status: use provider-aware context window lookup for fresh Anthropic 4.6 model overrides so `/status` shows the correct 1.0m window instead of an underreported shared-cache minimum. (#54796) Thanks @neeravmakwana.
- OpenAI/WebSocket: preserve reasoning replay metadata and tool-call item ids on WebSocket tool turns, and start a fresh response chain when full-context resend is required. (#53856) Thanks @xujingchen1996.
- OpenAI/WS: restore reasoning blocks for Responses WebSocket runs and keep reasoning/tool-call replay metadata intact so resumed sessions do not lose or break follow-up reasoning-capable turns. (#53856) Thanks @xujingchen1996.
- Agents/errors: surface provider quota/reset details when available, but keep HTML/Cloudflare rate-limit pages on the generic fallback so raw error pages are not shown to users. (#54512) Thanks @bugkill3r.
- Claude CLI: switch the bundled Claude CLI backend to `stream-json` output so watchdogs see progress on long runs, and keep session/usage metadata even when Claude finishes with an empty result line. (#49698) Thanks @felear2022.
- Claude CLI/MCP: always pass a strict generated `--mcp-config` overlay for background Claude CLI runs, including the empty-server case, so Claude does not inherit ambient user/global MCP servers. (#54961) Thanks @markojak.
- Agents/embedded replies: surface mid-turn 429 and overload failures when embedded runs end without a user-visible reply, while preserving successful media-only replies that still use legacy `mediaUrl`. (#50930) Thanks @infichen.
- WhatsApp/allowFrom: show a specific allowFrom policy error for valid blocked targets instead of the misleading `<E.164|group JID>` format hint. Thanks @mcaxtr.
- Agents/cooldowns: scope rate-limit cooldowns per model so one 429 no longer blocks every model on the same auth profile, replace the exponential 1 min -> 1 h escalation with a stepped 30 s / 1 min / 5 min ladder, and surface a user-facing countdown message when all models are rate-limited. (#49834) Thanks @kiranvk-2011.
@@ -71,10 +61,6 @@ Docs: https://docs.openclaw.ai
- Agents/failover: classify Codex accountId token extraction failures as auth errors so model fallback continues to the next configured candidate. (#55206) Thanks @cosmicnet.
- Talk/macOS: stop direct system-voice failures from replaying system speech, use app-locale fallback for shared watchdog timing, and add regression coverage for the macOS fallback route and language-aware timeout policy. (#53511) thanks @hongsw.
- Discord/gateway cleanup: keep late Carbon reconnect-exhausted errors suppressed through startup/dispose cleanup so Discord monitor shutdown no longer crashes on late gateway close events. (#55373) Thanks @Takhoffman.
- Discord/gateway shutdown: treat expected reconnect-exhausted events during intentional lifecycle stop as clean shutdowns so startup-abort cleanup no longer surfaces false gateway failures. (#55324) Thanks @joelnishanth.
- GitHub Copilot/auth refresh: treat large `expires_at` values as seconds epochs and clamp far-future runtime auth refresh timers so Copilot token refresh cannot fall into a `setTimeout` overflow hot loop. (#55360) Thanks @michael-abdo.
- Agents/status: use the persisted runtime session model in `session_status` when no explicit override exists, and honor per-agent `thinkingDefault` in both `session_status` and `/status`. (#55425) Thanks @scoootscooob, @xaeon2026, and @ysfbsf.
- Heartbeat/runner: guarantee the interval timer is re-armed after heartbeat runs and unexpected runner errors so scheduled heartbeats do not silently stop after an interrupted cycle. (#52270) Thanks @MiloStack.
## 2026.3.24

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026032600
versionName = "2026.3.26"
versionCode = 2026032500
versionName = "2026.3.25"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -1,8 +1,8 @@
// Shared iOS version defaults.
// Generated overrides live in build/Version.xcconfig (git-ignored).
OPENCLAW_GATEWAY_VERSION = 2026.3.26
OPENCLAW_MARKETING_VERSION = 2026.3.26
OPENCLAW_BUILD_VERSION = 202603260
OPENCLAW_GATEWAY_VERSION = 2026.3.25
OPENCLAW_MARKETING_VERSION = 2026.3.25
OPENCLAW_BUILD_VERSION = 202603250
#include? "../build/Version.xcconfig"

View File

@@ -65,9 +65,9 @@ Release behavior:
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- Root `package.json.version` is the only version source for iOS.
- A root version like `2026.3.26-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.26`
- `CFBundleVersion = next TestFlight build number for 2026.3.26`
- A root version like `2026.3.22-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.22`
- `CFBundleVersion = next TestFlight build number for 2026.3.22`
Required env for beta builds:

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.26</string>
<string>2026.3.25</string>
<key>CFBundleVersion</key>
<string>202603260</string>
<string>202603250</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -8773,8 +8773,13 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -9320,8 +9325,13 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -11130,8 +11140,13 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -11393,8 +11408,13 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -11763,8 +11783,14 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"media",
"network",
"security"
],
"hasChildren": true
},
{
@@ -14424,8 +14450,14 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"media",
"network",
"security"
],
"hasChildren": true
},
{
@@ -17023,8 +17055,12 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -17082,8 +17118,12 @@
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -17696,8 +17736,12 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -17755,8 +17799,12 @@
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -19830,8 +19878,13 @@
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": false
},
{
@@ -19880,8 +19933,13 @@
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": false
},
{
@@ -20642,8 +20700,13 @@
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": false
},
{
@@ -23548,8 +23611,13 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -27685,8 +27753,13 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -27778,8 +27851,13 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -28628,8 +28706,13 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -28819,8 +28902,13 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -30028,8 +30116,13 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -30619,8 +30712,13 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -32319,8 +32417,13 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": true
},
{
@@ -34464,8 +34567,13 @@
],
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"sensitive": true,
"tags": [
"auth",
"channels",
"network",
"security"
],
"hasChildren": true
},
{

View File

@@ -767,7 +767,7 @@
{"recordType":"path","path":"channels.bluebubbles.accounts.*.mediaLocalRoots.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.accounts.*.mediaMaxMb","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.accounts.*.password","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.bluebubbles.accounts.*.password","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.bluebubbles.accounts.*.password.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.accounts.*.password.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.accounts.*.password.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -817,7 +817,7 @@
{"recordType":"path","path":"channels.bluebubbles.mediaLocalRoots.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.mediaMaxMb","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.password","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.bluebubbles.password","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.bluebubbles.password.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.password.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.bluebubbles.password.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -988,7 +988,7 @@
{"recordType":"path","path":"channels.discord.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.pluralkit","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.pluralkit.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.pluralkit.token","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.pluralkit.token","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.pluralkit.token.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.pluralkit.token.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.pluralkit.token.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1012,7 +1012,7 @@
{"recordType":"path","path":"channels.discord.accounts.*.threadBindings.maxAgeHours","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.threadBindings.spawnAcpSessions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.threadBindings.spawnSubagentSessions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.token","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.token","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.token.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.token.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.token.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1047,7 +1047,7 @@
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*.*","kind":"channel","type":["array","boolean","null","number","object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*.*.*","kind":"channel","type":[],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*.apiKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*.apiKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","media","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*.apiKey.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*.apiKey.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.voice.tts.providers.*.apiKey.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1273,7 +1273,7 @@
{"recordType":"path","path":"channels.discord.voice.tts.providers.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.voice.tts.providers.*.*","kind":"channel","type":["array","boolean","null","number","object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.voice.tts.providers.*.*.*","kind":"channel","type":[],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.voice.tts.providers.*.apiKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.discord.voice.tts.providers.*.apiKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","media","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.discord.voice.tts.providers.*.apiKey.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.voice.tts.providers.*.apiKey.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.voice.tts.providers.*.apiKey.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1509,13 +1509,13 @@
{"recordType":"path","path":"channels.googlechat.accounts.*.replyToMode","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccount","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccount","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccount.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccount.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccount.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccount.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccountFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccountRef","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccountRef","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":true,"tags":["channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccountRef.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccountRef.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.accounts.*.serviceAccountRef.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1572,13 +1572,13 @@
{"recordType":"path","path":"channels.googlechat.replyToMode","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccount","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.serviceAccount","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.serviceAccount.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccount.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccount.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccount.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccountFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccountRef","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.serviceAccountRef","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":true,"tags":["channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.serviceAccountRef.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccountRef.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat.serviceAccountRef.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1773,12 +1773,12 @@
{"recordType":"path","path":"channels.irc.accounts.*.nick","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv.password","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv.password","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv.passwordFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv.register","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv.registerEmail","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.nickserv.service","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.password","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.password","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.passwordFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.port","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.accounts.*.realname","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1847,7 +1847,7 @@
{"recordType":"path","path":"channels.irc.nickserv.register","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"IRC NickServ Register","help":"If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable.","hasChildren":false}
{"recordType":"path","path":"channels.irc.nickserv.registerEmail","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"IRC NickServ Register Email","help":"Email used with NickServ REGISTER (required when register=true).","hasChildren":false}
{"recordType":"path","path":"channels.irc.nickserv.service","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"IRC NickServ Service","help":"NickServ service nick (default: NickServ).","hasChildren":false}
{"recordType":"path","path":"channels.irc.password","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.password","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":false}
{"recordType":"path","path":"channels.irc.passwordFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.port","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.irc.realname","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2110,7 +2110,7 @@
{"recordType":"path","path":"channels.msteams.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.allowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.appPassword","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.appPassword","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.appPassword.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.appPassword.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.appPassword.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2500,7 +2500,7 @@
{"recordType":"path","path":"channels.slack.accounts.*.allowBots","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.appToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.appToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.appToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.appToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.appToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2509,7 +2509,7 @@
{"recordType":"path","path":"channels.slack.accounts.*.blockStreamingCoalesce.idleMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.blockStreamingCoalesce.maxChars","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.blockStreamingCoalesce.minChars","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.botToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.botToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.botToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.botToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.botToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2588,7 +2588,7 @@
{"recordType":"path","path":"channels.slack.accounts.*.replyToModeByChatType.group","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.signingSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.signingSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.signingSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.signingSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.signingSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2605,7 +2605,7 @@
{"recordType":"path","path":"channels.slack.accounts.*.thread.inheritParent","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.thread.initialHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.typingReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.userToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.userToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.slack.accounts.*.userToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.userToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.accounts.*.userToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2713,7 +2713,7 @@
{"recordType":"path","path":"channels.slack.replyToModeByChatType.group","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.signingSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.signingSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.slack.signingSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.signingSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.signingSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2764,7 +2764,7 @@
{"recordType":"path","path":"channels.telegram.accounts.*.blockStreamingCoalesce.idleMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.blockStreamingCoalesce.maxChars","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.blockStreamingCoalesce.minChars","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.botToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.accounts.*.botToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.accounts.*.botToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.botToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.botToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2922,7 +2922,7 @@
{"recordType":"path","path":"channels.telegram.accounts.*.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.webhookSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.accounts.*.webhookSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.accounts.*.webhookSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.webhookSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.webhookSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -3107,7 +3107,7 @@
{"recordType":"path","path":"channels.telegram.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.webhookSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.webhookSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true}
{"recordType":"path","path":"channels.telegram.webhookSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.webhookSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.webhookSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -266,9 +266,8 @@ Available actions:
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
- **leaveGroup**: Leave a group chat (`chatGuid`)
- **upload-file**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
- Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos.
- Legacy alias: `sendAttachment` still works, but `upload-file` is the canonical action name.
### Message IDs (short vs full)

View File

@@ -201,7 +201,6 @@ Notes:
- Default webhook path is `/googlechat` if `webhookPath` isnt set.
- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode).
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
- Message actions expose `send` for text and `upload-file` for explicit attachment sends. `upload-file` accepts `media` / `filePath` / `path` plus optional `message`, `filename`, and thread targeting.
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).

View File

@@ -11,7 +11,7 @@ title: "Microsoft Teams"
Updated: 2026-01-21
Status: text + DM attachments are supported; channel/group file sending requires `sharePointSiteId` + Graph permissions (see [Sending files in group chats](#sending-files-in-group-chats)). Polls are sent via Adaptive Cards. Message actions expose explicit `upload-file` for file-first sends.
Status: text + DM attachments are supported; channel/group file sending requires `sharePointSiteId` + Graph permissions (see [Sending files in group chats](#sending-files-in-group-chats)). Polls are sent via Adaptive Cards.
## Plugin required
@@ -527,7 +527,6 @@ Teams recently introduced two channel UI styles over the same underlying data mo
- **DMs:** Images and file attachments work via Teams bot file APIs.
- **Channels/groups:** Attachments live in M365 storage (SharePoint/OneDrive). The webhook payload only includes an HTML stub, not the actual file bytes. **Graph API permissions are required** to download channel attachments.
- For explicit file-first sends, use `action=upload-file` with `media` / `filePath` / `path`; optional `message` becomes the accompanying text/comment, and `filename` overrides the uploaded name.
Without Graph permissions, channel messages with images will be received as text-only (the image content is not accessible to the bot).
By default, OpenClaw only downloads media from Microsoft/Teams hostnames. Override with `channels.msteams.mediaAllowHosts` (use `["*"]` to allow any host).

View File

@@ -100,10 +100,9 @@ semantic queries can find related notes even when wording differs. Hybrid search
(BM25 + vector) is available for combining semantic matching with exact keyword
lookups.
Memory search adapter ids come from the active memory plugin. The default
`memory-core` plugin ships built-ins for OpenAI, Gemini, Voyage, Mistral,
Ollama, and local GGUF models, plus an optional QMD sidecar backend for
advanced retrieval and post-processing features like MMR diversity re-ranking
Memory search supports multiple embedding providers (OpenAI, Gemini, Voyage,
Mistral, Ollama, and local GGUF models), an optional QMD sidecar backend for
advanced retrieval, and post-processing features like MMR diversity re-ranking
and temporal decay.
For the full configuration reference -- including embedding provider setup, QMD

View File

@@ -92,8 +92,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- `pnpm test:max` exposes that same planner profile for a full local run.
- On supported local Node versions, including Node 25, the normal profile can use top-level lane parallelism. `pnpm test:max` still pushes the planner harder when you want a more aggressive local run.
- The base Vitest config marks the wrapper manifests/config files as `forceRerunTriggers` so changed-mode reruns stay correct when scheduler inputs change.
- The wrapper keeps `OPENCLAW_VITEST_FS_MODULE_CACHE` enabled on supported hosts, but assigns a lane-local `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH` so concurrent Vitest processes do not race on one shared experimental cache directory.
- Set `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=/abs/path` if you want one explicit cache location for direct single-run profiling.
- Vitest's filesystem module cache is now enabled by default for Node-side test reruns.
- Opt out with `OPENCLAW_VITEST_FS_MODULE_CACHE=0` or `OPENCLAW_VITEST_FS_MODULE_CACHE=false` if you suspect stale transform cache behavior.
- Perf-debug note:
- `pnpm test:perf:imports` enables Vitest import-duration reporting plus import-breakdown output.
- `pnpm test:perf:imports:changed` scopes the same profiling view to files changed since `origin/main`.

View File

@@ -48,7 +48,7 @@ update **without** changing your persisted channel:
```bash
# Install a specific version
openclaw update --tag 2026.3.26
openclaw update --tag 2026.3.22
# Install from the beta dist-tag (one-off, does not persist)
openclaw update --tag beta
@@ -57,7 +57,7 @@ openclaw update --tag beta
openclaw update --tag main
# Install a specific npm package spec
openclaw update --tag openclaw@2026.3.26
openclaw update --tag openclaw@2026.3.22
```
Notes:
@@ -75,7 +75,7 @@ Preview what `openclaw update` would do without making changes:
```bash
openclaw update --dry-run
openclaw update --channel beta --dry-run
openclaw update --tag 2026.3.26 --dry-run
openclaw update --tag 2026.3.22 --dry-run
openclaw update --dry-run --json
```

View File

@@ -46,8 +46,6 @@ Use it for:
- config validation
- auth and onboarding metadata that should be available without booting plugin
runtime
- static capability ownership snapshots used for bundled compat wiring and
contract coverage
- config UI hints
Do not use it for:
@@ -131,7 +129,6 @@ Those belong in your plugin code and `package.json`.
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
| `providerAuthEnvVars` | No | `Record<string, string[]>` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. |
| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. |
| `contracts` | No | `object` | Static bundled capability snapshot for speech, media-understanding, image-generation, web search, and tool ownership. |
| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. |
| `name` | No | `string` | Human-readable plugin name. |
| `description` | No | `string` | Short summary shown in plugin surfaces. |
@@ -187,38 +184,6 @@ Each field hint can include:
| `sensitive` | `boolean` | Marks the field as secret or sensitive. |
| `placeholder` | `string` | Placeholder text for form inputs. |
## contracts reference
Use `contracts` only for static capability ownership metadata that OpenClaw can
read without importing the plugin runtime.
```json
{
"contracts": {
"speechProviders": ["openai"],
"mediaUnderstandingProviders": ["openai", "openai-codex"],
"imageGenerationProviders": ["openai"],
"webSearchProviders": ["gemini"],
"tools": ["firecrawl_search", "firecrawl_scrape"]
}
}
```
Each list is optional:
| Field | Type | What it means |
| ----------------------------- | ---------- | -------------------------------------------------------------- |
| `speechProviders` | `string[]` | Speech provider ids this plugin owns. |
| `mediaUnderstandingProviders` | `string[]` | Media-understanding provider ids this plugin owns. |
| `imageGenerationProviders` | `string[]` | Image-generation provider ids this plugin owns. |
| `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. |
| `tools` | `string[]` | Agent tool names this plugin owns for bundled contract checks. |
Legacy top-level `speechProviders`, `mediaUnderstandingProviders`, and
`imageGenerationProviders` are deprecated. Use `openclaw doctor --fix` to move
them under `contracts`; normal manifest loading no longer treats them as
capability ownership.
## Manifest versus package.json
The two files serve different jobs:

View File

@@ -159,22 +159,6 @@ AI CLI backend such as `claude-cli` or `codex-cli`.
| `api.registerContextEngine(id, factory)` | Context engine (one active at a time) |
| `api.registerMemoryPromptSection(builder)` | Memory prompt section builder |
| `api.registerMemoryFlushPlan(resolver)` | Memory flush plan resolver |
| `api.registerMemoryRuntime(runtime)` | Memory runtime adapter |
### Memory embedding adapters
| Method | What it registers |
| ---------------------------------------------- | ---------------------------------------------- |
| `api.registerMemoryEmbeddingProvider(adapter)` | Memory embedding adapter for the active plugin |
- `registerMemoryPromptSection`, `registerMemoryFlushPlan`, and
`registerMemoryRuntime` are exclusive to memory plugins.
- `registerMemoryEmbeddingProvider` lets the active memory plugin register one
or more embedding adapter ids (for example `openai`, `gemini`, or a custom
plugin-defined id).
- User config such as `agents.defaults.memorySearch.provider` and
`agents.defaults.memorySearch.fallback` resolves against those registered
adapter ids.
### Events and lifecycle

View File

@@ -20,12 +20,7 @@ automatic flush), see [Memory](/concepts/memory).
- Watches memory files for changes (debounced).
- Configure memory search under `agents.defaults.memorySearch` (not top-level
`memorySearch`).
- `memorySearch.provider` and `memorySearch.fallback` accept **adapter ids**
registered by the active memory plugin.
- The default `memory-core` plugin registers these built-in adapter ids:
`local`, `openai`, `gemini`, `voyage`, `mistral`, and `ollama`.
- With the default `memory-core` plugin, if `memorySearch.provider` is not set,
OpenClaw auto-selects:
- Uses remote embeddings by default. If `memorySearch.provider` is not set, OpenClaw auto-selects:
1. `local` if a `memorySearch.local.modelPath` is configured and the file exists.
2. `openai` if an OpenAI key can be resolved.
3. `gemini` if a Gemini key can be resolved.
@@ -34,9 +29,8 @@ automatic flush), see [Memory](/concepts/memory).
6. Otherwise memory search stays disabled until configured.
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
- With the default `memory-core` plugin, `memorySearch.provider = "ollama"` is
also supported for local/self-hosted Ollama embeddings (`/api/embeddings`),
but it is not auto-selected.
- `memorySearch.provider = "ollama"` is also supported for local/self-hosted
Ollama embeddings (`/api/embeddings`), but it is not auto-selected.
Remote embeddings **require** an API key for the embedding provider. OpenClaw
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
@@ -323,16 +317,15 @@ If you don't want to set an API key, use `memorySearch.provider = "local"` or se
### Fallbacks
- `memorySearch.fallback` can be any registered memory embedding adapter id, or `none`.
- With the default `memory-core` plugin, valid built-in fallback ids are `openai`, `gemini`, `voyage`, `mistral`, `ollama`, and `local`.
- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `ollama`, `local`, or `none`.
- The fallback provider is only used when the primary embedding provider fails.
### Batch indexing
### Batch indexing (OpenAI + Gemini + Voyage)
- Disabled by default. Set `agents.defaults.memorySearch.remote.batch.enabled = true` to enable batch indexing for providers whose adapter exposes batch support.
- Disabled by default. Set `agents.defaults.memorySearch.remote.batch.enabled = true` to enable for large-corpus indexing (OpenAI, Gemini, and Voyage).
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
- With the default `memory-core` plugin, batch indexing is available for `openai`, `gemini`, and `voyage`.
- Batch mode applies when `memorySearch.provider = "openai"` or `"gemini"` and uses the corresponding API key.
- Gemini batch jobs use the async embeddings batch endpoint and require Gemini Batch API availability.
Why OpenAI batch is fast and cheap:

View File

@@ -40,7 +40,7 @@ For local PR land/gate checks, run:
If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm vitest run <path/to/test>`. For memory-constrained hosts, use:
- `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test`
- `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=/tmp/openclaw-vitest-cache pnpm test:changed`
- `OPENCLAW_VITEST_FS_MODULE_CACHE=0 pnpm test:changed`
## Model latency bench (local keys)

View File

@@ -1,5 +1,5 @@
---
summary: "Use ACP runtime sessions for Codex, Claude Code, Cursor, Gemini CLI, OpenClaw ACP, and other harness agents"
summary: "Use ACP runtime sessions for Pi, Claude Code, Codex, OpenCode, Gemini CLI, and other harness agents"
read_when:
- Running coding harnesses through ACP
- Setting up thread-bound ACP sessions on thread-capable channels
@@ -11,7 +11,7 @@ title: "ACP Agents"
# ACP agents
[Agent Client Protocol (ACP)](https://agentclientprotocol.com/) sessions let OpenClaw run external coding harnesses (for example Pi, Claude Code, Codex, Cursor, Copilot, OpenClaw ACP, OpenCode, Gemini CLI, and other supported ACPX harnesses) through an ACP backend plugin.
[Agent Client Protocol (ACP)](https://agentclientprotocol.com/) sessions let OpenClaw run external coding harnesses (for example Pi, Claude Code, Codex, OpenCode, and Gemini CLI) through an ACP backend plugin.
If you ask OpenClaw in plain language to "run this in Codex" or "start Claude Code in a thread", OpenClaw should route that request to the ACP runtime (not the native sub-agent runtime).
@@ -441,23 +441,14 @@ Equivalent operations:
Current acpx built-in harness aliases:
- `pi`
- `claude`
- `codex`
- `copilot`
- `cursor` (Cursor CLI: `cursor-agent acp`)
- `droid`
- `gemini`
- `iflow`
- `kilocode`
- `kimi`
- `kiro`
- `openclaw`
- `opencode`
- `pi`
- `qwen`
- `gemini`
- `kimi`
When OpenClaw uses the acpx backend, prefer these values for `agentId` unless your acpx config defines custom agent aliases.
If your local Cursor install still exposes ACP as `agent acp`, override the `cursor` agent command in your acpx config instead of changing the built-in default.
Direct acpx CLI usage can also target arbitrary adapters via `--agent <command>`, but that raw escape hatch is an acpx CLI feature (not the normal OpenClaw `agentId` path).
@@ -473,22 +464,7 @@ Core ACP baseline:
dispatch: { enabled: true },
backend: "acpx",
defaultAgent: "codex",
allowedAgents: [
"claude",
"codex",
"copilot",
"cursor",
"droid",
"gemini",
"iflow",
"kilocode",
"kimi",
"kiro",
"openclaw",
"opencode",
"pi",
"qwen",
],
allowedAgents: ["pi", "claude", "codex", "opencode", "gemini", "kimi"],
maxConcurrentSessions: 8,
stream: {
coalesceIdleMs: 300,

View File

@@ -36,9 +36,8 @@ The tool accepts a single `input` string that wraps one or more file operations:
- `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.
- Use `*** Move to:` within an `*** Update File:` hunk to rename files.
- `*** End of File` marks an EOF-only insert when needed.
- Available by default for OpenAI and OpenAI Codex models. Set
`tools.exec.applyPatch.enabled: false` to disable it.
- Optionally gate by model via
- Experimental and disabled by default. Enable with `tools.exec.applyPatch.enabled`.
- OpenAI-only (including OpenAI Codex). Optionally gate by model via
`tools.exec.applyPatch.allowModels`.
- Config is only under `tools.exec`.

View File

@@ -184,17 +184,16 @@ Paste (bracketed by default):
{ "tool": "process", "action": "paste", "sessionId": "<id>", "text": "line1\nline2\n" }
```
## apply_patch
## apply_patch (experimental)
`apply_patch` is a subtool of `exec` for structured multi-file edits.
It is enabled by default for OpenAI and OpenAI Codex models. Use config only
when you want to disable it or restrict it to specific models:
Enable it explicitly:
```json5
{
tools: {
exec: {
applyPatch: { workspaceOnly: true, allowModels: ["gpt-5.2"] },
applyPatch: { enabled: true, workspaceOnly: true, allowModels: ["gpt-5.2"] },
},
},
}
@@ -203,7 +202,6 @@ when you want to disable it or restrict it to specific models:
Notes:
- Only available for OpenAI/OpenAI Codex models.
- Tool policy still applies; `allow: ["write"]` implicitly allows `apply_patch`.
- Tool policy still applies; `allow: ["exec"]` implicitly allows `apply_patch`.
- Config lives under `tools.exec.applyPatch`.
- `tools.exec.applyPatch.enabled` defaults to `true`; set it to `false` to disable the tool for OpenAI models.
- `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.

View File

@@ -15,7 +15,7 @@ It works anywhere OpenClaw can send audio.
## Supported services
- **ElevenLabs** (primary or fallback provider)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`, default when no API keys)
- **OpenAI** (primary or fallback provider; also used for summaries)
### Microsoft speech notes
@@ -38,7 +38,9 @@ If you want OpenAI or ElevenLabs:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `OPENAI_API_KEY`
Microsoft speech does **not** require an API key.
Microsoft speech does **not** require an API key. If no API keys are found,
OpenClaw defaults to Microsoft (unless disabled via
`messages.tts.microsoft.enabled=false` or `messages.tts.edge.enabled=false`).
If multiple providers are configured, the selected provider is used first and the others are fallback options.
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
@@ -58,8 +60,8 @@ so that provider must also be authenticated if you enable summaries.
No. AutoTTS is **off** by default. Enable it in config with
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
When `messages.tts.provider` is unset, OpenClaw picks the first configured
speech provider in registry auto-select order.
Microsoft speech **is** enabled by default once TTS is on, and is used automatically
when no OpenAI or ElevenLabs API keys are available.
## Config
@@ -91,28 +93,26 @@ Full schema is in [Gateway configuration](/gateway/configuration).
modelOverrides: {
enabled: true,
},
providers: {
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
},
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
},
},
},
@@ -128,15 +128,13 @@ Full schema is in [Gateway configuration](/gateway/configuration).
tts: {
auto: "always",
provider: "microsoft",
providers: {
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
},
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
},
},
},
@@ -149,10 +147,8 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{
messages: {
tts: {
providers: {
microsoft: {
enabled: false,
},
microsoft: {
enabled: false,
},
},
},
@@ -212,37 +208,37 @@ Then run:
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
- `provider`: speech provider id such as `"elevenlabs"`, `"microsoft"`, or `"openai"` (fallback is automatic).
- If `provider` is **unset**, OpenClaw uses the first configured speech provider in registry auto-select order.
- If `provider` is **unset**, OpenClaw prefers `openai` (if key), then `elevenlabs` (if key),
otherwise `microsoft`.
- Legacy `provider: "edge"` still works and is normalized to `microsoft`.
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
- Accepts `provider/model` or a configured model alias.
- `modelOverrides`: allow the model to emit TTS directives (on by default).
- `allowProvider` defaults to `false` (provider switching is opt-in).
- `providers.<id>`: provider-owned settings keyed by speech provider id.
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
- `timeoutMs`: request timeout (ms).
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
- `providers.elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `providers.openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.providers.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- Non-default values are treated as OpenAI-compatible TTS endpoints, so custom model and voice names are accepted.
- `providers.elevenlabs.voiceSettings`:
- `elevenlabs.voiceSettings`:
- `stability`, `similarityBoost`, `style`: `0..1`
- `useSpeakerBoost`: `true|false`
- `speed`: `0.5..2.0` (1.0 = normal)
- `providers.elevenlabs.applyTextNormalization`: `auto|on|off`
- `providers.elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `providers.elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `providers.microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `providers.microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `providers.microsoft.lang`: language code (e.g. `en-US`).
- `providers.microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- `elevenlabs.applyTextNormalization`: `auto|on|off`
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `microsoft.lang`: language code (e.g. `en-US`).
- `microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- See Microsoft Speech output formats for valid values; not all formats are supported by the bundled Edge-backed transport.
- `providers.microsoft.rate` / `providers.microsoft.pitch` / `providers.microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `providers.microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `providers.microsoft.proxy`: proxy URL for Microsoft speech requests.
- `providers.microsoft.timeoutMs`: request timeout override (ms).
- `microsoft.rate` / `microsoft.pitch` / `microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `microsoft.proxy`: proxy URL for Microsoft speech requests.
- `microsoft.timeoutMs`: request timeout override (ms).
- `edge.*`: legacy alias for the same Microsoft settings.
## Model-driven overrides (default on)

View File

@@ -15,7 +15,7 @@ It works anywhere OpenClaw can send audio.
## Supported services
- **ElevenLabs** (primary or fallback provider)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`, default when no API keys)
- **OpenAI** (primary or fallback provider; also used for summaries)
### Microsoft speech notes
@@ -38,7 +38,9 @@ If you want OpenAI or ElevenLabs:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `OPENAI_API_KEY`
Microsoft speech does **not** require an API key.
Microsoft speech does **not** require an API key. If no API keys are found,
OpenClaw defaults to Microsoft (unless disabled via
`messages.tts.microsoft.enabled=false` or `messages.tts.edge.enabled=false`).
If multiple providers are configured, the selected provider is used first and the others are fallback options.
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
@@ -58,8 +60,8 @@ so that provider must also be authenticated if you enable summaries.
No. AutoTTS is **off** by default. Enable it in config with
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
When `messages.tts.provider` is unset, OpenClaw picks the first configured
speech provider in registry auto-select order.
Microsoft speech **is** enabled by default once TTS is on, and is used automatically
when no OpenAI or ElevenLabs API keys are available.
## Config
@@ -91,28 +93,26 @@ Full schema is in [Gateway configuration](/gateway/configuration).
modelOverrides: {
enabled: true,
},
providers: {
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
},
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
},
},
},
@@ -128,15 +128,13 @@ Full schema is in [Gateway configuration](/gateway/configuration).
tts: {
auto: "always",
provider: "microsoft",
providers: {
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
},
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
},
},
},
@@ -149,10 +147,8 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{
messages: {
tts: {
providers: {
microsoft: {
enabled: false,
},
microsoft: {
enabled: false,
},
},
},
@@ -212,37 +208,37 @@ Then run:
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
- `provider`: speech provider id such as `"elevenlabs"`, `"microsoft"`, or `"openai"` (fallback is automatic).
- If `provider` is **unset**, OpenClaw uses the first configured speech provider in registry auto-select order.
- If `provider` is **unset**, OpenClaw prefers `openai` (if key), then `elevenlabs` (if key),
otherwise `microsoft`.
- Legacy `provider: "edge"` still works and is normalized to `microsoft`.
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
- Accepts `provider/model` or a configured model alias.
- `modelOverrides`: allow the model to emit TTS directives (on by default).
- `allowProvider` defaults to `false` (provider switching is opt-in).
- `providers.<id>`: provider-owned settings keyed by speech provider id.
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
- `timeoutMs`: request timeout (ms).
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
- `providers.elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `providers.openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.providers.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- Non-default values are treated as OpenAI-compatible TTS endpoints, so custom model and voice names are accepted.
- `providers.elevenlabs.voiceSettings`:
- `elevenlabs.voiceSettings`:
- `stability`, `similarityBoost`, `style`: `0..1`
- `useSpeakerBoost`: `true|false`
- `speed`: `0.5..2.0` (1.0 = normal)
- `providers.elevenlabs.applyTextNormalization`: `auto|on|off`
- `providers.elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `providers.elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `providers.microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `providers.microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `providers.microsoft.lang`: language code (e.g. `en-US`).
- `providers.microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- `elevenlabs.applyTextNormalization`: `auto|on|off`
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `microsoft.lang`: language code (e.g. `en-US`).
- `microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- See Microsoft Speech output formats for valid values; not all formats are supported by the bundled Edge-backed transport.
- `providers.microsoft.rate` / `providers.microsoft.pitch` / `providers.microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `providers.microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `providers.microsoft.proxy`: proxy URL for Microsoft speech requests.
- `providers.microsoft.timeoutMs`: request timeout override (ms).
- `microsoft.rate` / `microsoft.pitch` / `microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `microsoft.proxy`: proxy URL for Microsoft speech requests.
- `microsoft.timeoutMs`: request timeout override (ms).
- `edge.*`: legacy alias for the same Microsoft settings.
## Model-driven overrides (default on)

View File

@@ -8,16 +8,13 @@
"additionalProperties": false,
"properties": {
"command": {
"type": "string",
"minLength": 1
"type": "string"
},
"expectedVersion": {
"type": "string",
"minLength": 1
"type": "string"
},
"cwd": {
"type": "string",
"minLength": 1
"type": "string"
},
"permissionMode": {
"type": "string",
@@ -45,7 +42,6 @@
"properties": {
"command": {
"type": "string",
"minLength": 1,
"description": "Command to run the MCP server"
},
"args": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/acpx",
"version": "2026.3.26",
"version": "2026.3.22",
"description": "OpenClaw ACP runtime backend via acpx",
"type": "module",
"dependencies": {

View File

@@ -1,25 +1,25 @@
---
name: acp-router
description: Route plain-language requests for Pi, Claude Code, Codex, Cursor, Copilot, OpenClaw ACP, OpenCode, Gemini CLI, Qwen, Kiro, Kimi, iFlow, Factory Droid, Kilocode, or ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow). For coding-agent thread requests, read this skill first, then use only `sessions_spawn` for thread creation.
description: Route plain-language requests for Pi, Claude Code, Codex, OpenCode, Gemini CLI, or ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow). For coding-agent thread requests, read this skill first, then use only `sessions_spawn` for thread creation.
user-invocable: false
---
# ACP Harness Router
When user intent is "run this in Pi/Claude Code/Codex/Cursor/Copilot/OpenClaw/OpenCode/Gemini/Qwen/Kiro/Kimi/iFlow/Droid/Kilocode (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini/Kimi (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
## Intent detection
Trigger this skill when the user asks OpenClaw to:
- run something in Pi / Claude Code / Codex / Cursor / Copilot / OpenClaw / OpenCode / Gemini / Qwen / Kiro / Kimi / iFlow / Droid / Kilocode
- run something in Pi / Claude Code / Codex / OpenCode / Gemini
- continue existing harness work
- relay instructions to an external coding harness
- keep an external harness conversation in a thread-like conversation
Mandatory preflight for coding-agent thread requests:
- Before creating any thread for ACP harness work, read this skill first in the same turn.
- Before creating any thread for Pi/Claude/Codex/OpenCode/Gemini work, read this skill first in the same turn.
- After reading, follow `OpenClaw ACP runtime path` below; do not use `message(action="thread-create")` for ACP harness thread spawn.
## Mode selection
@@ -39,26 +39,18 @@ Do not use:
- `subagents` runtime for harness control
- `/acp` command delegation as a requirement for the user
- PTY scraping of supported ACP harness CLIs when `acpx` is available
- PTY scraping of pi/claude/codex/opencode/gemini/kimi CLIs when `acpx` is available
## AgentId mapping
Use these defaults when user names a harness directly:
- "pi" -> `agentId: "pi"`
- "openclaw" -> `agentId: "openclaw"`
- "claude" or "claude code" -> `agentId: "claude"`
- "codex" -> `agentId: "codex"`
- "copilot" or "github copilot" -> `agentId: "copilot"`
- "cursor" or "cursor cli" -> `agentId: "cursor"`
- "droid" or "factory droid" -> `agentId: "droid"`
- "opencode" -> `agentId: "opencode"`
- "gemini" or "gemini cli" -> `agentId: "gemini"`
- "iflow" -> `agentId: "iflow"`
- "kilocode" -> `agentId: "kilocode"`
- "kimi" or "kimi cli" -> `agentId: "kimi"`
- "kiro" or "kiro cli" -> `agentId: "kiro"`
- "qwen" or "qwen code" -> `agentId: "qwen"`
These defaults match current acpx built-in aliases.
@@ -96,7 +88,7 @@ Call:
## Thread spawn recovery policy
When the user asks to start a coding harness in a thread, treat that as an ACP runtime request and try to satisfy it end-to-end.
When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi/kimi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end.
Required behavior when ACP backend is unavailable:
@@ -187,42 +179,25 @@ ${ACPX_CMD} codex sessions close oc-codex-<conversationId>
### Harness aliases in acpx
- `pi`
- `claude`
- `codex`
- `copilot`
- `cursor`
- `droid`
- `gemini`
- `iflow`
- `kilocode`
- `kimi`
- `kiro`
- `openclaw`
- `opencode`
- `pi`
- `qwen`
- `gemini`
- `kimi`
### Built-in adapter commands in acpx
Defaults are:
- `openclaw -> openclaw acp`
- `claude -> npx -y @zed-industries/claude-agent-acp@0.21.0`
- `codex -> npx @zed-industries/codex-acp@^0.9.5`
- `copilot -> copilot --acp --stdio`
- `cursor -> cursor-agent acp`
- `droid -> droid exec --output-format acp`
- `gemini -> gemini --acp`
- `iflow -> iflow --experimental-acp`
- `kilocode -> npx -y @kilocode/cli acp`
- `kimi -> kimi acp`
- `kiro -> kiro-cli acp`
- `pi -> npx pi-acp`
- `claude -> npx -y @zed-industries/claude-agent-acp`
- `codex -> npx @zed-industries/codex-acp`
- `opencode -> npx -y opencode-ai acp`
- `pi -> npx pi-acp@^0.0.22`
- `qwen -> qwen --acp`
- `gemini -> gemini`
- `kimi -> kimi acp`
If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults.
If your local Cursor install still exposes ACP as `agent acp`, set that as the `cursor` agent override explicitly.
### Failure handling

View File

@@ -187,12 +187,4 @@ describe("acpx plugin config parsing", () => {
}),
).toThrow("strictWindowsCmdWrapper must be a boolean");
});
it("keeps the runtime json schema in sync with the manifest config schema", () => {
const manifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
) as { configSchema?: unknown };
expect(createAcpxPluginConfigSchema().jsonSchema).toEqual(manifest.configSchema);
});
});

View File

@@ -1,8 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildPluginConfigSchema } from "openclaw/plugin-sdk/core";
import { z } from "zod";
import type { OpenClawPluginConfigSchema } from "../runtime-api.js";
export const ACPX_PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const;
@@ -113,79 +111,169 @@ type ParseResult =
| { ok: true; value: AcpxPluginConfig | undefined }
| { ok: false; message: string };
const nonEmptyTrimmedString = (message: string) =>
z.string({ error: message }).trim().min(1, { error: message });
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const McpServerConfigSchema = z.object({
command: nonEmptyTrimmedString("command must be a non-empty string").describe(
"Command to run the MCP server",
),
args: z
.array(z.string({ error: "args must be an array of strings" }), {
error: "args must be an array of strings",
})
.optional()
.describe("Arguments to pass to the command"),
env: z
.record(z.string(), z.string({ error: "env values must be strings" }), {
error: "env must be an object of strings",
})
.optional()
.describe("Environment variables for the MCP server"),
});
function isPermissionMode(value: string): value is AcpxPermissionMode {
return ACPX_PERMISSION_MODES.includes(value as AcpxPermissionMode);
}
const AcpxPluginConfigSchema = z.strictObject({
command: nonEmptyTrimmedString("command must be a non-empty string").optional(),
expectedVersion: nonEmptyTrimmedString("expectedVersion must be a non-empty string").optional(),
cwd: nonEmptyTrimmedString("cwd must be a non-empty string").optional(),
permissionMode: z
.enum(ACPX_PERMISSION_MODES, {
error: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`,
})
.optional(),
nonInteractivePermissions: z
.enum(ACPX_NON_INTERACTIVE_POLICIES, {
error: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
})
.optional(),
strictWindowsCmdWrapper: z
.boolean({ error: "strictWindowsCmdWrapper must be a boolean" })
.optional(),
timeoutSeconds: z
.number({ error: "timeoutSeconds must be a number >= 0.001" })
.min(0.001, { error: "timeoutSeconds must be a number >= 0.001" })
.optional(),
queueOwnerTtlSeconds: z
.number({ error: "queueOwnerTtlSeconds must be a number >= 0" })
.min(0, { error: "queueOwnerTtlSeconds must be a number >= 0" })
.optional(),
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
});
function isNonInteractivePermissionPolicy(
value: string,
): value is AcpxNonInteractivePermissionPolicy {
return ACPX_NON_INTERACTIVE_POLICIES.includes(value as AcpxNonInteractivePermissionPolicy);
}
function formatAcpxConfigIssue(issue: z.ZodIssue | undefined): string {
if (!issue) {
return "invalid config";
function isMcpServerConfig(value: unknown): value is McpServerConfig {
if (!isRecord(value)) {
return false;
}
if (issue.code === "unrecognized_keys" && issue.keys.length > 0) {
return `unknown config key: ${issue.keys[0]}`;
if (typeof value.command !== "string" || value.command.trim() === "") {
return false;
}
if (issue.code === "invalid_type" && issue.path.length === 0) {
return "expected config object";
if (value.args !== undefined) {
if (!Array.isArray(value.args)) {
return false;
}
for (const arg of value.args) {
if (typeof arg !== "string") {
return false;
}
}
}
return issue.message;
if (value.env !== undefined) {
if (!isRecord(value.env)) {
return false;
}
for (const envValue of Object.values(value.env)) {
if (typeof envValue !== "string") {
return false;
}
}
}
return true;
}
function parseAcpxPluginConfig(value: unknown): ParseResult {
if (value === undefined) {
return { ok: true, value: undefined };
}
const parsed = AcpxPluginConfigSchema.safeParse(value);
if (!parsed.success) {
return { ok: false, message: formatAcpxConfigIssue(parsed.error.issues[0]) };
if (!isRecord(value)) {
return { ok: false, message: "expected config object" };
}
const allowedKeys = new Set([
"command",
"expectedVersion",
"cwd",
"permissionMode",
"nonInteractivePermissions",
"strictWindowsCmdWrapper",
"timeoutSeconds",
"queueOwnerTtlSeconds",
"mcpServers",
]);
for (const key of Object.keys(value)) {
if (!allowedKeys.has(key)) {
return { ok: false, message: `unknown config key: ${key}` };
}
}
const command = value.command;
if (command !== undefined && (typeof command !== "string" || command.trim() === "")) {
return { ok: false, message: "command must be a non-empty string" };
}
const expectedVersion = value.expectedVersion;
if (
expectedVersion !== undefined &&
(typeof expectedVersion !== "string" || expectedVersion.trim() === "")
) {
return { ok: false, message: "expectedVersion must be a non-empty string" };
}
const cwd = value.cwd;
if (cwd !== undefined && (typeof cwd !== "string" || cwd.trim() === "")) {
return { ok: false, message: "cwd must be a non-empty string" };
}
const permissionMode = value.permissionMode;
if (
permissionMode !== undefined &&
(typeof permissionMode !== "string" || !isPermissionMode(permissionMode))
) {
return {
ok: false,
message: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`,
};
}
const nonInteractivePermissions = value.nonInteractivePermissions;
if (
nonInteractivePermissions !== undefined &&
(typeof nonInteractivePermissions !== "string" ||
!isNonInteractivePermissionPolicy(nonInteractivePermissions))
) {
return {
ok: false,
message: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
};
}
const timeoutSeconds = value.timeoutSeconds;
if (
timeoutSeconds !== undefined &&
(typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)
) {
return { ok: false, message: "timeoutSeconds must be a positive number" };
}
const strictWindowsCmdWrapper = value.strictWindowsCmdWrapper;
if (strictWindowsCmdWrapper !== undefined && typeof strictWindowsCmdWrapper !== "boolean") {
return { ok: false, message: "strictWindowsCmdWrapper must be a boolean" };
}
const queueOwnerTtlSeconds = value.queueOwnerTtlSeconds;
if (
queueOwnerTtlSeconds !== undefined &&
(typeof queueOwnerTtlSeconds !== "number" ||
!Number.isFinite(queueOwnerTtlSeconds) ||
queueOwnerTtlSeconds < 0)
) {
return { ok: false, message: "queueOwnerTtlSeconds must be a non-negative number" };
}
const mcpServers = value.mcpServers;
if (mcpServers !== undefined) {
if (!isRecord(mcpServers)) {
return { ok: false, message: "mcpServers must be an object" };
}
for (const [key, serverConfig] of Object.entries(mcpServers)) {
if (!isMcpServerConfig(serverConfig)) {
return {
ok: false,
message: `mcpServers.${key} must have a command string, optional args array, and optional env object`,
};
}
}
}
return {
ok: true,
value: parsed.data as AcpxPluginConfig,
value: {
command: typeof command === "string" ? command.trim() : undefined,
expectedVersion: typeof expectedVersion === "string" ? expectedVersion.trim() : undefined,
cwd: typeof cwd === "string" ? cwd.trim() : undefined,
permissionMode: typeof permissionMode === "string" ? permissionMode : undefined,
nonInteractivePermissions:
typeof nonInteractivePermissions === "string" ? nonInteractivePermissions : undefined,
strictWindowsCmdWrapper:
typeof strictWindowsCmdWrapper === "boolean" ? strictWindowsCmdWrapper : undefined,
timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined,
queueOwnerTtlSeconds:
typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined,
mcpServers: mcpServers as Record<string, McpServerConfig> | undefined,
},
};
}
@@ -202,7 +290,63 @@ function resolveConfiguredCommand(params: { configured?: string; workspaceDir?:
}
export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema {
return buildPluginConfigSchema(AcpxPluginConfigSchema);
return {
safeParse(value: unknown):
| { success: true; data?: unknown }
| {
success: false;
error: { issues: Array<{ path: Array<string | number>; message: string }> };
} {
const parsed = parseAcpxPluginConfig(value);
if (parsed.ok) {
return { success: true, data: parsed.value };
}
return {
success: false,
error: {
issues: [{ path: [], message: parsed.message }],
},
};
},
jsonSchema: {
type: "object",
additionalProperties: false,
properties: {
command: { type: "string" },
expectedVersion: { type: "string" },
cwd: { type: "string" },
permissionMode: {
type: "string",
enum: [...ACPX_PERMISSION_MODES],
},
nonInteractivePermissions: {
type: "string",
enum: [...ACPX_NON_INTERACTIVE_POLICIES],
},
strictWindowsCmdWrapper: { type: "boolean" },
timeoutSeconds: { type: "number", minimum: 0.001 },
queueOwnerTtlSeconds: { type: "number", minimum: 0 },
mcpServers: {
type: "object",
additionalProperties: {
type: "object",
properties: {
command: { type: "string" },
args: {
type: "array",
items: { type: "string" },
},
env: {
type: "object",
additionalProperties: { type: "string" },
},
},
required: ["command"],
},
},
},
},
};
}
export function toAcpMcpServers(mcpServers: Record<string, McpServerConfig>): AcpxMcpServer[] {

View File

@@ -11,48 +11,6 @@ vi.mock("./process.js", () => ({
import { __testing, resolveAcpxAgentCommand } from "./mcp-agent-command.js";
describe("resolveAcpxAgentCommand", () => {
it.each([
["cursor", "cursor-agent acp"],
["gemini", "gemini --acp"],
["openclaw", "openclaw acp"],
["copilot", "copilot --acp --stdio"],
["pi", "npx -y pi-acp@0.0.22"],
["codex", "npx -y @zed-industries/codex-acp@0.9.5"],
["claude", "npx -y @zed-industries/claude-agent-acp@0.21.0"],
])("uses the current acpx built-in for %s by default", async (agent, expected) => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: JSON.stringify({ agents: {} }),
stderr: "",
code: 0,
error: null,
});
const command = await resolveAcpxAgentCommand({
acpxCommand: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
agent,
});
expect(command).toBe(expected);
});
it("returns null for unknown agent ids instead of falling back to raw commands", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: JSON.stringify({ agents: {} }),
stderr: "",
code: 0,
error: null,
});
const command = await resolveAcpxAgentCommand({
acpxCommand: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
agent: "sh -c whoami",
});
expect(command).toBeNull();
});
it("threads stripProviderAuthEnvVars through the config show probe", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: JSON.stringify({

View File

@@ -2,22 +2,12 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnAndCollect, type SpawnCommandOptions } from "./process.js";
// Keep this mirror aligned with openclaw/acpx src/agent-registry.ts built-ins.
const ACPX_BUILTIN_AGENT_COMMANDS: Record<string, string> = {
pi: "npx -y pi-acp@0.0.22",
openclaw: "openclaw acp",
codex: "npx -y @zed-industries/codex-acp@0.9.5",
claude: "npx -y @zed-industries/claude-agent-acp@0.21.0",
gemini: "gemini --acp",
cursor: "cursor-agent acp",
copilot: "copilot --acp --stdio",
droid: "droid exec --output-format acp",
iflow: "iflow --experimental-acp",
kilocode: "npx -y @kilocode/cli acp",
kimi: "kimi acp",
kiro: "kiro-cli acp",
codex: "npx @zed-industries/codex-acp",
claude: "npx -y @zed-industries/claude-agent-acp",
gemini: "gemini",
opencode: "npx -y opencode-ai acp",
qwen: "qwen --acp",
pi: "npx pi-acp",
};
const MCP_PROXY_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "mcp-proxy.mjs");
@@ -105,7 +95,7 @@ export async function resolveAcpxAgentCommand(params: {
agent: string;
stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise<string | null> {
}): Promise<string> {
const normalizedAgent = normalizeAgentName(params.agent);
const overrides = await loadAgentOverrides({
acpxCommand: params.acpxCommand,
@@ -113,7 +103,7 @@ export async function resolveAcpxAgentCommand(params: {
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
spawnOptions: params.spawnOptions,
});
return overrides[normalizedAgent] ?? ACPX_BUILTIN_AGENT_COMMANDS[normalizedAgent] ?? null;
return overrides[normalizedAgent] ?? ACPX_BUILTIN_AGENT_COMMANDS[normalizedAgent] ?? params.agent;
}
export function buildMcpProxyAgentCommand(params: {

View File

@@ -551,31 +551,6 @@ describe("AcpxRuntime", () => {
}
});
it("does not pass unknown agent ids through acpx --agent when MCP servers are configured", async () => {
const { runtime, logPath } = await createMockRuntimeFixture({
mcpServers: {
canva: {
command: "npx",
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
env: {
CANVA_TOKEN: "secret", // pragma: allowlist secret
},
},
},
});
await runtime.ensureSession({
sessionKey: "agent:sh:acp:mcp",
agent: "sh -c whoami",
mode: "persistent",
});
const logs = await readMockRuntimeLogEntries(logPath);
const ensureArgs = (logs.find((entry) => entry.kind === "ensure")?.args as string[]) ?? [];
expect(ensureArgs).not.toContain("--agent");
expect(ensureArgs).toContain("sh -c whoami");
});
it("skips prompt execution when runTurn starts with an already-aborted signal", async () => {
const { runtime, logPath } = await createMockRuntimeFixture();
const handle = await runtime.ensureSession({

View File

@@ -936,9 +936,6 @@ export class AcpxRuntime implements AcpRuntime {
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
spawnOptions: this.spawnCommandOptions,
});
if (!targetCommand) {
return null;
}
const resolved = buildMcpProxyAgentCommand({
targetCommand,
mcpServers: toAcpMcpServers(this.config.mcpServers),

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.3.26",
"version": "2026.3.22",
"private": true,
"description": "OpenClaw Amazon Bedrock provider plugin",
"type": "module",

View File

@@ -3,49 +3,102 @@ import {
CLI_FRESH_WATCHDOG_DEFAULTS,
CLI_RESUME_WATCHDOG_DEFAULTS,
} from "openclaw/plugin-sdk/cli-backend";
import {
CLAUDE_CLI_BACKEND_ID,
CLAUDE_CLI_CLEAR_ENV,
CLAUDE_CLI_MODEL_ALIASES,
CLAUDE_CLI_SESSION_ID_FIELDS,
normalizeClaudeBackendConfig,
} from "./cli-shared.js";
const CLAUDE_MODEL_ALIASES: Record<string, string> = {
opus: "opus",
"opus-4.6": "opus",
"opus-4.5": "opus",
"opus-4": "opus",
"claude-opus-4-6": "opus",
"claude-opus-4-5": "opus",
"claude-opus-4": "opus",
sonnet: "sonnet",
"sonnet-4.6": "sonnet",
"sonnet-4.5": "sonnet",
"sonnet-4.1": "sonnet",
"sonnet-4.0": "sonnet",
"claude-sonnet-4-6": "sonnet",
"claude-sonnet-4-5": "sonnet",
"claude-sonnet-4-1": "sonnet",
"claude-sonnet-4-0": "sonnet",
haiku: "haiku",
"haiku-3.5": "haiku",
"claude-haiku-3-5": "haiku",
};
const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions";
const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode";
const CLAUDE_BYPASS_PERMISSIONS_MODE = "bypassPermissions";
function normalizeClaudePermissionArgs(args?: string[]): string[] | undefined {
if (!args) {
return args;
}
const normalized: string[] = [];
let sawLegacySkip = false;
let hasPermissionMode = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG) {
sawLegacySkip = true;
continue;
}
if (arg === CLAUDE_PERMISSION_MODE_ARG) {
hasPermissionMode = true;
normalized.push(arg);
const maybeValue = args[i + 1];
if (typeof maybeValue === "string") {
normalized.push(maybeValue);
i += 1;
}
continue;
}
if (arg.startsWith(`${CLAUDE_PERMISSION_MODE_ARG}=`)) {
hasPermissionMode = true;
}
normalized.push(arg);
}
if (sawLegacySkip && !hasPermissionMode) {
normalized.push(CLAUDE_PERMISSION_MODE_ARG, CLAUDE_BYPASS_PERMISSIONS_MODE);
}
return normalized;
}
function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig {
return {
...config,
args: normalizeClaudePermissionArgs(config.args),
resumeArgs: normalizeClaudePermissionArgs(config.resumeArgs),
};
}
export function buildAnthropicCliBackend(): CliBackendPlugin {
return {
id: CLAUDE_CLI_BACKEND_ID,
id: "claude-cli",
bundleMcp: true,
config: {
command: "claude",
args: [
"-p",
"--output-format",
"stream-json",
"--verbose",
"--permission-mode",
"bypassPermissions",
],
args: ["-p", "--output-format", "json", "--permission-mode", "bypassPermissions"],
resumeArgs: [
"-p",
"--output-format",
"stream-json",
"--verbose",
"json",
"--permission-mode",
"bypassPermissions",
"--resume",
"{sessionId}",
],
output: "jsonl",
output: "json",
input: "arg",
modelArg: "--model",
modelAliases: CLAUDE_CLI_MODEL_ALIASES,
modelAliases: CLAUDE_MODEL_ALIASES,
sessionArg: "--session-id",
sessionMode: "always",
sessionIdFields: [...CLAUDE_CLI_SESSION_ID_FIELDS],
sessionIdFields: ["session_id", "sessionId", "conversation_id", "conversationId"],
systemPromptArg: "--append-system-prompt",
systemPromptMode: "append",
systemPromptWhen: "first",
clearEnv: [...CLAUDE_CLI_CLEAR_ENV],
clearEnv: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD"],
reliability: {
watchdog: {
fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS },

View File

@@ -1,84 +0,0 @@
import type { CliBackendConfig } from "openclaw/plugin-sdk/cli-backend";
export const CLAUDE_CLI_BACKEND_ID = "claude-cli";
export const CLAUDE_CLI_MODEL_ALIASES: Record<string, string> = {
opus: "opus",
"opus-4.6": "opus",
"opus-4.5": "opus",
"opus-4": "opus",
"claude-opus-4-6": "opus",
"claude-opus-4-5": "opus",
"claude-opus-4": "opus",
sonnet: "sonnet",
"sonnet-4.6": "sonnet",
"sonnet-4.5": "sonnet",
"sonnet-4.1": "sonnet",
"sonnet-4.0": "sonnet",
"claude-sonnet-4-6": "sonnet",
"claude-sonnet-4-5": "sonnet",
"claude-sonnet-4-1": "sonnet",
"claude-sonnet-4-0": "sonnet",
haiku: "haiku",
"haiku-3.5": "haiku",
"claude-haiku-3-5": "haiku",
};
export const CLAUDE_CLI_SESSION_ID_FIELDS = [
"session_id",
"sessionId",
"conversation_id",
"conversationId",
] as const;
export const CLAUDE_CLI_CLEAR_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD"] as const;
const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions";
const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode";
const CLAUDE_BYPASS_PERMISSIONS_MODE = "bypassPermissions";
export function isClaudeCliProvider(providerId: string): boolean {
return providerId.trim().toLowerCase() === CLAUDE_CLI_BACKEND_ID;
}
export function normalizeClaudePermissionArgs(args?: string[]): string[] | undefined {
if (!args) {
return args;
}
const normalized: string[] = [];
let sawLegacySkip = false;
let hasPermissionMode = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG) {
sawLegacySkip = true;
continue;
}
if (arg === CLAUDE_PERMISSION_MODE_ARG) {
hasPermissionMode = true;
normalized.push(arg);
const maybeValue = args[i + 1];
if (typeof maybeValue === "string") {
normalized.push(maybeValue);
i += 1;
}
continue;
}
if (arg.startsWith(`${CLAUDE_PERMISSION_MODE_ARG}=`)) {
hasPermissionMode = true;
}
normalized.push(arg);
}
if (sawLegacySkip && !hasPermissionMode) {
normalized.push(CLAUDE_PERMISSION_MODE_ARG, CLAUDE_BYPASS_PERMISSIONS_MODE);
}
return normalized;
}
export function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig {
return {
...config,
args: normalizeClaudePermissionArgs(config.args),
resumeArgs: normalizeClaudePermissionArgs(config.resumeArgs),
};
}

View File

@@ -1,6 +1,7 @@
{
"id": "anthropic",
"providers": ["anthropic"],
"mediaUnderstandingProviders": ["anthropic"],
"cliBackends": ["claude-cli"],
"providerAuthEnvVars": {
"anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
@@ -40,9 +41,6 @@
"cliDescription": "Anthropic API key"
}
],
"contracts": {
"mediaUnderstandingProviders": ["anthropic"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-provider",
"version": "2026.3.26",
"version": "2026.3.22",
"private": true,
"description": "OpenClaw Anthropic provider plugin",
"type": "module",

View File

@@ -1 +0,0 @@
export { BlueBubblesChannelConfigSchema } from "./src/config-schema.js";

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.3.26",
"version": "2026.3.22",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"dependencies": {
@@ -10,7 +10,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.26"
"openclaw": ">=2026.3.22"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -43,7 +43,7 @@
"npmSpec": "@openclaw/bluebubbles",
"localPath": "extensions/bluebubbles",
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.26"
"minHostVersion": ">=2026.3.22"
},
"release": {
"publishToNpm": true

View File

@@ -135,8 +135,7 @@ describe("bluebubblesMessageActions", () => {
},
};
const actions = describeMessageTool({ cfg })?.actions ?? [];
expect(actions).toContain("upload-file");
expect(actions).not.toContain("sendAttachment");
expect(actions).toContain("sendAttachment");
expect(actions).not.toContain("react");
expect(actions).not.toContain("reply");
expect(actions).not.toContain("sendWithEffect");
@@ -166,7 +165,6 @@ describe("bluebubblesMessageActions", () => {
expect(supportsAction({ action: "removeParticipant" })).toBe(true);
expect(supportsAction({ action: "leaveGroup" })).toBe(true);
expect(supportsAction({ action: "sendAttachment" })).toBe(true);
expect(supportsAction({ action: "upload-file" })).toBe(true);
});
it("returns false for unsupported actions", () => {
@@ -206,36 +204,6 @@ describe("bluebubblesMessageActions", () => {
});
describe("handleAction", () => {
it("maps upload-file to the attachment runtime using canonical naming", async () => {
const result = await callHandleAction({
action: "upload-file",
params: {
to: "+15551234567",
filename: "photo.png",
buffer: Buffer.from("img").toString("base64"),
message: "caption",
contentType: "image/png",
},
cfg: blueBubblesConfig(),
accountId: null,
});
expect(sendBlueBubblesAttachment).toHaveBeenCalledWith(
expect.objectContaining({
to: "+15551234567",
filename: "photo.png",
caption: "caption",
contentType: "image/png",
}),
);
expect(result).toMatchObject({
details: {
ok: true,
messageId: "att-msg-123",
},
});
});
it("throws for unsupported actions", async () => {
const cfg: OpenClawConfig = {
channels: {

View File

@@ -52,10 +52,7 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
}
/** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
...BLUEBUBBLES_ACTION_NAMES,
"upload-file",
]);
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
"react",
"edit",
@@ -110,9 +107,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
}
}
}
if (actions.delete("sendAttachment")) {
actions.add("upload-file");
}
return { actions: Array.from(actions) };
},
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
@@ -434,11 +428,11 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
return jsonResult({ ok: true, left: resolvedChatGuid });
}
// Handle sendAttachment action (legacy) and upload-file (canonical)
if (action === "sendAttachment" || action === "upload-file") {
// Handle sendAttachment action
if (action === "sendAttachment") {
const to = readStringParam(params, "to", { required: true });
const filename = readStringParam(params, "filename", { required: true });
const caption = readStringParam(params, "caption") ?? readStringParam(params, "message");
const caption = readStringParam(params, "caption");
const contentType =
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
const asVoice = readBooleanParam(params, "asVoice");
@@ -454,10 +448,10 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
} else if (filePath) {
// Read file from path (will be handled by caller providing buffer)
throw new Error(
`BlueBubbles ${action}: filePath not supported in action, provide buffer as base64.`,
"BlueBubbles sendAttachment: filePath not supported in action, provide buffer as base64.",
);
} else {
throw new Error(`BlueBubbles ${action} requires buffer (base64) parameter.`);
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
}
const result = await runtime.sendBlueBubblesAttachment({

View File

@@ -4,13 +4,14 @@ import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import {
listBlueBubblesAccountIds,
type ResolvedBlueBubblesAccount,
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { BlueBubblesChannelConfigSchema } from "./config-schema.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import type { ChannelPlugin } from "./runtime-api.js";
import { normalizeBlueBubblesHandle } from "./targets.js";
@@ -40,7 +41,7 @@ export const bluebubblesCapabilities: ChannelPlugin<ResolvedBlueBubblesAccount>[
};
export const bluebubblesReload = { configPrefixes: ["channels.bluebubbles"] };
export const bluebubblesConfigSchema = BlueBubblesChannelConfigSchema;
export const bluebubblesConfigSchema = buildChannelConfigSchema(BlueBubblesConfigSchema);
export const bluebubblesConfigAdapter =
createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({

View File

@@ -1,6 +1,5 @@
import {
AllowFromListSchema,
buildChannelConfigSchema,
buildCatchallMultiAccountChannelSchema,
DmPolicySchema,
GroupPolicySchema,
@@ -8,7 +7,6 @@ import {
ToolPolicySchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "zod";
import { bluebubblesChannelConfigUiHints } from "./config-ui-hints.js";
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
const bluebubblesActionSchema = z
@@ -73,7 +71,3 @@ export const BlueBubblesConfigSchema = buildCatchallMultiAccountChannelSchema(
).extend({
actions: bluebubblesActionSchema,
});
export const BlueBubblesChannelConfigSchema = buildChannelConfigSchema(BlueBubblesConfigSchema, {
uiHints: bluebubblesChannelConfigUiHints,
});

View File

@@ -1,12 +0,0 @@
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/core";
export const bluebubblesChannelConfigUiHints = {
"": {
label: "BlueBubbles",
help: "BlueBubbles channel provider configuration used for Apple messaging bridge integrations. Keep DM policy aligned with your trusted sender model in shared deployments.",
},
dmPolicy: {
label: "BlueBubbles DM Policy",
help: 'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].',
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -15,9 +15,6 @@
"help": "Brave Search mode: web or llm-context."
}
},
"contracts": {
"webSearchProviders": ["brave"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.3.26",
"version": "2026.3.22",
"private": true,
"description": "OpenClaw Brave plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/browser-plugin",
"version": "2026.3.26",
"version": "2026.3.25",
"private": true,
"description": "OpenClaw browser tool plugin",
"type": "module",

View File

@@ -1,11 +0,0 @@
export { isLiveTestEnabled } from "../../src/agents/live-test-helpers.js";
export {
createCliRuntimeCapture,
type CliMockOutputRuntime,
type CliRuntimeCapture,
} from "../../src/cli/test-runtime-capture.js";
export type { OpenClawConfig } from "openclaw/plugin-sdk/browser-support";
export { expectGeneratedTokenPersistedToGatewayAuth } from "../../test/helpers/extensions/auth-token-assertions.ts";
export { withEnv, withEnvAsync } from "../../test/helpers/extensions/env.ts";
export { withFetchPreconnect, type FetchMock } from "../../test/helpers/extensions/fetch-mock.ts";
export { createTempHomeEnv, type TempHomeEnv } from "../../test/helpers/extensions/temp-home.ts";

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/byteplus-provider",
"version": "2026.3.26",
"version": "2026.3.22",
"private": true,
"description": "OpenClaw BytePlus provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/chutes-provider",
"version": "2026.3.26",
"version": "2026.3.22",
"private": true,
"description": "OpenClaw Chutes.ai provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/cloudflare-ai-gateway-provider",
"version": "2026.3.26",
"version": "2026.3.22",
"private": true,
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.3.26",
"version": "2026.3.22",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",

View File

@@ -1,8 +1,6 @@
{
"id": "deepgram",
"contracts": {
"mediaUnderstandingProviders": ["deepgram"]
},
"mediaUnderstandingProviders": ["deepgram"],
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/deepgram-provider",
"version": "2026.3.26",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw Deepgram media-understanding provider",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/deepseek-provider",
"version": "2026.3.26",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw DeepSeek provider plugin",
"type": "module",

View File

@@ -6,6 +6,7 @@ import type {
PluginCommandContext,
} from "openclaw/plugin-sdk/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executePluginCommand } from "../../src/plugins/commands.js";
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
import type { OpenClawPluginApi } from "./api.js";
import type { PendingPairingRequest } from "./notify.ts";
@@ -120,9 +121,12 @@ function createChannelRuntime(
}
function createCommandContext(params?: Partial<PluginCommandContext>): PluginCommandContext {
const surface = params?.surface ?? params?.channel ?? "webchat";
return {
channel: "webchat",
surface,
channel: surface,
isAuthorizedSender: true,
senderIsOwner: false,
commandBody: "/pair qr",
args: "qr",
config: {},
@@ -446,14 +450,20 @@ describe("device-pair /pair approve", () => {
});
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "approve latest",
commandBody: "/pair approve latest",
gatewayClientScopes: ["operator.write"],
}),
);
const result = await executePluginCommand({
command: {
...command,
pluginId: "device-pair",
},
senderId: "writer-1",
surface: "webchat",
channel: "webchat",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
args: "approve latest",
commandBody: "/pair approve latest",
config: {} as never,
});
expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled();
expect(result).toEqual({
@@ -501,14 +511,20 @@ describe("device-pair /pair approve", () => {
});
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "approve latest",
commandBody: "/pair approve latest",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
);
const result = await executePluginCommand({
command: {
...command,
pluginId: "device-pair",
},
senderId: "pairing-1",
surface: "webchat",
channel: "webchat",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write", "operator.pairing"],
args: "approve latest",
commandBody: "/pair approve latest",
config: {} as never,
});
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1");
expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." });

View File

@@ -546,13 +546,14 @@ export default definePluginEntry({
name: "pair",
description: "Generate setup codes and approve device pairing requests.",
acceptsArgs: true,
resolveRequiredGatewayScopes: (ctx) => {
const action = ctx.args?.trim().split(/\s+/, 1)[0]?.toLowerCase();
return action === "approve" ? ["operator.pairing"] : undefined;
},
handler: async (ctx) => {
const args = ctx.args?.trim() ?? "";
const tokens = args.split(/\s+/).filter(Boolean);
const action = tokens[0]?.toLowerCase() ?? "";
const gatewayClientScopes = Array.isArray(ctx.gatewayClientScopes)
? ctx.gatewayClientScopes
: null;
api.logger.info?.(
`device-pair: /pair invoked channel=${ctx.channel} sender=${ctx.senderId ?? "unknown"} action=${
action || "new"
@@ -574,15 +575,6 @@ export default definePluginEntry({
}
if (action === "approve") {
if (
gatewayClientScopes &&
!gatewayClientScopes.includes("operator.pairing") &&
!gatewayClientScopes.includes("operator.admin")
) {
return {
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
};
}
const requested = tokens[1]?.trim();
const list = await listDevicePairing();
if (list.pending.length === 0) {

View File

@@ -1,19 +1,19 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.3.26",
"version": "2026.3.22",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-logs": "^0.214.0",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@opentelemetry/sdk-node": "^0.214.0",
"@opentelemetry/sdk-trace-base": "^2.6.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.213.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.213.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.213.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.213.0",
"@opentelemetry/resources": "^2.6.0",
"@opentelemetry/sdk-logs": "^0.213.0",
"@opentelemetry/sdk-metrics": "^2.6.0",
"@opentelemetry/sdk-node": "^0.213.0",
"@opentelemetry/sdk-trace-base": "^2.6.0",
"@opentelemetry/semantic-conventions": "^1.40.0"
},
"openclaw": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diffs",
"version": "2026.3.26",
"version": "2026.3.22",
"private": true,
"description": "OpenClaw diff viewer plugin",
"type": "module",
@@ -8,7 +8,7 @@
"build:viewer": "bun build src/viewer-client.ts --target browser --format esm --minify --outfile assets/viewer-runtime.js"
},
"dependencies": {
"@pierre/diffs": "1.1.5",
"@pierre/diffs": "1.1.3",
"@sinclair/typebox": "0.34.48",
"playwright-core": "1.58.2"
},

View File

@@ -1,5 +1,3 @@
import { buildPluginConfigSchema } from "openclaw/plugin-sdk/core";
import { z } from "zod";
import type { OpenClawPluginConfigSchema } from "../api.js";
import {
DIFF_IMAGE_QUALITY_PRESETS,
@@ -93,79 +91,130 @@ export const DEFAULT_DIFFS_PLUGIN_SECURITY: DiffsPluginSecurityConfig = {
allowRemoteViewer: false,
};
const DiffsPluginJsonSchemaSource = z.strictObject({
defaults: z
.strictObject({
fontFamily: z.string().default(DEFAULT_DIFFS_TOOL_DEFAULTS.fontFamily).optional(),
fontSize: z.number().min(10).max(24).default(DEFAULT_DIFFS_TOOL_DEFAULTS.fontSize).optional(),
lineSpacing: z
.number()
.min(1)
.max(3)
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing)
.optional(),
layout: z.enum(DIFF_LAYOUTS).default(DEFAULT_DIFFS_TOOL_DEFAULTS.layout).optional(),
showLineNumbers: z.boolean().default(DEFAULT_DIFFS_TOOL_DEFAULTS.showLineNumbers).optional(),
diffIndicators: z
.enum(DIFF_INDICATORS)
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.diffIndicators)
.optional(),
wordWrap: z.boolean().default(DEFAULT_DIFFS_TOOL_DEFAULTS.wordWrap).optional(),
background: z.boolean().default(DEFAULT_DIFFS_TOOL_DEFAULTS.background).optional(),
theme: z.enum(DIFF_THEMES).default(DEFAULT_DIFFS_TOOL_DEFAULTS.theme).optional(),
fileFormat: z
.enum(DIFF_OUTPUT_FORMATS)
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.fileFormat)
.optional(),
format: z.enum(DIFF_OUTPUT_FORMATS).optional(),
fileQuality: z
.enum(DIFF_IMAGE_QUALITY_PRESETS)
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.fileQuality)
.optional(),
fileScale: z.number().min(1).max(4).default(DEFAULT_DIFFS_TOOL_DEFAULTS.fileScale).optional(),
fileMaxWidth: z
.number()
.min(640)
.max(2400)
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.fileMaxWidth)
.optional(),
imageFormat: z.enum(DIFF_OUTPUT_FORMATS).optional(),
imageQuality: z.enum(DIFF_IMAGE_QUALITY_PRESETS).optional(),
imageScale: z.number().min(1).max(4).optional(),
imageMaxWidth: z.number().min(640).max(2400).optional(),
mode: z.enum(DIFF_MODES).default(DEFAULT_DIFFS_TOOL_DEFAULTS.mode).optional(),
})
.optional(),
security: z
.strictObject({
allowRemoteViewer: z
.boolean()
.default(DEFAULT_DIFFS_PLUGIN_SECURITY.allowRemoteViewer)
.optional(),
})
.optional(),
});
export const diffsPluginConfigSchema: OpenClawPluginConfigSchema = buildPluginConfigSchema(
DiffsPluginJsonSchemaSource,
{
safeParse(value: unknown) {
if (value === undefined) {
return { success: true, data: undefined };
}
try {
return { success: true, data: resolveDiffsPluginDefaults(value) };
} catch (error) {
return {
success: false,
error: {
issues: [{ path: [], message: error instanceof Error ? error.message : String(error) }],
},
};
}
const DIFFS_PLUGIN_CONFIG_JSON_SCHEMA = {
type: "object",
additionalProperties: false,
properties: {
defaults: {
type: "object",
additionalProperties: false,
properties: {
fontFamily: { type: "string", default: DEFAULT_DIFFS_TOOL_DEFAULTS.fontFamily },
fontSize: {
type: "number",
minimum: 10,
maximum: 24,
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fontSize,
},
lineSpacing: {
type: "number",
minimum: 1,
maximum: 3,
default: DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing,
},
layout: {
type: "string",
enum: [...DIFF_LAYOUTS],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.layout,
},
showLineNumbers: {
type: "boolean",
default: DEFAULT_DIFFS_TOOL_DEFAULTS.showLineNumbers,
},
diffIndicators: {
type: "string",
enum: [...DIFF_INDICATORS],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.diffIndicators,
},
wordWrap: { type: "boolean", default: DEFAULT_DIFFS_TOOL_DEFAULTS.wordWrap },
background: { type: "boolean", default: DEFAULT_DIFFS_TOOL_DEFAULTS.background },
theme: {
type: "string",
enum: [...DIFF_THEMES],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.theme,
},
fileFormat: {
type: "string",
enum: [...DIFF_OUTPUT_FORMATS],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fileFormat,
},
format: {
type: "string",
enum: [...DIFF_OUTPUT_FORMATS],
},
fileQuality: {
type: "string",
enum: [...DIFF_IMAGE_QUALITY_PRESETS],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fileQuality,
},
fileScale: {
type: "number",
minimum: 1,
maximum: 4,
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fileScale,
},
fileMaxWidth: {
type: "number",
minimum: 640,
maximum: 2400,
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fileMaxWidth,
},
imageFormat: {
type: "string",
enum: [...DIFF_OUTPUT_FORMATS],
},
imageQuality: {
type: "string",
enum: [...DIFF_IMAGE_QUALITY_PRESETS],
},
imageScale: {
type: "number",
minimum: 1,
maximum: 4,
},
imageMaxWidth: {
type: "number",
minimum: 640,
maximum: 2400,
},
mode: {
type: "string",
enum: [...DIFF_MODES],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.mode,
},
},
},
security: {
type: "object",
additionalProperties: false,
properties: {
allowRemoteViewer: {
type: "boolean",
default: DEFAULT_DIFFS_PLUGIN_SECURITY.allowRemoteViewer,
},
},
},
},
);
} as const;
export const diffsPluginConfigSchema: OpenClawPluginConfigSchema = {
safeParse(value: unknown) {
if (value === undefined) {
return { success: true, data: undefined };
}
try {
return { success: true, data: resolveDiffsPluginDefaults(value) };
} catch (error) {
return {
success: false,
error: {
issues: [{ path: [], message: error instanceof Error ? error.message : String(error) }],
},
};
}
},
jsonSchema: DIFFS_PLUGIN_CONFIG_JSON_SCHEMA,
};
export function resolveDiffsPluginDefaults(config: unknown): DiffToolDefaults {
if (!config || typeof config !== "object" || Array.isArray(config)) {

View File

@@ -1 +0,0 @@
export { DiscordChannelConfigSchema } from "./src/config-schema.js";

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.3.26",
"version": "2026.3.22",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"dependencies": {
@@ -14,7 +14,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.26"
"openclaw": ">=2026.3.22"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -40,7 +40,7 @@
"npmSpec": "@openclaw/discord",
"localPath": "extensions/discord",
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.26"
"minHostVersion": ">=2026.3.22"
},
"bundle": {
"stageRuntimeDependencies": true

View File

@@ -1,9 +1,3 @@
import {
buildChannelConfigSchema,
DiscordConfigSchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { discordChannelConfigUiHints } from "./config-ui-hints.js";
import { buildChannelConfigSchema, DiscordConfigSchema } from "./runtime-api.js";
export const DiscordChannelConfigSchema = buildChannelConfigSchema(DiscordConfigSchema, {
uiHints: discordChannelConfigUiHints,
});
export const DiscordChannelConfigSchema = buildChannelConfigSchema(DiscordConfigSchema);

View File

@@ -1,196 +0,0 @@
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/core";
export const discordChannelConfigUiHints = {
"": {
label: "Discord",
help: "Discord channel provider configuration for bot auth, retry policy, streaming, thread bindings, and optional voice capabilities. Keep privileged intents and advanced features disabled unless needed.",
},
dmPolicy: {
label: "Discord DM Policy",
help: 'Direct message access control ("pairing" recommended). "open" requires channels.discord.allowFrom=["*"].',
},
"dm.policy": {
label: "Discord DM Policy",
help: 'Direct message access control ("pairing" recommended). "open" requires channels.discord.allowFrom=["*"] (legacy: channels.discord.dm.allowFrom).',
},
configWrites: {
label: "Discord Config Writes",
help: "Allow Discord to write config in response to channel events/commands (default: true).",
},
proxy: {
label: "Discord Proxy URL",
help: "Proxy URL for Discord gateway + API requests (app-id lookup and allowlist resolution). Set per account via channels.discord.accounts.<id>.proxy.",
},
"commands.native": {
label: "Discord Native Commands",
help: 'Override native commands for Discord (bool or "auto").',
},
"commands.nativeSkills": {
label: "Discord Native Skill Commands",
help: 'Override native skill commands for Discord (bool or "auto").',
},
streaming: {
label: "Discord Streaming Mode",
help: 'Unified Discord stream preview mode: "off" | "partial" | "block" | "progress". "progress" maps to "partial" on Discord. Legacy boolean/streamMode keys are auto-mapped.',
},
streamMode: {
label: "Discord Stream Mode (Legacy)",
help: "Legacy Discord preview mode alias (off | partial | block); auto-migrated to channels.discord.streaming.",
},
"draftChunk.minChars": {
label: "Discord Draft Chunk Min Chars",
help: 'Minimum chars before emitting a Discord stream preview update when channels.discord.streaming="block" (default: 200).',
},
"draftChunk.maxChars": {
label: "Discord Draft Chunk Max Chars",
help: 'Target max size for a Discord stream preview chunk when channels.discord.streaming="block" (default: 800; clamped to channels.discord.textChunkLimit).',
},
"draftChunk.breakPreference": {
label: "Discord Draft Chunk Break Preference",
help: "Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph.",
},
"retry.attempts": {
label: "Discord Retry Attempts",
help: "Max retry attempts for outbound Discord API calls (default: 3).",
},
"retry.minDelayMs": {
label: "Discord Retry Min Delay (ms)",
help: "Minimum retry delay in ms for Discord outbound calls.",
},
"retry.maxDelayMs": {
label: "Discord Retry Max Delay (ms)",
help: "Maximum retry delay cap in ms for Discord outbound calls.",
},
"retry.jitter": {
label: "Discord Retry Jitter",
help: "Jitter factor (0-1) applied to Discord retry delays.",
},
maxLinesPerMessage: {
label: "Discord Max Lines Per Message",
help: "Soft max line count per Discord message (default: 17).",
},
"inboundWorker.runTimeoutMs": {
label: "Discord Inbound Worker Timeout (ms)",
help: "Optional queued Discord inbound worker timeout in ms. This is separate from Carbon listener timeouts; defaults to 1800000 and can be disabled with 0. Set per account via channels.discord.accounts.<id>.inboundWorker.runTimeoutMs.",
},
"eventQueue.listenerTimeout": {
label: "Discord EventQueue Listener Timeout (ms)",
help: "Canonical Discord listener timeout control in ms for gateway normalization/enqueue handlers. Default is 120000 in OpenClaw; set per account via channels.discord.accounts.<id>.eventQueue.listenerTimeout.",
},
"eventQueue.maxQueueSize": {
label: "Discord EventQueue Max Queue Size",
help: "Optional Discord EventQueue capacity override (max queued events before backpressure). Set per account via channels.discord.accounts.<id>.eventQueue.maxQueueSize.",
},
"eventQueue.maxConcurrency": {
label: "Discord EventQueue Max Concurrency",
help: "Optional Discord EventQueue concurrency override (max concurrent handler executions). Set per account via channels.discord.accounts.<id>.eventQueue.maxConcurrency.",
},
"threadBindings.enabled": {
label: "Discord Thread Binding Enabled",
help: "Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set.",
},
"threadBindings.idleHours": {
label: "Discord Thread Binding Idle Timeout (hours)",
help: "Inactivity window in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.",
},
"threadBindings.maxAgeHours": {
label: "Discord Thread Binding Max Age (hours)",
help: "Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.",
},
"threadBindings.spawnSubagentSessions": {
label: "Discord Thread-Bound Subagent Spawn",
help: "Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.",
},
"threadBindings.spawnAcpSessions": {
label: "Discord Thread-Bound ACP Spawn",
help: "Allow /acp spawn to auto-create and bind Discord threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.",
},
"ui.components.accentColor": {
label: "Discord Component Accent Color",
help: "Accent color for Discord component containers (hex). Set per account via channels.discord.accounts.<id>.ui.components.accentColor.",
},
"intents.presence": {
label: "Discord Presence Intent",
help: "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.",
},
"intents.guildMembers": {
label: "Discord Guild Members Intent",
help: "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.",
},
"voice.enabled": {
label: "Discord Voice Enabled",
help: "Enable Discord voice channel conversations (default: true). Omit channels.discord.voice to keep voice support disabled for the account.",
},
"voice.autoJoin": {
label: "Discord Voice Auto-Join",
help: "Voice channels to auto-join on startup (list of guildId/channelId entries).",
},
"voice.daveEncryption": {
label: "Discord Voice DAVE Encryption",
help: "Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this).",
},
"voice.decryptionFailureTolerance": {
label: "Discord Voice Decrypt Failure Tolerance",
help: "Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24).",
},
"voice.tts": {
label: "Discord Voice Text-to-Speech",
help: "Optional TTS overrides for Discord voice playback (merged with messages.tts).",
},
"pluralkit.enabled": {
label: "Discord PluralKit Enabled",
help: "Resolve PluralKit proxied messages and treat system members as distinct senders.",
},
"pluralkit.token": {
label: "Discord PluralKit Token",
help: "Optional PluralKit token for resolving private systems or members.",
},
activity: {
label: "Discord Presence Activity",
help: "Discord presence activity text (defaults to custom status).",
},
status: {
label: "Discord Presence Status",
help: "Discord presence status (online, dnd, idle, invisible).",
},
"autoPresence.enabled": {
label: "Discord Auto Presence Enabled",
help: "Enable automatic Discord bot presence updates based on runtime/model availability signals. When enabled: healthy=>online, degraded/unknown=>idle, exhausted/unavailable=>dnd.",
},
"autoPresence.intervalMs": {
label: "Discord Auto Presence Check Interval (ms)",
help: "How often to evaluate Discord auto-presence state in milliseconds (default: 30000).",
},
"autoPresence.minUpdateIntervalMs": {
label: "Discord Auto Presence Min Update Interval (ms)",
help: "Minimum time between actual Discord presence update calls in milliseconds (default: 15000). Prevents status spam on noisy state changes.",
},
"autoPresence.healthyText": {
label: "Discord Auto Presence Healthy Text",
help: "Optional custom status text while runtime is healthy (online). If omitted, falls back to static channels.discord.activity when set.",
},
"autoPresence.degradedText": {
label: "Discord Auto Presence Degraded Text",
help: "Optional custom status text while runtime/model availability is degraded or unknown (idle).",
},
"autoPresence.exhaustedText": {
label: "Discord Auto Presence Exhausted Text",
help: "Optional custom status text while runtime detects exhausted/unavailable model quota (dnd). Supports {reason} template placeholder.",
},
activityType: {
label: "Discord Presence Activity Type",
help: "Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing).",
},
activityUrl: {
label: "Discord Presence Activity URL",
help: "Discord presence streaming URL (required for activityType=1).",
},
allowBots: {
label: "Discord Allow Bot Messages",
help: 'Allow bot-authored messages to trigger Discord replies (default: false). Set "mentions" to only accept bot messages that mention the bot.',
},
token: {
label: "Discord Bot Token",
help: "Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.",
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -1,6 +1,6 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
createResolvedDirectoryEntriesLister,
listResolvedDirectoryEntriesFromSources,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId } from "./accounts.js";
@@ -18,36 +18,38 @@ function resolveDiscordDirectoryConfigAccount(
};
}
export const listDiscordDirectoryPeersFromConfig = createResolvedDirectoryEntriesLister<
ReturnType<typeof resolveDiscordDirectoryConfigAccount>
>({
kind: "user",
resolveAccount: (cfg, accountId) => resolveDiscordDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => {
const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? [];
const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [
...(guild.users ?? []),
...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []),
]);
return [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers];
},
normalizeId: (raw) => {
const mention = raw.match(/^<@!?(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim();
return /^\d+$/.test(cleaned) ? `user:${cleaned}` : null;
},
});
export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) {
return listResolvedDirectoryEntriesFromSources({
...params,
kind: "user",
resolveAccount: (cfg, accountId) => resolveDiscordDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => {
const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? [];
const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [
...(guild.users ?? []),
...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []),
]);
return [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers];
},
normalizeId: (raw) => {
const mention = raw.match(/^<@!?(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim();
return /^\d+$/.test(cleaned) ? `user:${cleaned}` : null;
},
});
}
export const listDiscordDirectoryGroupsFromConfig = createResolvedDirectoryEntriesLister<
ReturnType<typeof resolveDiscordDirectoryConfigAccount>
>({
kind: "group",
resolveAccount: (cfg, accountId) => resolveDiscordDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) =>
Object.values(account.config.guilds ?? {}).map((guild) => Object.keys(guild.channels ?? {})),
normalizeId: (raw) => {
const mention = raw.match(/^<#(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim();
return /^\d+$/.test(cleaned) ? `channel:${cleaned}` : null;
},
});
export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
return listResolvedDirectoryEntriesFromSources({
...params,
kind: "group",
resolveAccount: (cfg, accountId) => resolveDiscordDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) =>
Object.values(account.config.guilds ?? {}).map((guild) => Object.keys(guild.channels ?? {})),
normalizeId: (raw) => {
const mention = raw.match(/^<#(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim();
return /^\d+$/.test(cleaned) ? `channel:${cleaned}` : null;
},
});
}

View File

@@ -1,10 +1,13 @@
import type { EventEmitter } from "node:events";
import type { DiscordGatewayHandle } from "./monitor/gateway-handle.js";
import type {
DiscordGatewayEvent,
DiscordGatewaySupervisor,
} from "./monitor/gateway-supervisor.js";
export type DiscordGatewayHandle = {
disconnect?: () => void;
};
export type WaitForDiscordGatewayStopParams = {
gateway?: DiscordGatewayHandle;
abortSignal?: AbortSignal;

View File

@@ -1,31 +0,0 @@
import type { EventEmitter } from "node:events";
import type { GatewayPlugin } from "@buape/carbon/gateway";
export type DiscordGatewayHandle = Pick<GatewayPlugin, "disconnect"> & {
emitter?: EventEmitter;
};
type GatewaySocketListener = (...args: unknown[]) => void;
export type DiscordGatewaySocket = {
on: (event: "close" | "error", listener: GatewaySocketListener) => unknown;
listeners: (event: "close" | "error") => GatewaySocketListener[];
removeListener: (event: "close" | "error", listener: GatewaySocketListener) => unknown;
terminate?: () => void;
};
export type MutableDiscordGateway = GatewayPlugin & {
emitter?: EventEmitter;
options: Record<string, unknown> & {
reconnect?: {
maxAttempts?: number;
};
};
state?: {
sessionId?: string | null;
resumeGatewayUrl?: string | null;
sequence?: number | null;
};
sequence?: number | null;
ws?: DiscordGatewaySocket | null;
};

View File

@@ -40,7 +40,9 @@ describe("createDiscordGatewaySupervisor", () => {
error: vi.fn(),
};
const supervisor = createDiscordGatewaySupervisor({
gateway: { emitter },
client: {
getPlugin: vi.fn(() => ({ emitter })),
} as never,
isDisallowedIntentsError: (err) => String(err).includes("4014"),
runtime: runtime as never,
});
@@ -70,7 +72,9 @@ describe("createDiscordGatewaySupervisor", () => {
it("is idempotent on dispose and noops without an emitter", () => {
const supervisor = createDiscordGatewaySupervisor({
gateway: undefined,
client: {
getPlugin: vi.fn(() => undefined),
} as never,
isDisallowedIntentsError: () => false,
runtime: { error: vi.fn() } as never,
});
@@ -86,7 +90,9 @@ describe("createDiscordGatewaySupervisor", () => {
const emitter = new EventEmitter();
const runtime = { error: vi.fn() };
const supervisor = createDiscordGatewaySupervisor({
gateway: { emitter },
client: {
getPlugin: vi.fn(() => ({ emitter })),
} as never,
isDisallowedIntentsError: () => false,
runtime: runtime as never,
});

View File

@@ -1,4 +1,5 @@
import type { EventEmitter } from "node:events";
import type { Client } from "@buape/carbon";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { getDiscordGatewayEmitter } from "../monitor.gateway.js";
@@ -66,11 +67,12 @@ export function classifyDiscordGatewayEvent(params: {
}
export function createDiscordGatewaySupervisor(params: {
gateway?: unknown;
client: Client;
isDisallowedIntentsError: (err: unknown) => boolean;
runtime: RuntimeEnv;
}): DiscordGatewaySupervisor {
const emitter = getDiscordGatewayEmitter(params.gateway);
const gateway = params.client.getPlugin("gateway");
const emitter = getDiscordGatewayEmitter(gateway);
const pending: DiscordGatewayEvent[] = [];
if (!emitter) {
return {
@@ -84,36 +86,39 @@ export function createDiscordGatewaySupervisor(params: {
let lifecycleHandler: ((event: DiscordGatewayEvent) => void) | undefined;
let phase: GatewaySupervisorPhase = "buffering";
const logLateEvent =
(state: Extract<GatewaySupervisorPhase, "disposed" | "teardown">) =>
(event: DiscordGatewayEvent) => {
params.runtime.error?.(
danger(
`discord: suppressed late gateway ${event.type} error ${
state === "disposed" ? "after dispose" : "during teardown"
}: ${event.message}`,
),
);
};
let disposed = false;
const logLateTeardownEvent = (event: DiscordGatewayEvent) => {
params.runtime.error?.(
danger(
`discord: suppressed late gateway ${event.type} error during teardown: ${event.message}`,
),
);
};
const logLateDisposedEvent = (event: DiscordGatewayEvent) => {
params.runtime.error?.(
danger(
`discord: suppressed late gateway ${event.type} error after dispose: ${event.message}`,
),
);
};
const onGatewayError = (err: unknown) => {
const event = classifyDiscordGatewayEvent({
err,
isDisallowedIntentsError: params.isDisallowedIntentsError,
});
switch (phase) {
case "disposed":
logLateEvent("disposed")(event);
return;
case "active":
lifecycleHandler?.(event);
return;
case "teardown":
logLateEvent("teardown")(event);
return;
case "buffering":
pending.push(event);
return;
if (phase === "disposed") {
logLateDisposedEvent(event);
return;
}
if (phase === "active" && lifecycleHandler) {
lifecycleHandler(event);
return;
}
if (phase === "teardown") {
logLateTeardownEvent(event);
return;
}
pending.push(event);
};
emitter.on("error", onGatewayError);
@@ -141,9 +146,10 @@ export function createDiscordGatewaySupervisor(params: {
return "continue";
},
dispose: () => {
if (phase === "disposed") {
if (disposed) {
return;
}
disposed = true;
lifecycleHandler = undefined;
phase = "disposed";
pending.length = 0;

View File

@@ -19,7 +19,19 @@ import {
resolvePluginConversationBindingApprovalMock,
upsertPairingRequestMock,
} from "../../../../test/helpers/extensions/discord-component-runtime.js";
import { type DiscordComponentEntry, type DiscordModalEntry } from "../components.js";
import {
clearDiscordComponentEntries,
registerDiscordComponentEntries,
resolveDiscordComponentEntry,
resolveDiscordModalEntry,
} from "../components-registry.js";
import type { DiscordComponentEntry, DiscordModalEntry } from "../components.js";
import * as sendComponents from "../send.components.js";
import {
createDiscordComponentButton,
createDiscordComponentStringSelect,
createDiscordComponentModal,
} from "./agent-components.js";
import type { DiscordChannelConfigResolved } from "./allow-list.js";
import {
resolveDiscordMemberAllowed,
@@ -41,22 +53,6 @@ import {
resolveDiscordReplyDeliveryPlan,
} from "./threading.js";
type CreateDiscordComponentButton =
typeof import("./agent-components.js").createDiscordComponentButton;
type CreateDiscordComponentModal =
typeof import("./agent-components.js").createDiscordComponentModal;
type CreateDiscordComponentStringSelect =
typeof import("./agent-components.js").createDiscordComponentStringSelect;
let createDiscordComponentButton: CreateDiscordComponentButton;
let createDiscordComponentStringSelect: CreateDiscordComponentStringSelect;
let createDiscordComponentModal: CreateDiscordComponentModal;
let clearDiscordComponentEntries: typeof import("../components-registry.js").clearDiscordComponentEntries;
let registerDiscordComponentEntries: typeof import("../components-registry.js").registerDiscordComponentEntries;
let resolveDiscordComponentEntry: typeof import("../components-registry.js").resolveDiscordComponentEntry;
let resolveDiscordModalEntry: typeof import("../components-registry.js").resolveDiscordModalEntry;
let sendComponents: typeof import("../send.components.js");
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
const dispatchReplyMock = vi.hoisted(() => vi.fn());
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
@@ -140,9 +136,9 @@ describe("discord component interactions", () => {
};
};
type ComponentContext = Parameters<CreateDiscordComponentButton>[0];
const createComponentContext = (overrides?: Partial<ComponentContext>) =>
const createComponentContext = (
overrides?: Partial<Parameters<typeof createDiscordComponentButton>[0]>,
) =>
({
cfg: createCfg(),
accountId: "default",
@@ -151,7 +147,7 @@ describe("discord component interactions", () => {
discordConfig: createDiscordConfig(),
token: "token",
...overrides,
}) as ComponentContext;
}) as Parameters<typeof createDiscordComponentButton>[0];
const createComponentButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
@@ -311,20 +307,7 @@ describe("discord component interactions", () => {
expect(dispatchReplyMock).not.toHaveBeenCalled();
}
beforeEach(async () => {
vi.resetModules();
({
createDiscordComponentButton,
createDiscordComponentStringSelect,
createDiscordComponentModal,
} = await import("./agent-components.js"));
({
clearDiscordComponentEntries,
registerDiscordComponentEntries,
resolveDiscordComponentEntry,
resolveDiscordModalEntry,
} = await import("../components-registry.js"));
sendComponents = await import("../send.components.js");
beforeEach(() => {
editDiscordComponentMessageMock = vi
.spyOn(sendComponents, "editDiscordComponentMessage")
.mockResolvedValue({

View File

@@ -5,7 +5,7 @@ import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dis
import type { OpenClawConfig } from "../../../../src/config/config.js";
import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js";
import * as pluginCommandsModule from "../../../../src/plugins/commands.js";
import { __testing as nativeCommandTesting, createDiscordNativeCommand } from "./native-command.js";
import { createDiscordNativeCommand } from "./native-command.js";
import {
createMockCommandInteraction,
type MockCommandInteraction,
@@ -68,17 +68,13 @@ function createCommand(cfg: OpenClawConfig, discordConfig?: DiscordAccountConfig
}
function createDispatchSpy() {
const dispatchSpy = vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({
return vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({
counts: {
final: 1,
block: 0,
tool: 0,
},
} as never);
nativeCommandTesting.setDispatchReplyWithDispatcher(
dispatcherModule.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher,
);
return dispatchSpy;
}
async function runGuildSlashCommand(params?: {
@@ -114,9 +110,6 @@ function expectUnauthorizedReply(interaction: MockCommandInteraction) {
describe("Discord native slash commands with commands.allowFrom", () => {
beforeEach(() => {
vi.restoreAllMocks();
nativeCommandTesting.setDispatchReplyWithDispatcher(
dispatcherModule.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher,
);
});
it("authorizes guild slash commands when commands.allowFrom.discord matches the sender", async () => {
@@ -174,36 +167,6 @@ describe("Discord native slash commands with commands.allowFrom", () => {
expectUnauthorizedReply(interaction);
});
it("authorizes guild slash commands when commands.allowFrom.discord contains a matching guild: entry", async () => {
const { dispatchSpy, interaction } = await runGuildSlashCommand({
userId: "999999999999999999",
mutateConfig: (cfg) => {
cfg.commands = {
allowFrom: {
discord: ["guild:345678901234567890"],
},
};
},
});
expect(dispatchSpy).toHaveBeenCalledTimes(1);
expectNotUnauthorizedReply(interaction);
});
it("rejects guild slash commands when commands.allowFrom.discord has a guild: entry that does not match", async () => {
const { dispatchSpy, interaction } = await runGuildSlashCommand({
userId: "999999999999999999",
mutateConfig: (cfg) => {
cfg.commands = {
allowFrom: {
discord: ["guild:000000000000000000"],
},
};
},
});
expect(dispatchSpy).not.toHaveBeenCalled();
expectUnauthorizedReply(interaction);
});
it("uses the root discord maxLinesPerMessage when runtime discordConfig omits it", async () => {
const longReply = Array.from({ length: 20 }, (_value, index) => `Line ${index + 1}`).join("\n");
const { interaction } = await runGuildSlashCommand({

View File

@@ -15,7 +15,6 @@ import * as modelPickerModule from "./model-picker.js";
import { createModelsProviderData as createBaseModelsProviderData } from "./model-picker.test-utils.js";
import { replyWithDiscordModelPickerProviders } from "./native-command-ui.js";
import {
__testing as nativeCommandTesting,
createDiscordModelPickerFallbackButton,
createDiscordModelPickerFallbackSelect,
} from "./native-command.js";
@@ -239,22 +238,9 @@ function createBoundThreadBindingManager(params: {
};
}
function createDispatchSpy() {
const dispatchSpy = vi
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.mockResolvedValue({} as never);
nativeCommandTesting.setDispatchReplyWithDispatcher(
dispatcherModule.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher,
);
return dispatchSpy;
}
describe("Discord model picker interactions", () => {
beforeEach(() => {
vi.restoreAllMocks();
nativeCommandTesting.setDispatchReplyWithDispatcher(
dispatcherModule.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher,
);
});
it("registers distinct fallback ids for button and select handlers", () => {
@@ -300,7 +286,9 @@ describe("Discord model picker interactions", () => {
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
mockModelCommandPipeline(modelCommand);
const dispatchSpy = createDispatchSpy();
const dispatchSpy = vi
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.mockResolvedValue({} as never);
const selectInteraction = await runModelSelect({ context });
@@ -332,7 +320,9 @@ describe("Discord model picker interactions", () => {
const recordRecentSpy = vi
.spyOn(modelPickerPreferencesModule, "recordDiscordModelPickerRecentModel")
.mockResolvedValue();
const dispatchSpy = createDispatchSpy();
const dispatchSpy = vi
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.mockResolvedValue({} as never);
const withTimeoutSpy = vi
.spyOn(timeoutModule, "withTimeout")
.mockRejectedValue(new Error("timeout"));
@@ -405,7 +395,9 @@ describe("Discord model picker interactions", () => {
]);
mockModelCommandPipeline(modelCommand);
const dispatchSpy = createDispatchSpy();
const dispatchSpy = vi
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.mockResolvedValue({} as never);
// rs=2 -> first deduped recent (default is anthropic/claude-sonnet-4-5, so openai/gpt-4o remains)
const submitInteraction = await runSubmitButton({
@@ -438,7 +430,7 @@ describe("Discord model picker interactions", () => {
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
mockModelCommandPipeline(modelCommand);
createDispatchSpy();
vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({} as never);
const verboseSpy = vi.spyOn(globalsModule, "logVerbose").mockImplementation(() => {});
const select = createDiscordModelPickerFallbackSelect(context);

View File

@@ -10,6 +10,7 @@ import {
import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js";
import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions/store.js";
import { createConfiguredBindingConversationRuntimeModuleMock } from "../../../../test/helpers/extensions/configured-binding-runtime.js";
import { resolveDiscordNativeChoiceContext } from "./native-command-ui.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
@@ -51,12 +52,9 @@ const STORE_PATH = path.join(
`openclaw-discord-think-autocomplete-${process.pid}.json`,
);
const SESSION_KEY = "agent:main:main";
let resolveDiscordNativeChoiceContext: typeof import("./native-command-ui.js").resolveDiscordNativeChoiceContext;
describe("discord native /think autocomplete", () => {
beforeEach(async () => {
vi.resetModules();
({ resolveDiscordNativeChoiceContext } = await import("./native-command-ui.js"));
beforeEach(() => {
clearSessionStoreCacheForTest();
ensureConfiguredBindingRouteReadyMock.mockReset();
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });

View File

@@ -144,7 +144,6 @@ function resolveDiscordNativeCommandAllowlistAccess(params: {
sender: { id: string; name?: string; tag?: string };
chatType: "direct" | "group" | "thread" | "channel";
conversationId?: string;
guildId?: string | null;
}) {
const commandsAllowFrom = params.cfg.commands?.allowFrom;
if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") {
@@ -156,16 +155,6 @@ function resolveDiscordNativeCommandAllowlistAccess(params: {
if (!Array.isArray(rawAllowList)) {
return { configured: false, allowed: false } as const;
}
// Check guild-level entries (e.g. "guild:123456") before user matching.
const guildId = params.guildId?.trim();
if (guildId) {
for (const entry of rawAllowList) {
const text = String(entry).trim();
if (text.startsWith("guild:") && text.slice("guild:".length) === guildId) {
return { configured: true, allowed: true } as const;
}
}
}
const allowList = normalizeDiscordAllowList(rawAllowList.map(String), [
"discord:",
"user:",
@@ -336,7 +325,6 @@ async function resolveDiscordNativeAutocompleteAuthorized(params: {
? "channel"
: "group",
conversationId: rawChannelId || undefined,
guildId: interaction.guild?.id,
});
const guildInfo = resolveDiscordGuildEntry({
guild: interaction.guild ?? undefined,
@@ -718,7 +706,6 @@ async function dispatchDiscordCommandInteraction(params: {
? "channel"
: "group",
conversationId: rawChannelId || undefined,
guildId: interaction.guild?.id,
});
const guildInfo = resolveDiscordGuildEntry({
guild: interaction.guild ?? undefined,

View File

@@ -1,33 +1,40 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../../../src/runtime.js";
import { createNonExitingTypedRuntimeEnv } from "../../../../test/helpers/extensions/runtime-env.js";
import * as resolveChannelsModule from "../resolve-channels.js";
import * as resolveUsersModule from "../resolve-users.js";
const { resolveDiscordChannelAllowlistMock, resolveDiscordUserAllowlistMock } = vi.hoisted(() => ({
resolveDiscordChannelAllowlistMock: vi.fn(
async (_params: { entries: string[] }) => [] as Array<Record<string, unknown>>,
),
resolveDiscordUserAllowlistMock: vi.fn(async (params: { entries: string[] }) =>
params.entries.map((entry) => {
switch (entry) {
case "Alice":
return { input: entry, resolved: true, id: "111" };
case "Bob":
return { input: entry, resolved: true, id: "222" };
case "Carol":
return { input: entry, resolved: false };
case "387":
return { input: entry, resolved: true, id: "387", name: "Peter" };
default:
return { input: entry, resolved: true, id: entry };
}
}),
),
}));
vi.mock("../resolve-channels.js", () => ({
resolveDiscordChannelAllowlist: resolveDiscordChannelAllowlistMock,
}));
vi.mock("../resolve-users.js", () => ({
resolveDiscordUserAllowlist: resolveDiscordUserAllowlistMock,
}));
import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
describe("resolveDiscordAllowlistConfig", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(resolveChannelsModule, "resolveDiscordChannelAllowlist").mockResolvedValue([]);
vi.spyOn(resolveUsersModule, "resolveDiscordUserAllowlist").mockImplementation(
async (params: { entries: string[] }) =>
params.entries.map((entry) => {
switch (entry) {
case "Alice":
return { input: entry, resolved: true, id: "111" };
case "Bob":
return { input: entry, resolved: true, id: "222" };
case "Carol":
return { input: entry, resolved: false };
case "387":
return { input: entry, resolved: true, id: "387", name: "Peter" };
default:
return { input: entry, resolved: true, id: entry };
}
}),
);
});
it("canonicalizes resolved user names to ids in runtime config", async () => {
const runtime = createNonExitingTypedRuntimeEnv<RuntimeEnv>();
const result = await resolveDiscordAllowlistConfig({
@@ -50,11 +57,11 @@ describe("resolveDiscordAllowlistConfig", () => {
expect(result.allowFrom).toEqual(["111", "*"]);
expect(result.guildEntries?.["*"]?.users).toEqual(["222", "999"]);
expect(result.guildEntries?.["*"]?.channels?.["*"]?.users).toEqual(["Carol", "888"]);
expect(resolveUsersModule.resolveDiscordUserAllowlist).toHaveBeenCalledTimes(2);
expect(resolveDiscordUserAllowlistMock).toHaveBeenCalledTimes(2);
});
it("logs discord name metadata for resolved and unresolved allowlist entries", async () => {
vi.spyOn(resolveChannelsModule, "resolveDiscordChannelAllowlist").mockResolvedValueOnce([
resolveDiscordChannelAllowlistMock.mockResolvedValueOnce([
{
input: "145/c404",
resolved: false,

View File

@@ -1,497 +0,0 @@
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";
import type { MutableDiscordGateway } from "./gateway-handle.js";
import type { DiscordMonitorStatusSink } from "./status.js";
const DISCORD_GATEWAY_READY_TIMEOUT_MS = 15_000;
const DISCORD_GATEWAY_READY_POLL_MS = 250;
const DISCORD_GATEWAY_DISCONNECT_DRAIN_TIMEOUT_MS = 5_000;
const DISCORD_GATEWAY_FORCE_TERMINATE_CLOSE_TIMEOUT_MS = 1_000;
const DISCORD_GATEWAY_HELLO_TIMEOUT_MS = 30_000;
const DISCORD_GATEWAY_HELLO_CONNECTED_POLL_MS = 250;
const DISCORD_GATEWAY_MAX_CONSECUTIVE_HELLO_STALLS = 3;
const DISCORD_GATEWAY_RECONNECT_STALL_TIMEOUT_MS = 5 * 60_000;
type GatewayReadyWaitResult = "ready" | "timeout" | "stopped";
async function waitForDiscordGatewayReady(params: {
gateway?: Pick<MutableDiscordGateway, "isConnected">;
abortSignal?: AbortSignal;
timeoutMs: number;
beforePoll?: () => Promise<"continue" | "stop"> | "continue" | "stop";
}): Promise<GatewayReadyWaitResult> {
const deadlineAt = Date.now() + params.timeoutMs;
while (!params.abortSignal?.aborted) {
const pollDecision = await params.beforePoll?.();
if (pollDecision === "stop") {
return "stopped";
}
if (params.gateway?.isConnected) {
return "ready";
}
if (Date.now() >= deadlineAt) {
return "timeout";
}
await new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, DISCORD_GATEWAY_READY_POLL_MS);
timeout.unref?.();
});
}
return "stopped";
}
export function createDiscordGatewayReconnectController(params: {
accountId: string;
gateway?: MutableDiscordGateway;
runtime: RuntimeEnv;
abortSignal?: AbortSignal;
pushStatus: (patch: Parameters<DiscordMonitorStatusSink>[0]) => void;
isLifecycleStopping: () => boolean;
drainPendingGatewayErrors: () => "continue" | "stop";
}) {
let forceStopHandler: ((err: unknown) => void) | undefined;
let queuedForceStopError: unknown;
let helloTimeoutId: ReturnType<typeof setTimeout> | undefined;
let helloConnectedPollId: ReturnType<typeof setInterval> | undefined;
let reconnectInFlight: Promise<void> | undefined;
let consecutiveHelloStalls = 0;
const shouldStop = () => params.isLifecycleStopping() || params.abortSignal?.aborted;
const resetHelloStallCounter = () => {
consecutiveHelloStalls = 0;
};
const clearHelloWatch = () => {
if (helloTimeoutId) {
clearTimeout(helloTimeoutId);
helloTimeoutId = undefined;
}
if (helloConnectedPollId) {
clearInterval(helloConnectedPollId);
helloConnectedPollId = undefined;
}
};
const parseGatewayCloseCode = (message: string): number | undefined => {
const match = /code\s+(\d{3,5})/i.exec(message);
if (!match?.[1]) {
return undefined;
}
const code = Number.parseInt(match[1], 10);
return Number.isFinite(code) ? code : undefined;
};
const clearResumeState = () => {
if (!params.gateway?.state) {
return;
}
params.gateway.state.sessionId = null;
params.gateway.state.resumeGatewayUrl = null;
params.gateway.state.sequence = null;
params.gateway.sequence = null;
};
const triggerForceStop = (err: unknown) => {
if (forceStopHandler) {
forceStopHandler(err);
return;
}
queuedForceStopError = err;
};
const reconnectStallWatchdog = createArmableStallWatchdog({
label: `discord:${params.accountId}:reconnect`,
timeoutMs: DISCORD_GATEWAY_RECONNECT_STALL_TIMEOUT_MS,
abortSignal: params.abortSignal,
runtime: params.runtime,
onTimeout: () => {
if (shouldStop()) {
return;
}
const at = Date.now();
const error = new Error(
`discord reconnect watchdog timeout after ${DISCORD_GATEWAY_RECONNECT_STALL_TIMEOUT_MS}ms`,
);
params.pushStatus({
connected: false,
lastEventAt: at,
lastDisconnect: {
at,
error: error.message,
},
lastError: error.message,
});
params.runtime.error?.(
danger(
`discord: reconnect watchdog timeout after ${DISCORD_GATEWAY_RECONNECT_STALL_TIMEOUT_MS}ms; force-stopping monitor task`,
),
);
triggerForceStop(error);
},
});
const pushConnectedStatus = (at: number) => {
params.pushStatus({
...createConnectedChannelStatusPatch(at),
lastDisconnect: null,
});
};
const disconnectGatewaySocketWithoutAutoReconnect = async () => {
if (!params.gateway) {
return;
}
const gateway = params.gateway;
const socket = gateway.ws;
if (!socket) {
gateway.disconnect();
return;
}
// Carbon reconnects from the socket close handler even for intentional
// disconnects. Drop the current socket's close/error listeners so a forced
// reconnect does not race the old socket's automatic resume path.
for (const listener of socket.listeners("close")) {
socket.removeListener("close", listener);
}
for (const listener of socket.listeners("error")) {
socket.removeListener("error", listener);
}
await new Promise<void>((resolve, reject) => {
let settled = false;
let drainTimeout: ReturnType<typeof setTimeout> | undefined;
let terminateCloseTimeout: ReturnType<typeof setTimeout> | undefined;
const ignoreSocketError = () => {};
const clearPendingTimers = () => {
if (drainTimeout) {
clearTimeout(drainTimeout);
drainTimeout = undefined;
}
if (terminateCloseTimeout) {
clearTimeout(terminateCloseTimeout);
terminateCloseTimeout = undefined;
}
};
const cleanup = () => {
clearPendingTimers();
socket.removeListener("close", onClose);
socket.removeListener("error", ignoreSocketError);
};
const onClose = () => {
cleanup();
if (settled) {
return;
}
settled = true;
resolve();
};
const resolveStoppedWait = () => {
if (settled) {
return;
}
settled = true;
clearPendingTimers();
resolve();
};
const rejectClose = (error: Error) => {
if (shouldStop()) {
resolveStoppedWait();
return;
}
if (settled) {
return;
}
settled = true;
clearPendingTimers();
reject(error);
};
drainTimeout = setTimeout(() => {
if (settled) {
return;
}
if (shouldStop()) {
resolveStoppedWait();
return;
}
params.runtime.error?.(
danger(
`discord: gateway socket did not close within ${DISCORD_GATEWAY_DISCONNECT_DRAIN_TIMEOUT_MS}ms before reconnect; attempting forced terminate before giving up`,
),
);
let terminateStarted = false;
try {
if (typeof socket.terminate === "function") {
socket.terminate();
terminateStarted = true;
}
} catch {
// Best-effort only. If terminate fails, fail closed instead of
// opening another socket on top of an unknown old one.
}
if (!terminateStarted) {
params.runtime.error?.(
danger(
`discord: gateway socket did not expose a working terminate() after ${DISCORD_GATEWAY_DISCONNECT_DRAIN_TIMEOUT_MS}ms; force-stopping instead of opening a parallel socket`,
),
);
rejectClose(
new Error(
`discord gateway socket did not close within ${DISCORD_GATEWAY_DISCONNECT_DRAIN_TIMEOUT_MS}ms before reconnect`,
),
);
return;
}
terminateCloseTimeout = setTimeout(() => {
if (settled) {
return;
}
if (shouldStop()) {
resolveStoppedWait();
return;
}
params.runtime.error?.(
danger(
`discord: gateway socket did not close ${DISCORD_GATEWAY_FORCE_TERMINATE_CLOSE_TIMEOUT_MS}ms after forced terminate; force-stopping instead of opening a parallel socket`,
),
);
rejectClose(
new Error(
`discord gateway socket did not close within ${DISCORD_GATEWAY_DISCONNECT_DRAIN_TIMEOUT_MS}ms before reconnect`,
),
);
}, DISCORD_GATEWAY_FORCE_TERMINATE_CLOSE_TIMEOUT_MS);
terminateCloseTimeout.unref?.();
}, DISCORD_GATEWAY_DISCONNECT_DRAIN_TIMEOUT_MS);
drainTimeout.unref?.();
socket.on("error", ignoreSocketError);
socket.on("close", onClose);
gateway.disconnect();
});
};
const reconnectGateway = async (reconnectParams: {
resume: boolean;
forceFreshIdentify?: boolean;
}) => {
if (reconnectInFlight) {
return await reconnectInFlight;
}
reconnectInFlight = (async () => {
if (reconnectParams.forceFreshIdentify) {
// Carbon still sends RESUME on HELLO when session state is populated,
// even after connect(false). Clear cached session data first so this
// path truly forces a fresh IDENTIFY.
clearResumeState();
}
if (shouldStop()) {
return;
}
await disconnectGatewaySocketWithoutAutoReconnect();
if (shouldStop()) {
return;
}
params.gateway?.connect(reconnectParams.resume);
})().finally(() => {
reconnectInFlight = undefined;
});
return await reconnectInFlight;
};
const reconnectGatewayFresh = async () => {
await reconnectGateway({ resume: false, forceFreshIdentify: true });
};
const onGatewayDebug = (msg: unknown) => {
const message = String(msg);
const at = Date.now();
params.pushStatus({ lastEventAt: at });
if (message.includes("WebSocket connection closed")) {
if (params.gateway?.isConnected) {
resetHelloStallCounter();
}
reconnectStallWatchdog.arm(at);
params.pushStatus({
connected: false,
lastDisconnect: {
at,
status: parseGatewayCloseCode(message),
},
});
clearHelloWatch();
return;
}
if (!message.includes("WebSocket connection opened")) {
return;
}
reconnectStallWatchdog.disarm();
clearHelloWatch();
let sawConnected = params.gateway?.isConnected === true;
if (sawConnected) {
pushConnectedStatus(at);
}
helloConnectedPollId = setInterval(() => {
if (!params.gateway?.isConnected) {
return;
}
sawConnected = true;
resetHelloStallCounter();
reconnectStallWatchdog.disarm();
pushConnectedStatus(Date.now());
if (helloConnectedPollId) {
clearInterval(helloConnectedPollId);
helloConnectedPollId = undefined;
}
}, DISCORD_GATEWAY_HELLO_CONNECTED_POLL_MS);
helloTimeoutId = setTimeout(() => {
helloTimeoutId = undefined;
void (async () => {
try {
if (helloConnectedPollId) {
clearInterval(helloConnectedPollId);
helloConnectedPollId = undefined;
}
if (sawConnected || params.gateway?.isConnected) {
resetHelloStallCounter();
return;
}
consecutiveHelloStalls += 1;
const forceFreshIdentify =
consecutiveHelloStalls >= DISCORD_GATEWAY_MAX_CONSECUTIVE_HELLO_STALLS;
const stalledAt = Date.now();
reconnectStallWatchdog.arm(stalledAt);
params.pushStatus({
connected: false,
lastEventAt: stalledAt,
lastDisconnect: {
at: stalledAt,
error: "hello-timeout",
},
});
params.runtime.log?.(
danger(
forceFreshIdentify
? `connection stalled: no HELLO within ${DISCORD_GATEWAY_HELLO_TIMEOUT_MS}ms (${consecutiveHelloStalls}/${DISCORD_GATEWAY_MAX_CONSECUTIVE_HELLO_STALLS}); forcing fresh identify`
: `connection stalled: no HELLO within ${DISCORD_GATEWAY_HELLO_TIMEOUT_MS}ms (${consecutiveHelloStalls}/${DISCORD_GATEWAY_MAX_CONSECUTIVE_HELLO_STALLS}); retrying resume`,
),
);
if (forceFreshIdentify) {
resetHelloStallCounter();
}
if (shouldStop()) {
return;
}
if (forceFreshIdentify) {
await reconnectGatewayFresh();
return;
}
await reconnectGateway({ resume: true });
} catch (err) {
params.runtime.error?.(
danger(`discord: failed to restart stalled gateway socket: ${String(err)}`),
);
triggerForceStop(err);
}
})();
}, DISCORD_GATEWAY_HELLO_TIMEOUT_MS);
};
const onAbort = () => {
reconnectStallWatchdog.disarm();
const at = Date.now();
params.pushStatus({ connected: false, lastEventAt: at });
if (!params.gateway) {
return;
}
params.gateway.options.reconnect = { maxAttempts: 0 };
params.gateway.disconnect();
};
const ensureStartupReady = async () => {
if (!params.gateway || params.gateway.isConnected || shouldStop()) {
if (params.gateway?.isConnected && !shouldStop()) {
pushConnectedStatus(Date.now());
}
return;
}
const initialReady = await waitForDiscordGatewayReady({
gateway: params.gateway,
abortSignal: params.abortSignal,
timeoutMs: DISCORD_GATEWAY_READY_TIMEOUT_MS,
beforePoll: params.drainPendingGatewayErrors,
});
if (initialReady === "stopped" || shouldStop()) {
return;
}
if (initialReady === "timeout") {
params.runtime.error?.(
danger(
`discord: gateway was not ready after ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms; forcing a fresh reconnect`,
),
);
const startupRetryAt = Date.now();
params.pushStatus({
connected: false,
lastEventAt: startupRetryAt,
lastDisconnect: {
at: startupRetryAt,
error: "startup-not-ready",
},
});
await reconnectGatewayFresh();
const reconnected = await waitForDiscordGatewayReady({
gateway: params.gateway,
abortSignal: params.abortSignal,
timeoutMs: DISCORD_GATEWAY_READY_TIMEOUT_MS,
beforePoll: params.drainPendingGatewayErrors,
});
if (reconnected === "stopped" || shouldStop()) {
return;
}
if (reconnected === "timeout") {
const error = new Error(
`discord gateway did not reach READY within ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms after a forced reconnect`,
);
const startupFailureAt = Date.now();
params.pushStatus({
connected: false,
lastEventAt: startupFailureAt,
lastDisconnect: {
at: startupFailureAt,
error: "startup-reconnect-timeout",
},
lastError: error.message,
});
throw error;
}
}
if (params.gateway.isConnected && !shouldStop()) {
pushConnectedStatus(Date.now());
}
};
if (params.abortSignal?.aborted) {
onAbort();
} else {
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
}
return {
ensureStartupReady,
onAbort,
onGatewayDebug,
clearHelloWatch,
registerForceStop: (handler: (err: unknown) => void) => {
forceStopHandler = handler;
if (queuedForceStopError !== undefined) {
const queued = queuedForceStopError;
queuedForceStopError = undefined;
handler(queued);
}
},
dispose: () => {
reconnectStallWatchdog.stop();
clearHelloWatch();
params.abortSignal?.removeEventListener("abort", onAbort);
},
};
}

View File

@@ -1,29 +1,10 @@
import { EventEmitter } from "node:events";
import type { GatewayPlugin } from "@buape/carbon/gateway";
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import type { Client } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../../../src/runtime.js";
import type { WaitForDiscordGatewayStopParams } from "../monitor.gateway.js";
import type { MutableDiscordGateway } from "./gateway-handle.js";
import type { DiscordGatewayEvent } from "./gateway-supervisor.js";
type LifecycleParams = Parameters<
typeof import("./provider.lifecycle.js").runDiscordGatewayLifecycle
>[0];
type MockGateway = {
isConnected: boolean;
options: GatewayPlugin["options"];
disconnect: Mock<() => void>;
connect: Mock<(resume?: boolean) => void>;
state?: {
sessionId?: string | null;
resumeGatewayUrl?: string | null;
sequence?: number | null;
};
sequence?: number | null;
emitter: EventEmitter;
ws?: EventEmitter & { terminate?: () => void };
};
const {
attachDiscordGatewayLoggingMock,
getDiscordGatewayEmitterMock,
@@ -62,7 +43,6 @@ vi.mock("./gateway-registry.js", () => ({
describe("runDiscordGatewayLifecycle", () => {
beforeEach(() => {
vi.resetModules();
attachDiscordGatewayLoggingMock.mockClear();
getDiscordGatewayEmitterMock.mockClear();
waitForDiscordGatewayStopMock.mockClear();
@@ -77,15 +57,21 @@ describe("runDiscordGatewayLifecycle", () => {
stop?: () => Promise<void>;
isDisallowedIntentsError?: (err: unknown) => boolean;
pendingGatewayEvents?: DiscordGatewayEvent[];
gateway?: MockGateway;
gateway?: {
isConnected?: boolean;
options?: Record<string, unknown>;
disconnect?: () => void;
connect?: (resume?: boolean) => void;
state?: {
sessionId?: string | null;
resumeGatewayUrl?: string | null;
sequence?: number | null;
};
sequence?: number | null;
emitter?: EventEmitter;
ws?: EventEmitter & { terminate?: () => void };
};
}) => {
const gateway =
params?.gateway ??
(() => {
const defaultGateway = createGatewayHarness().gateway;
defaultGateway.isConnected = true;
return defaultGateway;
})();
const start = vi.fn(params?.start ?? (async () => undefined));
const stop = vi.fn(params?.stop ?? (async () => undefined));
const threadStop = vi.fn();
@@ -110,7 +96,7 @@ describe("runDiscordGatewayLifecycle", () => {
return "continue";
}),
dispose: vi.fn(),
emitter: gateway.emitter,
emitter: params?.gateway?.emitter,
};
const statusSink = vi.fn();
const runtime: RuntimeEnv = {
@@ -128,7 +114,9 @@ describe("runDiscordGatewayLifecycle", () => {
statusSink,
lifecycleParams: {
accountId: params?.accountId ?? "default",
gateway: gateway as unknown as MutableDiscordGateway,
client: {
getPlugin: vi.fn((name: string) => (name === "gateway" ? params?.gateway : undefined)),
} as unknown as Client,
runtime,
isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false),
voiceManager: null,
@@ -138,7 +126,7 @@ describe("runDiscordGatewayLifecycle", () => {
gatewaySupervisor,
statusSink,
abortSignal: undefined as AbortSignal | undefined,
} satisfies LifecycleParams,
},
};
};
@@ -166,11 +154,11 @@ describe("runDiscordGatewayLifecycle", () => {
};
sequence?: number | null;
ws?: EventEmitter & { terminate?: () => void };
}): { emitter: EventEmitter; gateway: MockGateway } {
}) {
const emitter = new EventEmitter();
const gateway: MockGateway = {
const gateway = {
isConnected: false,
options: { intents: 0 } as GatewayPlugin["options"],
options: {},
disconnect: vi.fn(),
connect: vi.fn(),
...(params?.state ? { state: params.state } : {}),
@@ -529,7 +517,7 @@ describe("runDiscordGatewayLifecycle", () => {
start,
stop,
threadStop,
waitCalls: 1,
waitCalls: 0,
gatewaySupervisor,
});
} finally {
@@ -813,56 +801,12 @@ describe("runDiscordGatewayLifecycle", () => {
}
});
it("does not suppress reconnect-exhausted already queued before shutdown", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
const pendingGatewayEvents: DiscordGatewayEvent[] = [];
const abortController = new AbortController();
const emitter = new EventEmitter();
const gateway: MockGateway = {
isConnected: true,
options: { intents: 0, reconnect: { maxAttempts: 50 } } as GatewayPlugin["options"],
disconnect: vi.fn(),
connect: vi.fn(),
emitter,
};
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
const { lifecycleParams, runtimeLog, runtimeError } = createLifecycleHarness({
gateway,
pendingGatewayEvents,
});
lifecycleParams.abortSignal = abortController.signal;
// Start lifecycle; it yields at execApprovalsHandler.start(). We then
// queue a reconnect-exhausted event and abort. The lifecycle resumes and
// drains the queued fatal event before shutdown teardown flips
// lifecycleStopping, so the event still rejects the run.
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
pendingGatewayEvents.push(
createGatewayEvent(
"reconnect-exhausted",
"Max reconnect attempts (0) reached after code 1005",
),
);
abortController.abort();
await expect(lifecyclePromise).rejects.toThrow(
"Max reconnect attempts (0) reached after code 1005",
);
expect(runtimeLog).not.toHaveBeenCalledWith(
expect.stringContaining("ignoring expected reconnect-exhausted during shutdown"),
);
expect(runtimeError).toHaveBeenCalledWith(expect.stringContaining("Max reconnect attempts"));
});
it("does not push connected: true when abortSignal is already aborted", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
const emitter = new EventEmitter();
const gateway: MockGateway = {
const gateway = {
isConnected: true,
options: { intents: 0, reconnect: { maxAttempts: 3 } } as GatewayPlugin["options"],
options: { reconnect: { maxAttempts: 3 } },
disconnect: vi.fn(),
connect: vi.fn(),
emitter,

View File

@@ -1,12 +1,14 @@
import type { Client } from "@buape/carbon";
import type { GatewayPlugin } from "@buape/carbon/gateway";
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";
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js";
import type { DiscordVoiceManager } from "../voice/manager.js";
import type { MutableDiscordGateway } from "./gateway-handle.js";
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
import type { DiscordGatewayEvent, DiscordGatewaySupervisor } from "./gateway-supervisor.js";
import { createDiscordGatewayReconnectController } from "./provider.lifecycle.reconnect.js";
import type { DiscordMonitorStatusSink } from "./status.js";
type ExecApprovalsHandler = {
@@ -14,9 +16,61 @@ type ExecApprovalsHandler = {
stop: () => Promise<void>;
};
const DISCORD_GATEWAY_READY_TIMEOUT_MS = 15_000;
const DISCORD_GATEWAY_READY_POLL_MS = 250;
const DISCORD_GATEWAY_DISCONNECT_DRAIN_TIMEOUT_MS = 5_000;
const DISCORD_GATEWAY_FORCE_TERMINATE_CLOSE_TIMEOUT_MS = 1_000;
type GatewaySocketListener = (...args: unknown[]) => void;
type DiscordGatewaySocket = {
on: (event: "close" | "error", listener: GatewaySocketListener) => unknown;
listeners: (event: "close" | "error") => GatewaySocketListener[];
removeListener: (event: "close" | "error", listener: GatewaySocketListener) => unknown;
terminate?: () => void;
};
type MutableGateway = GatewayPlugin & {
state?: {
sessionId?: string | null;
resumeGatewayUrl?: string | null;
sequence?: number | null;
};
sequence?: number | null;
ws?: DiscordGatewaySocket | null;
};
type GatewayReadyWaitResult = "ready" | "timeout" | "stopped";
async function waitForDiscordGatewayReady(params: {
gateway?: Pick<GatewayPlugin, "isConnected">;
abortSignal?: AbortSignal;
timeoutMs: number;
beforePoll?: () => Promise<"continue" | "stop"> | "continue" | "stop";
}): Promise<GatewayReadyWaitResult> {
const deadlineAt = Date.now() + params.timeoutMs;
while (!params.abortSignal?.aborted) {
const pollDecision = await params.beforePoll?.();
if (pollDecision === "stop") {
return "stopped";
}
if (params.gateway?.isConnected) {
return "ready";
}
if (Date.now() >= deadlineAt) {
return "timeout";
}
await new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, DISCORD_GATEWAY_READY_POLL_MS);
timeout.unref?.();
});
}
return "stopped";
}
export async function runDiscordGatewayLifecycle(params: {
accountId: string;
gateway?: MutableDiscordGateway;
client: Client;
runtime: RuntimeEnv;
abortSignal?: AbortSignal;
isDisallowedIntentsError: (err: unknown) => boolean;
@@ -27,7 +81,11 @@ export async function runDiscordGatewayLifecycle(params: {
gatewaySupervisor: DiscordGatewaySupervisor;
statusSink?: DiscordMonitorStatusSink;
}) {
const gateway = params.gateway;
const HELLO_TIMEOUT_MS = 30000;
const HELLO_CONNECTED_POLL_MS = 250;
const MAX_CONSECUTIVE_HELLO_STALLS = 3;
const RECONNECT_STALL_TIMEOUT_MS = 5 * 60_000;
const gateway = params.client.getPlugin<GatewayPlugin>("gateway");
if (gateway) {
registerGateway(params.accountId, gateway);
}
@@ -37,20 +95,384 @@ export async function runDiscordGatewayLifecycle(params: {
runtime: params.runtime,
});
let lifecycleStopping = false;
let forceStopHandler: ((err: unknown) => void) | undefined;
let queuedForceStopError: unknown;
const pushStatus = (patch: Parameters<DiscordMonitorStatusSink>[0]) => {
params.statusSink?.(patch);
};
const reconnectController = createDiscordGatewayReconnectController({
accountId: params.accountId,
gateway,
runtime: params.runtime,
const triggerForceStop = (err: unknown) => {
if (forceStopHandler) {
forceStopHandler(err);
return;
}
queuedForceStopError = err;
};
const reconnectStallWatchdog = createArmableStallWatchdog({
label: `discord:${params.accountId}:reconnect`,
timeoutMs: RECONNECT_STALL_TIMEOUT_MS,
abortSignal: params.abortSignal,
pushStatus,
isLifecycleStopping: () => lifecycleStopping,
drainPendingGatewayErrors: () => drainPendingGatewayErrors(),
runtime: params.runtime,
onTimeout: () => {
if (params.abortSignal?.aborted || lifecycleStopping) {
return;
}
const at = Date.now();
const error = new Error(
`discord reconnect watchdog timeout after ${RECONNECT_STALL_TIMEOUT_MS}ms`,
);
pushStatus({
connected: false,
lastEventAt: at,
lastDisconnect: {
at,
error: error.message,
},
lastError: error.message,
});
params.runtime.error?.(
danger(
`discord: reconnect watchdog timeout after ${RECONNECT_STALL_TIMEOUT_MS}ms; force-stopping monitor task`,
),
);
triggerForceStop(error);
},
});
const onGatewayDebug = reconnectController.onGatewayDebug;
const onAbort = () => {
lifecycleStopping = true;
reconnectStallWatchdog.disarm();
const at = Date.now();
pushStatus({ connected: false, lastEventAt: at });
if (!gateway) {
return;
}
gateway.options.reconnect = { maxAttempts: 0 };
gateway.disconnect();
};
if (params.abortSignal?.aborted) {
onAbort();
} else {
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
}
let helloTimeoutId: ReturnType<typeof setTimeout> | undefined;
let helloConnectedPollId: ReturnType<typeof setInterval> | undefined;
let consecutiveHelloStalls = 0;
const clearHelloWatch = () => {
if (helloTimeoutId) {
clearTimeout(helloTimeoutId);
helloTimeoutId = undefined;
}
if (helloConnectedPollId) {
clearInterval(helloConnectedPollId);
helloConnectedPollId = undefined;
}
};
const resetHelloStallCounter = () => {
consecutiveHelloStalls = 0;
};
const parseGatewayCloseCode = (message: string): number | undefined => {
const match = /code\s+(\d{3,5})/i.exec(message);
if (!match?.[1]) {
return undefined;
}
const code = Number.parseInt(match[1], 10);
return Number.isFinite(code) ? code : undefined;
};
const clearResumeState = () => {
const mutableGateway = gateway as MutableGateway | undefined;
if (!mutableGateway?.state) {
return;
}
mutableGateway.state.sessionId = null;
mutableGateway.state.resumeGatewayUrl = null;
mutableGateway.state.sequence = null;
mutableGateway.sequence = null;
};
const disconnectGatewaySocketWithoutAutoReconnect = async () => {
if (!gateway) {
return;
}
const mutableGateway = gateway as MutableGateway;
const socket = mutableGateway.ws;
if (!socket) {
gateway.disconnect();
return;
}
// Carbon reconnects from the socket close handler even for intentional
// disconnects. Drop the current socket's close/error listeners so a forced
// reconnect does not race the old socket's automatic resume path.
for (const listener of socket.listeners("close")) {
socket.removeListener("close", listener);
}
for (const listener of socket.listeners("error")) {
socket.removeListener("error", listener);
}
await new Promise<void>((resolve, reject) => {
let settled = false;
let drainTimeout: ReturnType<typeof setTimeout> | undefined;
let terminateCloseTimeout: ReturnType<typeof setTimeout> | undefined;
const ignoreSocketError = () => {};
const shouldStopWaiting = () => lifecycleStopping || params.abortSignal?.aborted;
const clearPendingTimers = () => {
if (drainTimeout) {
clearTimeout(drainTimeout);
drainTimeout = undefined;
}
if (terminateCloseTimeout) {
clearTimeout(terminateCloseTimeout);
terminateCloseTimeout = undefined;
}
};
const cleanup = () => {
clearPendingTimers();
socket.removeListener("close", onClose);
socket.removeListener("error", ignoreSocketError);
};
const onClose = () => {
cleanup();
if (settled) {
return;
}
settled = true;
resolve();
};
const resolveStoppedWait = () => {
if (settled) {
return;
}
settled = true;
clearPendingTimers();
// Keep suppressing late ws errors until the socket actually closes.
// The original Carbon listeners were removed above, and `terminate()`
// can still asynchronously emit "error" before "close".
resolve();
};
const rejectClose = (error: Error) => {
if (shouldStopWaiting()) {
resolveStoppedWait();
return;
}
if (settled) {
return;
}
settled = true;
clearPendingTimers();
// Keep suppressing late ws errors until the socket actually closes.
// The original Carbon listeners were removed above, and `terminate()`
// can still asynchronously emit "error" before "close".
reject(error);
};
drainTimeout = setTimeout(() => {
if (settled) {
return;
}
if (shouldStopWaiting()) {
resolveStoppedWait();
return;
}
params.runtime.error?.(
danger(
`discord: gateway socket did not close within ${DISCORD_GATEWAY_DISCONNECT_DRAIN_TIMEOUT_MS}ms before reconnect; attempting forced terminate before giving up`,
),
);
let terminateStarted = false;
try {
if (typeof socket.terminate === "function") {
socket.terminate();
terminateStarted = true;
}
} catch {
// Best-effort only. If terminate fails, fail closed instead of
// opening another socket on top of an unknown old one.
}
if (!terminateStarted) {
params.runtime.error?.(
danger(
`discord: gateway socket did not expose a working terminate() after ${DISCORD_GATEWAY_DISCONNECT_DRAIN_TIMEOUT_MS}ms; force-stopping instead of opening a parallel socket`,
),
);
rejectClose(
new Error(
`discord gateway socket did not close within ${DISCORD_GATEWAY_DISCONNECT_DRAIN_TIMEOUT_MS}ms before reconnect`,
),
);
return;
}
terminateCloseTimeout = setTimeout(() => {
if (settled) {
return;
}
if (shouldStopWaiting()) {
resolveStoppedWait();
return;
}
params.runtime.error?.(
danger(
`discord: gateway socket did not close ${DISCORD_GATEWAY_FORCE_TERMINATE_CLOSE_TIMEOUT_MS}ms after forced terminate; force-stopping instead of opening a parallel socket`,
),
);
rejectClose(
new Error(
`discord gateway socket did not close within ${DISCORD_GATEWAY_DISCONNECT_DRAIN_TIMEOUT_MS}ms before reconnect`,
),
);
}, DISCORD_GATEWAY_FORCE_TERMINATE_CLOSE_TIMEOUT_MS);
terminateCloseTimeout.unref?.();
}, DISCORD_GATEWAY_DISCONNECT_DRAIN_TIMEOUT_MS);
drainTimeout.unref?.();
socket.on("error", ignoreSocketError);
socket.on("close", onClose);
gateway.disconnect();
});
};
let reconnectInFlight: Promise<void> | undefined;
const reconnectGateway = async (reconnectParams: {
resume: boolean;
forceFreshIdentify?: boolean;
}) => {
if (reconnectInFlight) {
return await reconnectInFlight;
}
reconnectInFlight = (async () => {
if (reconnectParams.forceFreshIdentify) {
// Carbon still sends RESUME on HELLO when session state is populated,
// even after connect(false). Clear cached session data first so this
// path truly forces a fresh IDENTIFY.
clearResumeState();
}
if (lifecycleStopping || params.abortSignal?.aborted) {
return;
}
await disconnectGatewaySocketWithoutAutoReconnect();
if (lifecycleStopping || params.abortSignal?.aborted) {
return;
}
gateway?.connect(reconnectParams.resume);
})().finally(() => {
reconnectInFlight = undefined;
});
return await reconnectInFlight;
};
const reconnectGatewayFresh = async () => {
await reconnectGateway({ resume: false, forceFreshIdentify: true });
};
const onGatewayDebug = (msg: unknown) => {
const message = String(msg);
const at = Date.now();
pushStatus({ lastEventAt: at });
if (message.includes("WebSocket connection closed")) {
// Carbon marks `isConnected` true only after READY/RESUMED and flips it
// false during reconnect handling after this debug line is emitted.
if (gateway?.isConnected) {
resetHelloStallCounter();
}
reconnectStallWatchdog.arm(at);
pushStatus({
connected: false,
lastDisconnect: {
at,
status: parseGatewayCloseCode(message),
},
});
clearHelloWatch();
return;
}
if (!message.includes("WebSocket connection opened")) {
return;
}
reconnectStallWatchdog.disarm();
clearHelloWatch();
let sawConnected = gateway?.isConnected === true;
if (sawConnected) {
pushStatus({
...createConnectedChannelStatusPatch(at),
lastDisconnect: null,
});
}
helloConnectedPollId = setInterval(() => {
if (!gateway?.isConnected) {
return;
}
sawConnected = true;
resetHelloStallCounter();
const connectedAt = Date.now();
reconnectStallWatchdog.disarm();
pushStatus({
...createConnectedChannelStatusPatch(connectedAt),
lastDisconnect: null,
});
if (helloConnectedPollId) {
clearInterval(helloConnectedPollId);
helloConnectedPollId = undefined;
}
}, HELLO_CONNECTED_POLL_MS);
helloTimeoutId = setTimeout(() => {
helloTimeoutId = undefined;
void (async () => {
try {
if (helloConnectedPollId) {
clearInterval(helloConnectedPollId);
helloConnectedPollId = undefined;
}
if (sawConnected || gateway?.isConnected) {
resetHelloStallCounter();
return;
}
consecutiveHelloStalls += 1;
const forceFreshIdentify = consecutiveHelloStalls >= MAX_CONSECUTIVE_HELLO_STALLS;
const stalledAt = Date.now();
reconnectStallWatchdog.arm(stalledAt);
pushStatus({
connected: false,
lastEventAt: stalledAt,
lastDisconnect: {
at: stalledAt,
error: "hello-timeout",
},
});
params.runtime.log?.(
danger(
forceFreshIdentify
? `connection stalled: no HELLO within ${HELLO_TIMEOUT_MS}ms (${consecutiveHelloStalls}/${MAX_CONSECUTIVE_HELLO_STALLS}); forcing fresh identify`
: `connection stalled: no HELLO within ${HELLO_TIMEOUT_MS}ms (${consecutiveHelloStalls}/${MAX_CONSECUTIVE_HELLO_STALLS}); retrying resume`,
),
);
if (forceFreshIdentify) {
resetHelloStallCounter();
}
if (lifecycleStopping || params.abortSignal?.aborted) {
return;
}
if (forceFreshIdentify) {
await reconnectGatewayFresh();
return;
}
await reconnectGateway({ resume: true });
} catch (err) {
params.runtime.error?.(
danger(`discord: failed to restart stalled gateway socket: ${String(err)}`),
);
triggerForceStop(err);
}
})();
}, HELLO_TIMEOUT_MS);
};
gatewayEmitter?.on("debug", onGatewayDebug);
let sawDisallowedIntents = false;
@@ -64,15 +486,6 @@ export async function runDiscordGatewayLifecycle(params: {
);
return "stop";
}
// When we deliberately set maxAttempts=0 and disconnected (health-monitor
// stale-socket restart), Carbon fires "Max reconnect attempts (0)". This
// is expected — log at info instead of error to avoid false alarms.
if (lifecycleStopping && event.type === "reconnect-exhausted") {
params.runtime.log?.(
`discord: ignoring expected reconnect-exhausted during shutdown: ${event.message}`,
);
return "stop";
}
params.runtime.error?.(danger(`discord gateway error: ${event.message}`));
return event.shouldStopLifecycle ? "stop" : "continue";
};
@@ -82,13 +495,7 @@ export async function runDiscordGatewayLifecycle(params: {
if (decision !== "stop") {
return "continue";
}
// Don't throw for expected shutdown events — intentional disconnect
// (reconnect-exhausted with maxAttempts=0) and disallowed-intents are
// both handled without crashing the provider.
if (
event.type === "disallowed-intents" ||
(lifecycleStopping && event.type === "reconnect-exhausted")
) {
if (event.type === "disallowed-intents") {
return "stop";
}
throw event.err;
@@ -103,7 +510,76 @@ export async function runDiscordGatewayLifecycle(params: {
return;
}
await reconnectController.ensureStartupReady();
// Carbon starts the gateway during client construction, before OpenClaw can
// attach lifecycle listeners. Require a READY/RESUMED-connected gateway
// before continuing so the monitor does not look healthy while silently
// missing inbound events.
if (gateway && !gateway.isConnected && !lifecycleStopping) {
const initialReady = await waitForDiscordGatewayReady({
gateway,
abortSignal: params.abortSignal,
timeoutMs: DISCORD_GATEWAY_READY_TIMEOUT_MS,
beforePoll: drainPendingGatewayErrors,
});
if (initialReady === "stopped" || lifecycleStopping) {
return;
}
if (initialReady === "timeout" && !lifecycleStopping) {
params.runtime.error?.(
danger(
`discord: gateway was not ready after ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms; forcing a fresh reconnect`,
),
);
const startupRetryAt = Date.now();
pushStatus({
connected: false,
lastEventAt: startupRetryAt,
lastDisconnect: {
at: startupRetryAt,
error: "startup-not-ready",
},
});
await reconnectGatewayFresh();
const reconnected = await waitForDiscordGatewayReady({
gateway,
abortSignal: params.abortSignal,
timeoutMs: DISCORD_GATEWAY_READY_TIMEOUT_MS,
beforePoll: drainPendingGatewayErrors,
});
if (reconnected === "stopped" || lifecycleStopping) {
return;
}
if (reconnected === "timeout" && !lifecycleStopping) {
const error = new Error(
`discord gateway did not reach READY within ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms after a forced reconnect`,
);
const startupFailureAt = Date.now();
pushStatus({
connected: false,
lastEventAt: startupFailureAt,
lastDisconnect: {
at: startupFailureAt,
error: "startup-reconnect-timeout",
},
lastError: error.message,
});
throw error;
}
}
}
// If the gateway is already connected when the lifecycle starts (or becomes
// connected during the startup readiness guard), push the initial connected
// status now. Guard against lifecycleStopping: if the abortSignal was
// already aborted, onAbort() ran synchronously above and pushed connected:
// false, so don't contradict it with a spurious connected: true.
if (gateway?.isConnected && !lifecycleStopping) {
const at = Date.now();
pushStatus({
...createConnectedChannelStatusPatch(at),
lastDisconnect: null,
});
}
if (drainPendingGatewayErrors() === "stop") {
return;
@@ -118,7 +594,14 @@ export async function runDiscordGatewayLifecycle(params: {
abortSignal: params.abortSignal,
gatewaySupervisor: params.gatewaySupervisor,
onGatewayEvent: handleGatewayEvent,
registerForceStop: reconnectController.registerForceStop,
registerForceStop: (forceStop) => {
forceStopHandler = forceStop;
if (queuedForceStopError !== undefined) {
const queued = queuedForceStopError;
queuedForceStopError = undefined;
forceStop(queued);
}
},
});
} catch (err) {
if (!sawDisallowedIntents && !params.isDisallowedIntentsError(err)) {
@@ -129,8 +612,10 @@ export async function runDiscordGatewayLifecycle(params: {
params.gatewaySupervisor.detachLifecycle();
unregisterGateway(params.accountId);
stopGatewayLogging();
reconnectController.dispose();
reconnectStallWatchdog.stop();
clearHelloWatch();
gatewayEmitter?.removeListener("debug", onGatewayDebug);
params.abortSignal?.removeEventListener("abort", onAbort);
if (params.voiceManager) {
await params.voiceManager.destroy();
params.voiceManagerRef.current = null;

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { resolveDiscordRestFetch } from "./rest-fetch.js";
const { undiciFetchMock, proxyAgentSpy } = vi.hoisted(() => ({
undiciFetchMock: vi.fn(),
@@ -22,14 +23,7 @@ vi.mock("undici", () => {
};
});
let resolveDiscordRestFetch: typeof import("./rest-fetch.js").resolveDiscordRestFetch;
describe("resolveDiscordRestFetch", () => {
beforeEach(async () => {
vi.resetModules();
({ resolveDiscordRestFetch } = await import("./rest-fetch.js"));
});
it("uses undici proxy fetch when a proxy URL is configured", async () => {
const runtime = {
log: vi.fn(),

View File

@@ -1,237 +0,0 @@
import {
Client,
ReadyListener,
type BaseCommand,
type BaseMessageInteractiveComponent,
type Modal,
type Plugin,
} from "@buape/carbon";
import type { GatewayPlugin } from "@buape/carbon/gateway";
import { VoicePlugin } from "@buape/carbon/voice";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { DiscordGuildEntryResolved } from "./allow-list.js";
import { createDiscordAutoPresenceController } from "./auto-presence.js";
import type { DiscordDmPolicy } from "./dm-command-auth.js";
import type { MutableDiscordGateway } from "./gateway-handle.js";
import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
import { createDiscordGatewaySupervisor } from "./gateway-supervisor.js";
import {
DiscordMessageListener,
DiscordPresenceListener,
DiscordReactionListener,
DiscordReactionRemoveListener,
DiscordThreadUpdateListener,
registerDiscordListener,
} from "./listeners.js";
import { resolveDiscordPresenceUpdate } from "./presence.js";
type DiscordAutoPresenceController = ReturnType<typeof createDiscordAutoPresenceController>;
type DiscordListenerConfig = {
dangerouslyAllowNameMatching?: boolean;
intents?: { presence?: boolean };
};
type CreateClientFn = (
options: ConstructorParameters<typeof Client>[0],
handlers: ConstructorParameters<typeof Client>[1],
plugins: ConstructorParameters<typeof Client>[2],
) => Client;
export function createDiscordStatusReadyListener(params: {
discordConfig: Parameters<typeof resolveDiscordPresenceUpdate>[0];
getAutoPresenceController: () => DiscordAutoPresenceController | null;
}): ReadyListener {
return new (class DiscordStatusReadyListener extends ReadyListener {
async handle(_data: unknown, client: Client) {
const autoPresenceController = params.getAutoPresenceController();
if (autoPresenceController?.enabled) {
autoPresenceController.refresh();
return;
}
const gateway = client.getPlugin<GatewayPlugin>("gateway");
if (!gateway) {
return;
}
const presence = resolveDiscordPresenceUpdate(params.discordConfig);
if (!presence) {
return;
}
gateway.updatePresence(presence);
}
})();
}
export function createDiscordMonitorClient(params: {
accountId: string;
applicationId: string;
token: string;
commands: BaseCommand[];
components: BaseMessageInteractiveComponent[];
modals: Modal[];
voiceEnabled: boolean;
discordConfig: Parameters<typeof resolveDiscordPresenceUpdate>[0] & {
eventQueue?: { listenerTimeout?: number };
};
runtime: RuntimeEnv;
createClient: CreateClientFn;
createGatewayPlugin: typeof createDiscordGatewayPlugin;
createGatewaySupervisor: typeof createDiscordGatewaySupervisor;
createAutoPresenceController: typeof createDiscordAutoPresenceController;
isDisallowedIntentsError: (err: unknown) => boolean;
}) {
let autoPresenceController: DiscordAutoPresenceController | null = null;
const clientPlugins: Plugin[] = [
params.createGatewayPlugin({
discordConfig: params.discordConfig,
runtime: params.runtime,
}),
];
if (params.voiceEnabled) {
clientPlugins.push(new VoicePlugin());
}
// Pass eventQueue config to Carbon so the gateway listener budget can be tuned.
// Default listenerTimeout is 120s (Carbon defaults to 30s, which is too short for some
// Discord normalization/enqueue work).
const eventQueueOpts = {
listenerTimeout: 120_000,
...params.discordConfig.eventQueue,
};
const readyListener = createDiscordStatusReadyListener({
discordConfig: params.discordConfig,
getAutoPresenceController: () => autoPresenceController,
});
const client = params.createClient(
{
baseUrl: "http://localhost",
deploySecret: "a",
clientId: params.applicationId,
publicKey: "a",
token: params.token,
autoDeploy: false,
eventQueue: eventQueueOpts,
},
{
commands: params.commands,
listeners: [readyListener],
components: params.components,
modals: params.modals,
},
clientPlugins,
);
const gateway = client.getPlugin<GatewayPlugin>("gateway") as MutableDiscordGateway | undefined;
const gatewaySupervisor = params.createGatewaySupervisor({
gateway,
isDisallowedIntentsError: params.isDisallowedIntentsError,
runtime: params.runtime,
});
if (gateway) {
autoPresenceController = params.createAutoPresenceController({
accountId: params.accountId,
discordConfig: params.discordConfig,
gateway,
log: (message) => params.runtime.log?.(message),
});
autoPresenceController.start();
}
return {
client,
gateway,
gatewaySupervisor,
autoPresenceController,
eventQueueOpts,
};
}
export async function fetchDiscordBotIdentity(params: {
client: Pick<Client, "fetchUser">;
runtime: RuntimeEnv;
logStartupPhase: (phase: string, details?: string) => void;
}) {
params.logStartupPhase("fetch-bot-identity:start");
try {
const botUser = await params.client.fetchUser("@me");
const botUserId = botUser?.id;
const botUserName = botUser?.username?.trim() || botUser?.globalName?.trim() || undefined;
params.logStartupPhase(
"fetch-bot-identity:done",
`botUserId=${botUserId ?? "<missing>"} botUserName=${botUserName ?? "<missing>"}`,
);
return { botUserId, botUserName };
} catch (err) {
params.runtime.error?.(danger(`discord: failed to fetch bot identity: ${String(err)}`));
params.logStartupPhase("fetch-bot-identity:error", String(err));
return { botUserId: undefined, botUserName: undefined };
}
}
export function registerDiscordMonitorListeners(params: {
cfg: OpenClawConfig;
client: Pick<Client, "listeners">;
accountId: string;
discordConfig: DiscordListenerConfig;
runtime: RuntimeEnv;
botUserId?: string;
dmEnabled: boolean;
groupDmEnabled: boolean;
groupDmChannels?: string[];
dmPolicy: DiscordDmPolicy;
allowFrom?: string[];
groupPolicy: "open" | "allowlist" | "disabled";
guildEntries?: Record<string, DiscordGuildEntryResolved>;
logger: NonNullable<ConstructorParameters<typeof DiscordMessageListener>[1]>;
messageHandler: ConstructorParameters<typeof DiscordMessageListener>[0];
trackInboundEvent?: () => void;
eventQueueListenerTimeoutMs?: number;
}) {
registerDiscordListener(
params.client.listeners,
new DiscordMessageListener(params.messageHandler, params.logger, params.trackInboundEvent, {
timeoutMs: params.eventQueueListenerTimeoutMs,
}),
);
const reactionListenerOptions: ConstructorParameters<typeof DiscordReactionListener>[0] = {
cfg: params.cfg,
accountId: params.accountId,
runtime: params.runtime,
botUserId: params.botUserId,
dmEnabled: params.dmEnabled,
groupDmEnabled: params.groupDmEnabled,
groupDmChannels: params.groupDmChannels ?? [],
dmPolicy: params.dmPolicy,
allowFrom: params.allowFrom ?? [],
groupPolicy: params.groupPolicy,
allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig),
guildEntries: params.guildEntries,
logger: params.logger,
onEvent: params.trackInboundEvent,
};
registerDiscordListener(
params.client.listeners,
new DiscordReactionListener(reactionListenerOptions),
);
registerDiscordListener(
params.client.listeners,
new DiscordReactionRemoveListener(reactionListenerOptions),
);
registerDiscordListener(
params.client.listeners,
new DiscordThreadUpdateListener(params.cfg, params.accountId, params.logger),
);
if (params.discordConfig.intents?.presence) {
registerDiscordListener(
params.client.listeners,
new DiscordPresenceListener({ logger: params.logger, accountId: params.accountId }),
);
params.runtime.log?.("discord: GuildPresences intent enabled — presence listener registered");
}
}

View File

@@ -2,11 +2,14 @@ import { inspect } from "node:util";
import {
Client,
RateLimitError,
ReadyListener,
type BaseCommand,
type BaseMessageInteractiveComponent,
type Modal,
type Plugin,
} from "@buape/carbon";
import { GatewayCloseCodes, type GatewayPlugin } from "@buape/carbon/gateway";
import { VoicePlugin } from "@buape/carbon/voice";
import { Routes } from "discord-api-types/v10";
import {
listNativeCommandSpecsForConfig,
@@ -20,6 +23,7 @@ import {
} from "openclaw/plugin-sdk/config-runtime";
import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-runtime";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import {
GROUP_POLICY_BLOCKED_LABEL,
resolveOpenProviderRuntimeGroupPolicy,
@@ -58,23 +62,25 @@ import {
import { createDiscordAutoPresenceController } from "./auto-presence.js";
import { resolveDiscordSlashCommandConfig } from "./commands.js";
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
import type { MutableDiscordGateway } from "./gateway-handle.js";
import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
import { createDiscordGatewaySupervisor } from "./gateway-supervisor.js";
import { registerDiscordListener } from "./listeners.js";
import {
DiscordMessageListener,
DiscordPresenceListener,
DiscordReactionListener,
DiscordReactionRemoveListener,
DiscordThreadUpdateListener,
registerDiscordListener,
} from "./listeners.js";
import {
createDiscordCommandArgFallbackButton,
createDiscordModelPickerFallbackButton,
createDiscordModelPickerFallbackSelect,
createDiscordNativeCommand,
} from "./native-command.js";
import { resolveDiscordPresenceUpdate } from "./presence.js";
import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js";
import {
createDiscordMonitorClient,
fetchDiscordBotIdentity,
registerDiscordMonitorListeners,
} from "./provider.startup.js";
import { resolveDiscordRestFetch } from "./rest-fetch.js";
import { formatDiscordStartupStatusMessage } from "./startup-status.js";
import type { DiscordMonitorStatusSink } from "./status.js";
@@ -790,10 +796,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
let lifecycleStarted = false;
let gatewaySupervisor: ReturnType<typeof createDiscordGatewaySupervisor> | undefined;
let deactivateMessageHandler: (() => void) | undefined;
let autoPresenceController: ReturnType<
typeof createDiscordMonitorClient
>["autoPresenceController"] = null;
let lifecycleGateway: MutableDiscordGateway | undefined;
let autoPresenceController: ReturnType<typeof createDiscordAutoPresenceController> | null = null;
let lifecycleGateway: GatewayPlugin | undefined;
let earlyGatewayEmitter = gatewaySupervisor?.emitter;
let onEarlyGatewayDebug: ((msg: unknown) => void) | undefined;
try {
@@ -888,33 +892,70 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
modals.push(createDiscordComponentModal(componentContext));
}
const {
client,
gateway,
gatewaySupervisor: createdGatewaySupervisor,
autoPresenceController: createdAutoPresenceController,
eventQueueOpts,
} = createDiscordMonitorClient({
accountId: account.accountId,
applicationId,
token,
commands,
components,
modals,
voiceEnabled,
discordConfig: discordCfg,
runtime,
createClient: createClientForTesting ?? ((...args) => new Client(...args)),
createGatewayPlugin: createDiscordGatewayPluginForTesting ?? createDiscordGatewayPlugin,
createGatewaySupervisor:
createDiscordGatewaySupervisorForTesting ?? createDiscordGatewaySupervisor,
createAutoPresenceController: createDiscordAutoPresenceController,
isDisallowedIntentsError: isDiscordDisallowedIntentsError,
});
lifecycleGateway = gateway;
gatewaySupervisor = createdGatewaySupervisor;
autoPresenceController = createdAutoPresenceController;
class DiscordStatusReadyListener extends ReadyListener {
async handle(_data: unknown, client: Client) {
if (autoPresenceController?.enabled) {
autoPresenceController.refresh();
return;
}
const gateway = client.getPlugin<GatewayPlugin>("gateway");
if (!gateway) {
return;
}
const presence = resolveDiscordPresenceUpdate(discordCfg);
if (!presence) {
return;
}
gateway.updatePresence(presence);
}
}
const clientPlugins: Plugin[] = [
(createDiscordGatewayPluginForTesting ?? createDiscordGatewayPlugin)({
discordConfig: discordCfg,
runtime,
}),
];
if (voiceEnabled) {
clientPlugins.push(new VoicePlugin());
}
// Pass eventQueue config to Carbon so the gateway listener budget can be tuned.
// Default listenerTimeout is 120s (Carbon defaults to 30s, which is too short for some
// Discord normalization/enqueue work).
const eventQueueOpts = {
listenerTimeout: 120_000,
...discordCfg.eventQueue,
};
const client = (createClientForTesting ?? ((...args) => new Client(...args)))(
{
baseUrl: "http://localhost",
deploySecret: "a",
clientId: applicationId,
publicKey: "a",
token,
autoDeploy: false,
eventQueue: eventQueueOpts,
},
{
commands,
listeners: [new DiscordStatusReadyListener()],
components,
modals,
},
clientPlugins,
);
gatewaySupervisor = (
createDiscordGatewaySupervisorForTesting ?? createDiscordGatewaySupervisor
)({
client,
isDisallowedIntentsError: isDiscordDisallowedIntentsError,
runtime,
});
lifecycleGateway = client.getPlugin<GatewayPlugin>("gateway");
earlyGatewayEmitter = gatewaySupervisor.emitter;
onEarlyGatewayDebug = (msg: unknown) => {
if (!(isVerboseForTesting ?? isVerbose)()) {
@@ -925,6 +966,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
);
};
earlyGatewayEmitter?.on("debug", onEarlyGatewayDebug);
if (lifecycleGateway) {
autoPresenceController = createDiscordAutoPresenceController({
accountId: account.accountId,
discordConfig: discordCfg,
gateway: lifecycleGateway,
log: (message) => runtime.log?.(message),
});
autoPresenceController.start();
}
logDiscordStartupPhase({
runtime,
@@ -954,19 +1004,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
string,
import("openclaw/plugin-sdk/reply-history").HistoryEntry[]
>();
let { botUserId, botUserName } = await fetchDiscordBotIdentity({
client,
runtime,
logStartupPhase: (phase, details) =>
logDiscordStartupPhase({
runtime,
accountId: account.accountId,
phase,
startAt: startupStartedAt,
gateway: lifecycleGateway,
details,
}),
});
let botUserId: string | undefined;
let botUserName: string | undefined;
let voiceManager: DiscordVoiceManager | null = null;
if (nativeDisabledExplicit) {
@@ -991,6 +1030,37 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
});
}
logDiscordStartupPhase({
runtime,
accountId: account.accountId,
phase: "fetch-bot-identity:start",
startAt: startupStartedAt,
gateway: lifecycleGateway,
});
try {
const botUser = await client.fetchUser("@me");
botUserId = botUser?.id;
botUserName = botUser?.username?.trim() || botUser?.globalName?.trim() || undefined;
logDiscordStartupPhase({
runtime,
accountId: account.accountId,
phase: "fetch-bot-identity:done",
startAt: startupStartedAt,
gateway: lifecycleGateway,
details: `botUserId=${botUserId ?? "<missing>"} botUserName=${botUserName ?? "<missing>"}`,
});
} catch (err) {
runtime.error?.(danger(`discord: failed to fetch bot identity: ${String(err)}`));
logDiscordStartupPhase({
runtime,
accountId: account.accountId,
phase: "fetch-bot-identity:error",
startAt: startupStartedAt,
gateway: lifecycleGateway,
details: String(err),
});
}
if (voiceEnabled) {
const { DiscordVoiceManager, DiscordVoiceReadyListener } = await loadDiscordVoiceRuntime();
voiceManager = new DiscordVoiceManager({
@@ -1035,25 +1105,47 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
opts.setStatus?.({ lastEventAt: at, lastInboundAt: at });
}
: undefined;
registerDiscordMonitorListeners({
registerDiscordListener(
client.listeners,
new DiscordMessageListener(messageHandler, logger, trackInboundEvent, {
timeoutMs: eventQueueOpts.listenerTimeout,
}),
);
const reactionListenerOptions = {
cfg,
client,
accountId: account.accountId,
discordConfig: discordCfg,
runtime,
botUserId,
dmEnabled,
groupDmEnabled,
groupDmChannels,
groupDmChannels: groupDmChannels ?? [],
dmPolicy,
allowFrom,
allowFrom: allowFrom ?? [],
groupPolicy,
allowNameMatching: isDangerousNameMatchingEnabled(discordCfg),
guildEntries,
logger,
messageHandler,
trackInboundEvent,
eventQueueListenerTimeoutMs: eventQueueOpts.listenerTimeout,
});
onEvent: trackInboundEvent,
};
registerDiscordListener(client.listeners, new DiscordReactionListener(reactionListenerOptions));
registerDiscordListener(
client.listeners,
new DiscordReactionRemoveListener(reactionListenerOptions),
);
registerDiscordListener(
client.listeners,
new DiscordThreadUpdateListener(cfg, account.accountId, logger),
);
if (discordCfg.intents?.presence) {
registerDiscordListener(
client.listeners,
new DiscordPresenceListener({ logger, accountId: account.accountId }),
);
runtime.log?.("discord: GuildPresences intent enabled — presence listener registered");
}
const botIdentity =
botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? "");
@@ -1072,7 +1164,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
onEarlyGatewayDebug = undefined;
await (runDiscordGatewayLifecycleForTesting ?? runDiscordGatewayLifecycle)({
accountId: account.accountId,
gateway: lifecycleGateway,
client,
runtime,
abortSignal: opts.abortSignal,
statusSink: opts.setStatus,

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import type { RuntimeEnv } from "../../../../src/runtime.js";
import { deliverDiscordReply } from "./reply-delivery.js";
import {
__testing as threadBindingTesting,
createThreadBindingManager,
@@ -58,8 +59,6 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
};
});
let deliverDiscordReply: typeof import("./reply-delivery.js").deliverDiscordReply;
describe("deliverDiscordReply", () => {
const runtime = {} as RuntimeEnv;
const cfg = {
@@ -112,9 +111,7 @@ describe("deliverDiscordReply", () => {
return threadBindings;
};
beforeEach(async () => {
vi.resetModules();
({ deliverDiscordReply } = await import("./reply-delivery.js"));
beforeEach(() => {
sendMessageDiscordMock.mockClear().mockResolvedValue({
messageId: "msg-1",
channelId: "channel-1",

View File

@@ -1,26 +1,38 @@
import { ChannelType } from "discord-api-types/v10";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import * as discordClientModule from "../client.js";
import * as discordSendModule from "../send.js";
import type { ThreadBindingRecord } from "./thread-bindings.types.js";
const DEFAULT_SEND_RESULT = {
messageId: "msg-1",
channelId: "thread-1",
};
const hoisted = vi.hoisted(() => {
const restGet = vi.fn();
const sendMessageDiscord = vi.fn();
const sendWebhookMessageDiscord = vi.fn();
const createDiscordRestClient = vi.fn(() => ({
rest: {
get: restGet,
},
}));
return {
restGet,
sendMessageDiscord,
sendWebhookMessageDiscord,
createDiscordRestClient,
};
});
const restGet = vi.fn<(...args: unknown[]) => Promise<unknown>>();
const sendMessageDiscord = vi.fn<typeof discordSendModule.sendMessageDiscord>();
const sendWebhookMessageDiscord = vi.fn<typeof discordSendModule.sendWebhookMessageDiscord>();
const createDiscordRestClient = vi.fn<typeof discordClientModule.createDiscordRestClient>(
() =>
({
rest: {
get: restGet,
},
}) as unknown as ReturnType<typeof discordClientModule.createDiscordRestClient>,
);
vi.mock("../client.js", () => ({
createDiscordRestClient: hoisted.createDiscordRestClient,
}));
vi.mock("../send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.js")>();
return {
...actual,
addRoleDiscord: vi.fn(),
sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscord(...args),
sendWebhookMessageDiscord: (...args: unknown[]) => hoisted.sendWebhookMessageDiscord(...args),
};
});
let maybeSendBindingMessage: typeof import("./thread-bindings.discord-api.js").maybeSendBindingMessage;
let resolveChannelIdForBinding: typeof import("./thread-bindings.discord-api.js").resolveChannelIdForBinding;
@@ -32,30 +44,10 @@ beforeAll(async () => {
describe("resolveChannelIdForBinding", () => {
beforeEach(() => {
vi.restoreAllMocks();
restGet.mockReset();
sendMessageDiscord.mockReset().mockResolvedValue(DEFAULT_SEND_RESULT);
sendWebhookMessageDiscord.mockReset().mockResolvedValue(DEFAULT_SEND_RESULT);
createDiscordRestClient.mockReset().mockImplementation(
() =>
({
rest: {
get: restGet,
},
}) as unknown as ReturnType<typeof discordClientModule.createDiscordRestClient>,
);
vi.spyOn(discordClientModule, "createDiscordRestClient").mockImplementation(
(...args) =>
createDiscordRestClient(...args) as unknown as ReturnType<
typeof discordClientModule.createDiscordRestClient
>,
);
vi.spyOn(discordSendModule, "sendMessageDiscord").mockImplementation((...args) =>
sendMessageDiscord(...args),
);
vi.spyOn(discordSendModule, "sendWebhookMessageDiscord").mockImplementation((...args) =>
sendWebhookMessageDiscord(...args),
);
hoisted.restGet.mockClear();
hoisted.createDiscordRestClient.mockClear();
hoisted.sendMessageDiscord.mockClear().mockResolvedValue({});
hoisted.sendWebhookMessageDiscord.mockClear().mockResolvedValue({});
});
it("returns explicit channelId without resolving route", async () => {
@@ -66,12 +58,12 @@ describe("resolveChannelIdForBinding", () => {
});
expect(resolved).toBe("channel-explicit");
expect(createDiscordRestClient).not.toHaveBeenCalled();
expect(restGet).not.toHaveBeenCalled();
expect(hoisted.createDiscordRestClient).not.toHaveBeenCalled();
expect(hoisted.restGet).not.toHaveBeenCalled();
});
it("returns parent channel for thread channels", async () => {
restGet.mockResolvedValueOnce({
hoisted.restGet.mockResolvedValueOnce({
id: "thread-1",
type: ChannelType.PublicThread,
parent_id: "channel-parent",
@@ -89,7 +81,7 @@ describe("resolveChannelIdForBinding", () => {
const cfg = {
channels: { discord: { token: "tok" } },
} as OpenClawConfig;
restGet.mockResolvedValueOnce({
hoisted.restGet.mockResolvedValueOnce({
id: "thread-1",
type: ChannelType.PublicThread,
parent_id: "channel-parent",
@@ -101,12 +93,12 @@ describe("resolveChannelIdForBinding", () => {
threadId: "thread-1",
});
const createDiscordRestClientCalls = createDiscordRestClient.mock.calls as unknown[][];
const createDiscordRestClientCalls = hoisted.createDiscordRestClient.mock.calls as unknown[][];
expect(createDiscordRestClientCalls[0]?.[1]).toBe(cfg);
});
it("keeps non-thread channel id even when parent_id exists", async () => {
restGet.mockResolvedValueOnce({
hoisted.restGet.mockResolvedValueOnce({
id: "channel-text",
type: ChannelType.GuildText,
parent_id: "category-1",
@@ -121,7 +113,7 @@ describe("resolveChannelIdForBinding", () => {
});
it("keeps forum channel id instead of parent category", async () => {
restGet.mockResolvedValueOnce({
hoisted.restGet.mockResolvedValueOnce({
id: "forum-1",
type: ChannelType.GuildForum,
parent_id: "category-1",
@@ -138,15 +130,8 @@ describe("resolveChannelIdForBinding", () => {
describe("maybeSendBindingMessage", () => {
beforeEach(() => {
vi.restoreAllMocks();
sendMessageDiscord.mockReset().mockResolvedValue(DEFAULT_SEND_RESULT);
sendWebhookMessageDiscord.mockReset().mockResolvedValue(DEFAULT_SEND_RESULT);
vi.spyOn(discordSendModule, "sendMessageDiscord").mockImplementation((...args) =>
sendMessageDiscord(...args),
);
vi.spyOn(discordSendModule, "sendWebhookMessageDiscord").mockImplementation((...args) =>
sendWebhookMessageDiscord(...args),
);
hoisted.sendMessageDiscord.mockClear().mockResolvedValue({});
hoisted.sendWebhookMessageDiscord.mockClear().mockResolvedValue({});
});
it("forwards cfg to webhook send path", async () => {
@@ -173,14 +158,14 @@ describe("maybeSendBindingMessage", () => {
text: "hello webhook",
});
expect(sendWebhookMessageDiscord).toHaveBeenCalledTimes(1);
expect(sendWebhookMessageDiscord.mock.calls[0]?.[1]).toMatchObject({
expect(hoisted.sendWebhookMessageDiscord).toHaveBeenCalledTimes(1);
expect(hoisted.sendWebhookMessageDiscord.mock.calls[0]?.[1]).toMatchObject({
cfg,
webhookId: "wh_1",
webhookToken: "tok_1",
accountId: "default",
threadId: "thread-1",
});
expect(sendMessageDiscord).not.toHaveBeenCalled();
expect(hoisted.sendMessageDiscord).not.toHaveBeenCalled();
});
});

View File

@@ -30,9 +30,11 @@ const MATCHED_KEY = `agent:main:discord:channel:${THREAD_ID}`;
const UNMATCHED_KEY = `agent:main:discord:channel:${OTHER_ID}`;
describe("closeDiscordThreadSessions", () => {
beforeEach(async () => {
vi.resetModules();
beforeAll(async () => {
({ closeDiscordThreadSessions } = await import("./thread-session-close.js"));
});
beforeEach(() => {
hoisted.updateSessionStore.mockClear();
hoisted.resolveStorePath.mockClear();
hoisted.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions.json");

View File

@@ -1,12 +1,22 @@
import * as agentRuntimeModule from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const completeWithPreparedSimpleCompletionModelMock =
vi.fn<typeof agentRuntimeModule.completeWithPreparedSimpleCompletionModel>();
const prepareSimpleCompletionModelForAgentMock =
vi.fn<typeof agentRuntimeModule.prepareSimpleCompletionModelForAgent>();
const extractAssistantTextMock = vi.fn<typeof agentRuntimeModule.extractAssistantText>();
const hoisted = vi.hoisted(() => ({
completeWithPreparedSimpleCompletionModelMock: vi.fn(),
prepareSimpleCompletionModelForAgentMock: vi.fn(),
extractAssistantTextMock: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-runtime")>();
return {
...actual,
completeWithPreparedSimpleCompletionModel:
hoisted.completeWithPreparedSimpleCompletionModelMock,
prepareSimpleCompletionModelForAgent: hoisted.prepareSimpleCompletionModelForAgentMock,
extractAssistantText: hoisted.extractAssistantTextMock,
};
});
let generateThreadTitle: typeof import("./thread-title.js").generateThreadTitle;
@@ -15,12 +25,11 @@ beforeAll(async () => {
});
beforeEach(() => {
vi.restoreAllMocks();
completeWithPreparedSimpleCompletionModelMock.mockReset();
prepareSimpleCompletionModelForAgentMock.mockReset();
extractAssistantTextMock.mockReset();
hoisted.completeWithPreparedSimpleCompletionModelMock.mockReset();
hoisted.prepareSimpleCompletionModelForAgentMock.mockReset();
hoisted.extractAssistantTextMock.mockReset();
prepareSimpleCompletionModelForAgentMock.mockResolvedValue({
hoisted.prepareSimpleCompletionModelForAgentMock.mockResolvedValue({
selection: {
provider: "anthropic",
modelId: "claude-opus-4-6",
@@ -35,25 +44,14 @@ beforeEach(() => {
source: "env:TEST_API_KEY",
mode: "api-key",
},
} as Awaited<ReturnType<typeof agentRuntimeModule.prepareSimpleCompletionModelForAgent>>);
completeWithPreparedSimpleCompletionModelMock.mockResolvedValue(
{} as Awaited<ReturnType<typeof agentRuntimeModule.completeWithPreparedSimpleCompletionModel>>,
);
extractAssistantTextMock.mockReturnValue("Generated title");
vi.spyOn(agentRuntimeModule, "prepareSimpleCompletionModelForAgent").mockImplementation(
(...args) => prepareSimpleCompletionModelForAgentMock(...args),
);
vi.spyOn(agentRuntimeModule, "completeWithPreparedSimpleCompletionModel").mockImplementation(
(...args) => completeWithPreparedSimpleCompletionModelMock(...args),
);
vi.spyOn(agentRuntimeModule, "extractAssistantText").mockImplementation((...args) =>
extractAssistantTextMock(...args),
);
});
hoisted.completeWithPreparedSimpleCompletionModelMock.mockResolvedValue({});
hoisted.extractAssistantTextMock.mockReturnValue("Generated title");
});
describe("generateThreadTitle", () => {
it("calls shared one-shot model prep with aws-sdk allowance", async () => {
prepareSimpleCompletionModelForAgentMock.mockResolvedValueOnce({
hoisted.prepareSimpleCompletionModelForAgentMock.mockResolvedValueOnce({
selection: {
provider: "openrouter",
modelId: "anthropic/claude-sonnet-4-5",
@@ -69,7 +67,7 @@ describe("generateThreadTitle", () => {
source: "profile:work",
mode: "api-key",
},
} as Awaited<ReturnType<typeof agentRuntimeModule.prepareSimpleCompletionModelForAgent>>);
});
const cfg = {
agents: {
defaults: {
@@ -84,7 +82,7 @@ describe("generateThreadTitle", () => {
messageText: "Need a generated title.",
});
expect(prepareSimpleCompletionModelForAgentMock).toHaveBeenCalledWith({
expect(hoisted.prepareSimpleCompletionModelForAgentMock).toHaveBeenCalledWith({
cfg,
agentId: "main",
allowMissingApiKeyModes: ["aws-sdk"],
@@ -100,7 +98,7 @@ describe("generateThreadTitle", () => {
messageText: "Need a generated title.",
});
expect(prepareSimpleCompletionModelForAgentMock).toHaveBeenCalledWith({
expect(hoisted.prepareSimpleCompletionModelForAgentMock).toHaveBeenCalledWith({
cfg,
agentId: "main",
modelRef: "openai/gpt-4.1-mini@local",
@@ -109,9 +107,9 @@ describe("generateThreadTitle", () => {
});
it("returns null when shared model prep cannot resolve selection", async () => {
prepareSimpleCompletionModelForAgentMock.mockResolvedValueOnce({
hoisted.prepareSimpleCompletionModelForAgentMock.mockResolvedValueOnce({
error: "No model configured for agent main.",
} as Awaited<ReturnType<typeof agentRuntimeModule.prepareSimpleCompletionModelForAgent>>);
});
const result = await generateThreadTitle({
cfg: {} as OpenClawConfig,
@@ -120,18 +118,18 @@ describe("generateThreadTitle", () => {
});
expect(result).toBeNull();
expect(completeWithPreparedSimpleCompletionModelMock).not.toHaveBeenCalled();
expect(hoisted.completeWithPreparedSimpleCompletionModelMock).not.toHaveBeenCalled();
});
it("returns null when shared completion prep fails", async () => {
prepareSimpleCompletionModelForAgentMock.mockResolvedValue({
hoisted.prepareSimpleCompletionModelForAgentMock.mockResolvedValue({
error: 'No API key resolved for provider "anthropic" (auth mode: api-key).',
selection: {
provider: "anthropic",
modelId: "claude-opus-4-6",
agentDir: "/tmp/openclaw-agent",
},
} as Awaited<ReturnType<typeof agentRuntimeModule.prepareSimpleCompletionModelForAgent>>);
});
const result = await generateThreadTitle({
cfg: {} as OpenClawConfig,
@@ -140,7 +138,7 @@ describe("generateThreadTitle", () => {
});
expect(result).toBeNull();
expect(completeWithPreparedSimpleCompletionModelMock).not.toHaveBeenCalled();
expect(hoisted.completeWithPreparedSimpleCompletionModelMock).not.toHaveBeenCalled();
});
it("builds contextual prompt and forwards completion options", async () => {
@@ -153,8 +151,10 @@ describe("generateThreadTitle", () => {
});
expect(result).toBe("Generated title");
expect(completeWithPreparedSimpleCompletionModelMock).toHaveBeenCalledTimes(1);
expect(completeWithPreparedSimpleCompletionModelMock.mock.calls[0]?.[0]?.context).toEqual(
expect(hoisted.completeWithPreparedSimpleCompletionModelMock).toHaveBeenCalledTimes(1);
expect(
hoisted.completeWithPreparedSimpleCompletionModelMock.mock.calls[0]?.[0]?.context,
).toEqual(
expect.objectContaining({
systemPrompt:
"Generate a concise Discord thread title (3-6 words). Return only the title. Use channel context when provided and avoid redundant channel-name words unless needed for clarity.",
@@ -167,10 +167,12 @@ describe("generateThreadTitle", () => {
}),
);
expect(
completeWithPreparedSimpleCompletionModelMock.mock.calls[0]?.[0]?.context?.messages?.[0]
?.content,
hoisted.completeWithPreparedSimpleCompletionModelMock.mock.calls[0]?.[0]?.context
?.messages?.[0]?.content,
).toContain("Channel description: Deploy updates and incident notes");
expect(completeWithPreparedSimpleCompletionModelMock.mock.calls[0]?.[0]?.options).toEqual(
expect(
hoisted.completeWithPreparedSimpleCompletionModelMock.mock.calls[0]?.[0]?.options,
).toEqual(
expect.objectContaining({
maxTokens: 24,
temperature: 0.2,
@@ -179,7 +181,7 @@ describe("generateThreadTitle", () => {
});
it("returns null when completion throws", async () => {
completeWithPreparedSimpleCompletionModelMock.mockRejectedValueOnce(
hoisted.completeWithPreparedSimpleCompletionModelMock.mockRejectedValueOnce(
new Error("network timeout"),
);

View File

@@ -1,5 +1,5 @@
import { ChannelType } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../../src/config/config.js";
type MaybeCreateDiscordAutoThreadFn = typeof import("./threading.js").maybeCreateDiscordAutoThread;
@@ -46,8 +46,7 @@ async function flushAsyncWork() {
await Promise.resolve();
}
beforeEach(async () => {
vi.resetModules();
beforeAll(async () => {
postMock.mockReset();
getMock.mockReset();
patchMock.mockReset();
@@ -55,6 +54,13 @@ beforeEach(async () => {
({ maybeCreateDiscordAutoThread } = await import("./threading.js"));
});
beforeEach(() => {
postMock.mockReset();
getMock.mockReset();
patchMock.mockReset();
generateThreadTitleMock.mockReset();
});
describe("maybeCreateDiscordAutoThread", () => {
it("skips auto-thread if channelType is GuildForum", async () => {
const result = await maybeCreateDiscordAutoThread(

View File

@@ -9,9 +9,10 @@ import {
resolveDiscordAccount,
type ResolvedDiscordAccount,
} from "./accounts.js";
import { DiscordChannelConfigSchema } from "./config-schema.js";
import {
createScopedChannelConfigAdapter,
buildChannelConfigSchema,
DiscordConfigSchema,
getChatChannelMeta,
type ChannelPlugin,
} from "./runtime-api.js";
@@ -69,7 +70,7 @@ export function createDiscordPluginBase(params: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.discord"] },
configSchema: DiscordChannelConfigSchema,
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
config: {
...discordConfigAdapter,
isConfigured: (account) => Boolean(account.token?.trim()),

View File

@@ -10,9 +10,6 @@
"help": "SafeSearch level for DuckDuckGo results."
}
},
"contracts": {
"webSearchProviders": ["duckduckgo"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/duckduckgo-plugin",
"version": "2026.3.26",
"version": "2026.3.22",
"private": true,
"description": "OpenClaw DuckDuckGo plugin",
"type": "module",

View File

@@ -1,8 +1,6 @@
{
"id": "elevenlabs",
"contracts": {
"speechProviders": ["elevenlabs"]
},
"speechProviders": ["elevenlabs"],
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/elevenlabs-speech",
"version": "2026.3.26",
"version": "2026.3.22",
"private": true,
"description": "OpenClaw ElevenLabs speech plugin",
"type": "module",

View File

@@ -11,9 +11,6 @@
"placeholder": "exa-..."
}
},
"contracts": {
"webSearchProviders": ["exa"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/exa-plugin",
"version": "2026.3.26",
"version": "2026.3.22",
"private": true,
"description": "OpenClaw Exa plugin",
"type": "module",

View File

@@ -1,6 +1,7 @@
{
"id": "fal",
"providers": ["fal"],
"imageGenerationProviders": ["fal"],
"providerAuthEnvVars": {
"fal": ["FAL_KEY"]
},
@@ -20,9 +21,6 @@
"cliDescription": "fal API key"
}
],
"contracts": {
"imageGenerationProviders": ["fal"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/fal-provider",
"version": "2026.3.26",
"version": "2026.3.22",
"private": true,
"description": "OpenClaw fal provider plugin",
"type": "module",

View File

@@ -1,10 +1,10 @@
{
"name": "@openclaw/feishu",
"version": "2026.3.26",
"version": "2026.3.22",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.60.0",
"@larksuiteoapi/node-sdk": "^1.59.0",
"@sinclair/typebox": "0.34.48",
"https-proxy-agent": "^8.0.0",
"zod": "^4.3.6"
@@ -13,7 +13,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.26"
"openclaw": ">=2026.3.22"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -42,7 +42,7 @@
"npmSpec": "@openclaw/feishu",
"localPath": "extensions/feishu",
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.26"
"minHostVersion": ">=2026.3.22"
},
"bundle": {
"stageRuntimeDependencies": true

View File

@@ -15,10 +15,6 @@
"help": "Firecrawl Search base URL override."
}
},
"contracts": {
"webSearchProviders": ["firecrawl"],
"tools": ["firecrawl_search", "firecrawl_scrape"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/firecrawl-plugin",
"version": "2026.3.26",
"version": "2026.3.22",
"private": true,
"description": "OpenClaw Firecrawl plugin",
"type": "module",

Some files were not shown because too many files have changed in this diff Show More