Compare commits

..

415 Commits

Author SHA1 Message Date
Tyler Yust
852575a959 Fix BlueBubbles send confirmation reliability 2026-03-26 02:54:46 -07:00
Tyler Yust
00e932a83c fix: restore inbound image embedding for CLI routed BlueBubbles turns (#51373)
* fix(cli): hydrate prompt image refs for inbound media

* Agents: harden CLI prompt image hydration (#51373)

* test: fix CLI prompt image hydration helper mocks
2026-03-26 15:47:44 +09:00
MetaX e|acc
a16dd967da feat: Add Microsoft Foundry provider with Entra ID authentication (#51973)
* Microsoft Foundry: add native provider

* Microsoft Foundry: tighten review fixes

* Microsoft Foundry: enable by default

* Microsoft Foundry: stabilize API routing
2026-03-26 01:33:14 -05:00
Ayaan Zaidi
06de515b6c fix(plugins): skip allowlist warning for config paths 2026-03-26 11:44:23 +05:30
sudie-codes
6329edfb8d msteams: add search message action (#54832)
* msteams: add pin/unpin, list-pins, and read message actions

Wire up Graph API endpoints for message read, pin, unpin, and list-pins
in the MS Teams extension, following the same patterns as edit/delete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: address PR review comments for pin/unpin/read actions

- Handle 204 No Content in postGraphJson (Graph mutations may return empty body)
- Strip conversation:/user: prefixes in resolveConversationPath to avoid Graph 404s
- Remove dead variable in channel pin branch
- Rename unpin param from messageId to pinnedMessageId for semantic clarity
- Accept both pinnedMessageId and messageId in unpin action handler for compat

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: resolve user targets + add User-Agent to Graph helpers

- Resolve user:<aadId> targets to actual conversation IDs via conversation
  store before Graph API calls (fixes 404 for DM-context actions)
- Add User-Agent header to postGraphJson/deleteGraphRequest for consistency
  with fetchGraphJson after rebase onto main

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: resolve DM targets to Graph chat IDs + expose pin IDs

- Prefer cached graphChatId over Bot Framework conversation IDs for user
  targets; throw descriptive error when no Graph-compatible ID is available
- Add `id` field to list-pins rows so default formatters surface the pinned
  resource ID needed for the unpin flow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: add react and reactions (list) message actions

* msteams: add search message action via Graph API

* msteams: fix search query injection, add ConsistencyLevel header, use manual query string

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 01:09:53 -05:00
sudie-codes
8c852d86f7 msteams: fetch thread history via Graph API for channel replies (#51643)
* msteams: fetch thread history via Graph API for channel replies

* msteams: address PR #51643 review feedback

- Wrap resolveTeamGroupId Graph call in try/catch, fall back to raw
  conversationTeamId when Team.ReadBasic.All permission is missing
- Remove dead fetchChatMessages function (exported but never called)
- Add JSDoc documenting oldest-50-replies Graph API limitation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: address thread history PR review comments

* msteams: only cache team group IDs on successful Graph lookup

Avoid caching raw conversationTeamId as a Graph team GUID when the
/teams/{id} lookup fails — the raw ID may be a Bot Framework conversation
key, not a valid GUID, causing silent thread-history failures for the
entire cache TTL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 01:09:33 -05:00
George Zhang
6cbd2d36f8 Revert "feat: add video generation core infrastructure and extend image generation parameters (#53681)" (#54943)
This reverts commit 4cb8dde894.
2026-03-25 23:00:14 -07:00
OfflynAI
e45533d568 fix(whatsapp): drop fromMe echoes in self-chat DMs using outbound ID tracking (#54570)
Merged via squash.

Prepared head SHA: dad53caf39
Co-authored-by: joelnishanth <140015627+joelnishanth@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-03-26 02:24:24 -03:00
Neerav Makwana
6fd9d2ff38 fix: support OpenAI Codex media understanding (#54829) (thanks @neeravmakwana)
* OpenAI: register Codex media understanding provider

* fix: route codex image prompts through system instructions

* fix: add changelog for codex image tool fix (#54829) (thanks @neeravmakwana)

* fix: remove any from provider registration tests (#54829) (thanks @neeravmakwana)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-26 10:10:11 +05:30
Ted Li
76ff0d9298 fix: restore image-tool generic provider fallback (#54858) (thanks @MonkeyLeeT)
* Image tool: restore generic provider fallback

* Image tool: cover multi-image generic fallback

* test: tighten minimax-portal image fallback coverage

* fix: restore image-tool generic provider fallback (#54858) (thanks @MonkeyLeeT)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-26 10:07:43 +05:30
Neerav Makwana
8efc6e001e fix: auto-enable configured channel plugins in routed CLI commands (#54809) (thanks @neeravmakwana)
* CLI: auto-enable configured channel plugins in routed commands

* fix: auto-enable configured channel plugins in routed CLI commands (#54809) (thanks @neeravmakwana)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-26 10:06:16 +05:30
sparkyrider
1bc30b7fb9 fix: restore Kimi Code under Moonshot setup (#54619) (thanks @sparkyrider)
* Onboarding: restore Kimi Code under Moonshot setup

* Update extensions/kimi-coding/index.ts

Fix naming convention in metadata

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-26 09:46:40 +05:30
Kevin Boyle
99deba798c fix: restore CLI message transcript mirroring (#54187) (thanks @KevInTheCloud5617)
* fix: pass agentId in CLI message command to enable session transcript writes

The CLI `openclaw message send` command was not passing `agentId` to
`runMessageAction()`, causing the outbound session route resolution to
be skipped (it's gated on `agentId && !dryRun`). Without a route, the
`mirror` object is never constructed, and `appendAssistantMessageToSessionTranscript()`
is never called.

This fix resolves the agent ID from the config (defaulting to "main")
and passes it through, enabling transcript mirroring for all channels
when using the CLI.

Closes #54186

* fix: format message.ts with oxfmt

* fix: use resolveDefaultAgentId instead of cfg.agent

* fix: restore CLI message transcript mirroring (#54187) (thanks @KevInTheCloud5617)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-26 09:32:43 +05:30
Neerav Makwana
68d854cb9c fix: use provider-aware context window lookup (#54796) (thanks @neeravmakwana)
* fix(status): use provider-aware context window lookup

* test(status): cover provider-aware context lookup

* fix: use provider-aware context window lookup (#54796) (thanks @neeravmakwana)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-26 09:28:20 +05:30
Greg Retkowski
14430ade57 fix: tighten systemd duplicate gateway detection (#45328) (thanks @gregretkowski)
* daemon: tighten systemd duplicate gateway detection (#15849)

* fix three issues from PR review

* fix windows unit tests due to posix/windows path differences
* ensure line continuations are handled in systemd units
* fix misleading test name

* attempt fix windows test due to fs path separator

* fix system_dir separator, fix platform side-effect

* change approach for mocking systemd filesystem test

* normalize systemd paths to linux style

* revert to vers that didnt impact win32 tests

* back out all systemd inspect tests

* change test approach to avoid other tests issues

* fix: tighten systemd duplicate gateway detection (#45328) (thanks @gregretkowski)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-26 09:20:10 +05:30
wangchunyue
ebad7490b4 fix: resolve telegram token fallback for binding-created accounts (#54362) (thanks @openperf)
* fix(telegram): resolve channel-level token fallthrough for binding-created accountIds

Fixes #53876

* fix(telegram): align isConfigured with resolveTelegramToken multi-bot guard

* fix(telegram): use normalized account lookup and require available token
2026-03-26 09:16:15 +05:30
Marcus Castro
bc1c308383 fix(whatsapp): clarify allowFrom policy error (#54850) 2026-03-26 00:44:10 -03:00
Tak Hoffman
5b68e52894 ci: collapse preflight manifest routing (#54773)
* ci: collapse preflight manifest routing

* ci: fix preflight workflow outputs

* ci: restore compat workflow tasks

* ci: match macos shards to windows

* ci: collapse macos swift jobs

* ci: skip empty submodule setup

* ci: drop submodule setup from node env
2026-03-25 22:38:30 -05:00
Ted Li
4f297a094a docs: add WeChat channel via official Tencent iLink Bot plugin (#52131) (thanks @MonkeyLeeT)
* docs: add WeChat channel via official Tencent iLink Bot plugin

Add WeChat to the README channel lists and setup section.

Uses the official Tencent-published plugin @tencent-weixin/openclaw-weixin
which connects via the iLink Bot API (QR code login, long-poll).
Requires WeChat 8.0.70+ with the ClawBot plugin enabled; the plugin
is being rolled out gradually by Tencent.

Covers: setup steps, capabilities (DM-only, media up to 100 MB,
multi-account, pairing authorization, typing indicators, config path),
and the context token restart caveat.

* docs: update WeChat plugin install for v2.0 compatibility

- Add version compatibility note (v2.x requires OpenClaw >= 2026.3.22,
  @legacy tag for older hosts)
- Add plugins.allow step (required since plugins.allow was introduced)

* docs: drop manual plugins.allow/enable steps (handled by plugins install)

* docs: fix multi-account instruction to require explicit --account id

* docs: trim WeChat section to match neighboring channels, fix pairing link

* docs: sync WeChat channel docs

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-26 09:07:01 +05:30
Frank the Builder
74ed75f2e7 fix: deliver verbose tool summaries in Telegram forum topics (#43236) (thanks @frankbuild)
* fix(auto-reply): deliver verbose tool summaries in Telegram forum topics

Forum topics have ChatType 'group' but are threaded conversations where
verbose tool output should be delivered (same as DMs). The
shouldSendToolSummaries gate now checks IsForum to allow tool summaries
in forum topic sessions.

Fixes #43206

* test: add sendToolResult count assertion per review feedback

* fix: add changelog for forum topic verbose tool summaries (#43236) (thanks @frankbuild)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-26 09:04:55 +05:30
xieyongliang
4cb8dde894 feat: add video generation core infrastructure and extend image generation parameters (#53681)
* feat: add video generation core infrastructure and extend image generation parameters

Add full video generation capability to OpenClaw core:

- New `video_generate` agent tool with support for prompt, duration, aspect ratio,
  resolution, seed, watermark, I2V (first/last frame), camerafixed, and draft mode
- New `VideoGenerationProvider` plugin SDK type and `registerVideoGenerationProvider` API
- New `src/video-generation/` module (types, runtime with fallback, provider registry)
- New `openclaw/plugin-sdk/video-generation` export for external plugins
- 200MB max file size for generated videos (vs default 5MB for images)

Extend image generation with additional parameters:
- `seed`, `watermark`, `guidanceScale`, `optimizePrompt`, `providerOptions`
- New `readBooleanParam()` helper in tool common utilities

Update plugin registry, contracts, and all test mocks to include
`videoGenerationProviders` and `videoGenerationProviderIds`.

Made-with: Cursor

* fix: validate aspect ratio against target provider when model override is set

* cleanup: remove redundant ?? undefined from video/image generate tools

* chore: regenerate plugin SDK API baseline after video generation additions

---------

Co-authored-by: yongliang.xie <yongliang.xie@bytedance.com>
2026-03-25 18:45:06 -07:00
Mathias Nagler
39fbfd9b28 fix(mattermost): thread resolved cfg through reply delivery send calls (#48347)
Merged via squash.

Prepared head SHA: 7ca468e365
Co-authored-by: mathiasnagler <9951231+mathiasnagler@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
2026-03-26 01:31:12 +00:00
gumclaw
208ff68298 fix: allow msteams feedback and welcome config keys (#54679)
Merged via squash.

Prepared head SHA: f56a15ddea
Co-authored-by: gumclaw <265388744+gumclaw@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-26 03:00:52 +03:00
Devin Robison
81ebc7e034 fix(gateway): block silent reconnect scope-upgrade escalation (#54694)
* fix(gateway): block silent reconnect scope-upgrade escalation

* formatting updateas

* Resolve feedback

* formatting fixes

* Update src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Feedback updates

* fix unit test

* Feedback update

* Review feedback update

* More Greptile nit fixes

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-25 17:54:14 -06:00
adzendo
19d91aaa8f fix: make buttons schema optional in message tool (#54418)
Merged via squash.

Prepared head SHA: 0805c095e9
Co-authored-by: adzendo <246828680+adzendo@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-26 02:43:15 +03:00
Erhhung Yuan
b6f631e045 fix(schema): tools.web.fetch.maxResponseBytes #53397 (#53401)
Merged via squash.

Prepared head SHA: 5d10a98bdb
Co-authored-by: erhhung <5808864+erhhung@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-26 02:40:00 +03:00
Mikhail Beliakov
fd934a566b feat(cli): add json schema to cli tool (#54523)
Merged via squash.

Prepared head SHA: 39c15ee70d
Co-authored-by: kvokka <15954013+kvokka@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-26 02:30:32 +03:00
Tak Hoffman
ab37d8810d test: introduce planner-backed test runner, stabilize local builds (#54650)
* test: stabilize ci and local vitest workers

* test: introduce planner-backed test runner

* test: address planner review follow-ups

* test: derive planner budgets from host capabilities

* test: restore planner filter helper import

* test: align planner explain output with execution

* test: keep low profile as serial alias

* test: restrict explicit planner file targets

* test: clean planner exits and pnpm launch

* test: tighten wrapper flag validation

* ci: gate heavy fanout on check

* test: key shard assignments by unit identity

* ci(bun): shard vitest lanes further

* test: restore ci overlap and stabilize planner tests

* test: relax planner output worker assertions

* test: reset plugin runtime state in optional tools suite

* ci: split macos node and swift jobs

* test: honor no-isolate top-level concurrency budgets

* ci: fix macos swift format lint

* test: cap max-profile top-level concurrency

* ci: shard macos node checks

* ci: use four macos node shards

* test: normalize explain targets before classification
2026-03-25 18:11:58 -05:00
Devin Robison
764394c78b fix: enforce localRoots sandbox on Feishu docx upload file reads (#54693)
* fix: enforce localRoots sandbox on Feishu docx upload file reads

* Formatting fixes

* Update tests

* Feedback updates
2026-03-25 16:09:00 -06:00
Devin Robison
6a79324802 Filter untrusted CWD .env entries before OpenClaw startup (#54631)
* Filter untrusted CWD .env entries before OpenClaw startup

* Add missing test file

* Fix missing and updated files

* Address feedback

* Feedback updates

* Feedback update

* Add test coverage

* Unit test fix
2026-03-25 15:49:26 -06:00
Tak Hoffman
79fbcfc03b fix(ci): restore main green 2026-03-25 16:17:42 -05:00
Nimrod Gutman
501190d2e8 refactor(sandbox): remove tool policy facade (#54684)
* refactor(sandbox): remove tool policy facade

* fix(sandbox): harden blocked-tool guidance

* fix(sandbox): avoid control-char guidance leaks

* fix: harden sandbox blocked-tool guidance (#54684) (thanks @ngutman)
2026-03-25 23:03:24 +02:00
Jared
c6d8318d07 Trigger preflight compaction from transcript estimates when usage is stale (#49479)
Merged via squash.

Prepared head SHA: 8d214b708b
Co-authored-by: jared596 <37019497+jared596@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-25 13:22:16 -07:00
Jacob Tomlinson
c02ee8a3a4 OpenShell: exclude hooks/ from mirror sync (#54657)
* OpenShell: exclude hooks/ from mirror sync

* OpenShell: make excludeDirs case-insensitive for cross-platform safety
2026-03-25 19:59:07 +00:00
Jacob Tomlinson
d1bfe08424 fix: apply host-env blocklist to auth-profile env refs in daemon install (#54627)
* fix: apply host-env blocklist to auth-profile env refs in daemon install

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ci: retrigger checks

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:57:22 +00:00
Jacob Tomlinson
e34694733f fix(talk-voice): enforce operator.admin scope on /voice set config writes (#54461)
* fix(talk-voice): enforce operator.admin scope on /voice set config writes

* fix(talk-voice): align scope guard with phone-control pattern

Use optional chaining (?.) instead of Array.isArray so webchat callers
with undefined scopes are rejected, matching the established pattern in
phone-control. Add test for webchat-with-no-scopes case.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:55:26 +00:00
Joseph Krug
d81593c6e2 fix: trigger compaction on LLM timeout with high context usage (#46417)
Merged via squash.

Prepared head SHA: 619bc4c1fa
Co-authored-by: joeykrug <5925937+joeykrug@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-25 12:51:36 -07:00
Devin Robison
1b3a1246d0 Block reset-profile on lower-privilege browser request surfaces (#54618)
* Block reset-profile on lower-privilege browser request surfaces

* add missing tests

* Fix tests

* Test fix
2026-03-25 13:36:59 -06:00
Devin Robison
4797bbc5b9 fix: reject path traversal and home-dir patterns in media parse layer (#54642)
* fix: reject path traversal and home-dir patterns in media parse layer

* Update parse tests
2026-03-25 13:35:16 -06:00
kiranvk2011
84401223c7 fix: per-model cooldown scope, stepped backoff, and user-facing rate-limit message (#49834)
Merged via squash.

Prepared head SHA: 7c488c070c
Co-authored-by: kiranvk-2011 <91108465+kiranvk-2011@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-25 22:03:49 +03:00
Tak Hoffman
6efc4e8ef2 test: fix windows tmp root assertions 2026-03-25 13:44:54 -05:00
Devin Robison
b7d70ade3b Fix/telegram writeback admin scope gate (#54561)
* fix(telegram): require operator.admin for legacy target writeback persistence

* Address claude feedback

* Update extensions/telegram/src/target-writeback.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Remove stray brace

* Add updated docs

* Add missing test file, address codex concerns

* Fix test formatting error

* Address comments, fix tests

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-25 12:12:09 -06:00
Andrii Furmanets
89c4c674d1 fix(compaction): surface safeguard cancel reasons and clarify /compact skips (#51072)
Merged via squash.

Prepared head SHA: f1dbef0443
Co-authored-by: afurm <6375192+afurm@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-25 11:03:22 -07:00
M1a0
7847e67f8a plugin-runtime: expose runHeartbeatOnce in system API (#40299)
* plugin-runtime: expose runHeartbeatOnce in system API

Plugins that enqueue system events and need the agent to deliver
responses to the originating channel currently have no way to
override the default `heartbeat.target: "none"` behaviour.

Expose `runHeartbeatOnce` in the plugin runtime `system` namespace
so plugins can trigger a single heartbeat cycle with an explicit
`heartbeat: { target: "last" }` override — the same pattern the
cron service already uses (see #28508).

Changes:
- Add `RunHeartbeatOnceOptions` type and `runHeartbeatOnce` to
  `PluginRuntimeCore.system` (types-core.ts)
- Wire the function through a thin wrapper in runtime-system.ts
- Update the test-utils plugin-runtime mock

Made-with: Cursor

* feat(plugins): expose runHeartbeatOnce in system API (#40299) (thanks @loveyana)

---------

Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-03-25 10:47:01 -07:00
chenxingzhen
4ae4d1fabe fix: mid-turn 429 rate limit silent no-reply and context engine registration failure (#50930)
Merged via squash.

Prepared head SHA: eea7800df3
Co-authored-by: infichen <13826604+infichen@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-25 10:43:08 -07:00
Matt Van Horn
e0972db7a2 fix: stop leaking reply tags in iMessage outbound text (#39512) (thanks @mvanhorn)
* fix: stop leaking reply tags in iMessage outbound text (#39512) (thanks @mvanhorn)

* fix: preserve iMessage outbound whitespace without directive tags (#39512) (thanks @mvanhorn)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-25 23:00:16 +05:30
Tak Hoffman
f63c4b0856 test: keep vitest on forks only 2026-03-25 12:22:22 -05:00
Harold Hunt
055ad65896 Telegram: ignore self-authored DM message updates (#54530)
Merged via squash.

Prepared head SHA: c1c8a85168
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-25 13:16:35 -04:00
Peter Steinberger
685f17460d build: update appcast for 2026.3.24 2026-03-25 10:10:34 -07:00
Jackal Xin
2de32fbf14 fix: reconcile session compaction count after late compaction success (#45493)
Merged via squash.

Prepared head SHA: d0715a5555
Co-authored-by: jackal092927 <3854860+jackal092927@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-25 10:00:41 -07:00
Peter Steinberger
cff6dc94e3 docs: format changelog for release 2026-03-25 09:34:30 -07:00
Peter Steinberger
97a7e93db4 build: prepare 2026.3.24 release 2026-03-25 09:31:05 -07:00
liyuan97
e2e9f979ca feat(minimax): add image generation provider and trim model catalog to M2.7 (#54487)
* feat(minimax): add image generation and TTS providers, trim TUI model list

Register MiniMax image-01 and speech-2.8 models as plugin providers for
the image_generate and TTS tools. Both resolve CN/global base URLs from
the configured model endpoint origin.

- Image generation: base64 response, aspect-ratio support, image-to-image
  via subject_reference, registered for minimax and minimax-portal
- TTS: speech-2.8-turbo (default) and speech-2.8-hd, hex-encoded audio,
  voice listing via get_voice API, telephony PCM support
- Add MiniMax to TTS auto-detection cascade (after ElevenLabs, before
  Microsoft) and TTS config section
- Remove MiniMax-VL-01, M2, M2.1, M2.5 and variants from TUI picker;
  keep M2.7 and M2.7-highspeed only (backend routing unchanged)

* feat(minimax): trim legacy model catalog to M2.7 only

Cherry-picked from temp/feat/minimax-trim-legacy-models (949ed28).
Removes MiniMax-VL-01, M2, M2.1, M2.5 and variants from the model
catalog, model order, modern model matchers, OAuth config, docs, and
tests. Keeps only M2.7 and M2.7-highspeed.

Conflicts resolved:
- provider-catalog.ts: removed MINIMAX_TUI_MODELS filter (no longer
  needed since source array is now M2.7-only)
- index.ts: kept image generation + speech provider registrations
  (added by this branch), moved media understanding registrations
  earlier (as intended by the cherry-picked commit)

* fix(minimax): update discovery contract test to reflect M2.7-only catalog

Cherry-picked from temp/feat/minimax-trim-legacy-models (2c750cb).

* feat(minimax): add web search provider and register in plugin entry

* fix(minimax): resolve OAuth credentials for TTS speech provider

* MiniMax: remove web search and TTS providers

* fix(minimax): throw on empty images array after generation failure

* feat(minimax): add image generation provider and trim catalog to M2.7 (#54487) (thanks @liyuan97)

---------

Co-authored-by: tars90percent <tars@minimaxi.com>
Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-03-25 09:29:35 -07:00
xieyongliang
7cc86e9685 fix(release): add plugin-sdk:check-exports to release:check (#54283)
* fix(plugins): resolve sdk alias from import.meta.url for external plugins

When a plugin is installed outside the openclaw package (e.g.
~/.openclaw/extensions/), resolveLoaderPluginSdkPackageRoot() fails to
locate the openclaw root via cwd or argv1 hints, resulting in an empty
alias map. Jiti then cannot resolve openclaw/plugin-sdk/* imports and
the plugin fails to load with "Cannot find module".

Since sdk-alias.ts is always compiled into the openclaw package itself,
import.meta.url reliably points inside the installation directory. Add it
as an unconditional fallback in resolveLoaderPluginSdkPackageRoot() so
external plugins can always resolve the plugin SDK.

Fixes: Error: Cannot find module 'openclaw/plugin-sdk/plugin-entry'

* fix(plugins): pass loader moduleUrl to resolve sdk alias for external plugins

The previous approach of adding import.meta.url as an unconditional
fallback inside resolveLoaderPluginSdkPackageRoot() broke test isolation:
tests that expected null from untrusted fixtures started finding the real
openclaw root. Revert that and instead thread an optional moduleUrl through
buildPluginLoaderAliasMap → resolvePluginSdkScopedAliasMap →
listPluginSdkExportedSubpaths → resolveLoaderPluginSdkPackageRoot.

loader.ts passes its own import.meta.url as the hint, which is always
inside the openclaw installation. This guarantees the sdk alias map is
built correctly even when argv1 does not resolve to the openclaw root
(e.g. single-binary distributions, custom launchers, or Docker images
where the binary wrapper is not a standard npm symlink).

Tests that call sdk-alias helpers directly without moduleUrl are
unaffected and continue to enforce the existing isolation semantics.
A new test covers the moduleUrl resolution path explicitly.

* fix(plugins): use existing fixture file for moduleUrl hint in test

The previous test pointed loaderModuleUrl to dist/plugins/loader.js
which is not created by createPluginSdkAliasFixture, causing resolution
to fall back to the real openclaw root instead of the fixture root.
Use fixture.root/openclaw.mjs (created by the bin+marker fixture) so
the moduleUrl hint reliably resolves to the fixture package root.

* fix(test): use fixture.root as cwd in external plugin alias test

When process.cwd() is mocked to the external plugin dir, the
findNearestPluginSdkPackageRoot(process.cwd()) fallback resolves to
the real openclaw repo root in the CI test runner, making the test
resolve the wrong aliases. Using fixture.root as cwd ensures all
resolution paths consistently point to the fixture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(release): add plugin-sdk:check-exports to release:check

plugin-sdk subpath exports (e.g. openclaw/plugin-sdk/plugin-entry,
openclaw/plugin-sdk/provider-auth) were missing from the published
package.json, causing external plugins to fail at load time with
'Cannot find module openclaw/plugin-sdk/plugin-entry'.

Root cause: sync-plugin-sdk-exports.mjs syncs plugin-sdk-entrypoints.json
into package.json exports, but this sync was never validated in the
release:check pipeline. As a result, any drift between
plugin-sdk-entrypoints.json and the published package.json goes
undetected until users hit the runtime error.

Fix: add plugin-sdk:check-exports to release:check so the CI gate
fails loudly if the exports are out of sync before publishing.

* fix(test): isolate moduleUrl hint test from process.cwd() fallback

Use externalPluginRoot as cwd instead of fixture.root, so only the
moduleUrl hint can resolve the openclaw package root. Previously,
withCwd(fixture.root) allowed the process.cwd() fallback to also
resolve the fixture root, making the moduleUrl path untested.

Spotted by greptile-apps review on #54283.

* fix(test): use empty string to disable argv1 in moduleUrl hint test

Passing undefined for argv1 in buildPluginLoaderAliasMap triggers the
STARTUP_ARGV1 default (process.argv[1], the vitest runner binary inside
the openclaw repo). resolveTrustedOpenClawRootFromArgvHint then resolves
to the real openclaw root before the moduleUrl hint is checked, making
the test resolve wrong aliases.

Pass "" instead: falsy so the hint is skipped, but does not trigger the
default parameter value. Only the moduleUrl can bridge the gap.

Made-with: Cursor

* fix(plugins): thread moduleUrl through SDK alias resolution for external plugins (#54283) Thanks @xieyongliang

---------

Co-authored-by: bojsun <bojie.sun@bytedance.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Jerry <jerry@JerrydeMacBook-Air-2.local>
Co-authored-by: yongliang.xie <yongliang.xie@bytedance.com>
Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-03-25 09:11:17 -07:00
Devin Robison
c2a2edb329 Fix local copied package installs honoring staged project .npmrc (#54543) 2026-03-25 09:59:33 -06:00
Lin Z
a0b9dc0078 fix(feishu): use message create_time for inbound timestamps (#52809)
* fix(feishu): use message create_time instead of Date.now() for Timestamp field

When a message is sent offline and later retried by the Feishu client
upon reconnection, Date.now() captures the *delivery* time rather than
the *authoring* time.  This causes downstream consumers to see a
timestamp that can be minutes or hours after the user actually composed
the message, leading to incorrect temporal semantics — for example, a
"delete this" command may target the wrong resource because the agent
believes the instruction was issued much later than it actually was.

Replace every Date.now() used for message timestamps with the original
create_time from the Feishu event payload (millisecond-epoch string),
falling back to Date.now() only when the field is absent.  The
definition is also hoisted to the top of handleFeishuMessage so that
both the pending-history path and the main inbound-payload path share
the same authoritative value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(feishu): verify Timestamp uses message create_time

Add two test cases:
1. When create_time is present, Timestamp must equal the parsed value
2. When create_time is absent, Timestamp falls back to Date.now()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: revert unrelated formatting change to lifecycle.test.ts

This file was inadvertently formatted in a prior commit. Reverting to
match main and keep the PR scoped to the Feishu timestamp fix only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(feishu): use message create_time for inbound timestamps (#52809) (thanks @schumilin)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-03-25 08:36:12 -07:00
Lin Z
bd4237c16c fix(feishu): close WebSocket connections on monitor stop (#52844)
* fix(feishu): close WebSocket connections on monitor stop/abort

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(feishu): add WebSocket cleanup tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(feishu): close WebSocket connections on monitor stop (#52844) (thanks @schumilin)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-03-25 08:32:21 -07:00
Nimrod Gutman
edb5123f26 fix(sandbox): honor sandbox alsoAllow and explicit re-allows (#54492)
* fix(sandbox): honor effective sandbox alsoAllow policy

* fix(sandbox): prefer resolved sandbox context policy

* fix: honor sandbox alsoAllow policy (#54492) (thanks @ngutman)
2026-03-25 16:51:13 +02:00
Peter Steinberger
e9ac2860c1 docs: prepare 2026.3.24-beta.2 release 2026-03-25 06:58:39 -07:00
Harold Hunt
da60aff17a Tests: isolate security audit home skill resolution (#54473)
Merged via squash.

Prepared head SHA: 82181e15fb
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-25 09:43:19 -04:00
Peter Steinberger
ee714f5a42 test(media): make local roots fixture windows-safe 2026-03-25 06:24:39 -07:00
Peter Steinberger
ea08f2eb8c fix(runtime): support Node 22.14 installs 2026-03-25 06:22:18 -07:00
Harold Hunt
3c3fd8c386 Discord: log rejected native command deploy failures (#54118)
Merged via squash.

Prepared head SHA: be250f9620
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-25 09:19:46 -04:00
Peter Steinberger
436aa838fe test(release): sync llama peer fixture 2026-03-25 06:06:47 -07:00
Peter Steinberger
284084672a fix(ci): restore e2e docker cache boundary 2026-03-25 06:06:47 -07:00
Peter Steinberger
66c88b4c77 fix(update): preflight npm target node engine 2026-03-25 06:01:20 -07:00
Peter Steinberger
c92002e1de fix(media): align outbound media access with fs policy 2026-03-25 05:50:21 -07:00
Peter Steinberger
39ad51426c test: add Open WebUI docker smoke 2026-03-25 05:28:51 -07:00
Peter Steinberger
9e95125f06 fix(config): ignore same-base correction publish warnings 2026-03-25 04:58:44 -07:00
Peter Steinberger
b19cc399b6 test: fix clobbered config snapshot expectation 2026-03-25 04:54:37 -07:00
Peter Steinberger
3b6d980c52 refactor: unify whatsapp identity handling 2026-03-25 04:46:24 -07:00
Peter Steinberger
cdba1e6771 fix: copy openclaw bin before docker install 2026-03-25 04:45:31 -07:00
Peter Steinberger
d874f3970a build: prepare 2026.3.24-beta.1 2026-03-25 04:41:26 -07:00
Peter Steinberger
7c2790cec4 test: isolate voice-call temp stores 2026-03-25 11:39:47 +00:00
Peter Steinberger
c3d1dbc696 refactor(openai): extract codex auth identity helper 2026-03-25 04:24:46 -07:00
Peter Steinberger
d363af8c13 refactor(auth): separate profile ids from email metadata 2026-03-25 04:24:46 -07:00
khhjoe
f3fe019e3d fix(whatsapp): use async fs.promises.readFile for selfLid creds read 2026-03-25 04:24:31 -07:00
khhjoe
770a5ee5b1 fix(whatsapp): read selfLid from creds.json for reply-to-bot detection 2026-03-25 04:24:31 -07:00
khhjoe
93594a1440 fix(whatsapp): compare selfLid for reply-to-bot implicit mention in groups 2026-03-25 04:24:31 -07:00
khhjoe
ff25407861 fix(whatsapp): unwrap FutureProofMessage (botInvokeMessage) to restore reply-to-bot detection 2026-03-25 04:24:31 -07:00
Peter Steinberger
52bec1612c test: collapse telegram transport and status suites 2026-03-25 11:23:18 +00:00
Peter Steinberger
12082f47bd test: collapse telegram button and access suites 2026-03-25 11:23:18 +00:00
Peter Steinberger
b7f2b0d7b9 refactor: align pairing replies, daemon hints, and feishu mention policy 2026-03-25 04:22:53 -07:00
Peter Steinberger
524004ff32 docs: add missing changelog items 2026-03-25 04:22:23 -07:00
Peter Steinberger
3de04bdd6d test: collapse telegram context and transport suites 2026-03-25 11:17:58 +00:00
Peter Steinberger
fc49258c12 test: collapse telegram helper suites 2026-03-25 11:17:58 +00:00
Peter Steinberger
9873ef0e39 docs: sort changelog by user impact 2026-03-25 04:14:52 -07:00
Peter Steinberger
94041f06b4 test: harden parallels npm update runner 2026-03-25 11:13:09 +00:00
Ayaan Zaidi
b497f3cda0 fix: normalize before_dispatch conversation id 2026-03-25 16:28:31 +05:30
Ayaan Zaidi
15776091a8 fix(whatsapp): avoid eager login tool runtime access 2026-03-25 16:25:00 +05:30
ZhangXuan
a10d587b41 fix: preserve before_dispatch delivery semantics (#50444) (thanks @gfzhx)
* Plugins: add before_dispatch hook

* Tests: fix before_dispatch hook mock typing

* Rebase: adapt before_dispatch hook to routeReplyRuntime refactor

* fix: preserve before_dispatch delivery semantics (#50444) (thanks @gfzhx)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-25 16:16:08 +05:30
Ayaan Zaidi
765182dcc6 fix: skip session:patch hook clone without listeners 2026-03-25 16:12:39 +05:30
Ayaan Zaidi
ee0dcaa7b0 fix: unify log timestamp offsets (#38904) (thanks @sahilsatralkar) 2026-03-25 16:06:33 +05:30
Gracie Gould
3e2e9bc238 fix: isolate session:patch hook payload (#53880) (thanks @graciegould)
* gateway: make session:patch hook typed and non-blocking

* gateway(test): add session:patch hook coverage

* docs(gateway): clarify session:patch security note

* fix: address review feedback on session:patch hook

Remove unused createInternalHookEvent import and fix doc example
to use inline event.type check matching existing hook examples.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: isolate hook payload to prevent mutation leaking into response

Shallow-copy sessionEntry and patch in the session:patch hook event
so fire-and-forget handlers cannot mutate objects used by the
response path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: isolate session:patch hook payload (#53880) (thanks @graciegould)

---------

Co-authored-by: “graciegould” <“graciegould5@gmail.com”>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-25 15:59:38 +05:30
Liu Yuan
419824729a fix: fail loud when PTY cursor mode is unknown (#51490) (thanks @liuy)
* fix(process): auto-detect PTY cursor key mode for send-keys

When a PTY session sends smkx (\x1b[?1h) or rmkx (\x1b[?1l) to switch
cursor key mode, send-keys now detects this and encodes cursor keys
accordingly.

- smkx/rmkx detection in handleStdout before sanitizeBinaryOutput
- cursorKeyMode stored in ProcessSession
- encodeKeySequence accepts cursorKeyMode parameter
- DECCKM_SS3_KEYS for application mode (arrows + home/end)
- CSI sequences for normal mode
- Modified keys (including alt) always use xterm modifier scheme
- Extract detectCursorKeyMode for unit testing
- Use lastIndexOf to find last toggle in chunk (later one wins)

Fixes #51488

* fix: fail loud when PTY cursor mode is unknown (#51490) (thanks @liuy)

* style: format process send-keys guard (#51490) (thanks @liuy)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-25 15:51:27 +05:30
Ayaan Zaidi
717ff0d667 fix: cover macOS Edge osascript fallback path (#48561) (thanks @zoherghadyali) 2026-03-25 15:47:04 +05:30
Ayaan Zaidi
0295271f97 fix: add changelog for macOS Edge default browser detection (#48561) (thanks @zoherghadyali) 2026-03-25 15:47:04 +05:30
Zoher Ghadyali
2fe38b0201 fix(browser): add Edge LaunchServices bundle IDs for macOS default browser detection
macOS registers Edge as 'com.microsoft.edgemac' in LaunchServices, which
differs from the CFBundleIdentifier 'com.microsoft.Edge' in the app's own
Info.plist. Without recognising the LaunchServices IDs, Edge users who set
Edge as their default browser are not detected as having a Chromium browser.

Add the four com.microsoft.edgemac* variants to CHROMIUM_BUNDLE_IDS and a
corresponding test that mocks the LaunchServices → osascript resolution
path for Edge.
2026-03-25 15:47:04 +05:30
Sparkyrider
55dc6a8bb2 cron: queue isolated delivery awareness 2026-03-25 15:21:14 +05:30
Ayaan Zaidi
2a40612058 fix: make telegram thread create use topic payload (#54336) (thanks @andyliu) 2026-03-25 13:43:09 +05:30
Andy
e1cd90db6e fix(cli): route telegram thread create to topic-create 2026-03-25 13:43:09 +05:30
ToToKr
4140100807 fix: clarify cron best-effort partial delivery status (#42535) (thanks @MoerAI)
* fix(cron): track and log bestEffort delivery failures, mark not delivered on partial failure

* fix(cron): cache successful results on partial failure to preserve replay idempotency

When a best-effort send partially fails, we now still cache the successful delivery results via rememberCompletedDirectCronDelivery. This prevents duplicate sends on same-process replay while still correctly marking the job as not fully delivered.

* fix(cron): preserve partial-failure state on replay (#27069)

* fix(cron): restore test infrastructure and fix formatting

* fix: clarify cron best-effort partial delivery status (#42535) (thanks @MoerAI)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-25 12:49:32 +05:30
hnshah
c7f021f70f fix: preflight invalid telegram photos (#52545) (thanks @hnshah)
* fix(telegram): validate photo dimensions before sendPhoto

Prevents PHOTO_INVALID_DIMENSIONS errors by checking image dimensions
against Telegram Bot API requirements before calling sendPhoto.

If dimensions exceed limits (width + height > 10,000px), automatically
falls back to sending as document instead of crashing with 400 error.

Tested in production (openclaw 2026.3.13) where this error occurred:
  [telegram] tool reply failed: GrammyError: Call to 'sendPhoto' failed!
  (400: Bad Request: PHOTO_INVALID_DIMENSIONS)

Uses existing sharp dependency to read image metadata. Gracefully
degrades if sharp fails (lets Telegram handle validation, backward
compatible behavior).

Closes: #XXXXX (will reference OpenClaw issue if one exists)

* fix(telegram): validate photo aspect ratio

* refactor: use shared telegram image metadata

* fix: fail closed on telegram image metadata

* fix: preflight invalid telegram photos (#52545) (thanks @hnshah)

---------

Co-authored-by: Bob Shah <bobshah@Macs-Mac-Studio.local>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-25 12:00:20 +05:30
Peter Steinberger
b9857a2b79 test: allow daemon start hints to grow on linux (#54058) (thanks @byungsker) 2026-03-24 23:09:04 -07:00
Peter Steinberger
8b80690a1a test: accept fenced discord pairing codes (#54058) (thanks @byungsker) 2026-03-24 23:09:04 -07:00
Peter Steinberger
fac0a172e5 test: refresh pairing reply assertions for fenced codes (#54058) (thanks @byungsker) 2026-03-24 23:09:04 -07:00
Peter Steinberger
2566d6b300 fix: finish feishu open-group docs and baselines (#54058) (thanks @byungsker) 2026-03-24 23:09:04 -07:00
lbo728
a322059efa test(feishu): update config-schema test for removed requireMention default 2026-03-24 23:09:04 -07:00
lbo728
69195f7e9d fix(feishu): default requireMention to false for groupPolicy open
Groups configured with groupPolicy: open are expected to respond to all
messages. Previously, requireMention defaulted to true regardless of
groupPolicy, causing image (and other non-text) messages to be silently
dropped because they cannot carry @-mentions.

Fix: when groupPolicy is 'open' and requireMention is not explicitly
configured, resolve it to false instead of true. Users who want
mention-required behaviour in open groups can still set requireMention: true
explicitly.

Adds three regression tests covering the new default, explicit override, and
the unchanged allowlist-policy behaviour.

Closes #52553
2026-03-24 23:09:04 -07:00
VACInc
89b7fee352 fix: preserve Telegram forum topic last-route delivery (#53052) (thanks @VACInc)
* fix(telegram): preserve forum topic thread in last-route delivery

* style(telegram): format last-route update

* test(telegram): cover General topic last-route thread

* test(telegram): align topic route helper

* fix(telegram): skip bound-topic last-route writes

---------

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-25 11:31:01 +05:30
Peter Steinberger
1c82b06645 test: collapse msteams state and monitor suites 2026-03-25 05:57:02 +00:00
Peter Steinberger
e53809035e test: collapse msteams graph suites 2026-03-25 05:57:02 +00:00
Peter Steinberger
b99b521a92 test: collapse msteams helper suites 2026-03-25 05:57:02 +00:00
Peter Steinberger
f5408d82d2 refactor: unify gateway handshake timeout wiring 2026-03-24 22:53:55 -07:00
Peter Steinberger
258a214bcb refactor: centralize daemon service start state flow 2026-03-24 22:49:34 -07:00
Liren Pan
5dec3dddc4 style(auth): wrap codex fallback formatting 2026-03-24 22:49:06 -07:00
Liren Pan
773427470a test(auth): cover codex jwt fallback branches 2026-03-24 22:49:06 -07:00
Liren Pan
b6e70a5cdd auth: derive codex oauth profile ids from jwt claims 2026-03-24 22:49:06 -07:00
nimbleenigma
abec3ed645 fix: keep Telegram native commands on runtime snapshot (#53179) (thanks @nimbleenigma)
* fix(telegram): use runtime snapshot for native commands

* fix: keep Telegram native commands on runtime snapshot (#53179) (thanks @nimbleenigma)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-25 11:18:54 +05:30
Peter Steinberger
57e2223eec test: align pairing reply assertions 2026-03-25 05:48:31 +00:00
Peter Steinberger
6c3e767289 refactor: centralize Discord gateway supervision 2026-03-24 22:47:12 -07:00
Peter Steinberger
efafbece17 test: collapse nextcloud-talk send and helper suites 2026-03-25 05:39:11 +00:00
dobbylorenzbot
717ee2fa59 fix(gateway): raise default connect challenge timeout 2026-03-24 22:38:17 -07:00
HCL
db35f30005 fix: validate config before restart + derive loaded from real state
Address Codex P1 + Greptile P2:
- Move config validation before the restart attempt so invalid config
  is caught in the stop→start path (not just the already-loaded path)
- Derive service.loaded from actual isLoaded() after restart instead
  of hardcoded true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: HCL <chenglunhu@gmail.com>
2026-03-24 22:35:09 -07:00
HCL
d2248534d8 fix(daemon): bootstrap stopped service on gateway start
After `gateway stop` (which runs `launchctl bootout`), `gateway start`
checks `isLoaded` → false → prints "not loaded" hints and exits.
The service is never re-bootstrapped, so `start` cannot recover from
`stop` — only `gateway install` works.

Root cause: src/cli/daemon-cli/lifecycle-core.ts:208-217 — runServiceStart
calls handleServiceNotLoaded which only prints hints, never attempts
service.restart() (which already handles bootstrap via
bootstrapLaunchAgentOrThrow at launchd.ts:598).

Fix: when service is not loaded, attempt service.restart() first (which
handles re-bootstrapping on all platforms). If restart fails (e.g. plist
was deleted, not just booted out), fall back to the existing hints.

The restart path is already proven: restartLaunchAgent (launchd.ts:556)
handles "not loaded" via bootstrapLaunchAgentOrThrow. This fix routes
the start command through the same recovery path.

Closes #53878

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: HCL <chenglunhu@gmail.com>
2026-03-24 22:35:09 -07:00
Peter Steinberger
7467f304a7 test: collapse nextcloud-talk helper suites 2026-03-25 05:33:57 +00:00
Peter Steinberger
e8e45a4936 test: collapse synology-chat helper suites 2026-03-25 05:33:57 +00:00
Peter Steinberger
c22f3c514b test: collapse googlechat helper suites 2026-03-25 05:33:57 +00:00
SUMUKH
149c4683a3 fix: scope Telegram pairing code blocks (#52784) (thanks @sumukhj1219)
* Telegram: format pairing challenge for easier copy

* test: restore Telegram pairing chatId assertion

* fix: scope Telegram pairing code blocks (#52784) (thanks @sumukhj1219)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-25 11:03:33 +05:30
sfwn
9ab226d275 fix(discord): remove safety error listener after teardown to prevent leak 2026-03-24 22:31:03 -07:00
sfwn
731016472c fix(discord): prevent uncaught gateway errors from crashing the process
Move cleanup() after disconnect() in waitForDiscordGatewayStop so the
error listener is still active during disconnect. Add a safety error
listener in the lifecycle finally block to suppress late errors emitted
by Carbon during teardown.

Fixes the "Max reconnect attempts (0) reached after code 1006" uncaught
exception that kills the entire gateway process when a Discord WebSocket
drops and reconnection fails.
2026-03-24 22:31:03 -07:00
w-sss
247f82119c fix: improve Telegram 403 membership delivery errors (#53635) (thanks @w-sss)
* fix(telegram): improve error messages for 403 bot not member errors

- Detect 403 'bot is not a member' errors specifically
- Provide actionable guidance for users to fix the issue
- Fixes #48273 where outbound sendMessage fails with 403

Root cause:
When a Telegram bot tries to send a message to a channel/group it's not
a member of, the API returns 403 'bot is not a member of the channel chat'.
The error message was not clear about how to fix this.

Fix:
1. Detect 403 errors in wrapTelegramChatNotFoundError
2. Provide clear error message explaining the issue
3. Suggest adding the bot to the channel/group

* fix(telegram): fix regex precedence for 403 error detection

- Group alternatives correctly: /403.*(bot.*not.*member|bot was blocked)/i
- Require 403 for both alternatives (previously bot.*blocked matched any error)
- Update error message to cover both scenarios
- Fixes Greptile review feedback

* fix(telegram): correct regex alternation precedence for 403 errors

- Fix: /403.*(bot.*not.*member|bot was blocked)/ → /403.*(bot.*not.*member|bot.*blocked)/
- Ensures 403 requirement applies to both alternatives
- Fixes Greptile review comment on PR #48650

* fix(telegram): add 'bot was kicked' to 403 error regex and message

* fix(telegram): preserve membership delivery errors

* fix: improve Telegram 403 membership delivery errors (#53635) (thanks @w-sss)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-25 10:59:29 +05:30
w-sss
0bdb8ac7ad fix(ui): add width/height to context-notice SVG icon
- Fixes #47924
- Prevents SVG icon from expanding and covering entire chat window
- Adds explicit 24x24px dimensions to context-notice__icon SVG

Root cause:
The SVG element lacked explicit width and height attributes,
causing it to expand to fill the parent container when the context
usage warning appears (at ~85% token limit).
2026-03-25 00:25:55 -05:00
Peter Steinberger
33d31e2b0d test: collapse imessage test suites 2026-03-25 05:21:16 +00:00
Peter Steinberger
bc8622c659 test: collapse helper extension test suites 2026-03-25 05:21:16 +00:00
Peter Steinberger
6f137fff76 test: collapse telegram and whatsapp target suites 2026-03-25 05:21:16 +00:00
Ayaan Zaidi
793b36c5d2 fix: bootstrap proxy for LanceDB embeddings (#54119) (thanks @neeravmakwana) 2026-03-25 10:50:00 +05:30
Neerav Makwana
1a815e323c test(memory): unmock infra runtime cleanup 2026-03-25 10:50:00 +05:30
Neerav Makwana
09a4453026 fix(memory): bootstrap proxy for LanceDB embeddings 2026-03-25 10:50:00 +05:30
Jealous
2c3cf4f387 chore(tts): rename VOICE_BUBBLE identifiers to OPUS and update docs 2026-03-25 10:49:21 +05:30
Peter Steinberger
46d3617d25 refactor: split gateway plugin bootstrap and registry surfaces 2026-03-24 22:16:26 -07:00
Josh Avant
10161c2d79 Plugins: enforce terminal hook decision semantics for tool/message guards (#54241)
* Plugins: enforce terminal hook decision policies

* Tests: assert terminal hook behavior in integration paths

* Docs: clarify terminal hook decision semantics

* Docs: add hook guard semantics to plugin guides

* Tests: isolate outbound format label expectations

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-25 00:11:13 -05:00
dongdong
5a5c5d4cde fix: refresh DeepSeek pricing to current V3.2 rates (#54143) (thanks @arkyu2077)
* fix: add actual DeepSeek API pricing to model catalog

Replace zero-cost placeholder with real pricing from DeepSeek docs:
- deepseek-chat (V3): /bin/bash.27/1M input, .10/1M output, /bin/bash.07 cache read
- deepseek-reasoner (R1): /bin/bash.55/1M input, .19/1M output, /bin/bash.14 cache read

Fixes #54134

* fix: refresh DeepSeek pricing to current V3.2 rates

* fix: refresh DeepSeek pricing to current V3.2 rates (#54143) (thanks @arkyu2077)

---------

Co-authored-by: Jasmine Zhang <jasminezhang@192.168.1.75>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-25 10:34:03 +05:30
Quinn H.
d43dda465d fix: note marketplace streaming and ClawHub URL (#54160) (thanks @QuinnH496)
* fix: correct ClawHub URL in system prompt and use streaming download in marketplace

- Fix #54154: Change clawhub.com to clawhub.ai in system prompt
- Fix #54156: Replace arrayBuffer() with streaming pipeline for marketplace
  plugin downloads to avoid OOM on memory-constrained devices

* fix: guard marketplace archive stream body

* fix: note marketplace streaming and ClawHub URL (#54160) (thanks @QuinnH496)

---------

Co-authored-by: Li Enying <li.enying@openclaw.ai>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-25 10:29:21 +05:30
Peter Steinberger
61dd61e917 refactor: tighten split-runtime live-state guardrails 2026-03-24 21:58:50 -07:00
Peter Steinberger
94425764a8 refactor: centralize systemd unavailable classification 2026-03-24 21:57:48 -07:00
Jonathan Jing
30e80fb947 fix: isolate channel startup failures (#54215) (thanks @JonathanJing)
* fix(gateway): isolate channel startup failures to prevent cascade

When one channel (e.g., WhatsApp) fails to start due to missing runtime
modules, it should not block other channels (e.g., Discord) from starting.

Changes:
- Use Promise.allSettled to start channels concurrently
- Catch individual channel startup errors without affecting others
- Add startup summary logging for observability

Before: Sequential await startChannel() - if one throws, subsequent
channels never start.

After: Concurrent startup with per-channel error handling - all channels
attempt to start, failures are logged but don't cascade.

Fixes: P0 - WhatsApp runtime exception no longer blocks Discord startup

* fix(gateway): keep channel startup isolation sequential

* fix: isolate channel startup failures (#54215) (thanks @JonathanJing)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-25 10:22:42 +05:30
Peter Steinberger
8a463e7aa9 test: collapse helper plugin test suites 2026-03-25 04:52:36 +00:00
Peter Steinberger
fe84148724 test: collapse messaging target test suites 2026-03-25 04:52:36 +00:00
Peter Steinberger
6e050808ef test: collapse channel setup test suites 2026-03-25 04:52:36 +00:00
Sally O'Malley
e5d0d810e1 fixes for cli-containerized (#54223)
Signed-off-by: sallyom <somalley@redhat.com>
2026-03-25 00:51:55 -04:00
VACInc
1c9f62fad3 fix(gateway): restart sentinel wakes session after restart and preserves thread routing (#53940) thanks @VACInc
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-03-24 23:47:21 -05:00
Peter Steinberger
23a4932997 refactor: share channel card selectors and layout 2026-03-24 21:44:28 -07:00
kevinten10
c00372e559 fix(agents): correct ClawHub URL in system prompt
Change clawhub.com to clawhub.ai in agent system prompt.
The .com domain is incorrect and doesn't point to the real ClawHub.

Fixes #54154
2026-03-25 10:10:37 +05:30
Peter Steinberger
039e87c942 fix: restore WhatsApp active listener singleton (#54232) 2026-03-24 21:36:20 -07:00
chocobo9
762fed1f90 fix(daemon): add headless server hints to systemd unavailable error
Add loginctl enable-linger and XDG_RUNTIME_DIR recovery hints to the
generic (non-WSL) systemd unavailable error path, helping users on
SSH/headless servers diagnose and fix the issue without a desktop
session.

Fixes #11805

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:33:18 -07:00
Ayaan Zaidi
2aaea9f99e chore: drop unrelated gitignore change (#53944) (thanks @affsantos) 2026-03-24 21:33:04 -07:00
Ayaan Zaidi
03dc287a29 fix: keep minimal gateway channel registry live (#53944) (thanks @affsantos) 2026-03-24 21:33:04 -07:00
Ayaan Zaidi
5eb6fdca6f fix(gateway): close runtime state on startup abort 2026-03-24 21:33:04 -07:00
Ayaan Zaidi
ef5e554def fix(gateway): invalidate channel caches on re-pin 2026-03-24 21:33:04 -07:00
affsantos
fae4492d92 fix: re-pin channel registry after deferred plugin reload
When preferSetupRuntimeForChannelPlugins is active, gateway boot performs
two plugin loads: a setup-runtime pass and a full reload after listen.
The initial pin captured the setup-entry snapshot. The deferred reload now
re-pins so getChannelPlugin() resolves against the full implementations.
2026-03-24 21:33:04 -07:00
affsantos
61d866838f fix: add inline comment clarifying dual-release scope
Address Greptile review: releasePluginRouteRegistry now releases both
HTTP-route and channel registry pins. Added comment for clarity.
2026-03-24 21:33:04 -07:00
affsantos
3a4c860798 fix(gateway): pin channel registry at startup to survive registry swaps
Channel plugin resolution fails with 'Channel is unavailable: <channel>'
after the active plugin registry is replaced at runtime. The root cause is
that getChannelPlugin() resolves against the live registry snapshot, which
is replaced when non-primary registry loads (e.g., config-schema reads)
call loadOpenClawPlugins(). If the replacement registry does not carry the
same channel entries, outbound message delivery and subagent announce
silently break.

This mirrors the existing pinActivePluginHttpRouteRegistry pattern: the
channel registry is pinned at gateway startup and released on shutdown.
Subsequent setActivePluginRegistry calls no longer evict the channel
snapshot, so getChannelPlugin() always resolves against the registry that
was active when the gateway booted.
2026-03-24 21:33:04 -07:00
ted
4d41b8664c fix(ui): return null when default account ID is stale instead of falling back to first account 2026-03-24 21:28:53 -07:00
ted
dc85235bf0 UI: derive channel configured state from default account 2026-03-24 21:28:53 -07:00
Peter Steinberger
43058c021e test: collapse setup and monitor channel suites 2026-03-25 04:25:02 +00:00
Peter Steinberger
cb76ba2406 test: collapse line channel suites 2026-03-25 04:25:02 +00:00
Peter Steinberger
ed9646516d test: collapse utility plugin suites 2026-03-25 04:25:02 +00:00
Peter Steinberger
410c2dba65 test: collapse provider plugin suites 2026-03-25 04:25:02 +00:00
fishking
6c04ce3092 fix(reasoning): guard model default reasoning when thinking active
- Add hasAgentReasoningDefault to reasoningExplicitlySet check
  This prevents model default from overriding agent's explicit "off"
- Restore !thinkingActive guard for model default fallback
  Prevents redundant Reasoning: output alongside internal thinking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 09:54:12 +05:30
fishking
b91374eb0d fix(reasoning): apply reasoningDefault independently of thinking level
The reasoningDefault was incorrectly skipped when thinking was active.
Thinking controls reasoning depth while reasoning controls visibility -
they should be independent settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 09:54:12 +05:30
Tak Hoffman
f48571bec6 fix: prefer freshest duplicate rows in session loads 2026-03-24 22:36:50 -05:00
Tak Hoffman
40f820ff7f fix: prefer freshest duplicate session rows in reads 2026-03-24 22:28:50 -05:00
Tak Hoffman
93656da672 test: make vitest config tests platform-aware 2026-03-24 22:23:23 -05:00
Tak Hoffman
f3eb620824 fix: refresh available tools when the session model changes (#54184) 2026-03-24 22:07:06 -05:00
Tak Hoffman
0c35ac4423 fix: prefer freshest transcript session owners 2026-03-24 21:58:53 -05:00
Tak Hoffman
64432f8e46 test: disable Vitest fs cache on Windows 2026-03-24 21:51:55 -05:00
Tak Hoffman
3c46e0307a fix: prefer deterministic transcript session keys 2026-03-24 21:30:54 -05:00
Tak Hoffman
7a7e4cd4c4 fix: prefer deterministic session usage targets 2026-03-24 21:21:57 -05:00
Tak Hoffman
df58b4f5fb fix: prefer deterministic session id resume targets 2026-03-24 21:18:40 -05:00
Tak Hoffman
9c7823350b feat: add /tools runtime availability view (#54088)
* test(memory): lock qmd status counts regression

* feat: make /tools show what the agent can use right now

* fix: sync web ui slash commands with the shared registry

* feat: add profile and unavailable counts to /tools

* refine: keep /tools focused on available tools

* fix: resolve /tools review regressions

* fix: honor model compat in /tools inventory

* fix: sync generated protocol models for /tools

* fix: restore canonical slash command names

* fix: avoid ci lint drift in google helper exports

* perf: stop computing unused /tools unavailable counts

* docs: clarify /tools runtime behavior
2026-03-24 21:09:51 -05:00
Tak Hoffman
fb04801ed7 fix: enforce sandbox visibility for session_status ids 2026-03-24 21:05:25 -05:00
Tak Hoffman
2c1d16e261 fix: drop spawned visibility list caps 2026-03-24 20:52:05 -05:00
Tak Hoffman
6651511e90 fix: verify exact spawned session visibility 2026-03-24 20:39:00 -05:00
Tak Hoffman
57fd0a9b23 fix: enforce spawned session visibility in key resolve 2026-03-24 20:26:29 -05:00
Tak Hoffman
154e14f18f fix: resolve exact session ids without fuzzy limits 2026-03-24 20:26:29 -05:00
Ted Li
5799322d9e Discord: resolve /think autocomplete from session model (#49176)
* Discord: use session model for /think autocomplete

* Discord: use cached session store in think autocomplete

* Discord: align think autocomplete with effective bound route

* Discord: fix think autocomplete route-resolution test mocks

* Discord: stabilize think autocomplete CI coverage

* Discord: gate think autocomplete context behind auth

* Discord: share slash auth for think autocomplete

* Discord: localize think autocomplete auth gate

* Discord: drop think autocomplete auth gating

* Discord: align native autocomplete auth and route readiness

* Discord: use effective route for model picker

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-24 20:13:11 -05:00
Vincent Koc
2069e124a9 chore(agents): normalize pi embedded runner imports 2026-03-24 18:06:24 -07:00
Vincent Koc
d10669629d feat(gateway): make openai compatibility agent-first 2026-03-24 18:06:24 -07:00
Vincent Koc
e1d16ba42e test(parallel): force unit-fast batch planning 2026-03-24 18:04:20 -07:00
Vincent Koc
8d87e85705 test(browser): stabilize default browser detection mocks 2026-03-24 18:02:47 -07:00
Tak Hoffman
1b5b23d2b1 fix: prefer current parents in session rows 2026-03-24 20:00:17 -05:00
Tak Hoffman
475983a364 fix: prefer current subagent owners in session rows 2026-03-24 19:54:07 -05:00
Tak Hoffman
ad818bda84 fix: ignore moved child rows in spawnedBy session filters 2026-03-24 19:47:36 -05:00
Tak Hoffman
6eaff70b55 fix: ignore moved child rows in subagent announces 2026-03-24 19:47:36 -05:00
Tak Hoffman
16d2e68610 fix: ignore stale store ownership in session child lists 2026-03-24 19:47:36 -05:00
Peter Steinberger
f7de5c3b83 test: collapse search helper suites 2026-03-25 00:42:09 +00:00
Peter Steinberger
83591fabfb test: consolidate plugin provider suites 2026-03-25 00:42:09 +00:00
Peter Steinberger
3a1b517581 fix: repair CI regression checks 2026-03-25 00:20:24 +00:00
Tak Hoffman
e6db1dde45 fix: hide moved subagents from stale command targets 2026-03-24 19:15:47 -05:00
Peter Steinberger
f6205de73a refactor: split feishu helpers and tests 2026-03-24 17:12:25 -07:00
Peter Steinberger
5cdb50abe6 refactor: unify Google Generative AI normalization 2026-03-24 17:09:11 -07:00
Devin Robison
56eeec4099 fix: require operator.admin for mutating internal /allowlist commands (#54097) 2026-03-24 18:05:59 -06:00
Peter Steinberger
561acd1675 test: tighten shared card schema coverage 2026-03-24 17:04:07 -07:00
Tak Hoffman
639706f298 fix: ignore moved child rows in subagent status 2026-03-24 18:57:42 -05:00
Peter Steinberger
3664c2ce46 fix: polish feishu retry helper (#43788) (thanks @lefarcen) 2026-03-24 16:55:37 -07:00
Elian
b9f48707dc fix(feishu): prevent silent group message drops when bot-info probe times out
When OpenClaw restarts under load, the Feishu bot-info probe
(`/open-apis/bot/v3/info`) can exceed the 10-second timeout due to
event-loop contention during channel initialization. This leaves
`botOpenId` empty, causing `checkBotMentioned()` to return `false`
for every group message — silently dropping them all while DMs
continue to work fine.

Two fixes:

1. **Increase startup probe timeout from 10s to 30s** and make it
   configurable via `OPENCLAW_FEISHU_STARTUP_PROBE_TIMEOUT_MS` env var.
   The previous 10s budget was too tight when multiple channels
   (Slack, Discord, Feishu) initialize concurrently.

2. **Graceful degradation in `checkBotMentioned()`**: when `botOpenId`
   is unknown, return `true` (assume mentioned) instead of `false`.
   This prevents group messages from being silently discarded when the
   probe fails for any reason. The trade-off is that the bot may
   respond to non-@-mentioned messages temporarily until the next
   successful probe, which is far preferable to total silence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 16:55:37 -07:00
Peter Steinberger
d4fda79ff7 fix: add merged message tool schema guardrail (#53715) (thanks @lndyzwdxhs) 2026-03-24 16:53:56 -07:00
grassylcao
ca578a9183 fix: mark card field as optional in message tool schema
The `createMessageToolCardSchema()` helper returned a bare `Type.Object()`
which TypeBox treats as required when merged into the parent tool schema via
`Type.Object({ card: ... })`. This caused schema validation to reject
media-only sends on Feishu and MSTeams with "must have required property
card", even though the implementation correctly treats card as optional.

Wrap the return value in `Type.Optional()` so the card field is excluded
from the JSON Schema `required` array. Fixes the catch-22 where omitting
card fails validation and including an empty card triggers the runtime
"does not support card with media" guard.

Closes #53697

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:53:56 -07:00
Vincent Koc
eaad4ad1be feat(gateway): add missing OpenAI-compatible endpoints (models and embeddings) (#53992)
* feat(gateway): add OpenAI-compatible models and embeddings

* docs(gateway): clarify model list and agent routing

* Update index.md

* fix(gateway): harden embeddings HTTP provider selection

* fix(gateway): validate compat model overrides

* fix(gateway): harden embeddings and response continuity

* fix(gateway): restore compat model id handling
2026-03-24 16:53:51 -07:00
Peter Steinberger
0709224ce3 fix: tighten gateway compose port parsing (#44083) (thanks @bebule) 2026-03-24 16:51:36 -07:00
Kwanghee Park (hugh.k)
ac7ca52090 Gateway: harden Compose-style gateway port parsing 2026-03-24 16:51:36 -07:00
Kwanghee Park (hugh.k)
b665749e9f Gateway: parse Compose-style gateway port env values 2026-03-24 16:51:36 -07:00
Tak Hoffman
e48a0b80a8 fix: ignore moved subagent children on stale parents 2026-03-24 18:46:37 -05:00
Peter Steinberger
33e9e485b8 refactor: clarify docker setup cli phases 2026-03-24 16:46:12 -07:00
Peter Steinberger
1ba436b372 test: speed up media and image-generation suites 2026-03-24 23:45:33 +00:00
Peter Steinberger
1a7914521b test: speed up infra and shared suites 2026-03-24 23:45:33 +00:00
Peter Steinberger
c9f4dd3c1b test: speed up browser control suites 2026-03-24 23:45:33 +00:00
Aria
63b0036248 fix: normalize baseUrl for custom Google Generative AI providers
Custom providers using `api: "google-generative-ai"` (e.g. a paid
Google tier) resolved in the model picker but failed at runtime with
HTTP 404 because the base URL lacked the required `/v1beta` path
segment and provider normalization was gated on the provider key
being exactly `"google"`.

Two targeted fixes, both keyed on the semantic `api` field rather
than provider name strings:

1. `models-config.providers.ts` — change the normalization gate from
   `normalizedKey === "google"` to
   `normalizedProvider?.api === "google-generative-ai"` and add
   `normalizeGoogleBaseUrl()` to ensure the canonical `/v1beta` suffix.

2. `pi-embedded-runner/model.ts` — apply
   `normalizeGoogleGenerativeAiBaseUrl()` in three resolution paths
   (`applyConfiguredProviderOverrides`, `buildInlineProviderModels`,
   fallback model construction) so the base URL is corrected at
   runtime regardless of how the model was discovered.

No changes to name-only call sites (`model-selection`,
`live-model-filter`, `model-forward-compat`); those paths are not
required for custom provider resolution and broadening their provider
checks would incorrectly capture unrelated providers like
`google-antigravity`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 16:42:56 -07:00
Peter Steinberger
e10ea53ea1 fix: add changelog for docker setup namespace loop (#53385) (thanks @amsminn) 2026-03-24 16:35:42 -07:00
김채완
d21ecd7642 Tests: match any pre-start openclaw-cli run 2026-03-24 16:35:42 -07:00
김채완
3ce09bd071 Tests: reset docker setup log before isolated assert 2026-03-24 16:35:42 -07:00
김채완
81be4b45a6 Docker: seed localhost control UI origin 2026-03-24 16:35:42 -07:00
김채완
dbb806d257 Docker: avoid setup CLI namespace loop 2026-03-24 16:35:42 -07:00
Peter Steinberger
6f6468027a refactor: dedupe test and runtime seams 2026-03-24 23:33:30 +00:00
Tak Hoffman
369119b6b5 fix: ignore stale parent rows in session child lists 2026-03-24 18:29:03 -05:00
Devin Robison
1d7cb6fc03 fix: close sandbox media root bypass for mediaUrl/fileUrl aliases (#54034)
* fix: close sandbox media root bypass for mediaUrl/fileUrl aliases

* Address Greptile feedback

* Fix windows test case failure
2026-03-24 17:28:53 -06:00
Tak Hoffman
907b5254f6 fix: ignore stale rows in subagent kill cascade 2026-03-24 18:12:48 -05:00
Tak Hoffman
1fd684329d fix: ignore stale rows in fast abort 2026-03-24 17:52:28 -05:00
Tak Hoffman
a03bbca4df fix: cascade bulk subagent kills past stale rows 2026-03-24 17:43:21 -05:00
Tak Hoffman
b6031a98e7 fix: ignore stale rows in subagent steer 2026-03-24 17:38:38 -05:00
Tak Hoffman
fee9d4cf37 fix: dedupe stale child completion announces 2026-03-24 17:25:14 -05:00
Tak Hoffman
2c5c5acb1b fix: ignore stale rows in subagent admin kill 2026-03-24 17:25:14 -05:00
Tak Hoffman
c90ae1ee7f fix: prefer latest subagent rows for session control 2026-03-24 17:25:14 -05:00
Tak Hoffman
b8a0258618 fix: ignore stale rows in subagent activity checks 2026-03-24 17:25:14 -05:00
Peter Steinberger
40ab7aca3d test: speed up slack monitor suites 2026-03-24 22:17:12 +00:00
Peter Steinberger
d282667321 test: speed up cli and command suites 2026-03-24 22:17:12 +00:00
Peter Steinberger
3dc139b0c0 test: speed up discord monitor suites 2026-03-24 22:17:12 +00:00
Vincent Koc
e28b516fb5 fix(slack): trim DM reply overhead and restore Codex auto transport (#53957)
* perf(slack): instrument runtime and trim DM overhead

* perf(slack): lazy-init draft previews

* perf(slack): add turn summary diagnostics

* perf(core): trim repeated runtime setup noise

* perf(core): preselect default web search providers

* perf(agent): restore OpenAI auto transport defaults

* refactor(slack): drop temporary perf wiring

* fix(slack): address follow-up review notes

* fix(security): tighten slack and runtime defaults

* style(web-search): fix import ordering

* style(agent): remove useless spread fallback

* docs(changelog): note slack runtime hardening
2026-03-24 15:03:40 -07:00
Devin Robison
47dc7fe816 fix: blcok non-owner authorized senders from chaning /send policy (#53994) 2026-03-24 15:58:39 -06:00
Tak Hoffman
c541cde0f6 fix: dedupe restarted descendant session counts 2026-03-24 16:52:50 -05:00
Tak Hoffman
e24704d5eb fix: dedupe active child session counts 2026-03-24 16:52:50 -05:00
Tak Hoffman
eb40f0b961 fix: clean up matrix /agents binding labels 2026-03-24 16:52:49 -05:00
Vincent Koc
ac8a5a614b ci: increase test shard fanout 2026-03-24 14:50:31 -07:00
Peter Steinberger
a18e156316 test: speed up telegram and whatsapp suites 2026-03-24 21:48:07 +00:00
Peter Steinberger
14e3c2de5f test: speed up discord channel suites 2026-03-24 21:48:07 +00:00
Peter Steinberger
e5173af77e test: speed up slack monitor suites 2026-03-24 21:48:07 +00:00
Peter Steinberger
3622569853 test: speed up memory provider suites 2026-03-24 21:48:07 +00:00
Vincent Koc
d648aebf4d perf(memory): builtin sqlite hot-path follow-ups (#53939)
* chore(perf): start builtin sqlite hotpath workstream

* perf(memory): reuse sqlite statements during sync

* perf(memory): snapshot file state during sync

* perf(memory): consolidate status sqlite reads

* docs(changelog): note builtin sqlite perf work

* perf(memory): avoid session table scans on targeted sync
2026-03-24 14:47:40 -07:00
Peter Steinberger
23a4ae4759 refactor: dedupe test helpers and harnesses 2026-03-24 21:41:46 +00:00
Tak Hoffman
9f4f997472 fix: align /agents ids with subagent targets 2026-03-24 16:29:49 -05:00
Tak Hoffman
a4ccd75ff3 fix: dedupe verbose subagent status counts 2026-03-24 16:18:03 -05:00
Tak Hoffman
51e59983a1 fix: report deduped subagent totals 2026-03-24 16:13:25 -05:00
Peter Steinberger
cf96fa67af ci: batch shared extensions test lane 2026-03-24 21:07:40 +00:00
Tak Hoffman
69d6e95c2a fix: dedupe stale subagent rows in reply views 2026-03-24 16:07:19 -05:00
Devin Robison
3031f061fc Adjust Feishu webhook request body limits (#53933) 2026-03-24 15:02:05 -06:00
Peter Steinberger
68b36cd9de test: fix rebase gate regressions 2026-03-24 21:01:04 +00:00
Peter Steinberger
bcd61f0a38 refactor: dedupe helpers and source seams 2026-03-24 21:00:36 +00:00
Tak Hoffman
ebe18c0379 fix: keep active-descendant subagents visible in reply status 2026-03-24 15:55:57 -05:00
Vincent Koc
0d2315ed15 fix(test): isolate github copilot token imports 2026-03-24 13:54:07 -07:00
Vincent Koc
6bf90a1d68 fix(test): stabilize memory vector dedupe assertion 2026-03-24 13:45:18 -07:00
Vincent Koc
eda1ef7b1a fix(ci): align lazy memory provider tests 2026-03-24 13:40:03 -07:00
Peter Steinberger
ddf65a995a test: speed up memory and secrets suites 2026-03-24 20:39:13 +00:00
Peter Steinberger
e2acfcf527 test: speed up browser pw-tools-core suites 2026-03-24 20:39:13 +00:00
Tak Hoffman
caa718a554 fix: steer ended subagent orchestrators with live descendants 2026-03-24 15:27:19 -05:00
Tak Hoffman
e99c270684 fix: allow follow-up sends to finished subagents 2026-03-24 15:20:39 -05:00
Vincent Koc
7d6d112656 perf(sqlite): use existence probes for empty memory search 2026-03-24 13:15:41 -07:00
Tak Hoffman
f6a0cdc25a fix: let subagent kill cascade through ended parents 2026-03-24 15:15:01 -05:00
Vincent Koc
aaf2d6359e fix(test): satisfy cli backend config typing 2026-03-24 13:06:20 -07:00
Vincent Koc
7330e2ce23 perf(memory): avoid eager provider init on empty search 2026-03-24 13:03:02 -07:00
Tak Hoffman
db0f957aba fix: surface finished subagent send targets 2026-03-24 15:01:34 -05:00
Devin Robison
c2fb7f1948 Adjust CLI backend environment handling before spawn (#53921)
security(agents): sanitize CLI backend env overrides before spawn
2026-03-24 12:58:10 -07:00
Vincent Koc
1beda4aff1 fix(ci): use target-platform npm path semantics 2026-03-24 12:47:34 -07:00
Tak Hoffman
231d62582f fix: prefer current subagent targets over stale rows 2026-03-24 14:38:34 -05:00
Peter Steinberger
4029ce738c test: speed up targeted unit suites 2026-03-24 19:36:08 +00:00
Vincent Koc
698c02e775 test(gateway): align safe open error code 2026-03-24 12:33:15 -07:00
Vincent Koc
87919dec2c fix(test): stabilize npm runner path assertion 2026-03-24 12:32:01 -07:00
Vincent Koc
805bff6e7e fix(cli): precompute bare root help startup path 2026-03-24 12:24:52 -07:00
Tak Hoffman
91b1e41132 fix: ignore stale bulk subagent kill targets 2026-03-24 14:17:28 -05:00
Tak Hoffman
ec23552b58 test: fix manifest registry fixture typing 2026-03-24 14:17:28 -05:00
Peter Steinberger
a4327ad544 refactor: dedupe tests and harden suite isolation 2026-03-24 19:16:19 +00:00
Devin Robison
d60112287f fix: validate agent workspace paths before writing identity files (#53882)
* fix: validate agent workspace paths before writing identity files

* Feedback updates and formatting fixes
2026-03-24 19:15:11 +00:00
Tak Hoffman
870c52aac7 fix: ignore stale subagent send targets 2026-03-24 14:05:00 -05:00
Vincent Koc
40315556d0 perf(plugins): scope web search plugin loads 2026-03-24 12:01:16 -07:00
Tak Hoffman
627ab895e2 fix: ignore stale subagent kill targets 2026-03-24 13:57:03 -05:00
Peter Steinberger
7101ddc5d3 chore: refresh plugin sdk api baseline 2026-03-24 18:49:51 +00:00
Vincent Koc
783cbd1e9d fix(ci): refresh plugin sdk baseline and formatting 2026-03-24 11:45:37 -07:00
Vincent Koc
a9da52da50 refactor(core): make event and queue state lazy 2026-03-24 11:45:27 -07:00
Peter Steinberger
f6b3377af2 test: stabilize low-profile parallel gate 2026-03-24 18:40:46 +00:00
Peter Steinberger
2383107711 fix: unblock supervisor and memory gate failures 2026-03-24 18:40:46 +00:00
Vincent Koc
a97188ceb3 ci: start required checks earlier (#53844)
* ci: start required checks earlier

* ci: restore pnpm in security-fast

* ci: skip docs-only payloads in early check jobs

* ci: harden untrusted pull request execution

* ci: pin gradle setup action

* ci: normalize pull request concurrency cancellation

* ci: remove duplicate early-lane setup

* ci: keep install-smoke push runs unique
2026-03-24 11:37:58 -07:00
Vincent Koc
e4ce1d9a0e fix(runtime): stabilize dist runtime artifacts (#53855)
* fix(build): stabilize lazy runtime entry paths

* fix(runtime): harden bundled plugin npm staging

* docs(changelog): note runtime artifact fixes

* fix(runtime): stop trusting npm_execpath

* fix(runtime): harden Windows npm staging

* fix(runtime): add safe Windows npm fallback
2026-03-24 11:37:39 -07:00
Vincent Koc
0cdd4db6e9 fix(memory): align status manager concurrency test 2026-03-24 11:31:35 -07:00
Vincent Koc
0caafa587f refactor(plugins): make interactive state lazy 2026-03-24 11:29:20 -07:00
Vincent Koc
d0002c5e1e refactor(gateway): make plugin fallback state lazy 2026-03-24 11:26:21 -07:00
scoootscooob
3a4cc89c53 fix: allow compact retry after failed session compaction (#53875) 2026-03-24 11:23:42 -07:00
Vincent Koc
6bef8deda9 refactor(plugins): make command registry lazy 2026-03-24 11:09:34 -07:00
Vincent Koc
f41bdf3c54 refactor(plugins): make hook runner global lazy 2026-03-24 11:07:37 -07:00
Vincent Koc
6451beddb2 refactor(plugins): make runtime registry lazy 2026-03-24 11:04:03 -07:00
Vincent Koc
e16f0cf908 refactor(channels): route registry lookups through runtime 2026-03-24 11:01:48 -07:00
Bob
7fab2c2897 fix(discord): notify user on discord when inbound worker times out (#53823)
* fix(discord): notify user on discord when inbound worker times out.

* fix(discord): notify user on discord when inbound worker times out.

* Discord: await timeout fallback reply

* Discord: add changelog for timeout reply fix (#53823) (thanks @Kimbo7870)

---------

Co-authored-by: VioGarden <111024100+VioGarden@users.noreply.github.com>
Co-authored-by: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
2026-03-24 19:01:12 +01:00
Tak Hoffman
03ed0bccf1 fix: ignore stale subagent steer targets 2026-03-24 13:00:48 -05:00
scoootscooob
a395c757ab Chat UI: guard compact retries 2026-03-24 10:58:09 -07:00
scoootscooob
19093112ce Chat UI: tighten compact transport handling 2026-03-24 10:58:09 -07:00
scoootscooob
44e27c6092 Webchat: handle bare /compact as session compaction 2026-03-24 10:58:09 -07:00
scoootscooob
01d3442246 Plugins: sanitize sdk export subpaths 2026-03-24 10:58:06 -07:00
scoootscooob
fc60ced03c Plugins: trust only startup cli sdk roots 2026-03-24 10:58:06 -07:00
scoootscooob
f163759167 Plugins: resolve sdk aliases from the running CLI 2026-03-24 10:58:06 -07:00
scoootscooob
8633c7fa73 Providers: fix kimi fallback normalization 2026-03-24 10:58:03 -07:00
scoootscooob
9acb4c8fbc Providers: fix kimi-coding thinking normalization 2026-03-24 10:58:03 -07:00
Tak Hoffman
d25b4a2943 fix: fail closed when subagent steer remap fails 2026-03-24 12:55:43 -05:00
Vincent Koc
7daaefdb08 test(memory): recycle shared channels batches 2026-03-24 10:54:51 -07:00
Vincent Koc
3b03ff11fc test(memory): isolate slack action-runtime hotspot 2026-03-24 10:51:15 -07:00
Vincent Koc
548c2019f1 test(memory): isolate telegram monitor hotspot 2026-03-24 10:50:32 -07:00
Peter Steinberger
6e9591c4ce test: speed up browser suites 2026-03-24 17:49:25 +00:00
Peter Steinberger
217cb0ac58 test: speed up plugin-sdk and cron suites 2026-03-24 17:49:25 +00:00
Vincent Koc
e7ae7d921a test(memory): isolate telegram fetch hotspot 2026-03-24 10:47:30 -07:00
Tak Hoffman
7ab46301a9 fix: continue subagent kill after session store write failures 2026-03-24 12:46:58 -05:00
Vincent Koc
488ad4ac70 test(memory): isolate telegram bot hotspot 2026-03-24 10:46:17 -07:00
Vincent Koc
86de8b65b1 test(memory): isolate plugin-core hotspot 2026-03-24 10:45:11 -07:00
Vincent Koc
a088109327 test(memory): isolate browser remote-tab hotspot 2026-03-24 10:43:51 -07:00
Vincent Koc
fbe5f45340 test(memory): isolate new unit hotspot files 2026-03-24 10:42:22 -07:00
Tak Hoffman
240479abef fix(ci): stop dropping pending main workflow runs 2026-03-24 12:38:07 -05:00
Peter Steinberger
d58d90074f refactor: isolate ACP final delivery flow 2026-03-24 10:36:46 -07:00
Peter Steinberger
822563d1ab fix: unify pi runner usage snapshot fallback 2026-03-24 10:33:18 -07:00
Peter Steinberger
69a0a6c847 fix: tighten ACP final fallback semantics (#53692) (thanks @w-sss) 2026-03-24 10:29:27 -07:00
w-sss
7b8142997f fix(acp): deliver final result text as fallback when no blocks routed
- Check routedCounts.final to detect prior delivery
- Skip fallback for ttsMode='all' to avoid duplicate TTS processing
- Use delivery.deliver for proper routing in cross-provider turns
- Fixes #46814 where ACP child run results were not delivered
2026-03-24 10:28:33 -07:00
Peter Steinberger
d2e0cfc09f test: speed up media fetch suite 2026-03-24 17:27:02 +00:00
Peter Steinberger
a8bf75f03e test: speed up browser and gateway suites 2026-03-24 17:27:02 +00:00
Vincent Koc
435e2c5967 fix(memory): avoid caching qmd status managers 2026-03-24 10:25:00 -07:00
Peter Steinberger
a37ed72829 test: preserve child_process exports in restart bun mock 2026-03-24 17:24:18 +00:00
Vincent Koc
f2475a7f70 fix(slack): improve interactive reply parity (#53389)
* fix(slack): improve interactive reply parity

* fix(slack): isolate reply interactions from plugins

* docs(changelog): note slack interactive parity fixes

* fix(slack): preserve preview text for local agent replies

* fix(agent): preserve directive text in local previews
2026-03-24 10:23:10 -07:00
Peter Steinberger
398d58fb8a fix: stabilize logging config imports 2026-03-24 17:21:28 +00:00
Vincent Koc
a1c91bdb75 fix(memory): avoid caching status-only managers 2026-03-24 10:21:23 -07:00
Peter Steinberger
f47549c5f6 test: speed up backup and doctor suites 2026-03-24 17:16:25 +00:00
Peter Steinberger
cc9d1103d9 test: speed up command runtime suites 2026-03-24 17:16:25 +00:00
Peter Steinberger
6e20c26397 test: speed up cli and model command suites 2026-03-24 17:16:25 +00:00
Peter Steinberger
4518f6e820 test: speed up slack and telegram suites 2026-03-24 17:16:25 +00:00
Peter Steinberger
b11f4835e2 fix: suppress only recent whatsapp group echoes (#53624) (thanks @w-sss) 2026-03-24 10:10:48 -07:00
w-sss
0d4b47a14e fix(whatsapp): filter fromMe messages in groups to prevent infinite loop (#53386) 2026-03-24 10:10:48 -07:00
Peter Steinberger
f52752889b fix: audit clobbered config reads 2026-03-24 17:10:06 +00:00
Vincent Koc
14f1b65c70 test(memory): enable lower-interval heap snapshots 2026-03-24 10:09:06 -07:00
Tak Hoffman
2990446b21 fix: avoid duplicate orphaned subagent resumes 2026-03-24 12:08:44 -05:00
Peter Steinberger
44d5e6d672 fix(types): add workspace module shims 2026-03-24 10:07:14 -07:00
Vincent Koc
7eefddd0ed test(memory): clear browser and plugin caches between cases 2026-03-24 10:05:32 -07:00
Peter Steinberger
ba95d43e3c refactor: split feishu runtime and inspect secret resolution 2026-03-24 10:05:15 -07:00
Peter Steinberger
8e9e2d2f4e refactor(auth): unify external CLI credential sync 2026-03-24 10:03:00 -07:00
Peter Steinberger
27448c3113 refactor(msteams): split reply and reflection helpers 2026-03-24 10:02:49 -07:00
Peter Steinberger
9f47892bef refactor: centralize google API base URL handling 2026-03-24 10:01:22 -07:00
Tak Hoffman
129b1b5037 fix: return structured errors for subagent control send failures 2026-03-24 11:54:30 -05:00
giulio-leone
bbe6f7fdd9 fix(auth): protect fresher codex reauth state
- invalidate cached Codex CLI credentials when auth.json changes within the TTL window
- skip external CLI sync when the stored Codex OAuth credential is newer
- cover both behaviors with focused regression tests

Refs #53466

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-24 09:53:24 -07:00
Josh Lehman
559b3a5fd4 test: stabilize preaction process title assertion (#53808)
Regeneration-Prompt: |
  Current origin/main fails src/cli/program/preaction.test.ts because the
  test asserts on process.title directly inside Vitest, where that runtime
  interaction is not stable enough to observe the write reliably. Keep the
  production preaction behavior unchanged. Make the test verify that the
  hook assigns the expected title by wrapping process.title with a local
  getter/setter during each test and restoring the original descriptor
  afterward so other tests keep the real process object behavior.
2026-03-24 09:50:11 -07:00
Peter Steinberger
e727ad6898 fix(msteams): harden feedback reflection follow-ups 2026-03-24 09:50:04 -07:00
Peter Steinberger
72300e8fd0 docs: add changelog for PR #53675 (thanks @hpt) 2026-03-24 09:48:32 -07:00
Peter Steinberger
700ec2f25d fix: use v1beta for migrated google nano banana provider (#53757) (thanks @mahopan) 2026-03-24 09:47:59 -07:00
Maho Pan
2f238b5d7d fix(doctor): add missing baseUrl and models when migrating nano-banana apiKey to google provider
The legacy nano-banana-pro skill migration moves the Gemini API key to
models.providers.google.apiKey but does not populate the required baseUrl
and models fields on the provider entry. When the google provider object
is freshly created (no pre-existing config), the resulting config fails
Zod validation on write:

  Config validation failed: models.providers.google.baseUrl:
  Invalid input: expected string, received undefined

Fix: default baseUrl to 'https://generativelanguage.googleapis.com' and
models to [] when they are not already set, matching the defaults used
elsewhere in the codebase (embeddings-gemini, pdf-native-providers).

Fixes the 'doctor --fix' crash for users who only have a legacy
nano-banana-pro skill entry and no existing models.providers.google.
2026-03-24 09:47:21 -07:00
Han Pingtian
a1cb302c20 Feishu: avoid CLI startup failure on unresolved SecretRef 2026-03-24 09:47:18 -07:00
Tak Hoffman
ada703a7b4 fix: preserve session cleanup hooks after subagent announce 2026-03-24 11:44:10 -05:00
Tak Hoffman
79ef86c305 fix: preserve cleanup hooks after subagent register failure 2026-03-24 11:32:19 -05:00
Peter Steinberger
49e3f2db06 test: speed up core unit suites 2026-03-24 16:26:58 +00:00
Peter Steinberger
27b92f8335 test: speed up google and twitch suites 2026-03-24 16:26:58 +00:00
Peter Steinberger
332d2ebfe8 test: speed up whatsapp and signal suites 2026-03-24 16:26:58 +00:00
Peter Steinberger
5edba12f79 test: speed up discord slack telegram suites 2026-03-24 16:26:58 +00:00
Tak Hoffman
f0761b4914 fix(lockfile): sync discord dependency removal 2026-03-24 11:24:12 -05:00
Tak Hoffman
3e9ff16645 fix(discord): avoid bundling pi-ai runtime deps 2026-03-24 11:17:08 -05:00
Peter Steinberger
49ae71fa62 test: speed up signal and whatsapp extension suites 2026-03-24 15:57:16 +00:00
Peter Steinberger
86921b624c test: speed up telegram extension suites 2026-03-24 15:57:16 +00:00
Peter Steinberger
a29b9f2c20 test: speed up slack extension suites 2026-03-24 15:57:16 +00:00
Peter Steinberger
1d4db9920d test: speed up discord extension suites 2026-03-24 15:57:16 +00:00
Peter Steinberger
781295c14b refactor: dedupe test and script helpers 2026-03-24 15:48:35 +00:00
David Guttman
66e954858b add missing autoArchiveDuration to DiscordGuildChannelConfig type (#43427)
* add missing autoArchiveDuration to DiscordGuildChannelConfig type

The autoArchiveDuration field is present in the Zod schema
(DiscordGuildChannelSchema) and actively used at runtime in
threading.ts and allow-list.ts, but was missing from the
canonical TypeScript type definition.

Add autoArchiveDuration to DiscordGuildChannelConfig to align
the type with the schema and runtime usage.

* Discord: add changelog for config type fix (#43427) (thanks @davidguttman)

---------

Co-authored-by: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
2026-03-24 16:30:24 +01:00
David Guttman
aa91000a5d feat(discord): add autoThreadName 'generated' strategy (#43366)
* feat(discord): add autoThreadName 'generated' strategy

Adds async thread title generation for auto-created threads:
- autoThread: boolean - enables/disables auto-threading
- autoThreadName: 'message' | 'generated' - naming strategy
- 'generated' uses LLM to create concise 3-6 word titles
- Includes channel name/description context for better titles
- 10s timeout with graceful fallback

* Discord: support non-key auth for generated thread titles

* Discord: skip fallback auto-thread rename

* Discord: normalize generated thread title first content line

* Discord: split thread title generation helpers

* Discord: tidy thread title generation constants and order

* Discord: use runtime fallback model resolution for thread titles

* Discord: resolve thread-title model aliases

* Discord: fallback thread-title model selection to runtime defaults

* Agents: centralize simple completion runtime

* fix(discord): pass apiKey to complete() for thread title generation

The setRuntimeApiKey approach only works for full agent runs that use
authStorage.getApiKey(). The pi-ai complete() function expects apiKey
directly in options or falls back to env vars — it doesn't read from
authStorage.runtimeOverrides.

Fixes thread title generation for Claude/Anthropic users.

* fix(agents): return exchanged Copilot token from prepareSimpleCompletionModel

The recent thread-title fix (3346ba6) passes prepared.auth.apiKey to
complete(). For github-copilot, this was still the raw GitHub token
rather than the exchanged runtime token, causing auth failures.

Now setRuntimeApiKeyForCompletion returns the resolved token and
prepareSimpleCompletionModel includes it in auth.apiKey, so both the
authStorage path and direct apiKey pass-through work correctly.

* fix(agents): catch auth lookup exceptions in completion model prep

getApiKeyForModel can throw for credential issues (missing profile, etc).
Wrap in try/catch to return { error } for fail-soft handling rather than
propagating rejected promises to callers like thread title generation.

* Discord: strip markdown wrappers from generated thread titles

* Discord/agents: align thread-title model and local no-auth completion headers

* Tests: import fresh modules for mocked thread-title/simple-completion suites

* Agents: apply exchanged Copilot baseUrl in simple completions

* Discord: route thread runtime imports through plugin SDK

* Lockfile: add Discord pi-ai runtime dependency

* Lockfile: regenerate Discord pi-ai runtime dependency entries

* Agents: use published Copilot token runtime module

* Discord: refresh config baseline and lockfile

* Tests: split extension runs by isolation

* Discord: add changelog for generated thread titles (#43366) (thanks @davidguttman)

---------

Co-authored-by: Onur Solmaz <onur@textcortex.com>
Co-authored-by: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
2026-03-24 16:27:19 +01:00
Tak Hoffman
3f99a30163 fix: clean up attachments when replacing subagent runs 2026-03-24 10:21:15 -05:00
Tak Hoffman
0bda670d9a fix(ci): do not cancel in-progress bun runs on main 2026-03-24 10:19:59 -05:00
Peter Steinberger
d884676dd2 test: speed up whatsapp and shared test suites 2026-03-24 15:16:18 +00:00
Peter Steinberger
83bb647238 test: speed up telegram extension suites 2026-03-24 15:16:18 +00:00
Peter Steinberger
db4572b459 test: speed up slack extension suites 2026-03-24 15:16:18 +00:00
Peter Steinberger
88f49c27a0 test: speed up discord extension suites 2026-03-24 15:16:18 +00:00
Tak Hoffman
df2f900677 fix: clean up attachments for orphaned subagent runs 2026-03-24 10:05:53 -05:00
Tak Hoffman
075ece3dac fix(ci): do not cancel in-progress main runs 2026-03-24 10:02:25 -05:00
Tak Hoffman
938f8f4d83 fix: clean up attachments for released subagent runs 2026-03-24 09:56:20 -05:00
Harold Hunt
35de467b1a Telegram: recover General topic bindings (#53699)
Merged via squash.

Prepared head SHA: 546f0c8134
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-24 10:51:26 -04:00
Tak Hoffman
8754d8e330 fix: ci 2026-03-24 09:25:28 -05:00
Sally O'Malley
91adc5e718 feat(cli): support targeting running containerized openclaw instances (#52651)
Signed-off-by: sallyom <somalley@redhat.com>
2026-03-24 10:17:17 -04:00
Tak Hoffman
dd11bdd003 fix: clean up attachments for killed subagent runs 2026-03-24 09:14:06 -05:00
Tak Hoffman
807daf54fe fix: finalize killed delete-mode subagent cleanup 2026-03-24 09:01:55 -05:00
Tak Hoffman
d7e48d4883 fix: ci 2026-03-24 08:40:55 -05:00
Neerav Makwana
f56a79f838 fix: report qmd status counts from real qmd manager (#53683) (thanks @neeravmakwana)
* fix(memory): report qmd status counts from index

* fix(memory): reuse full qmd manager for status

* fix(memory): harden qmd status manager lifecycle
2026-03-24 19:10:20 +05:30
Tak Hoffman
e6e2407cee fix: initialize plugins before killed subagent hooks 2026-03-24 08:27:50 -05:00
Tak Hoffman
b72d0c8459 fix: clean up failed non-thread subagent spawns 2026-03-24 08:26:59 -05:00
Ayaan Zaidi
0a04ef494d fix: merge explicit reply config overrides onto fresh config 2026-03-24 18:52:04 +05:30
Taras Lukavyi
ac07d8814a fix(secrets): prevent unresolved SecretRef from crashing embedded agent runs
Root cause: Telegram channel monitor captures config at startup before secrets
are resolved and passes it as configOverride into the reply pipeline. Since
getReplyFromConfig() uses configOverride directly (skipping loadConfig() which
reads the resolved runtime snapshot), the unresolved SecretRef objects propagate
into FollowupRun.run.config and crash runEmbeddedPiAgent().

Fix (defense in depth):
- get-reply.ts: detect unresolved SecretRefs in configOverride and fall back to
  loadConfig() which returns the resolved runtime snapshot
- message-tool.ts: try-catch around schema/description building at tool creation
  time so channel discovery errors don't crash the agent
- message-tool.ts: detect unresolved SecretRefs in pre-bound config at tool
  execution time and fall back to gateway secret resolution

Fixes: https://github.com/openclaw/openclaw/issues/45838
2026-03-24 18:52:04 +05:30
HollyChou
c84c630b4c fix(docs): correct json55 typo to json5 in IRC channel docs (#50831) (#50842)
Merged via squash.

Prepared head SHA: 0f743bf472
Co-authored-by: Hollychou924 <128659251+Hollychou924@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-24 16:20:49 +03:00
Mariano
922f4e66ea fix(agents): harden edit tool recovery (#52516)
Merged via squash.

Prepared head SHA: e23bde893a
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-24 13:19:16 +01:00
Peter Steinberger
60cd98a841 test: defer slack bolt interop for helper-only suites 2026-03-24 09:34:23 +00:00
Peter Steinberger
b1b162fcdb test: harden threaded channel follow-ups 2026-03-24 09:24:29 +00:00
Peter Steinberger
43131dcc08 test: harden threaded shared-worker suites 2026-03-24 08:37:00 +00:00
Peter Steinberger
e7817ad12a test: continue vitest threads migration 2026-03-24 08:37:00 +00:00
Peter Steinberger
2833b27f52 test: continue vitest threads migration 2026-03-24 08:37:00 +00:00
Ayaan Zaidi
d41b92fff2 docs: update CONTRIBUTING.md 2026-03-24 13:36:38 +05:30
Val Alexander
b61a875d56 fix: widen installer regex allowlists and deduplicate safeExternalHref calls
- SAFE_GO_MODULE: allow uppercase in module paths (A-Z)
- SAFE_BREW_FORMULA: allow @ for versioned formulas (python@3.12)
- SAFE_UV_PACKAGE: allow extras [standard] and equality pins ==
- Cache safeExternalHref result in skills detail API key section
2026-03-24 01:46:33 -05:00
Val Alexander
cb58e45130 fix(security): resolve Aisle findings — skill installer validation, terminal sanitization, URL scheme allowlisting (#53471) thanks @BunsDev
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Co-authored-by: Nova <nova@openknot.ai>
2026-03-24 01:43:48 -05:00
Val Alexander
a710366e9e feat(ui): Control UI polish — skills revamp, markdown preview, agent workspace, macOS config tree (#53411) thanks @BunsDev
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Co-authored-by: Nova <nova@openknot.ai>
2026-03-24 01:21:13 -05:00
Peter Steinberger
ecb3aa7fe0 test: sync app chat model override expectation 2026-03-23 23:18:59 -07:00
Peter Steinberger
ff2e9a52ff fix: preserve deferred TUI history sync (#53130) (thanks @joelnishanth) 2026-03-23 23:18:59 -07:00
joelnishanth
cc8ed8d25b fix(tui): preserve user message during slow model responses (#53115)
When a local run ends with an empty final event while another run is active,
skip history reload to prevent clearing the user's pending message from the
chat log. This fixes the 'message disappears' issue with slow models like Ollama.
2026-03-23 23:18:59 -07:00
Tak Hoffman
5e9ea804d4 fix: finalize deferred subagent expiry cleanup 2026-03-24 01:12:54 -05:00
Peter Steinberger
5dc42dfb17 fix: format subagent registry test 2026-03-24 06:10:55 +00:00
Peter Steinberger
fd0fa97952 refactor: centralize plugin install config policy 2026-03-23 23:07:40 -07:00
Tak Hoffman
c3744fbfc4 fix: finalize resumed subagent cleanup give-ups 2026-03-24 01:06:39 -05:00
Peter Steinberger
a2d3b9f317 fix: unblock live harness provider discovery 2026-03-23 23:02:44 -07:00
Tak Hoffman
ab8c834aab fix: report dropped subagent announce queue deliveries 2026-03-24 00:54:46 -05:00
Tak Hoffman
0fc27409c0 fix: preserve direct subagent dispatch failures on abort 2026-03-24 00:47:01 -05:00
Peter Steinberger
687ce31f88 test: harden parallels smoke harness 2026-03-24 05:43:22 +00:00
Peter Steinberger
da10b6026a test: prune low-signal live model sweeps 2026-03-24 05:43:07 +00:00
1584 changed files with 80319 additions and 40041 deletions

View File

@@ -23,10 +23,15 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Preferred entrypoint: `pnpm test:parallels:npm-update`
- Flow: fresh snapshot -> install npm package baseline -> smoke -> install current main tgz on the same guest -> smoke again.
- Same-guest update verification should set the default model explicitly to `openai/gpt-5.4` before the agent turn and use a fresh explicit `--session-id` so old session model state does not leak into the check.
- Keep the aggregate npm-update Linux VM name aligned with the default Linux smoke VM (`Ubuntu 24.04.3 ARM64` on Peter's host today). Do not hardcode a different Linux guest in the wrapper unless the per-OS Linux smoke default changed too.
- The aggregate npm-update wrapper must resolve the Linux VM with the same Ubuntu fallback policy as `parallels-linux-smoke.sh` before both fresh and update lanes. On Peter's current host, missing `Ubuntu 24.04.3 ARM64` should fall back to `Ubuntu 25.10`.
- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service.
- For Windows same-guest update checks, prefer the done-file/log-drain PowerShell runner pattern over one long-lived `prlctl exec ... powershell -EncodedCommand ...` transport. The guest can finish successfully while the outer `prlctl exec` still hangs.
- Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials.
## CLI invocation footgun
- The Parallels smoke shell scripts should tolerate a literal bare `--` arg so `pnpm test:parallels:* -- --json` and similar forwarded invocations work without needing to call `bash scripts/e2e/...` directly.
## macOS flow
- Preferred entrypoint: `pnpm test:parallels:macos`

View File

@@ -23,6 +23,16 @@ runs:
exit 0
fi
if ! [[ "$BASE_SHA" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
echo "::error title=ensure-base-commit invalid base sha::Refusing invalid base SHA: $BASE_SHA"
exit 2
fi
if ! git check-ref-format --branch "$FETCH_REF" >/dev/null 2>&1; then
echo "::error title=ensure-base-commit invalid fetch ref::Refusing invalid fetch ref: $FETCH_REF"
exit 2
fi
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
echo "Base commit already present: $BASE_SHA"
exit 0
@@ -30,7 +40,7 @@ runs:
for deepen_by in 25 100 300; do
echo "Base commit missing; deepening $FETCH_REF by $deepen_by."
if ! git fetch --no-tags --deepen="$deepen_by" origin "$FETCH_REF"; then
if ! git fetch --no-tags --deepen="$deepen_by" origin -- "$FETCH_REF"; then
echo "::warning title=ensure-base-commit fetch failed::Failed to deepen $FETCH_REF by $deepen_by while looking for $BASE_SHA"
fi
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
@@ -40,7 +50,7 @@ runs:
done
echo "Base commit still missing; fetching full history for $FETCH_REF."
if ! git fetch --no-tags origin "$FETCH_REF"; then
if ! git fetch --no-tags origin -- "$FETCH_REF"; then
echo "::warning title=ensure-base-commit fetch failed::Failed to fetch full history for $FETCH_REF while looking for $BASE_SHA"
fi
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then

View File

@@ -1,7 +1,7 @@
name: Setup Node environment
description: >
Initialize submodules with retry, install Node 24 by default, pnpm, optionally Bun,
and optionally run pnpm install. Requires actions/checkout to run first.
Install Node 24 by default, pnpm, optionally Bun, and optionally run pnpm
install. Requires actions/checkout to run first.
inputs:
node-version:
description: Node.js version to install.
@@ -34,20 +34,6 @@ inputs:
runs:
using: composite
steps:
- name: Checkout submodules (retry)
shell: bash
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node.js
uses: actions/setup-node@v6
with:

View File

@@ -11,6 +11,10 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
permissions: {}
jobs:

View File

@@ -5,14 +5,49 @@ on:
branches: [main]
concurrency:
group: ci-bun-push-${{ github.ref_name }}
cancel-in-progress: true
group: ci-bun-push-${{ github.run_id }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
preflight:
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
outputs:
run_bun_checks: ${{ steps.manifest.outputs.run_bun_checks }}
bun_checks_matrix: ${{ steps.manifest.outputs.bun_checks_matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
use-sticky-disk: "false"
- name: Build Bun CI manifest
id: manifest
env:
OPENCLAW_CI_DOCS_ONLY: "false"
OPENCLAW_CI_DOCS_CHANGED: "false"
OPENCLAW_CI_RUN_NODE: "true"
OPENCLAW_CI_RUN_MACOS: "false"
OPENCLAW_CI_RUN_ANDROID: "false"
OPENCLAW_CI_RUN_WINDOWS: "false"
OPENCLAW_CI_RUN_SKILLS_PYTHON: "false"
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: "false"
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: '{"include":[]}'
run: node scripts/ci-write-manifest-outputs.mjs --workflow ci-bun
build-bun-artifacts:
needs: [preflight]
if: needs.preflight.outputs.run_bun_checks == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
@@ -37,19 +72,14 @@ jobs:
path: src/canvas-host/a2ui/
bun-checks:
needs: [build-bun-artifacts]
name: ${{ matrix.check_name }}
needs: [preflight, build-bun-artifacts]
if: needs.preflight.outputs.run_bun_checks == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- shard_index: 1
shard_count: 2
command: OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard 1/2
- shard_index: 2
shard_count: 2
command: OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard 2/2
matrix: ${{ fromJson(needs.preflight.outputs.bun_checks_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -69,4 +99,10 @@ jobs:
path: src/canvas-host/a2ui/
- name: Run Bun test shard
run: ${{ matrix.command }}
env:
SHARD_COUNT: ${{ matrix.shard_count }}
SHARD_INDEX: ${{ matrix.shard_index }}
shell: bash
run: |
set -euo pipefail
OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard "$SHARD_INDEX/$SHARD_COUNT"

View File

@@ -6,41 +6,60 @@ on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-push-{0}', github.ref_name) }}
cancel-in-progress: true
group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
# Preflight: establish the fast global truth for this revision before the
# expensive platform and test lanes fan out.
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
# Run scope detection, changed-extension detection, and fast security checks in
# one visible job so operators have a single preflight box to inspect and rerun.
# Fail-safe: if detection steps are skipped, downstream outputs fall back to
# conservative defaults that keep heavy lanes enabled.
# Preflight: establish routing truth and planner-owned matrices once, then let
# real work fan out from a single source of truth.
preflight:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
outputs:
docs_only: ${{ steps.docs_scope.outputs.docs_only }}
docs_changed: ${{ steps.docs_scope.outputs.docs_changed }}
run_node: ${{ steps.changed_scope.outputs.run_node || 'false' }}
run_macos: ${{ steps.changed_scope.outputs.run_macos || 'false' }}
run_android: ${{ steps.changed_scope.outputs.run_android || 'false' }}
run_skills_python: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }}
run_windows: ${{ steps.changed_scope.outputs.run_windows || 'false' }}
has_changed_extensions: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
changed_extensions_matrix: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
docs_only: ${{ steps.manifest.outputs.docs_only }}
docs_changed: ${{ steps.manifest.outputs.docs_changed }}
run_node: ${{ steps.manifest.outputs.run_node }}
run_macos: ${{ steps.manifest.outputs.run_macos }}
run_android: ${{ steps.manifest.outputs.run_android }}
run_skills_python: ${{ steps.manifest.outputs.run_skills_python }}
run_skills_python_job: ${{ steps.manifest.outputs.run_skills_python_job }}
run_windows: ${{ steps.manifest.outputs.run_windows }}
has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }}
changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }}
run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
run_release_check: ${{ steps.manifest.outputs.run_release_check }}
run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
checks_fast_matrix: ${{ steps.manifest.outputs.checks_fast_matrix }}
run_checks: ${{ steps.manifest.outputs.run_checks }}
checks_matrix: ${{ steps.manifest.outputs.checks_matrix }}
run_extension_fast: ${{ steps.manifest.outputs.run_extension_fast }}
extension_fast_matrix: ${{ steps.manifest.outputs.extension_fast_matrix }}
run_check: ${{ steps.manifest.outputs.run_check }}
run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
run_build_smoke: ${{ steps.manifest.outputs.run_build_smoke }}
run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
run_checks_windows: ${{ steps.manifest.outputs.run_checks_windows }}
checks_windows_matrix: ${{ steps.manifest.outputs.checks_windows_matrix }}
run_macos_node: ${{ steps.manifest.outputs.run_macos_node }}
macos_node_matrix: ${{ steps.manifest.outputs.macos_node_matrix }}
run_macos_swift: ${{ steps.manifest.outputs.run_macos_swift }}
run_android_job: ${{ steps.manifest.outputs.run_android_job }}
android_matrix: ${{ steps.manifest.outputs.android_matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure preflight base commit
@@ -53,8 +72,6 @@ jobs:
id: docs_scope
uses: ./.github/actions/detect-docs-changes
# Detect which heavy areas are touched so CI can skip unrelated expensive jobs.
# Fail-safe: if skipped, downstream lanes run.
- name: Detect changed scopes
id: changed_scope
if: steps.docs_scope.outputs.docs_only != 'true'
@@ -101,6 +118,60 @@ jobs:
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
EOF
- name: Build CI manifest
id: manifest
env:
OPENCLAW_CI_DOCS_ONLY: ${{ steps.docs_scope.outputs.docs_only }}
OPENCLAW_CI_DOCS_CHANGED: ${{ steps.docs_scope.outputs.docs_changed }}
OPENCLAW_CI_RUN_NODE: ${{ steps.changed_scope.outputs.run_node || 'false' }}
OPENCLAW_CI_RUN_MACOS: ${{ steps.changed_scope.outputs.run_macos || 'false' }}
OPENCLAW_CI_RUN_ANDROID: ${{ steps.changed_scope.outputs.run_android || 'false' }}
OPENCLAW_CI_RUN_WINDOWS: ${{ steps.changed_scope.outputs.run_windows || 'false' }}
OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }}
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
run: node scripts/ci-write-manifest-outputs.mjs --workflow ci
# Run the fast security/SCM checks in parallel with scope detection so the
# main Node jobs do not have to wait for Python/pre-commit setup.
security-fast:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
env:
PRE_COMMIT_CACHE_KEY_SUFFIX: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure security base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Prepare trusted pre-commit config
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config"
echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
use-sticky-disk: "false"
- name: Setup Python
id: setup-python
uses: actions/setup-python@v6
@@ -116,15 +187,17 @@ jobs:
uses: actions/cache@v5
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}
key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}-${{ env.PRE_COMMIT_CACHE_KEY_SUFFIX }}
restore-keys: |
pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}-
- name: Install pre-commit
run: |
python -m pip install --upgrade pip
python -m pip install pre-commit
python -m pip install pre-commit==4.2.0
- name: Detect committed private keys
run: pre-commit run --all-files detect-private-key
run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files detect-private-key
- name: Audit changed GitHub workflows with zizmor
env:
@@ -151,24 +224,24 @@ jobs:
fi
printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
pre-commit run zizmor --files "${workflow_files[@]}"
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
- name: Audit production dependencies
if: steps.docs_scope.outputs.docs_only != 'true'
run: pre-commit run --all-files pnpm-audit-prod
run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files pnpm-audit-prod
# Fanout: downstream lanes branch from preflight outputs instead of waiting
# on unrelated Linux checks.
# Build dist once for Node-relevant changes and share it with downstream jobs.
# Keep this overlapping with the fast correctness lanes so green PRs get heavy
# test/build feedback sooner instead of waiting behind a full `check` pass.
build-artifacts:
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
if: needs.preflight.outputs.run_build_artifacts == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Ensure secrets base commit (PR fast path)
@@ -208,13 +281,14 @@ jobs:
# Validate npm pack contents after build (only on push to main, not PRs).
release-check:
needs: [preflight, build-artifacts]
if: github.event_name == 'push' && needs.preflight.outputs.docs_only != 'true'
if: needs.preflight.outputs.run_release_check == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -232,61 +306,57 @@ jobs:
- name: Check release contents
run: pnpm release:check
checks:
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && needs.build-artifacts.result == 'success'
checks-fast:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- runtime: node
task: test
shard_index: 1
shard_count: 2
command: pnpm test
- runtime: node
task: test
shard_index: 2
shard_count: 2
command: pnpm test
- runtime: node
task: extensions
command: pnpm test:extensions
- runtime: node
task: channels
shard_index: 1
shard_count: 3
command: pnpm test:channels
- runtime: node
task: contracts
command: pnpm test:contracts
- runtime: node
task: channels
shard_index: 2
shard_count: 3
command: pnpm test:channels
- runtime: node
task: channels
shard_index: 3
shard_count: 3
command: pnpm test:channels
- runtime: node
task: protocol
command: pnpm protocol:check
- runtime: node
task: compat-node22
node_version: "22.x"
cache_key_suffix: "node22"
command: |
pnpm build
pnpm ui:build
node openclaw.mjs --help
node openclaw.mjs status --json --timeout 1
pnpm test:build:singleton
node scripts/stage-bundled-plugin-runtime-deps.mjs
node --import tsx scripts/release-check.ts
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
env:
TASK: ${{ matrix.task }}
shell: bash
run: |
set -euo pipefail
case "$TASK" in
extensions)
pnpm test:extensions
;;
contracts|contracts-protocol)
pnpm test:contracts
pnpm protocol:check
;;
*)
echo "Unsupported checks-fast task: $TASK" >&2
exit 1
;;
esac
checks:
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_matrix) }}
steps:
- name: Skip compatibility lanes on pull requests
if: github.event_name == 'pull_request' && matrix.task == 'compat-node22'
@@ -296,6 +366,7 @@ jobs:
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -310,6 +381,7 @@ jobs:
- name: Configure Node test resources
if: (github.event_name != 'pull_request' || matrix.task != 'compat-node22') && matrix.runtime == 'node' && (matrix.task == 'test' || matrix.task == 'channels' || matrix.task == 'compat-node22')
env:
TASK: ${{ matrix.task }}
SHARD_COUNT: ${{ matrix.shard_count || '' }}
SHARD_INDEX: ${{ matrix.shard_index || '' }}
run: |
@@ -317,7 +389,7 @@ jobs:
# Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
if [ "${{ matrix.task }}" = "channels" ]; then
if [ "$TASK" = "channels" ]; then
echo "OPENCLAW_TEST_WORKERS=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_ISOLATE=1" >> "$GITHUB_ENV"
fi
@@ -342,21 +414,47 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
run: ${{ matrix.command }}
env:
TASK: ${{ matrix.task }}
shell: bash
run: |
set -euo pipefail
case "$TASK" in
test)
pnpm test
;;
channels)
pnpm test:channels
;;
compat-node22)
pnpm build
pnpm ui:build
node openclaw.mjs --help
node openclaw.mjs status --json --timeout 1
pnpm test:build:singleton
node scripts/stage-bundled-plugin-runtime-deps.mjs
node --import tsx scripts/release-check.ts
;;
*)
echo "Unsupported checks task: $TASK" >&2
exit 1
;;
esac
extension-fast:
name: "extension-fast"
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && needs.preflight.outputs.has_changed_extensions == 'true'
if: needs.preflight.outputs.run_extension_fast == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.changed_extensions_matrix) }}
matrix: ${{ fromJson(needs.preflight.outputs.extension_fast_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -374,13 +472,14 @@ jobs:
check:
name: "check"
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
if: always() && needs.preflight.outputs.run_check == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -398,13 +497,14 @@ jobs:
check-additional:
name: "check-additional"
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
if: always() && needs.preflight.outputs.run_check_additional == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -496,13 +596,14 @@ jobs:
build-smoke:
name: "build-smoke"
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
if: always() && needs.preflight.outputs.run_build_smoke == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -537,13 +638,14 @@ jobs:
# Validate docs (format, lint, broken links) only when docs files changed.
check-docs:
needs: [preflight]
if: needs.preflight.outputs.docs_changed == 'true'
if: needs.preflight.outputs.run_check_docs == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -557,13 +659,14 @@ jobs:
skills-python:
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.preflight.outputs.run_skills_python == 'true')
if: needs.preflight.outputs.run_skills_python_job == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Python
@@ -583,8 +686,9 @@ jobs:
run: python -m pytest -q skills
checks-windows:
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_windows == 'true' && needs.build-artifacts.result == 'success'
if: always() && needs.preflight.outputs.run_checks_windows == 'true' && needs.build-artifacts.result == 'success'
runs-on: blacksmith-32vcpu-windows-2025
timeout-minutes: 20
env:
@@ -597,52 +701,12 @@ jobs:
shell: bash
strategy:
fail-fast: false
matrix:
include:
- runtime: node
task: test
shard_index: 1
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 2
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 3
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 4
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 5
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 6
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 7
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 8
shard_count: 8
command: pnpm test
matrix: ${{ fromJson(needs.preflight.outputs.checks_windows_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Try to exclude workspace from Windows Defender (best-effort)
@@ -706,9 +770,12 @@ jobs:
- name: Configure test shard (Windows)
if: matrix.task == 'test'
env:
SHARD_COUNT: ${{ matrix.shard_count }}
SHARD_INDEX: ${{ matrix.shard_index }}
run: |
echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_SHARDS=$SHARD_COUNT" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV"
- name: Download dist artifact
if: matrix.task == 'test'
@@ -725,21 +792,35 @@ jobs:
path: src/canvas-host/a2ui/
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
env:
TASK: ${{ matrix.task }}
shell: bash
run: |
set -euo pipefail
case "$TASK" in
test)
pnpm test
;;
*)
echo "Unsupported Windows checks task: $TASK" >&2
exit 1
;;
esac
# Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
# on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
# running 4 separate jobs per PR (as before) starved the queue. One job
# per PR allows 5 PRs to run macOS checks simultaneously.
macos:
needs: [preflight]
if: github.event_name == 'pull_request' && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_macos == 'true'
macos-node:
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.run_macos_node == 'true' && needs.build-artifacts.result == 'success'
runs-on: macos-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.macos_node_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -747,16 +828,56 @@ jobs:
with:
install-bun: "false"
- name: Build dist (macOS)
run: pnpm build
- name: Download dist artifact
uses: actions/download-artifact@v8
with:
name: dist-build
path: dist/
- name: Download A2UI bundle artifact
uses: actions/download-artifact@v8
with:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
- name: Configure test shard (macOS)
env:
SHARD_COUNT: ${{ matrix.shard_count }}
SHARD_INDEX: ${{ matrix.shard_index }}
run: |
echo "OPENCLAW_TEST_SHARDS=$SHARD_COUNT" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV"
# --- Run all checks sequentially (fast gates first) ---
- name: TS tests (macOS)
env:
NODE_OPTIONS: --max-old-space-size=4096
run: pnpm test
TASK: ${{ matrix.task }}
shell: bash
run: |
set -euo pipefail
case "$TASK" in
test)
pnpm test
;;
*)
echo "Unsupported macOS node task: $TASK" >&2
exit 1
;;
esac
macos-swift:
name: "macos-swift"
needs: [preflight]
if: needs.preflight.outputs.run_macos_swift == 'true'
runs-on: macos-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
# --- Xcode/Swift setup ---
- name: Select Xcode 26.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
@@ -765,6 +886,14 @@ jobs:
- name: Install XcodeGen / SwiftLint / SwiftFormat
run: brew install xcodegen swiftlint swiftformat
- name: Cache SwiftPM
uses: actions/cache@v5
with:
path: ~/Library/Caches/org.swift.swiftpm
key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
restore-keys: |
${{ runner.os }}-swiftpm-
- name: Show toolchain
run: |
sw_vers
@@ -776,14 +905,6 @@ jobs:
swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
- name: Cache SwiftPM
uses: actions/cache@v5
with:
path: ~/Library/Caches/org.swift.swiftpm
key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
restore-keys: |
${{ runner.os }}-swiftpm-
- name: Swift build (release)
run: |
set -euo pipefail
@@ -809,26 +930,19 @@ jobs:
exit 1
android:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_android == 'true'
if: needs.preflight.outputs.run_android_job == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- task: test-play
command: ./gradlew --no-daemon :app:testPlayDebugUnitTest
- task: test-third-party
command: ./gradlew --no-daemon :app:testThirdPartyDebugUnitTest
- task: build-play
command: ./gradlew --no-daemon :app:assemblePlayDebug
- task: build-third-party
command: ./gradlew --no-daemon :app:assembleThirdPartyDebug
matrix: ${{ fromJson(needs.preflight.outputs.android_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Java
@@ -858,7 +972,7 @@ jobs:
echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5
with:
gradle-version: 8.11.1
@@ -872,4 +986,26 @@ jobs:
- name: Run Android ${{ matrix.task }}
working-directory: apps/android
run: ${{ matrix.command }}
env:
TASK: ${{ matrix.task }}
shell: bash
run: |
set -euo pipefail
case "$TASK" in
test-play)
./gradlew --no-daemon :app:testPlayDebugUnitTest
;;
test-third-party)
./gradlew --no-daemon :app:testThirdPartyDebugUnitTest
;;
build-play)
./gradlew --no-daemon :app:assemblePlayDebug
;;
build-third-party)
./gradlew --no-daemon :app:assembleThirdPartyDebug
;;
*)
echo "Unsupported Android task: $TASK" >&2
exit 1
;;
esac

View File

@@ -20,7 +20,7 @@ on:
type: string
concurrency:
group: docker-release-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
group: ${{ github.event_name == 'workflow_dispatch' && format('docker-release-manual-{0}', inputs.tag) || format('docker-release-push-{0}', github.run_id) }}
cancel-in-progress: false
env:

View File

@@ -8,56 +8,41 @@ on:
workflow_dispatch:
concurrency:
group: ${{ github.event_name == 'pull_request' && format('install-smoke-pr-{0}', github.event.pull_request.number) || format('install-smoke-push-{0}', github.run_id) }}
group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
docs-scope:
preflight:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: blacksmith-16vcpu-ubuntu-2404
outputs:
docs_only: ${{ steps.check.outputs.docs_only }}
docs_only: ${{ steps.manifest.outputs.docs_only }}
run_install_smoke: ${{ steps.manifest.outputs.run_install_smoke }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure docs-scope base commit
- name: Ensure preflight base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Detect docs-only changes
id: check
id: docs_scope
uses: ./.github/actions/detect-docs-changes
changed-smoke:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
outputs:
run_changed_smoke: ${{ steps.scope.outputs.run_changed_smoke }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tags: false
- name: Ensure changed-smoke base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Detect changed smoke scope
id: scope
id: changed_scope
if: steps.docs_scope.outputs.docs_only != 'true'
shell: bash
run: |
set -euo pipefail
@@ -70,9 +55,32 @@ jobs:
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
- name: Setup Node environment
if: steps.docs_scope.outputs.docs_only != 'true'
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
use-sticky-disk: "false"
- name: Build install-smoke CI manifest
id: manifest
env:
OPENCLAW_CI_DOCS_ONLY: ${{ steps.docs_scope.outputs.docs_only }}
OPENCLAW_CI_DOCS_CHANGED: "false"
OPENCLAW_CI_RUN_NODE: "false"
OPENCLAW_CI_RUN_MACOS: "false"
OPENCLAW_CI_RUN_ANDROID: "false"
OPENCLAW_CI_RUN_WINDOWS: "false"
OPENCLAW_CI_RUN_SKILLS_PYTHON: "false"
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: "false"
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: '{"include":[]}'
OPENCLAW_CI_RUN_CHANGED_SMOKE: ${{ steps.changed_scope.outputs.run_changed_smoke || 'false' }}
run: node scripts/ci-write-manifest-outputs.mjs --workflow install-smoke
install-smoke:
needs: [docs-scope, changed-smoke]
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-smoke.outputs.run_changed_smoke == 'true'
needs: [preflight]
if: needs.preflight.outputs.run_install_smoke == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
env:
DOCKER_BUILD_SUMMARY: "false"

View File

@@ -19,6 +19,10 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
permissions: {}
jobs:

View File

@@ -15,7 +15,7 @@ on:
- scripts/sandbox-common-setup.sh
concurrency:
group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:

View File

@@ -7,7 +7,7 @@ on:
workflow_dispatch:
concurrency:
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:

View File

@@ -119,8 +119,8 @@
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
- Do not set test workers above 16; tried already.
- Do not reintroduce Vitest VM pools by default without fresh green evidence on current `main`; keep CI on `forks`.
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
- Keep Vitest on `forks` only. Do not introduce or reintroduce any non-`forks` Vitest pool or alternate execution mode in configs, wrapper scripts, or default test commands without explicit approval in this chat. This includes `threads`, `vmThreads`, `vmForks`, and any future/nonstandard pool variant.
- If local Vitest runs cause memory pressure, the wrapper now derives budgets from host capabilities (CPU, memory band, current load). For a conservative explicit override during land/gate runs, use `OPENCLAW_TEST_PROFILE=serial OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test`.
- Live tests (real keys): `OPENCLAW_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- Full kit + whats covered: `docs/help/testing.md`.
- Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process).

View File

@@ -8,10 +8,161 @@ Docs: https://docs.openclaw.ai
### Changes
- MiniMax: add image generation provider for `image-01` model, supporting generate and image-to-image editing with aspect ratio control. (#54487) Thanks @liyuan97.
- MiniMax: trim model catalog to M2.7 only, removing legacy M2, M2.1, M2.5, and VL-01 models. (#54487) Thanks @liyuan97.
- Plugins/runtime: expose `runHeartbeatOnce` in the plugin runtime `system` namespace so plugins can trigger a single heartbeat cycle with an explicit delivery target override (e.g. `heartbeat: { target: "last" }`). (#40299) Thanks @loveyana.
- Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual `/compact` no-op cases as skipped instead of failed. (#51072) Thanks @afurm.
- Agents/compaction: preserve the post-compaction AGENTS refresh on stale-usage preflight compaction for both immediate replies and queued followups. (#49479) Thanks @jared596.
- CLI: add `openclaw config schema` to print the generated JSON schema for `openclaw.json`. (#54523) Thanks @kvokka.
### Fixes
- BlueBubbles/CLI agents: restore inbound prompt image refs for CLI routed turns, reapply embedded runner image size guardrails, and cover both CLI image transport paths with regression tests. (#51373)
- 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.
- Telegram: deliver verbose tool summaries inside forum topic sessions again, so threaded topic chats now match DM verbose behavior. (#43236) Thanks @frankbuild.
- Agents/sandbox: honor `tools.sandbox.tools.alsoAllow`, let explicit sandbox re-allows remove matching built-in default-deny tools, and keep sandbox explain/error guidance aligned with the effective sandbox tool policy. (#54492) Thanks @ngutman.
- Agents/sandbox: make blocked-tool guidance glob-aware again, redact/sanitize session-specific explain hints for safer copy-paste, and avoid leaking control-character session keys in those hints. (#54684) Thanks @ngutman.
- Feishu: close WebSocket connections on monitor stop/abort so ghost connections no longer persist, preventing duplicate event processing and resource leaks across restart cycles. (#52844) Thanks @schumilin.
- Feishu: use the original message `create_time` instead of `Date.now()` for inbound timestamps so offline-retried messages carry the correct authoring time, preventing mis-targeted agent actions on stale instructions. (#52809) Thanks @schumilin.
- Daemon/Linux: stop flagging non-gateway systemd services as duplicate gateways just because their unit files mention OpenClaw, reducing false-positive doctor/log noise. (#45328) Thanks @gregretkowski.
- Plugins/SDK: thread `moduleUrl` through plugin-sdk alias resolution so user-installed plugins outside the openclaw directory (e.g. `~/.openclaw/extensions/`) correctly resolve `openclaw/plugin-sdk/*` subpath imports, and gate `plugin-sdk:check-exports` in `release:check`. (#54283) Thanks @xieyongliang.
- Telegram/pairing: ignore self-authored DM `message` updates so bot-pinned status cards and similar service updates do not trigger bogus pairing requests or re-enter inbound dispatch. (#54530) thanks @huntharo
- iMessage: stop leaking inline `[[reply_to:...]]` tags into delivered text by sending `reply_to` as RPC metadata and stripping stray directive tags from outbound messages. (#39512) Thanks @mvanhorn.
- 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.
- 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.
- Agents/compaction: trigger timeout recovery compaction before retrying high-context LLM timeouts so embedded runs stop repeating oversized requests. (#46417) thanks @joeykrug.
- Microsoft Teams/config: accept the existing `welcomeCard`, `groupWelcomeCard`, `promptStarters`, and feedback/reflection keys in strict config validation so already-supported Teams runtime settings stop failing schema checks. (#54679) Thanks @gumclaw.
- CLI/plugins: make routed commands use the same auto-enabled bundled-channel snapshot as gateway startup, so configured bundled channels like Slack load without requiring a prior config rewrite. (#54809) Thanks @neeravmakwana.
- 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.
- 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
- 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
## 2026.3.24
### Breaking
### Changes
- Gateway/OpenAI compatibility: add `/v1/models` and `/v1/embeddings`, and forward explicit model overrides through `/v1/chat/completions` and `/v1/responses` for broader client and RAG compatibility. Thanks @vincentkoc.
- Agents/tools: make `/tools` show the tools the current agent can actually use right now, add a compact default view with an optional detailed mode, and add a live "Available Right Now" section in the Control UI so it is easier to see what will work before you ask.
- Microsoft Teams: migrate to the official Teams SDK and add AI-agent UX best practices including streaming 1:1 replies, welcome cards with prompt starters, feedback/reflection, informative status updates, typing indicators, and native AI labeling. (#51808)
- Microsoft Teams: add message edit and delete support for sent messages, including in-thread fallbacks when no explicit target is provided. (#49925)
- Skills/install metadata: add one-click install recipes to bundled skills (coding-agent, gh-issues, openai-whisper-api, session-logs, tmux, trello, weather) so the CLI and Control UI can offer dependency installation when requirements are missing. (#53411) Thanks @BunsDev.
- Control UI/skills: add status-filter tabs (All / Ready / Needs Setup / Disabled) with counts, replace inline skill cards with a click-to-detail dialog showing requirements, toggle switch, install action, API key entry, source metadata, and homepage link. (#53411) Thanks @BunsDev.
- Slack/interactive replies: restore rich reply parity for direct deliveries, auto-render simple trailing `Options:` lines as buttons/selects, improve Slack interactive setup defaults, and isolate reply controls from plugin interactive handlers. (#53389) Thanks @vincentkoc.
- CLI/containers: add `--container` and `OPENCLAW_CONTAINER` to run `openclaw` commands inside a running Docker or Podman OpenClaw container. (#52651) Thanks @sallyom.
- Discord/auto threads: add optional `autoThreadName: "generated"` naming so new auto-created threads can be renamed asynchronously with concise LLM-generated titles while keeping the existing message-based naming as the default. (#43366) Thanks @davidguttman.
- Plugins/hooks: add `before_dispatch` with canonical inbound metadata and route handled replies through the normal final-delivery path, preserving TTS and routed delivery semantics. (#50444) Thanks @gfzhx.
- Control UI/agents: convert agent workspace file rows to expandable `<details>` with lazy-loaded inline markdown preview, and add comprehensive `.sidebar-markdown` styles for headings, lists, code blocks, tables, blockquotes, and details/summary elements. (#53411) Thanks @BunsDev.
- Control UI/markdown preview: restyle the agent workspace file preview dialog with a frosted backdrop, sized panel, and styled header, and integrate `@create-markdown/preview` v2 system theme for rich markdown rendering (headings, tables, code blocks, callouts, blockquotes) that auto-adapts to the app's light/dark design tokens. (#53411) Thanks @BunsDev.
- macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev.
- CLI/skills: soften missing-requirements label from "missing" to "needs setup" and surface API key setup guidance (where to get a key, CLI save command, storage path) in `openclaw skills info` output. (#53411) Thanks @BunsDev.
- macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.
- Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev.
- Runtime/install: lower the supported Node 22 floor to `22.14+` while continuing to recommend Node 24, so npm installs and self-updates do not strand Node 22.14 users on older releases.
- CLI/update: preflight the target npm package `engines.node` before `openclaw update` runs a global package install, so outdated Node runtimes fail with a clear upgrade message instead of attempting an unsupported latest release.
### Fixes
- Outbound media/local files: align outbound media access with the configured fs policy so host-local files and inbound-media paths keep sending when `workspaceOnly` is off, while strict workspace-only agents remain sandboxed.
- Security/sandbox media dispatch: close the `mediaUrl`/`fileUrl` alias bypass so outbound tool and message actions cannot escape media-root restrictions. (#54034)
- Gateway/restart sentinel: wake the interrupted agent session via heartbeat after restart instead of only sending a best-effort restart note, retry outbound delivery once on transient failure, and preserve explicit thread/topic routing through the wake path so replies land in the correct Telegram topic or Slack thread. (#53940) Thanks @VACInc.
- Docker/setup: avoid the pre-start `openclaw-cli` shared-network namespace loop by routing setup-time onboard/config writes through `openclaw-gateway`, so fresh Docker installs stop failing before the gateway comes up. (#53385) Thanks @amsminn.
- Gateway/channels: keep channel startup sequential while isolating per-channel boot failures, so one broken channel no longer blocks later channels from starting. (#54215) Thanks @JonathanJing.
- Embedded runs/secrets: stop unresolved `SecretRef` config from crashing embedded agent runs by falling back to the resolved runtime snapshot when needed. Fixes #45838.
- WhatsApp/groups: track recent gateway-sent message IDs and suppress only matching group echoes, preserving owner `/status`, `/new`, and `/activation` commands from linked-account `fromMe` traffic. (#53624) Thanks @w-sss.
- WhatsApp/reply-to-bot detection: restore implicit group reply detection by unwrapping `botInvokeMessage` payloads and reading `selfLid` from `creds.json`, so reply-based mentions reach the bot again in linked-account group chats.
- Telegram/forum topics: recover `#General` topic `1` routing when Telegram omits forum metadata, including native commands, interactive callbacks, inbound message context, and fallback error replies. (#53699) thanks @huntharo
- Discord/gateway supervision: centralize gateway error handling behind a lifetime-owned supervisor so early, active, and late-teardown Carbon gateway errors stay classified consistently and stop surfacing as process-killing teardown crashes.
- Discord/timeouts: send a visible timeout reply when the inbound Discord worker times out before a final reply starts, including created auto-thread targets and queued-run ordering. (#53823) Thanks @Kimbo7870.
- ACP/direct chats: always deliver a terminal ACP result when final TTS does not yield audio, even if block text already streamed earlier, and skip redundant empty-text final synthesis. (#53692) Thanks @w-sss.
- Telegram/outbound errors: preserve actionable 403 membership/block/kick details and treat `bot not a member` as a permanent delivery failure so Telegram sends stop retrying doomed chats. (#53635) Thanks @w-sss.
- Telegram/photos: preflight Telegram photo dimension and aspect-ratio rules, and fall back to document sends when image metadata is invalid or unavailable so photo uploads stop failing with `PHOTO_INVALID_DIMENSIONS`. (#52545) Thanks @hnshah.
- Slack/runtime defaults: trim Slack DM reply overhead, restore Codex auto transport, and tighten Slack/web-search runtime defaults around DM preview threading, cache scoping, warning dedupe, and explicit web-search opt-in. (#53957) Thanks @vincentkoc.
- WhatsApp/allowFrom: show a specific allowFrom policy error for valid blocked targets instead of the misleading `<E.164|group JID>` format hint. Thanks @mcaxtr.
## 2026.3.24-beta.2
### Breaking
### Changes
### Fixes
- Outbound media/local files: align outbound media access with the configured fs policy so host-local files and inbound-media paths keep sending when `workspaceOnly` is off, while strict workspace-only agents remain sandboxed.
- Runtime/install: lower the supported Node 22 floor to `22.14+` while continuing to recommend Node 24, so npm installs and self-updates do not strand Node 22.14 users on older releases.
- CLI/update: preflight the target npm package `engines.node` before `openclaw update` runs a global package install, so outdated Node runtimes fail with a clear upgrade message instead of attempting an unsupported latest release.
- Tests/security audit: isolate audit-test home and personal skill resolution so local `~/.agents/skills` installs no longer make maintainer prep runs fail nondeterministically. (#54473) thanks @huntharo
## 2026.3.24-beta.1
### Breaking
### Changes
- Gateway/OpenAI compatibility: add `/v1/models` and `/v1/embeddings`, and forward explicit model overrides through `/v1/chat/completions` and `/v1/responses` for broader client and RAG compatibility. Thanks @vincentkoc.
- Agents/tools: make `/tools` show the tools the current agent can actually use right now, add a compact default view with an optional detailed mode, and add a live “Available Right Now” section in the Control UI so it is easier to see what will work before you ask.
- Microsoft Teams: migrate to the official Teams SDK and add AI-agent UX best practices including streaming 1:1 replies, welcome cards with prompt starters, feedback/reflection, informative status updates, typing indicators, and native AI labeling. (#51808)
- Microsoft Teams: add message edit and delete support for sent messages, including in-thread fallbacks when no explicit target is provided. (#49925)
- Skills/install metadata: add one-click install recipes to bundled skills (coding-agent, gh-issues, openai-whisper-api, session-logs, tmux, trello, weather) so the CLI and Control UI can offer dependency installation when requirements are missing. (#53411) Thanks @BunsDev.
- Control UI/skills: add status-filter tabs (All / Ready / Needs Setup / Disabled) with counts, replace inline skill cards with a click-to-detail dialog showing requirements, toggle switch, install action, API key entry, source metadata, and homepage link. (#53411) Thanks @BunsDev.
- Slack/interactive replies: restore rich reply parity for direct deliveries, auto-render simple trailing `Options:` lines as buttons/selects, improve Slack interactive setup defaults, and isolate reply controls from plugin interactive handlers. (#53389) Thanks @vincentkoc.
- CLI/containers: add `--container` and `OPENCLAW_CONTAINER` to run `openclaw` commands inside a running Docker or Podman OpenClaw container. (#52651) Thanks @sallyom.
- Discord/auto threads: add optional `autoThreadName: "generated"` naming so new auto-created threads can be renamed asynchronously with concise LLM-generated titles while keeping the existing message-based naming as the default. (#43366) Thanks @davidguttman.
- Plugins/hooks: add `before_dispatch` with canonical inbound metadata and route handled replies through the normal final-delivery path, preserving TTS and routed delivery semantics. (#50444) Thanks @gfzhx.
- Control UI/agents: convert agent workspace file rows to expandable `<details>` with lazy-loaded inline markdown preview, and add comprehensive `.sidebar-markdown` styles for headings, lists, code blocks, tables, blockquotes, and details/summary elements. (#53411) Thanks @BunsDev.
- Control UI/markdown preview: restyle the agent workspace file preview dialog with a frosted backdrop, sized panel, and styled header, and integrate `@create-markdown/preview` v2 system theme for rich markdown rendering (headings, tables, code blocks, callouts, blockquotes) that auto-adapts to the app's light/dark design tokens. (#53411) Thanks @BunsDev.
- macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev.
- CLI/skills: soften missing-requirements label from "missing" to "needs setup" and surface API key setup guidance (where to get a key, CLI save command, storage path) in `openclaw skills info` output. (#53411) Thanks @BunsDev.
- macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.
- Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev.
### Fixes
- Security/sandbox media dispatch: close the `mediaUrl`/`fileUrl` alias bypass so outbound tool and message actions cannot escape media-root restrictions. (#54034)
- Gateway/restart sentinel: wake the interrupted agent session via heartbeat after restart instead of only sending a best-effort restart note, retry outbound delivery once on transient failure, and preserve explicit thread/topic routing through the wake path so replies land in the correct Telegram topic or Slack thread. (#53940) Thanks @VACInc.
- Docker/setup: avoid the pre-start `openclaw-cli` shared-network namespace loop by routing setup-time onboard/config writes through `openclaw-gateway`, so fresh Docker installs stop failing before the gateway comes up. (#53385) Thanks @amsminn.
- Gateway/channels: keep channel startup sequential while isolating per-channel boot failures, so one broken channel no longer blocks later channels from starting. (#54215) Thanks @JonathanJing.
- Embedded runs/secrets: stop unresolved `SecretRef` config from crashing embedded agent runs by falling back to the resolved runtime snapshot when needed. Fixes #45838.
- WhatsApp/groups: track recent gateway-sent message IDs and suppress only matching group echoes, preserving owner `/status`, `/new`, and `/activation` commands from linked-account `fromMe` traffic. (#53624) Thanks @w-sss.
- WhatsApp/reply-to-bot detection: restore implicit group reply detection by unwrapping `botInvokeMessage` payloads and reading `selfLid` from `creds.json`, so reply-based mentions reach the bot again in linked-account group chats.
- Telegram/forum topics: recover `#General` topic `1` routing when Telegram omits forum metadata, including native commands, interactive callbacks, inbound message context, and fallback error replies. (#53699) thanks @huntharo
- Discord/gateway supervision: centralize gateway error handling behind a lifetime-owned supervisor so early, active, and late-teardown Carbon gateway errors stay classified consistently and stop surfacing as process-killing teardown crashes.
- Discord/timeouts: send a visible timeout reply when the inbound Discord worker times out before a final reply starts, including created auto-thread targets and queued-run ordering. (#53823) Thanks @Kimbo7870.
- ACP/direct chats: always deliver a terminal ACP result when final TTS does not yield audio, even if block text already streamed earlier, and skip redundant empty-text final synthesis. (#53692) Thanks @w-sss.
- Telegram/outbound errors: preserve actionable 403 membership/block/kick details and treat `bot not a member` as a permanent delivery failure so Telegram sends stop retrying doomed chats. (#53635) Thanks @w-sss.
- Telegram/photos: preflight Telegram photo dimension and aspect-ratio rules, and fall back to document sends when image metadata is invalid or unavailable so photo uploads stop failing with `PHOTO_INVALID_DIMENSIONS`. (#52545) Thanks @hnshah.
- Slack/runtime defaults: trim Slack DM reply overhead, restore Codex auto transport, and tighten Slack/web-search runtime defaults around DM preview threading, cache scoping, warning dedupe, and explicit web-search opt-in. (#53957) Thanks @vincentkoc.
- Doctor/image generation: seed migrated legacy Nano Banana Google provider config with the `/v1beta` API root and an empty model list so `openclaw doctor --fix` completes and the migrated native Google image path keeps hitting the correct endpoint. (#53757) Thanks @mahopan.
- Models/google: normalize bare Google Generative AI API roots for custom provider names, and keep built-in Google model-id rewrites working when `api` is declared only on individual models, so custom Google lanes and older configs stop missing `/v1beta` or preview-id normalization. (#44969) Thanks @Kathie-yu.
- Feishu/startup: treat unresolved `SecretRef` app credentials as not configured during account resolution so CLI startup and read-only Feishu config surfaces stop crashing before runtime-backed secret resolution is available. (#53675) Thanks @hpt.
- Feishu/groups: when `groupPolicy` is `open`, stop implicitly requiring @mentions for unset `requireMention`, so image, file, audio, and other non-text group messages reach the bot unless operators explicitly keep mention gating on. (#54058) Thanks @byungsker.
- Feishu/startup: keep `requireMention` enforcement strict when bot identity startup probes fail, raise the startup bot-info timeout to 30s, and add cancellable background identity recovery so mention-gated groups recover without noisy fallback. (#43788) Thanks @lefarcen.
- Feishu/MSTeams message tool: keep provider-native `card` payloads optional in merged tool schemas so media-only sends stop failing validation before channel runtime dispatch. (#53715) Thanks @lndyzwdxhs.
- Feishu/docx block ordering: preserve the document tree order from `docx.document.convert` when inserting blocks, fixing heading/paragraph/list misordering in newly written Feishu documents. (#40524) Thanks @TaoXieSZ.
- Telegram/native commands: run native slash-command execution against the resolved runtime snapshot so DM commands still reply when fresh config reads surface unresolved SecretRefs. (#53179) Thanks @nimbleenigma.
- Gateway/ports: parse Docker Compose-style `OPENCLAW_GATEWAY_PORT` host publish values correctly without reviving the legacy `CLAWDBOT_GATEWAY_PORT` override. (#44083) Thanks @bebule.
- Plugins/memory-lancedb: bootstrap the env-configured HTTP/HTTPS proxy dispatcher before OpenAI embeddings requests so memory capture and recall work in proxy-required environments again. (#54119) Thanks @neeravmakwana.
- Runtime/build: stabilize long-lived lazy `dist` runtime entry paths and harden bundled plugin npm staging so local rebuilds stop breaking on missing hashed chunks or broken shell `npm` shims. (#53855) Thanks @vincentkoc.
- Security/skills: validate skill installer metadata against strict regex allowlists per package manager, sanitize skill metadata for terminal output, add URL protocol allowlisting in markdown preview and skill homepage links, warn on non-bundled skill install sources, and remove unsafe `file://` workspace links. (#53471) Thanks @BunsDev.
- Memory/builtin sqlite: cut redundant sync and status query churn by snapshotting file state once per source, reusing sync statements, and consolidating status aggregation reads, which reduces builtin memory overhead on sync/status/doctor-style paths. Thanks @vincentkoc.
- TUI/chat: preserve pending user messages when a slow local run emits an empty final event, but still defer and flush the needed history reload after the newer active run finishes so silent/tool-only runs do not stay incomplete. (#53130) Thanks @joelnishanth.
- DeepSeek/pricing: replace the zero-cost DeepSeek catalog rates with the current DeepSeek V3.2 pricing so usage totals stop showing `$0.00` for DeepSeek sessions. (#54143) Thanks @arkyu2077.
- CLI/logging: make pretty log timestamps always include an explicit timezone offset in default UTC and `--local-time` modes, so incident triage no longer mixes ambiguous clock displays. (#38904) Thanks @sahilsatralkar.
- Browser/default detection: recognize macOS LaunchServices Edge bundle ids so default Chromium detection stops falling back to Chrome when Edge is the system default. (#48561) Thanks @zoherghadyali.
- CLI/Telegram topics: route `message thread create` through Telegram `topic-create` with the required topic `name` field so Telegram forum topic creation works from the CLI again. (#54336) Thanks @andyliu.
- Telegram/pairing: render pairing codes and approval commands as Telegram-only code blocks while keeping shared pairing replies plain text for other channels. (#52784) Thanks @sumukhj1219.
- Agents/cron: suppress the default heartbeat system prompt for cron-triggered embedded runs even when they target non-cron session keys, so cron tasks stop reading `HEARTBEAT.md` and polluting unrelated threads. (#53152) Thanks @Protocol-zero-0.
- Agents/cron: mark best-effort announce runs as not delivered when any payload fails, and log those partial delivery failures instead of silently reporting success. (#42535) Thanks @MoerAI.
- Plugins: enforce terminal hook decision semantics for tool/message guards (#54241) Thanks @joshavant.
- Marketplace/agents: correct the ClawHub skill URL in agent docs and stream marketplace archive downloads to disk so installs avoid excess memory use and fail cleanly on empty responses. (#54160) Thanks @QuinnH496.
- Discord/config types: add missing `autoArchiveDuration` to `DiscordGuildChannelConfig` so TypeScript config definitions match the existing schema and runtime support. (#43427) Thanks @davidguttman.
- Docs/IRC: fix five `json55` code-fence typos in the IRC channel examples so Mintlify applies JSON5 syntax highlighting correctly. (#50842) Thanks @Hollychou924.
- Discord/commands: trim overlong slash-command descriptions to Discord's 100-character limit and map rejected deploy indexes from Discord validation payloads back to command names/descriptions, so deploys stop failing on long descriptions and startup logs identify the rejected commands. (#54118) thanks @huntharo
- 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.
- Config/web fetch: allow the documented `tools.web.fetch.maxResponseBytes` setting in runtime schema validation so valid configs no longer fail with unrecognized-key errors. (#53401) Thanks @erhhung.
- Message tool/buttons: keep the shared `buttons` schema optional in merged tool definitions so plain `action=send` calls stop failing validation when no buttons are provided. (#54418) Thanks @adzendo.
## 2026.3.23
@@ -72,6 +223,8 @@ Docs: https://docs.openclaw.ai
- Gateway/supervision: stop lock conflicts from crash-looping under launchd and systemd by keeping the duplicate process in a retry wait instead of exiting as a failure while another healthy gateway still owns the lock. Fixes #52922. Thanks @vincentkoc.
- Gateway/auth: require auth for canvas routes and admin scope for agent session reset, so anonymous canvas access and non-admin reset requests fail closed.
- Release/install: keep previously released bundled plugins and Control UI assets in published openclaw npm installs, and fail release checks when those shipped artifacts are missing. Thanks @vincentkoc.
- WhatsApp/outbound sends: keep the active Web listener on a direct process-global symbol so split runtime chunks keep sharing the connected Baileys session and `openclaw message send --channel whatsapp` stops failing after connect. Fixes #52574. Thanks @MonkeyLeeT.
- Agents/process: fail loud when `send-keys` tries cursor-sensitive keys before a background PTY reports its cursor mode, so startup races no longer silently send the wrong arrow/Home/End sequences. (#51490) Thanks @liuy.
## 2026.3.22
@@ -290,6 +443,8 @@ Docs: https://docs.openclaw.ai
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc.
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
- Gateway/Telegram shutdown: abort stalled Telegram polling fetches on shutdown, clean up per-cycle abort listeners, and keep the in-process watchdog ahead of supervisor stop timeouts so SIGTERM no longer leaves zombie gateways behind. (#51242) Thanks @juliabush.
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
- Telegram/setup: warn when setup leaves DMs on pairing without an allowlist, and show valid account-scoped remediation commands. (#50710) Thanks @ernestodeoliveira.
- Doctor/Telegram: replace the fresh-install empty group-allowlist false positive with first-run guidance that explains DM pairing approval and the next group setup steps, so new Telegram installs get actionable setup help instead of a broken-config warning. Thanks @vincentkoc.
- Doctor/extensions: keep Matrix DM `allowFrom` repairs on the canonical `dm.allowFrom` path and stop treating Zalouser group sender gating as if it fell back to `allowFrom`, so doctor warnings and `--fix` stay aligned with runtime access control. Thanks @vincentkoc.
@@ -342,6 +497,8 @@ Docs: https://docs.openclaw.ai
- Exec: harden host env override handling across gateway and node (#51207) Thanks @gladiator9797 and @joshavant.
- Voice Call: enforce spoken-output contract and fix stream TTS silence regression (#51500) Thanks @joshavant.
- xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek
- Plugins/Matrix TTS: send auto-TTS replies as native Matrix voice bubbles instead of generic audio attachments. (#37080) thanks @Matthew19990919.
- Plugins/Matrix TTS: send auto-TTS replies as native Matrix voice bubbles instead of generic audio attachments. (#37080) thanks @Matthew19990919.
- Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus.
- Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc.
- ACP/configured bindings: reinitialize configured ACP sessions that are stuck in `error` state instead of reusing the failed runtime.
@@ -361,6 +518,9 @@ Docs: https://docs.openclaw.ai
- Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys.
- Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras.
- Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman.
- Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.
- CLI/update status: explicitly say `up to date` when the local version already matches npm latest, while keeping the availability logic unchanged. (#51409) Thanks @dongzhenye.
- Agents/embedded transport errors: distinguish common network failures like connection refused, DNS lookup failure, and interrupted sockets from true timeouts in embedded-run user messaging and lifecycle diagnostics. (#51419) Thanks @scoootscooob.
- Discord/startup logging: report client initialization while the gateway is still connecting instead of claiming Discord is logged in before readiness is reached. (#51425) Thanks @scoootscoob.
- Agents/compaction safeguard: preserve split-turn context and preserved recent turns when capped retry fallback reuses the last successful summary. (#27727) thanks @Pandadadadazxf.
- Agents/memory flush: keep transcript-hash dedup active across memory-flush fallback retries so a write-then-throw flush attempt cannot append duplicate `MEMORY.md` entries before the fallback cycle completes. (#34222) Thanks @lml2468.
@@ -396,6 +556,12 @@ Docs: https://docs.openclaw.ai
- Slack/embedded delivery: suppress transcript-only `delivery-mirror` assistant messages before embedded re-delivery and raise the default Slack chunk fallback so messages just over 4000 characters stay in a single post. (#45489) Thanks @theo674.
- Slack/embedded delivery: suppress transcript-only `delivery-mirror` assistant messages before embedded re-delivery and raise the default Slack chunk fallback so messages just over 4000 characters stay in a single post. (#45489) Thanks @theo674.
### Fixes
- Agents/edit tool: accept common path/text alias spellings, show current file contents on exact-match failures, and avoid false edit failures after successful writes. (#52516) thanks @mbelinky.
- Agents/compaction: reconcile `sessions.json.compactionCount` after a late embedded auto-compaction success so persisted session counts catch up once the handler reports completion. (#45493) Thanks @jackal092927.
- Mattermost/replies: keep pairing replies, slash-command fallback replies, and model-picker messages on the resolved config path so `exec:` SecretRef bot tokens work across all outbound reply branches. (#48347) thanks @mathiasnagler.
## 2026.3.13
### Changes

View File

@@ -24,7 +24,7 @@ Welcome to the lobster tank! 🦞
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
- **Ayaan Zaidi** - Telegram subsystem, Android app
- GitHub: [@obviyus](https://github.com/obviyus) · X: [@0bviyus](https://x.com/0bviyus)
- GitHub: [@obviyus](https://github.com/obviyus) · X: [@obviyus](https://x.com/obviyus)
- **Tyler Yust** - Agents/subagents, cron, BlueBubbles, macOS app
- GitHub: [@tyler6204](https://github.com/tyler6204) · X: [@tyleryust](https://x.com/tyleryust)
@@ -58,6 +58,7 @@ Welcome to the lobster tank! 🦞
- **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT
- GitHub [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
- **Josh Lehman** - Compaction, Tlon/Urbit subsystem
- GitHub [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_)
@@ -76,9 +77,6 @@ Welcome to the lobster tank! 🦞
- **Tengji (George) Zhang** - Chinese model APIs, cloud, pi
- GitHub: [@odysseus0](https://github.com/odysseus0) · X: [@odysseus0z](https://x.com/odysseus0z)
- **Andrew (Bubbles) Demczuk** - Agents/Gateway/TTS/VTT
- GitHub: [@ademczuk](https://github.com/ademczuk) · X: [@ademczuk](https://x.com/ademczuk)
## How to Contribute
1. **Bugs & small fixes** → Open a PR!

View File

@@ -19,7 +19,7 @@
</p>
**OpenClaw** is a _personal AI assistant_ you run on your own devices.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WebChat). It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WeChat, WebChat). It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
@@ -74,7 +74,7 @@ openclaw gateway --port 18789 --verbose
# Send a message
openclaw message send --to +1234567890 --message "Hello from OpenClaw"
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/IRC/Microsoft Teams/Matrix/Feishu/LINE/Mattermost/Nextcloud Talk/Nostr/Synology Chat/Tlon/Twitch/Zalo/Zalo Personal/WebChat)
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/IRC/Microsoft Teams/Matrix/Feishu/LINE/Mattermost/Nextcloud Talk/Nostr/Synology Chat/Tlon/Twitch/Zalo/Zalo Personal/WeChat/WebChat)
openclaw agent --message "Ship checklist" --thinking high
```
@@ -126,7 +126,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
## Highlights
- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events.
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WeChat, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — wake words on macOS/iOS and continuous voice on Android (ElevenLabs + system TTS fallback).
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
@@ -150,7 +150,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
### Channels
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [IRC](https://docs.openclaw.ai/channels/irc), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams), [Matrix](https://docs.openclaw.ai/channels/matrix), [Feishu](https://docs.openclaw.ai/channels/feishu), [LINE](https://docs.openclaw.ai/channels/line), [Mattermost](https://docs.openclaw.ai/channels/mattermost), [Nextcloud Talk](https://docs.openclaw.ai/channels/nextcloud-talk), [Nostr](https://docs.openclaw.ai/channels/nostr), [Synology Chat](https://docs.openclaw.ai/channels/synology-chat), [Tlon](https://docs.openclaw.ai/channels/tlon), [Twitch](https://docs.openclaw.ai/channels/twitch), [Zalo](https://docs.openclaw.ai/channels/zalo), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser), [WebChat](https://docs.openclaw.ai/web/webchat).
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [IRC](https://docs.openclaw.ai/channels/irc), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams), [Matrix](https://docs.openclaw.ai/channels/matrix), [Feishu](https://docs.openclaw.ai/channels/feishu), [LINE](https://docs.openclaw.ai/channels/line), [Mattermost](https://docs.openclaw.ai/channels/mattermost), [Nextcloud Talk](https://docs.openclaw.ai/channels/nextcloud-talk), [Nostr](https://docs.openclaw.ai/channels/nostr), [Synology Chat](https://docs.openclaw.ai/channels/synology-chat), [Tlon](https://docs.openclaw.ai/channels/tlon), [Twitch](https://docs.openclaw.ai/channels/twitch), [Zalo](https://docs.openclaw.ai/channels/zalo), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser), WeChat (`@tencent-weixin/openclaw-weixin`), [WebChat](https://docs.openclaw.ai/web/webchat).
- [Group routing](https://docs.openclaw.ai/channels/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
### Apps + nodes
@@ -185,7 +185,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
## How it works (short)
```
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / IRC / Microsoft Teams / Matrix / Feishu / LINE / Mattermost / Nextcloud Talk / Nostr / Synology Chat / Tlon / Twitch / Zalo / Zalo Personal / WebChat
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / IRC / Microsoft Teams / Matrix / Feishu / LINE / Mattermost / Nextcloud Talk / Nostr / Synology Chat / Tlon / Twitch / Zalo / Zalo Personal / WeChat / WebChat
┌───────────────────────────────┐
@@ -397,6 +397,12 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
### WeChat
- Official Tencent plugin via [`@tencent-weixin/openclaw-weixin`](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin) (iLink Bot API). Private chats only; v2.x requires OpenClaw `>=2026.3.22`.
- Install: `openclaw plugins install "@tencent-weixin/openclaw-weixin"`, then `openclaw channels login --channel openclaw-weixin` to scan the QR code.
- Requires the WeChat ClawBot plugin (WeChat > Me > Settings > Plugins); gradual rollout by Tencent.
### [WebChat](https://docs.openclaw.ai/web/webchat)
- Uses the Gateway WebSocket; no separate WebChat port/config.

View File

@@ -2,6 +2,58 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.3.24</title>
<pubDate>Wed, 25 Mar 2026 17:06:31 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026032490</sparkle:version>
<sparkle:shortVersionString>2026.3.24</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.3.24</h2>
<h3>Breaking</h3>
<h3>Changes</h3>
<ul>
<li>Gateway/OpenAI compatibility: add <code>/v1/models</code> and <code>/v1/embeddings</code>, and forward explicit model overrides through <code>/v1/chat/completions</code> and <code>/v1/responses</code> for broader client and RAG compatibility. Thanks @vincentkoc.</li>
<li>Agents/tools: make <code>/tools</code> show the tools the current agent can actually use right now, add a compact default view with an optional detailed mode, and add a live "Available Right Now" section in the Control UI so it is easier to see what will work before you ask.</li>
<li>Microsoft Teams: migrate to the official Teams SDK and add AI-agent UX best practices including streaming 1:1 replies, welcome cards with prompt starters, feedback/reflection, informative status updates, typing indicators, and native AI labeling. (#51808)</li>
<li>Microsoft Teams: add message edit and delete support for sent messages, including in-thread fallbacks when no explicit target is provided. (#49925)</li>
<li>Skills/install metadata: add one-click install recipes to bundled skills (coding-agent, gh-issues, openai-whisper-api, session-logs, tmux, trello, weather) so the CLI and Control UI can offer dependency installation when requirements are missing. (#53411) Thanks @BunsDev.</li>
<li>Control UI/skills: add status-filter tabs (All / Ready / Needs Setup / Disabled) with counts, replace inline skill cards with a click-to-detail dialog showing requirements, toggle switch, install action, API key entry, source metadata, and homepage link. (#53411) Thanks @BunsDev.</li>
<li>Slack/interactive replies: restore rich reply parity for direct deliveries, auto-render simple trailing <code>Options:</code> lines as buttons/selects, improve Slack interactive setup defaults, and isolate reply controls from plugin interactive handlers. (#53389) Thanks @vincentkoc.</li>
<li>CLI/containers: add <code>--container</code> and <code>OPENCLAW_CONTAINER</code> to run <code>openclaw</code> commands inside a running Docker or Podman OpenClaw container. (#52651) Thanks @sallyom.</li>
<li>Discord/auto threads: add optional <code>autoThreadName: "generated"</code> naming so new auto-created threads can be renamed asynchronously with concise LLM-generated titles while keeping the existing message-based naming as the default. (#43366) Thanks @davidguttman.</li>
<li>Plugins/hooks: add <code>before_dispatch</code> with canonical inbound metadata and route handled replies through the normal final-delivery path, preserving TTS and routed delivery semantics. (#50444) Thanks @gfzhx.</li>
<li>Control UI/agents: convert agent workspace file rows to expandable <code><details></code> with lazy-loaded inline markdown preview, and add comprehensive <code>.sidebar-markdown</code> styles for headings, lists, code blocks, tables, blockquotes, and details/summary elements. (#53411) Thanks @BunsDev.</li>
<li>Control UI/markdown preview: restyle the agent workspace file preview dialog with a frosted backdrop, sized panel, and styled header, and integrate <code>@create-markdown/preview</code> v2 system theme for rich markdown rendering (headings, tables, code blocks, callouts, blockquotes) that auto-adapts to the app's light/dark design tokens. (#53411) Thanks @BunsDev.</li>
<li>macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev.</li>
<li>CLI/skills: soften missing-requirements label from "missing" to "needs setup" and surface API key setup guidance (where to get a key, CLI save command, storage path) in <code>openclaw skills info</code> output. (#53411) Thanks @BunsDev.</li>
<li>macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.</li>
<li>Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev.</li>
<li>Runtime/install: lower the supported Node 22 floor to <code>22.14+</code> while continuing to recommend Node 24, so npm installs and self-updates do not strand Node 22.14 users on older releases.</li>
<li>CLI/update: preflight the target npm package <code>engines.node</code> before <code>openclaw update</code> runs a global package install, so outdated Node runtimes fail with a clear upgrade message instead of attempting an unsupported latest release.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Outbound media/local files: align outbound media access with the configured fs policy so host-local files and inbound-media paths keep sending when <code>workspaceOnly</code> is off, while strict workspace-only agents remain sandboxed.</li>
<li>Security/sandbox media dispatch: close the <code>mediaUrl</code>/<code>fileUrl</code> alias bypass so outbound tool and message actions cannot escape media-root restrictions. (#54034)</li>
<li>Gateway/restart sentinel: wake the interrupted agent session via heartbeat after restart instead of only sending a best-effort restart note, retry outbound delivery once on transient failure, and preserve explicit thread/topic routing through the wake path so replies land in the correct Telegram topic or Slack thread. (#53940) Thanks @VACInc.</li>
<li>Docker/setup: avoid the pre-start <code>openclaw-cli</code> shared-network namespace loop by routing setup-time onboard/config writes through <code>openclaw-gateway</code>, so fresh Docker installs stop failing before the gateway comes up. (#53385) Thanks @amsminn.</li>
<li>Gateway/channels: keep channel startup sequential while isolating per-channel boot failures, so one broken channel no longer blocks later channels from starting. (#54215) Thanks @JonathanJing.</li>
<li>Embedded runs/secrets: stop unresolved <code>SecretRef</code> config from crashing embedded agent runs by falling back to the resolved runtime snapshot when needed. Fixes #45838.</li>
<li>WhatsApp/groups: track recent gateway-sent message IDs and suppress only matching group echoes, preserving owner <code>/status</code>, <code>/new</code>, and <code>/activation</code> commands from linked-account <code>fromMe</code> traffic. (#53624) Thanks @w-sss.</li>
<li>WhatsApp/reply-to-bot detection: restore implicit group reply detection by unwrapping <code>botInvokeMessage</code> payloads and reading <code>selfLid</code> from <code>creds.json</code>, so reply-based mentions reach the bot again in linked-account group chats.</li>
<li>Telegram/forum topics: recover <code>#General</code> topic <code>1</code> routing when Telegram omits forum metadata, including native commands, interactive callbacks, inbound message context, and fallback error replies. (#53699) thanks @huntharo</li>
<li>Discord/gateway supervision: centralize gateway error handling behind a lifetime-owned supervisor so early, active, and late-teardown Carbon gateway errors stay classified consistently and stop surfacing as process-killing teardown crashes.</li>
<li>Discord/timeouts: send a visible timeout reply when the inbound Discord worker times out before a final reply starts, including created auto-thread targets and queued-run ordering. (#53823) Thanks @Kimbo7870.</li>
<li>ACP/direct chats: always deliver a terminal ACP result when final TTS does not yield audio, even if block text already streamed earlier, and skip redundant empty-text final synthesis. (#53692) Thanks @w-sss.</li>
<li>Telegram/outbound errors: preserve actionable 403 membership/block/kick details and treat <code>bot not a member</code> as a permanent delivery failure so Telegram sends stop retrying doomed chats. (#53635) Thanks @w-sss.</li>
<li>Telegram/photos: preflight Telegram photo dimension and aspect-ratio rules, and fall back to document sends when image metadata is invalid or unavailable so photo uploads stop failing with <code>PHOTO_INVALID_DIMENSIONS</code>. (#52545) Thanks @hnshah.</li>
<li>Slack/runtime defaults: trim Slack DM reply overhead, restore Codex auto transport, and tighten Slack/web-search runtime defaults around DM preview threading, cache scoping, warning dedupe, and explicit web-search opt-in. (#53957) Thanks @vincentkoc.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.24/OpenClaw-2026.3.24.zip" length="24749233" type="application/octet-stream" sparkle:edSignature="gLm2VvI+PPEnNy4klYSs9WmZLkJTF5BcfFparrtPdnmeE4xgc8kFfICg445I039ev9/A6xGav7pm08reUHDcAg=="/>
</item>
<item>
<title>2026.3.23</title>
<pubDate>Mon, 23 Mar 2026 16:59:51 -0700</pubDate>
@@ -119,97 +171,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.13/OpenClaw-2026.3.13.zip" length="23640917" type="application/octet-stream" sparkle:edSignature="Me63UHSpFLocTo5Lt7Iqsl0Hq61y3jTcZ9DUkiFl9xQvTE0+ORuqRMFWqPgYwfaKMgcgQmUbrV/uFzEoTIRHBA=="/>
</item>
<item>
<title>2026.3.12</title>
<pubDate>Fri, 13 Mar 2026 04:25:50 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026031290</sparkle:version>
<sparkle:shortVersionString>2026.3.12</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.3.12</h2>
<h3>Changes</h3>
<ul>
<li>Control UI/dashboard-v2: refresh the gateway dashboard with modular overview, chat, config, agent, and session views, plus a command palette, mobile bottom tabs, and richer chat tools like slash commands, search, export, and pinned messages. (#41503) Thanks @BunsDev.</li>
<li>OpenAI/GPT-5.4 fast mode: add configurable session-level fast toggles across <code>/fast</code>, TUI, Control UI, and ACP, with per-model config defaults and OpenAI/Codex request shaping.</li>
<li>Anthropic/Claude fast mode: map the shared <code>/fast</code> toggle and <code>params.fastMode</code> to direct Anthropic API-key <code>service_tier</code> requests, with live verification for both Anthropic and OpenAI fast-mode tiers.</li>
<li>Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular.</li>
<li>Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi</li>
<li>Agents/subagents: add <code>sessions_yield</code> so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff</li>
<li>Slack/agent replies: support <code>channelData.slack.blocks</code> in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Security/device pairing: switch <code>/pair</code> and <code>openclaw qr</code> setup codes to short-lived bootstrap tokens so the next release no longer embeds shared gateway credentials in chat or QR pairing payloads. Thanks @lintsinghua.</li>
<li>Security/plugins: disable implicit workspace plugin auto-load so cloned repositories cannot execute workspace plugin code without an explicit trust decision. (<code>GHSA-99qw-6mr3-36qr</code>)(#44174) Thanks @lintsinghua and @vincentkoc.</li>
<li>Models/Kimi Coding: send <code>anthropic-messages</code> tools in native Anthropic format again so <code>kimi-coding</code> stops degrading tool calls into XML/plain-text pseudo invocations instead of real <code>tool_use</code> blocks. (#38669, #39907, #40552) Thanks @opriz.</li>
<li>TUI/chat log: reuse the active assistant message component for the same streaming run so <code>openclaw tui</code> no longer renders duplicate assistant replies. (#35364) Thanks @lisitan.</li>
<li>Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in <code>/models</code> button validation. (#40105) Thanks @avirweb.</li>
<li>Cron/proactive delivery: keep isolated direct cron sends out of the write-ahead resend queue so transient-send retries do not replay duplicate proactive messages after restart. (#40646) Thanks @openperf and @vincentkoc.</li>
<li>Models/Kimi Coding: send the built-in <code>User-Agent: claude-code/0.1.0</code> header by default for <code>kimi-coding</code> while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc.</li>
<li>Models/OpenAI Codex Spark: keep <code>gpt-5.3-codex-spark</code> working on the <code>openai-codex/*</code> path via resolver fallbacks and clearer Codex-only handling, while continuing to suppress the stale direct <code>openai/*</code> Spark row that OpenAI rejects live.</li>
<li>Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like <code>kimi-k2.5:cloud</code>, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc.</li>
<li>Moonshot CN API: respect explicit <code>baseUrl</code> (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt.</li>
<li>Kimi Coding/provider config: respect explicit <code>models.providers["kimi-coding"].baseUrl</code> when resolving the implicit provider so custom Kimi Coding endpoints no longer get overwritten by the built-in default. (#36353) Thanks @2233admin.</li>
<li>Gateway/main-session routing: keep TUI and other <code>mode:UI</code> main-session sends on the internal surface when <code>deliver</code> is enabled, so replies no longer inherit the session's persisted Telegram/WhatsApp route. (#43918) Thanks @obviyus.</li>
<li>BlueBubbles/self-chat echo dedupe: drop reflected duplicate webhook copies only when a matching <code>fromMe</code> event was just seen for the same chat, body, and timestamp, preventing self-chat loops without broad webhook suppression. Related to #32166. (#38442) Thanks @vincentkoc.</li>
<li>iMessage/self-chat echo dedupe: drop reflected duplicate copies only when a matching <code>is_from_me</code> event was just seen for the same chat, text, and <code>created_at</code>, preventing self-chat loops without broad text-only suppression. Related to #32166. (#38440) Thanks @vincentkoc.</li>
<li>Subagents/completion announce retries: raise the default announce timeout to 90 seconds and stop retrying gateway-timeout failures for externally delivered completion announces, preventing duplicate user-facing completion messages after slow gateway responses. Fixes #41235. Thanks @vasujain00 and @vincentkoc.</li>
<li>Mattermost/block streaming: fix duplicate message delivery (one threaded, one top-level) when block streaming is active by excluding <code>replyToId</code> from the block reply dedup key and adding an explicit <code>threading</code> dock to the Mattermost plugin. (#41362) Thanks @mathiasnagler and @vincentkoc.</li>
<li>Mattermost/reply media delivery: pass agent-scoped <code>mediaLocalRoots</code> through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.</li>
<li>macOS/Reminders: add the missing <code>NSRemindersUsageDescription</code> to the bundled app so <code>apple-reminders</code> can trigger the system permission prompt from OpenClaw.app. (#8559) Thanks @dinakars777.</li>
<li>Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated <code>session.store</code> roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.</li>
<li>Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process <code>HOME</code>/<code>OPENCLAW_HOME</code> changes no longer reuse stale plugin state or misreport <code>~/...</code> plugins as untracked. (#44046) thanks @gumadeiras.</li>
<li>Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and <code>models list --plain</code>, and migrate legacy duplicated <code>openrouter/openrouter/...</code> config entries forward on write.</li>
<li>Windows/native update: make package installs use the npm update path instead of the git path, carry portable Git into native Windows updates, and mirror the installer's Windows npm env so <code>openclaw update</code> no longer dies early on missing <code>git</code> or <code>node-llama-cpp</code> download setup.</li>
<li>Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed <code>write</code> no longer reports success while creating empty files. (#43876) Thanks @glitch418x.</li>
<li>Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible <code>\u{...}</code> escapes instead of spoofing the reviewed command. (<code>GHSA-pcqg-f7rg-xfvv</code>)(#43687) Thanks @EkiXu and @vincentkoc.</li>
<li>Hooks/loader: fail closed when workspace hook paths cannot be resolved with <code>realpath</code>, so unreadable or broken internal hook paths are skipped instead of falling back to unresolved imports. (#44437) Thanks @vincentkoc.</li>
<li>Hooks/agent deliveries: dedupe repeated hook requests by optional idempotency key so webhook retries can reuse the first run instead of launching duplicate agent executions. (#44438) Thanks @vincentkoc.</li>
<li>Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (<code>GHSA-9r3v-37xh-2cf6</code>)(#44091) Thanks @wooluo and @vincentkoc.</li>
<li>Security/exec allowlist: preserve POSIX case sensitivity and keep <code>?</code> within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (<code>GHSA-f8r2-vg7x-gh8m</code>)(#43798) Thanks @zpbrent and @vincentkoc.</li>
<li>Security/commands: require sender ownership for <code>/config</code> and <code>/debug</code> so authorized non-owner senders can no longer reach owner-only config and runtime debug surfaces. (<code>GHSA-r7vr-gr74-94p8</code>)(#44305) Thanks @tdjackey and @vincentkoc.</li>
<li>Security/gateway auth: clear unbound client-declared scopes on shared-token WebSocket connects so device-less shared-token operators cannot self-declare elevated scopes. (<code>GHSA-rqpp-rjj8-7wv8</code>)(#44306) Thanks @LUOYEcode and @vincentkoc.</li>
<li>Security/browser.request: block persistent browser profile create/delete routes from write-scoped <code>browser.request</code> so callers can no longer persist admin-only browser profile changes through the browser control surface. (<code>GHSA-vmhq-cqm9-6p7q</code>)(#43800) Thanks @tdjackey and @vincentkoc.</li>
<li>Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external <code>agent</code> callers can no longer override the gateway workspace boundary. (<code>GHSA-2rqg-gjgv-84jm</code>)(#43801) Thanks @tdjackey and @vincentkoc.</li>
<li>Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via <code>session_status</code>. (<code>GHSA-wcxr-59v9-rxr8</code>)(#43754) Thanks @tdjackey and @vincentkoc.</li>
<li>Security/agent tools: mark <code>nodes</code> as explicitly owner-only and document/test that <code>canvas</code> remains a shared trusted-operator surface unless a real boundary bypass exists.</li>
<li>Security/exec approvals: fail closed for Ruby approval flows that use <code>-r</code>, <code>--require</code>, or <code>-I</code> so approval-backed commands no longer bind only the main script while extra local code-loading flags remain outside the reviewed file snapshot.</li>
<li>Security/device pairing: cap issued and verified device-token scopes to each paired device's approved scope baseline so stale or overbroad tokens cannot exceed approved access. (<code>GHSA-2pwv-x786-56f8</code>)(#43686) Thanks @tdjackey and @vincentkoc.</li>
<li>Docs/onboarding: align the legacy wizard reference and <code>openclaw onboard</code> command docs with the Ollama onboarding flow so all onboarding reference paths now document <code>--auth-choice ollama</code>, Cloud + Local mode, and non-interactive usage. (#43473) Thanks @BruceMacD.</li>
<li>Models/secrets: enforce source-managed SecretRef markers in generated <code>models.json</code> so runtime-resolved provider secrets are not persisted when runtime projection is skipped. (#43759) Thanks @joshavant.</li>
<li>Security/WebSocket preauth: shorten unauthenticated handshake retention and reject oversized pre-auth frames before application-layer parsing to reduce pre-pairing exposure on unsupported public deployments. (<code>GHSA-jv4g-m82p-2j93</code>)(#44089) (<code>GHSA-xwx2-ppv2-wx98</code>)(#44089) Thanks @ez-lbz and @vincentkoc.</li>
<li>Security/proxy attachments: restore the shared media-store size cap for persisted browser proxy files so oversized payloads are rejected instead of overriding the intended 5 MB limit. (<code>GHSA-6rph-mmhp-h7h9</code>)(#43684) Thanks @tdjackey and @vincentkoc.</li>
<li>Security/host env: block inherited <code>GIT_EXEC_PATH</code> from sanitized host exec environments so Git helper resolution cannot be steered by host environment state. (<code>GHSA-jf5v-pqgw-gm5m</code>)(#43685) Thanks @zpbrent and @vincentkoc.</li>
<li>Security/Feishu webhook: require <code>encryptKey</code> alongside <code>verificationToken</code> in webhook mode so unsigned forged events are rejected instead of being processed with token-only configuration. (<code>GHSA-g353-mgv3-8pcj</code>)(#44087) Thanks @lintsinghua and @vincentkoc.</li>
<li>Security/Feishu reactions: preserve looked-up group chat typing and fail closed on ambiguous reaction context so group authorization and mention gating cannot be bypassed through synthetic <code>p2p</code> reactions. (<code>GHSA-m69h-jm2f-2pv8</code>)(#44088) Thanks @zpbrent and @vincentkoc.</li>
<li>Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a <code>200</code> response. (<code>GHSA-mhxh-9pjm-w7q5</code>)(#44090) Thanks @TerminalsandCoffee and @vincentkoc.</li>
<li>Security/Zalo webhook: rate limit invalid secret guesses before auth so weak webhook secrets cannot be brute-forced through unauthenticated churned requests without pre-auth <code>429</code> responses. (<code>GHSA-5m9r-p9g7-679c</code>)(#44173) Thanks @zpbrent and @vincentkoc.</li>
<li>Security/Zalouser groups: require stable group IDs for allowlist auth by default and gate mutable group-name matching behind <code>channels.zalouser.dangerouslyAllowNameMatching</code>. Thanks @zpbrent.</li>
<li>Security/Slack and Teams routing: require stable channel and team IDs for allowlist routing by default, with mutable name matching only via each channel's <code>dangerouslyAllowNameMatching</code> break-glass flag.</li>
<li>Security/exec approvals: fail closed for ambiguous inline loader and shell-payload script execution, bind the real script after POSIX shell value-taking flags, and unwrap <code>pnpm</code>/<code>npm exec</code>/<code>npx</code> script runners before approval binding. (<code>GHSA-57jw-9722-6rf2</code>)(<code>GHSA-jvqh-rfmh-jh27</code>)(<code>GHSA-x7pp-23xv-mmr4</code>)(<code>GHSA-jc5j-vg4r-j5jx</code>)(#44247) Thanks @tdjackey and @vincentkoc.</li>
<li>Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman.</li>
<li>Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub.</li>
<li>Gateway/session stores: regenerate the Swift push-test protocol models and align Windows native session-store realpath handling so protocol checks and sync session discovery stop drifting on Windows. (#44266) thanks @jalehman.</li>
<li>Context engine/session routing: forward optional <code>sessionKey</code> through context-engine lifecycle calls so plugins can see structured routing metadata during bootstrap, assembly, post-turn ingestion, and compaction. (#44157) thanks @jalehman.</li>
<li>Agents/failover: classify z.ai <code>network_error</code> stop reasons as retryable timeouts so provider connectivity failures trigger fallback instead of surfacing raw unhandled-stop-reason errors. (#43884) Thanks @hougangdev.</li>
<li>Memory/session sync: add mode-aware post-compaction session reindexing with <code>agents.defaults.compaction.postIndexSync</code> plus <code>agents.defaults.memorySearch.sync.sessions.postCompactionForce</code>, so compacted session memory can refresh immediately without forcing every deployment into synchronous reindexing. (#25561) thanks @rodrigouroz.</li>
<li>Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in <code>/models</code> button validation. (#40105) Thanks @avirweb.</li>
<li>Telegram/native command sync: suppress expected <code>BOT_COMMANDS_TOO_MUCH</code> retry error noise, add a final fallback summary log, and document the difference between command-menu overflow and real Telegram network failures.</li>
<li>Mattermost/reply media delivery: pass agent-scoped <code>mediaLocalRoots</code> through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.</li>
<li>Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process <code>HOME</code>/<code>OPENCLAW_HOME</code> changes no longer reuse stale plugin state or misreport <code>~/...</code> plugins as untracked. (#44046) thanks @gumadeiras.</li>
<li>Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated <code>session.store</code> roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.</li>
<li>Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and <code>models list --plain</code>, and migrate legacy duplicated <code>openrouter/openrouter/...</code> config entries forward on write.</li>
<li>Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when <code>hooks.allowedAgentIds</code> leaves hook routing unrestricted.</li>
<li>Agents/compaction: skip the post-compaction <code>cache-ttl</code> marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI.</li>
<li>Native chat/macOS: add <code>/new</code>, <code>/reset</code>, and <code>/clear</code> reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639.</li>
<li>Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777.</li>
<li>Cron/doctor: stop flagging canonical <code>agentTurn</code> and <code>systemEvent</code> payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici.</li>
<li>ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving <code>end_turn</code>, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby.</li>
<li>Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.12/OpenClaw-2026.3.12.zip" length="23628700" type="application/octet-stream" sparkle:edSignature="o6Zdcw36l3I0jUg14H+RBqNwrhuuSsq1WMDi4tBRa1+5TC3VCVdFKZ2hzmH2Xjru9lDEzVMP8v2A6RexSbOCBQ=="/>
</item>
</channel>
</rss>
</rss>

View File

@@ -3,6 +3,6 @@
OPENCLAW_GATEWAY_VERSION = 2026.3.24
OPENCLAW_MARKETING_VERSION = 2026.3.24
OPENCLAW_BUILD_VERSION = 202603240
OPENCLAW_BUILD_VERSION = 2026032490
#include? "../build/Version.xcconfig"

View File

@@ -46,6 +46,13 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
_ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10)
}
func compactSession(sessionKey: String) async throws {
struct Params: Codable { var key: String }
let data = try JSONEncoder().encode(Params(key: sessionKey))
let json = String(data: data, encoding: .utf8)
_ = try await self.gateway.request(method: "sessions.compact", paramsJSON: json, timeoutSeconds: 10)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
struct Params: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))

View File

@@ -73,7 +73,7 @@ extension ConfigSettings {
private var sidebar: some View {
SettingsSidebarScroll {
LazyVStack(alignment: .leading, spacing: 8) {
LazyVStack(alignment: .leading, spacing: 4) {
if self.sections.isEmpty {
Text("No config sections available.")
.font(.caption)
@@ -82,7 +82,7 @@ extension ConfigSettings {
.padding(.vertical, 4)
} else {
ForEach(self.sections) { section in
self.sidebarRow(section)
self.sidebarSection(section)
}
}
}
@@ -128,7 +128,6 @@ extension ConfigSettings {
}
self.actionRow
self.sectionHeader(section)
self.subsectionNav(section)
self.sectionForm(section)
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
@@ -182,78 +181,76 @@ extension ConfigSettings {
.buttonStyle(.bordered)
}
private func sidebarRow(_ section: ConfigSection) -> some View {
let isSelected = self.activeSectionKey == section.key
return Button {
self.selectSection(section)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(section.label)
if let help = section.help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
private func sidebarSection(_ section: ConfigSection) -> some View {
let isExpanded = self.activeSectionKey == section.key
let subsections = isExpanded ? self.resolveSubsections(for: section) : []
return VStack(alignment: .leading, spacing: 2) {
Button {
self.selectSection(section)
} label: {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
Text(section.label)
.lineLimit(1)
}
.padding(.vertical, 5)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isExpanded && subsections.isEmpty
? Color.accentColor.opacity(0.18)
: Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.contentShape(Rectangle())
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background(Color.clear)
.buttonStyle(.plain)
.contentShape(Rectangle())
if isExpanded, !subsections.isEmpty {
VStack(alignment: .leading, spacing: 1) {
self.sidebarSubRow(title: "All", key: nil, sectionKey: section.key)
ForEach(subsections) { sub in
self.sidebarSubRow(title: sub.label, key: sub.key, sectionKey: section.key)
}
}
.padding(.leading, 20)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.animation(.easeInOut(duration: 0.18), value: isExpanded)
}
private func sidebarSubRow(title: String, key: String?, sectionKey: String) -> some View {
let isSelected: Bool = {
guard self.activeSectionKey == sectionKey else { return false }
if let key { return self.activeSubsection == .key(key) }
return self.activeSubsection == .all
}()
return Button {
if let key {
self.activeSubsection = .key(key)
} else {
self.activeSubsection = .all
}
} label: {
Text(title)
.font(.callout)
.lineLimit(1)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
.contentShape(Rectangle())
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
.contentShape(Rectangle())
}
@ViewBuilder
private func subsectionNav(_ section: ConfigSection) -> some View {
let subsections = self.resolveSubsections(for: section)
if subsections.isEmpty {
EmptyView()
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
self.subsectionButton(
title: "All",
isSelected: self.activeSubsection == .all)
{
self.activeSubsection = .all
}
ForEach(subsections) { subsection in
self.subsectionButton(
title: subsection.label,
isSelected: self.activeSubsection == .key(subsection.key))
{
self.activeSubsection = .key(subsection.key)
}
}
}
.padding(.vertical, 2)
}
}
}
private func subsectionButton(
title: String,
isSelected: Bool,
action: @escaping () -> Void) -> some View
{
Button(action: action) {
Text(title)
.font(.callout.weight(.semibold))
.foregroundStyle(isSelected ? Color.accentColor : .primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private func sectionForm(_ section: ConfigSection) -> some View {
let subsection = self.activeSubsection
let defaultPath: ConfigPath = [.key(section.key)]

View File

@@ -493,8 +493,12 @@ enum OpenClawConfigFile {
return
}
let backup = self.readConfigFingerprint(at: configURL.deletingLastPathComponent().appendingPathComponent("\(configURL.lastPathComponent).bak"))
let clobberedPath = self.persistClobberedSnapshot(data: data, configURL: configURL, observedAt: observedAt)
let backup = self.readConfigFingerprint(
at: configURL.deletingLastPathComponent().appendingPathComponent("\(configURL.lastPathComponent).bak"))
let clobberedPath = self.persistClobberedSnapshot(
data: data,
configURL: configURL,
observedAt: observedAt)
self.logger.warning("config observe anomaly (\(suspicious.joined(separator: ", "))) at \(configURL.path)")
self.appendConfigObserveAudit([
"phase": "read",

View File

@@ -17,7 +17,7 @@
<key>CFBundleShortVersionString</key>
<string>2026.3.24</string>
<key>CFBundleVersion</key>
<string>202603240</string>
<string>2026032490</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -95,7 +95,8 @@ struct SkillsSettings: View {
skillKey: skill.skillKey,
skillName: skill.name,
envKey: envKey,
isPrimary: isPrimary)
isPrimary: isPrimary,
homepage: skill.homepage)
})
}
if !self.model.skills.isEmpty, self.filteredSkills.isEmpty {
@@ -258,8 +259,15 @@ private struct SkillRow: View {
guard let raw = self.skill.homepage?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
guard !raw.isEmpty else { return nil }
return URL(string: raw)
guard
!raw.isEmpty,
let url = URL(string: raw),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https"
else {
return nil
}
return url
}
private var enabledBinding: Binding<Bool> {
@@ -428,6 +436,7 @@ private struct EnvEditorState: Identifiable {
let skillName: String
let envKey: String
let isPrimary: Bool
let homepage: String?
var id: String {
"\(self.skillKey)::\(self.envKey)"
@@ -447,8 +456,15 @@ private struct EnvEditorView: View {
Text(self.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
if let homepageUrl = self.homepageUrl {
Link("Get your key →", destination: homepageUrl)
.font(.caption)
}
SecureField(self.editor.envKey, text: self.$value)
.textFieldStyle(.roundedBorder)
Text("Saved to openclaw.json under skills.entries.\(self.editor.skillKey)")
.font(.caption2)
.foregroundStyle(.tertiary)
HStack {
Button("Cancel") { self.dismiss() }
Spacer()
@@ -464,6 +480,21 @@ private struct EnvEditorView: View {
.frame(width: 420)
}
private var homepageUrl: URL? {
guard let raw = self.editor.homepage?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
guard
!raw.isEmpty,
let url = URL(string: raw),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https"
else {
return nil
}
return url
}
private var title: String {
self.editor.isPrimary ? "Set API Key" : "Set Environment Variable"
}
@@ -539,12 +570,12 @@ final class SkillsSettingsModel {
_ = try await GatewayConnection.shared.skillsUpdate(
skillKey: skillKey,
apiKey: value)
self.statusMessage = "Saved API key"
self.statusMessage = "Saved API key — stored in openclaw.json (skills.entries.\(skillKey))"
} else {
_ = try await GatewayConnection.shared.skillsUpdate(
skillKey: skillKey,
env: [envKey: value])
self.statusMessage = "Saved \(envKey)"
self.statusMessage = "Saved \(envKey) — stored in openclaw.json (skills.entries.\(skillKey).env)"
}
} catch {
self.statusMessage = error.localizedDescription
@@ -608,7 +639,8 @@ extension SkillsSettings {
skillKey: "test",
skillName: "Test Skill",
envKey: "API_KEY",
isPrimary: true),
isPrimary: true,
homepage: "https://example.com"),
onSave: { _ in })
_ = editor.body
}

View File

@@ -126,6 +126,13 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
timeoutMs: 10000)
}
func compactSession(sessionKey: String) async throws {
_ = try await GatewayConnection.shared.request(
method: "sessions.compact",
params: ["key": AnyCodable(sessionKey)],
timeoutMs: 10000)
}
func events() -> AsyncStream<OpenClawChatTransportEvent> {
AsyncStream { continuation in
let task = Task {

View File

@@ -2764,6 +2764,110 @@ public struct ToolsCatalogResult: Codable, Sendable {
}
}
public struct ToolsEffectiveParams: Codable, Sendable {
public let agentid: String?
public let sessionkey: String
public init(
agentid: String?,
sessionkey: String)
{
self.agentid = agentid
self.sessionkey = sessionkey
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case sessionkey = "sessionKey"
}
}
public struct ToolsEffectiveEntry: Codable, Sendable {
public let id: String
public let label: String
public let description: String
public let rawdescription: String
public let source: AnyCodable
public let pluginid: String?
public let channelid: String?
public init(
id: String,
label: String,
description: String,
rawdescription: String,
source: AnyCodable,
pluginid: String?,
channelid: String?)
{
self.id = id
self.label = label
self.description = description
self.rawdescription = rawdescription
self.source = source
self.pluginid = pluginid
self.channelid = channelid
}
private enum CodingKeys: String, CodingKey {
case id
case label
case description
case rawdescription = "rawDescription"
case source
case pluginid = "pluginId"
case channelid = "channelId"
}
}
public struct ToolsEffectiveGroup: Codable, Sendable {
public let id: AnyCodable
public let label: String
public let source: AnyCodable
public let tools: [ToolsEffectiveEntry]
public init(
id: AnyCodable,
label: String,
source: AnyCodable,
tools: [ToolsEffectiveEntry])
{
self.id = id
self.label = label
self.source = source
self.tools = tools
}
private enum CodingKeys: String, CodingKey {
case id
case label
case source
case tools
}
}
public struct ToolsEffectiveResult: Codable, Sendable {
public let agentid: String
public let profile: String
public let groups: [ToolsEffectiveGroup]
public init(
agentid: String,
profile: String,
groups: [ToolsEffectiveGroup])
{
self.agentid = agentid
self.profile = profile
self.groups = groups
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case profile
case groups
}
}
public struct SkillsBinsParams: Codable, Sendable {}
public struct SkillsBinsResult: Codable, Sendable {

View File

@@ -196,7 +196,7 @@ struct OpenClawConfigFileTests {
#expect(clobberedPath != nil)
if let clobberedPath {
let preserved = try String(contentsOfFile: clobberedPath, encoding: .utf8)
#expect(preserved == "\(clobbered)\n")
#expect(preserved == clobbered)
}
}
}

View File

@@ -28,6 +28,7 @@ public protocol OpenClawChatTransport: Sendable {
func setActiveSessionKey(_ sessionKey: String) async throws
func resetSession(sessionKey: String) async throws
func compactSession(sessionKey: String) async throws
}
extension OpenClawChatTransport {
@@ -40,6 +41,13 @@ extension OpenClawChatTransport {
userInfo: [NSLocalizedDescriptionKey: "sessions.reset not supported by this transport"])
}
public func compactSession(sessionKey _: String) async throws {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.compact not supported by this transport"])
}
public func abortRun(sessionKey _: String, runId _: String) async throws {
throw NSError(
domain: "OpenClawChatTransport",

View File

@@ -60,6 +60,9 @@ public final class OpenClawChatViewModel {
private var nextThinkingSelectionRequestID: UInt64 = 0
private var latestThinkingSelectionRequestIDsBySession: [String: UInt64] = [:]
private var latestThinkingLevelsBySession: [String: String] = [:]
private var isCompacting = false
private var lastCompactAt: Date?
private let compactCooldown: TimeInterval = 60
private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] {
didSet {
@@ -465,6 +468,7 @@ public final class OpenClawChatViewModel {
}
private static let resetTriggers: Set<String> = ["/new", "/reset", "/clear"]
private static let compactTriggers: Set<String> = ["/compact"]
private func performSend() async {
guard !self.isSending else { return }
@@ -476,6 +480,11 @@ public final class OpenClawChatViewModel {
await self.performReset()
return
}
if Self.compactTriggers.contains(trimmed.lowercased()) {
self.input = ""
await self.performCompact()
return
}
let sessionKey = self.sessionKey
@@ -623,6 +632,42 @@ public final class OpenClawChatViewModel {
await self.bootstrap()
}
private func performCompact() async {
guard !self.isCompacting else { return }
guard !self.isSending, self.pendingRuns.isEmpty, !self.isAborting else {
self.errorText = "Wait for the current response before compacting the session."
return
}
if let lastCompactAt,
Date().timeIntervalSince(lastCompactAt) < self.compactCooldown
{
self.errorText = "Please wait before compacting this session again."
return
}
self.isCompacting = true
self.isLoading = true
self.errorText = nil
defer {
self.isLoading = false
self.isCompacting = false
}
do {
try await self.transport.compactSession(sessionKey: self.sessionKey)
} catch {
self.errorText = "Unable to compact the session. Please try again."
let nsError = error as NSError
chatUILogger.error(
"session compact failed domain=\(nsError.domain, privacy: .public) code=\(nsError.code, privacy: .public) details=\(String(describing: error), privacy: .private)"
)
return
}
self.lastCompactAt = Date()
await self.bootstrap()
}
private func performSelectThinkingLevel(_ level: String) async {
let next = Self.normalizedThinkingLevel(level) ?? "off"
guard next != self.thinkingLevel else { return }

View File

@@ -2764,6 +2764,110 @@ public struct ToolsCatalogResult: Codable, Sendable {
}
}
public struct ToolsEffectiveParams: Codable, Sendable {
public let agentid: String?
public let sessionkey: String
public init(
agentid: String?,
sessionkey: String)
{
self.agentid = agentid
self.sessionkey = sessionkey
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case sessionkey = "sessionKey"
}
}
public struct ToolsEffectiveEntry: Codable, Sendable {
public let id: String
public let label: String
public let description: String
public let rawdescription: String
public let source: AnyCodable
public let pluginid: String?
public let channelid: String?
public init(
id: String,
label: String,
description: String,
rawdescription: String,
source: AnyCodable,
pluginid: String?,
channelid: String?)
{
self.id = id
self.label = label
self.description = description
self.rawdescription = rawdescription
self.source = source
self.pluginid = pluginid
self.channelid = channelid
}
private enum CodingKeys: String, CodingKey {
case id
case label
case description
case rawdescription = "rawDescription"
case source
case pluginid = "pluginId"
case channelid = "channelId"
}
}
public struct ToolsEffectiveGroup: Codable, Sendable {
public let id: AnyCodable
public let label: String
public let source: AnyCodable
public let tools: [ToolsEffectiveEntry]
public init(
id: AnyCodable,
label: String,
source: AnyCodable,
tools: [ToolsEffectiveEntry])
{
self.id = id
self.label = label
self.source = source
self.tools = tools
}
private enum CodingKeys: String, CodingKey {
case id
case label
case source
case tools
}
}
public struct ToolsEffectiveResult: Codable, Sendable {
public let agentid: String
public let profile: String
public let groups: [ToolsEffectiveGroup]
public init(
agentid: String,
profile: String,
groups: [ToolsEffectiveGroup])
{
self.agentid = agentid
self.profile = profile
self.groups = groups
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case profile
case groups
}
}
public struct SkillsBinsParams: Codable, Sendable {}
public struct SkillsBinsResult: Codable, Sendable {

View File

@@ -84,6 +84,7 @@ private func makeViewModel(
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
initialThinkingLevel: String? = nil,
@@ -95,6 +96,7 @@ private func makeViewModel(
sessionsResponses: sessionsResponses,
modelResponses: modelResponses,
resetSessionHook: resetSessionHook,
compactSessionHook: compactSessionHook,
setSessionModelHook: setSessionModelHook,
setSessionThinkingHook: setSessionThinkingHook)
let vm = await MainActor.run {
@@ -219,11 +221,25 @@ private actor AsyncGate {
}
}
private actor AsyncCounter {
private var value: Int
init(_ initialValue: Int = 0) {
self.value = initialValue
}
func increment() -> Int {
self.value += 1
return self.value
}
}
private actor TestChatTransportState {
var historyCallCount: Int = 0
var sessionsCallCount: Int = 0
var modelsCallCount: Int = 0
var resetSessionKeys: [String] = []
var compactSessionKeys: [String] = []
var sentRunIds: [String] = []
var sentThinkingLevels: [String] = []
var abortedRunIds: [String] = []
@@ -237,6 +253,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
private let sessionsResponses: [OpenClawChatSessionsListResponse]
private let modelResponses: [[OpenClawChatModelChoice]]
private let resetSessionHook: (@Sendable (String) async throws -> Void)?
private let compactSessionHook: (@Sendable (String) async throws -> Void)?
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
@@ -248,6 +265,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil)
{
@@ -255,6 +273,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
self.sessionsResponses = sessionsResponses
self.modelResponses = modelResponses
self.resetSessionHook = resetSessionHook
self.compactSessionHook = compactSessionHook
self.setSessionModelHook = setSessionModelHook
self.setSessionThinkingHook = setSessionThinkingHook
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
@@ -336,6 +355,13 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
}
}
func compactSession(sessionKey: String) async throws {
await self.state.compactSessionKeysAppend(sessionKey)
if let compactSessionHook = self.compactSessionHook {
try await compactSessionHook(sessionKey)
}
}
func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws {
await self.state.patchedThinkingLevelsAppend(thinkingLevel)
if let setSessionThinkingHook = self.setSessionThinkingHook {
@@ -375,6 +401,10 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
func resetSessionKeys() async -> [String] {
await self.state.resetSessionKeys
}
func compactSessionKeys() async -> [String] {
await self.state.compactSessionKeys
}
}
extension TestChatTransportState {
@@ -413,6 +443,10 @@ extension TestChatTransportState {
fileprivate func resetSessionKeysAppend(_ v: String) {
self.resetSessionKeys.append(v)
}
fileprivate func compactSessionKeysAppend(_ v: String) {
self.compactSessionKeys.append(v)
}
}
@Suite struct ChatViewModelTests {
@@ -915,6 +949,140 @@ extension TestChatTransportState {
#expect(await transport.lastSentRunId() == nil)
}
@Test func compactTriggerCompactsSessionAndReloadsHistory() async throws {
let before = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "before compact", timestamp: 1),
])
let after = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "after compact", timestamp: 2),
])
let (transport, vm) = await makeViewModel(historyResponses: [before, after])
try await loadAndWaitBootstrap(vm: vm)
try await waitUntil("initial history loaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "before compact" }
}
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await waitUntil("compact called") {
await transport.compactSessionKeys() == ["main"]
}
try await waitUntil("history reloaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "after compact" }
}
#expect(await transport.lastSentRunId() == nil)
}
@Test func compactTriggerShowsGenericErrorMessageOnFailure() async throws {
let history = historyPayload()
let (transport, vm) = await makeViewModel(
historyResponses: [history],
compactSessionHook: { _ in
throw NSError(
domain: "TestCompact",
code: 42,
userInfo: [NSLocalizedDescriptionKey: "backend details should not leak"])
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await waitUntil("compact attempted") {
await transport.compactSessionKeys() == ["main"]
}
#expect(await MainActor.run { vm.errorText } == "Unable to compact the session. Please try again.")
}
@Test func compactTriggerIgnoresConcurrentAndImmediateRepeatRequests() async throws {
let before = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "before compact", timestamp: 1),
])
let after = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "after compact", timestamp: 2),
])
let gate = AsyncGate()
let (transport, vm) = await makeViewModel(
historyResponses: [before, after],
compactSessionHook: { _ in
await gate.wait()
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.input = "/compact"
vm.send()
vm.input = "/compact"
vm.send()
}
try await waitUntil("single compact request issued") {
await transport.compactSessionKeys() == ["main"]
}
#expect(await MainActor.run { vm.errorText } == nil)
await gate.open()
try await waitUntil("history reloaded after compact") {
await MainActor.run { vm.messages.first?.content.first?.text == "after compact" }
}
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await Task.sleep(for: .milliseconds(50))
#expect(await transport.compactSessionKeys() == ["main"])
#expect(await MainActor.run { vm.errorText } == "Please wait before compacting this session again.")
}
@Test func compactTriggerAllowsImmediateRetryAfterFailure() async throws {
let history = historyPayload()
let attemptCount = AsyncCounter()
let (transport, vm) = await makeViewModel(
historyResponses: [history],
compactSessionHook: { _ in
let next = await attemptCount.increment()
if next == 1 {
throw NSError(
domain: "TestCompact",
code: 42,
userInfo: [NSLocalizedDescriptionKey: "temporary failure"])
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await waitUntil("first compact attempted") {
await transport.compactSessionKeys() == ["main"]
}
#expect(await MainActor.run { vm.errorText } == "Unable to compact the session. Please try again.")
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await waitUntil("second compact attempted") {
await transport.compactSessionKeys() == ["main", "main"]
}
#expect(await MainActor.run { vm.errorText } == nil)
}
@Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()

View File

@@ -7530,6 +7530,16 @@
"tags": [],
"hasChildren": true
},
{
"path": "auth.profiles.*.displayName",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "auth.profiles.*.email",
"kind": "core",
@@ -10382,6 +10392,20 @@
"tags": [],
"hasChildren": false
},
{
"path": "channels.discord.accounts.*.guilds.*.channels.*.autoThreadName",
"kind": "channel",
"type": "string",
"required": false,
"enumValues": [
"message",
"generated"
],
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.discord.accounts.*.guilds.*.channels.*.enabled",
"kind": "channel",
@@ -13314,6 +13338,20 @@
"tags": [],
"hasChildren": false
},
{
"path": "channels.discord.guilds.*.channels.*.autoThreadName",
"kind": "channel",
"type": "string",
"required": false,
"enumValues": [
"message",
"generated"
],
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.discord.guilds.*.channels.*.enabled",
"kind": "channel",
@@ -17079,8 +17117,7 @@
"path": "channels.feishu.requireMention",
"kind": "channel",
"type": "boolean",
"required": true,
"defaultValue": true,
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
@@ -24581,6 +24618,36 @@
"tags": [],
"hasChildren": false
},
{
"path": "channels.msteams.feedbackEnabled",
"kind": "channel",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.msteams.feedbackReflection",
"kind": "channel",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.msteams.feedbackReflectionCooldownMs",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.msteams.groupAllowFrom",
"kind": "channel",
@@ -24617,6 +24684,16 @@
"tags": [],
"hasChildren": false
},
{
"path": "channels.msteams.groupWelcomeCard",
"kind": "channel",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.msteams.healthMonitor",
"kind": "channel",
@@ -24762,6 +24839,26 @@
"tags": [],
"hasChildren": false
},
{
"path": "channels.msteams.promptStarters",
"kind": "channel",
"type": "array",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "channels.msteams.promptStarters.*",
"kind": "channel",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.msteams.replyStyle",
"kind": "channel",
@@ -25244,6 +25341,16 @@
"tags": [],
"hasChildren": false
},
{
"path": "channels.msteams.welcomeCard",
"kind": "channel",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.nextcloud-talk",
"kind": "channel",
@@ -64573,6 +64680,21 @@
"help": "Maximum redirects allowed for web_fetch (default: 3).",
"hasChildren": false
},
{
"path": "tools.web.fetch.maxResponseBytes",
"kind": "core",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"performance",
"tools"
],
"label": "Web Fetch Max Download Size (bytes)",
"help": "Max download size before truncation.",
"hasChildren": false
},
{
"path": "tools.web.fetch.readability",
"kind": "core",

View File

@@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5628}
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5639}
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -669,6 +669,7 @@
{"recordType":"path","path":"auth.order.*.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"auth.profiles","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["access","auth","storage"],"label":"Auth Profiles","help":"Named auth profiles (provider + mode + optional email).","hasChildren":true}
{"recordType":"path","path":"auth.profiles.*","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"auth.profiles.*.displayName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"auth.profiles.*.email","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"auth.profiles.*.mode","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"auth.profiles.*.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -916,6 +917,7 @@
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.autoArchiveDuration","kind":"channel","type":["number","string"],"required":false,"enumValues":["60","1440","4320","10080"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.autoThread","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.autoThreadName","kind":"channel","type":"string","required":false,"enumValues":["message","generated"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.ignoreOtherMentions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.includeThreadStarter","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1182,6 +1184,7 @@
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.autoArchiveDuration","kind":"channel","type":["number","string"],"required":false,"enumValues":["60","1440","4320","10080"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.autoThread","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.autoThreadName","kind":"channel","type":"string","required":false,"enumValues":["message","generated"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.ignoreOtherMentions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.includeThreadStarter","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1516,7 +1519,7 @@
{"recordType":"path","path":"channels.feishu.reactionNotifications","kind":"channel","type":"string","required":true,"enumValues":["off","own","all"],"defaultValue":"own","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.renderMode","kind":"channel","type":"string","required":false,"enumValues":["auto","raw","card"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.resolveSenderNames","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.streaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2205,9 +2208,13 @@
{"recordType":"path","path":"channels.msteams.dms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.dms.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.feedbackEnabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.feedbackReflection","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.feedbackReflectionCooldownMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.groupAllowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.groupWelcomeCard","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
@@ -2222,6 +2229,8 @@
{"recordType":"path","path":"channels.msteams.mediaAuthAllowHosts","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.mediaAuthAllowHosts.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.promptStarters","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.promptStarters.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.replyStyle","kind":"channel","type":"string","required":false,"enumValues":["thread","top-level"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2269,6 +2278,7 @@
{"recordType":"path","path":"channels.msteams.webhook","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.msteams.webhook.path","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.webhook.port","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.msteams.welcomeCard","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.nextcloud-talk","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Nextcloud Talk","help":"Self-hosted chat via Nextcloud Talk webhook bots.","hasChildren":true}
{"recordType":"path","path":"channels.nextcloud-talk.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.nextcloud-talk.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
@@ -5542,6 +5552,7 @@
{"recordType":"path","path":"tools.web.fetch.maxChars","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Fetch Max Chars","help":"Max characters returned by web_fetch (truncated).","hasChildren":false}
{"recordType":"path","path":"tools.web.fetch.maxCharsCap","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Fetch Hard Max Chars","help":"Hard cap for web_fetch maxChars (applies to config and tool calls).","hasChildren":false}
{"recordType":"path","path":"tools.web.fetch.maxRedirects","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","storage","tools"],"label":"Web Fetch Max Redirects","help":"Maximum redirects allowed for web_fetch (default: 3).","hasChildren":false}
{"recordType":"path","path":"tools.web.fetch.maxResponseBytes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Fetch Max Download Size (bytes)","help":"Max download size before truncation.","hasChildren":false}
{"recordType":"path","path":"tools.web.fetch.readability","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Web Fetch Readability Extraction","help":"Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).","hasChildren":false}
{"recordType":"path","path":"tools.web.fetch.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Fetch Timeout (sec)","help":"Timeout in seconds for web_fetch requests.","hasChildren":false}
{"recordType":"path","path":"tools.web.fetch.userAgent","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Web Fetch User-Agent","help":"Override User-Agent header for web_fetch requests.","hasChildren":false}

View File

@@ -19,7 +19,7 @@
"exportName": "buildGoogleImageGenerationProvider",
"kind": "function",
"source": {
"line": 95,
"line": 98,
"path": "extensions/google/image-generation-provider.ts"
}
},
@@ -127,7 +127,7 @@
"exportName": "ChannelConfiguredBindingConversationRef",
"kind": "type",
"source": {
"line": 553,
"line": 554,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -136,7 +136,7 @@
"exportName": "ChannelConfiguredBindingMatch",
"kind": "type",
"source": {
"line": 558,
"line": 559,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -145,7 +145,7 @@
"exportName": "ChannelConfiguredBindingProvider",
"kind": "type",
"source": {
"line": 562,
"line": 563,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -154,7 +154,7 @@
"exportName": "ChannelGatewayContext",
"kind": "type",
"source": {
"line": 238,
"line": 239,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -496,7 +496,7 @@
"exportName": "RuntimeLogger",
"kind": "type",
"source": {
"line": 4,
"line": 7,
"path": "src/plugins/runtime/types-core.ts"
}
},
@@ -914,7 +914,7 @@
"exportName": "createMessageToolCardSchema",
"kind": "function",
"source": {
"line": 27,
"line": 29,
"path": "src/plugin-sdk/channel-actions.ts"
}
},
@@ -1044,7 +1044,7 @@
"exportName": "BaseProbeResult",
"kind": "type",
"source": {
"line": 558,
"line": 559,
"path": "src/channels/plugins/types.core.ts"
}
},
@@ -1053,7 +1053,7 @@
"exportName": "BaseTokenResolution",
"kind": "type",
"source": {
"line": 564,
"line": 565,
"path": "src/channels/plugins/types.core.ts"
}
},
@@ -1518,7 +1518,7 @@
"exportName": "BaseProbeResult",
"kind": "type",
"source": {
"line": 558,
"line": 559,
"path": "src/channels/plugins/types.core.ts"
}
},
@@ -1527,7 +1527,7 @@
"exportName": "BaseTokenResolution",
"kind": "type",
"source": {
"line": 564,
"line": 565,
"path": "src/channels/plugins/types.core.ts"
}
},
@@ -1581,7 +1581,7 @@
"exportName": "ChannelAllowlistAdapter",
"kind": "type",
"source": {
"line": 497,
"line": 498,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1590,7 +1590,7 @@
"exportName": "ChannelAuthAdapter",
"kind": "type",
"source": {
"line": 362,
"line": 363,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1635,7 +1635,7 @@
"exportName": "ChannelCommandAdapter",
"kind": "type",
"source": {
"line": 444,
"line": 445,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1653,7 +1653,7 @@
"exportName": "ChannelConfiguredBindingConversationRef",
"kind": "type",
"source": {
"line": 553,
"line": 554,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1662,7 +1662,7 @@
"exportName": "ChannelConfiguredBindingMatch",
"kind": "type",
"source": {
"line": 558,
"line": 559,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1671,7 +1671,7 @@
"exportName": "ChannelConfiguredBindingProvider",
"kind": "type",
"source": {
"line": 562,
"line": 563,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1680,7 +1680,7 @@
"exportName": "ChannelDirectoryAdapter",
"kind": "type",
"source": {
"line": 406,
"line": 407,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1707,7 +1707,7 @@
"exportName": "ChannelElevatedAdapter",
"kind": "type",
"source": {
"line": 437,
"line": 438,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1716,7 +1716,7 @@
"exportName": "ChannelExecApprovalAdapter",
"kind": "type",
"source": {
"line": 463,
"line": 464,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1743,7 +1743,7 @@
"exportName": "ChannelGatewayAdapter",
"kind": "type",
"source": {
"line": 346,
"line": 347,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1752,7 +1752,7 @@
"exportName": "ChannelGatewayContext",
"kind": "type",
"source": {
"line": 238,
"line": 239,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1779,7 +1779,7 @@
"exportName": "ChannelHeartbeatAdapter",
"kind": "type",
"source": {
"line": 372,
"line": 373,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1806,7 +1806,7 @@
"exportName": "ChannelLifecycleAdapter",
"kind": "type",
"source": {
"line": 449,
"line": 450,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1815,7 +1815,7 @@
"exportName": "ChannelLoginWithQrStartResult",
"kind": "type",
"source": {
"line": 317,
"line": 318,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1824,7 +1824,7 @@
"exportName": "ChannelLoginWithQrWaitResult",
"kind": "type",
"source": {
"line": 322,
"line": 323,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1833,7 +1833,7 @@
"exportName": "ChannelLogoutContext",
"kind": "type",
"source": {
"line": 327,
"line": 328,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1842,7 +1842,7 @@
"exportName": "ChannelLogoutResult",
"kind": "type",
"source": {
"line": 311,
"line": 312,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1950,7 +1950,7 @@
"exportName": "ChannelOutboundAdapter",
"kind": "type",
"source": {
"line": 154,
"line": 155,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1977,7 +1977,7 @@
"exportName": "ChannelPairingAdapter",
"kind": "type",
"source": {
"line": 335,
"line": 336,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -2013,7 +2013,7 @@
"exportName": "ChannelResolveKind",
"kind": "type",
"source": {
"line": 417,
"line": 418,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -2022,7 +2022,7 @@
"exportName": "ChannelResolverAdapter",
"kind": "type",
"source": {
"line": 427,
"line": 428,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -2031,7 +2031,7 @@
"exportName": "ChannelResolveResult",
"kind": "type",
"source": {
"line": 419,
"line": 420,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -2040,7 +2040,7 @@
"exportName": "ChannelSecurityAdapter",
"kind": "type",
"source": {
"line": 575,
"line": 576,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -2085,7 +2085,7 @@
"exportName": "ChannelStatusAdapter",
"kind": "type",
"source": {
"line": 184,
"line": 185,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -2359,7 +2359,7 @@
"exportName": "buildCommandsMessage",
"kind": "function",
"source": {
"line": 961,
"line": 1049,
"path": "src/auto-reply/status.ts"
}
},
@@ -2368,7 +2368,7 @@
"exportName": "buildCommandsMessagePaginated",
"kind": "function",
"source": {
"line": 970,
"line": 1058,
"path": "src/auto-reply/status.ts"
}
},
@@ -2377,7 +2377,7 @@
"exportName": "buildCommandsPaginationKeyboard",
"kind": "function",
"source": {
"line": 89,
"line": 196,
"path": "src/auto-reply/reply/commands-info.ts"
}
},
@@ -2404,7 +2404,7 @@
"exportName": "buildHelpMessage",
"kind": "function",
"source": {
"line": 841,
"line": 844,
"path": "src/auto-reply/status.ts"
}
},
@@ -3009,7 +3009,7 @@
"exportName": "buildChannelOutboundSessionRoute",
"kind": "function",
"source": {
"line": 162,
"line": 163,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -3045,7 +3045,7 @@
"exportName": "createChannelPluginBase",
"kind": "function",
"source": {
"line": 436,
"line": 437,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -3054,7 +3054,7 @@
"exportName": "createChatChannelPlugin",
"kind": "function",
"source": {
"line": 413,
"line": 414,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -3063,7 +3063,7 @@
"exportName": "defineChannelPluginEntry",
"kind": "function",
"source": {
"line": 245,
"line": 246,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -3081,7 +3081,7 @@
"exportName": "defineSetupPluginEntry",
"kind": "function",
"source": {
"line": 276,
"line": 277,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -3135,7 +3135,7 @@
"exportName": "getChatChannelMeta",
"kind": "function",
"source": {
"line": 158,
"line": 155,
"path": "src/channels/registry.ts"
}
},
@@ -3229,6 +3229,15 @@
"path": "src/shared/gateway-bind-url.ts"
}
},
{
"declaration": "export function resolveGatewayPort(cfg?: OpenClawConfig | undefined, env?: ProcessEnv): number;",
"exportName": "resolveGatewayPort",
"kind": "function",
"source": {
"line": 285,
"path": "src/config/paths.ts"
}
},
{
"declaration": "export function resolveTailnetHostWithRunner(runCommandWithTimeout?: TailscaleStatusCommandRunner | undefined): Promise<string | null>;",
"exportName": "resolveTailnetHostWithRunner",
@@ -3270,7 +3279,7 @@
"exportName": "stripChannelTargetPrefix",
"kind": "function",
"source": {
"line": 142,
"line": 143,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -3279,7 +3288,7 @@
"exportName": "stripTargetKindPrefix",
"kind": "function",
"source": {
"line": 154,
"line": 155,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -3351,7 +3360,7 @@
"exportName": "ChannelOutboundSessionRouteParams",
"kind": "type",
"source": {
"line": 137,
"line": 138,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -4875,7 +4884,7 @@
"exportName": "ChannelGatewayContext",
"kind": "type",
"source": {
"line": 238,
"line": 239,
"path": "src/channels/plugins/types.adapters.ts"
}
},

View File

@@ -1,6 +1,6 @@
{"category":"legacy","entrypoint":"index","importSpecifier":"openclaw/plugin-sdk","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/index.ts"}
{"declaration":"export function buildFalImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildFalImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":190,"sourcePath":"extensions/fal/image-generation-provider.ts"}
{"declaration":"export function buildGoogleImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildGoogleImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":95,"sourcePath":"extensions/google/image-generation-provider.ts"}
{"declaration":"export function buildGoogleImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildGoogleImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":98,"sourcePath":"extensions/google/image-generation-provider.ts"}
{"declaration":"export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildOpenAIImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":22,"sourcePath":"extensions/openai/image-generation-provider.ts"}
{"declaration":"export function delegateCompactionToRuntime(params: { sessionId: string; sessionKey?: string | undefined; sessionFile: string; tokenBudget?: number | undefined; force?: boolean | undefined; currentTokenCount?: number | undefined; compactionTarget?: \"budget\" | ... 1 more ... | undefined; customInstructions?: string | undefined; runtimeContext?: ContextEngineRuntimeContext | undefined; }): Promise<...>;","entrypoint":"index","exportName":"delegateCompactionToRuntime","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/context-engine/delegate.ts"}
{"declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"index","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":13,"sourcePath":"src/plugins/config-schema.ts"}
@@ -12,10 +12,10 @@
{"declaration":"export type ChannelAgentToolFactory = ChannelAgentToolFactory;","entrypoint":"index","exportName":"ChannelAgentToolFactory","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":23,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelCapabilities = ChannelCapabilities;","entrypoint":"index","exportName":"ChannelCapabilities","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":230,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelConfigSchema = ChannelConfigSchema;","entrypoint":"index","exportName":"ChannelConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":48,"sourcePath":"src/channels/plugins/types.plugin.ts"}
{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"index","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":553,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"index","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":558,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":562,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext<ResolvedAccount>;","entrypoint":"index","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":238,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"index","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":554,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"index","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":559,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":563,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext<ResolvedAccount>;","entrypoint":"index","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":239,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelId = ChannelId;","entrypoint":"index","exportName":"ChannelId","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":13,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"index","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":516,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"index","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":482,"sourcePath":"src/channels/plugins/types.core.ts"}
@@ -53,7 +53,7 @@
{"declaration":"export type ProviderRuntimeModel = ProviderRuntimeModel;","entrypoint":"index","exportName":"ProviderRuntimeModel","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":295,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type ReplyPayload = ReplyPayload;","entrypoint":"index","exportName":"ReplyPayload","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":76,"sourcePath":"src/auto-reply/types.ts"}
{"declaration":"export type RuntimeEnv = RuntimeEnv;","entrypoint":"index","exportName":"RuntimeEnv","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":4,"sourcePath":"src/runtime.ts"}
{"declaration":"export type RuntimeLogger = RuntimeLogger;","entrypoint":"index","exportName":"RuntimeLogger","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":4,"sourcePath":"src/plugins/runtime/types-core.ts"}
{"declaration":"export type RuntimeLogger = RuntimeLogger;","entrypoint":"index","exportName":"RuntimeLogger","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":7,"sourcePath":"src/plugins/runtime/types-core.ts"}
{"declaration":"export type SecretInput = SecretInput;","entrypoint":"index","exportName":"SecretInput","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":16,"sourcePath":"src/config/types.secrets.ts"}
{"declaration":"export type SecretRef = SecretRef;","entrypoint":"index","exportName":"SecretRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":10,"sourcePath":"src/config/types.secrets.ts"}
{"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"index","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":933,"sourcePath":"src/plugins/types.ts"}
@@ -99,7 +99,7 @@
{"declaration":"export type CompiledAllowlist = CompiledAllowlist;","entrypoint":"allow-from","exportName":"CompiledAllowlist","importSpecifier":"openclaw/plugin-sdk/allow-from","kind":"type","recordType":"export","sourceLine":19,"sourcePath":"src/channels/allowlist-match.ts"}
{"category":"channel","entrypoint":"channel-actions","importSpecifier":"openclaw/plugin-sdk/channel-actions","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/channel-actions.ts"}
{"declaration":"export function createMessageToolButtonsSchema(): TSchema;","entrypoint":"channel-actions","exportName":"createMessageToolButtonsSchema","importSpecifier":"openclaw/plugin-sdk/channel-actions","kind":"function","recordType":"export","sourceLine":11,"sourcePath":"src/plugin-sdk/channel-actions.ts"}
{"declaration":"export function createMessageToolCardSchema(): TSchema;","entrypoint":"channel-actions","exportName":"createMessageToolCardSchema","importSpecifier":"openclaw/plugin-sdk/channel-actions","kind":"function","recordType":"export","sourceLine":27,"sourcePath":"src/plugin-sdk/channel-actions.ts"}
{"declaration":"export function createMessageToolCardSchema(): TSchema;","entrypoint":"channel-actions","exportName":"createMessageToolCardSchema","importSpecifier":"openclaw/plugin-sdk/channel-actions","kind":"function","recordType":"export","sourceLine":29,"sourcePath":"src/plugin-sdk/channel-actions.ts"}
{"declaration":"export function createUnionActionGate<TAccount, TKey extends string>(accounts: readonly TAccount[], createGate: (account: TAccount) => OptionalDefaultGate<TKey>): OptionalDefaultGate<TKey>;","entrypoint":"channel-actions","exportName":"createUnionActionGate","importSpecifier":"openclaw/plugin-sdk/channel-actions","kind":"function","recordType":"export","sourceLine":13,"sourcePath":"src/channels/plugins/actions/shared.ts"}
{"declaration":"export function listTokenSourcedAccounts<TAccount extends TokenSourcedAccount>(accounts: readonly TAccount[]): TAccount[];","entrypoint":"channel-actions","exportName":"listTokenSourcedAccounts","importSpecifier":"openclaw/plugin-sdk/channel-actions","kind":"function","recordType":"export","sourceLine":7,"sourcePath":"src/channels/plugins/actions/shared.ts"}
{"declaration":"export function resolveReactionMessageId(params: { args: Record<string, unknown>; toolContext?: ReactionToolContext | undefined; }): string | number | undefined;","entrypoint":"channel-actions","exportName":"resolveReactionMessageId","importSpecifier":"openclaw/plugin-sdk/channel-actions","kind":"function","recordType":"export","sourceLine":7,"sourcePath":"src/channels/plugins/actions/reaction-message-id.ts"}
@@ -113,8 +113,8 @@
{"declaration":"export const MarkdownConfigSchema: z.ZodOptional<z.ZodObject<{ tables: z.ZodOptional<z.ZodEnum<{ off: \"off\"; bullets: \"bullets\"; code: \"code\"; }>>; }, z.core.$strict>>;","entrypoint":"channel-config-schema","exportName":"MarkdownConfigSchema","importSpecifier":"openclaw/plugin-sdk/channel-config-schema","kind":"const","recordType":"export","sourceLine":371,"sourcePath":"src/config/zod-schema.core.ts"}
{"declaration":"export const ToolPolicySchema: z.ZodOptional<z.ZodObject<{ allow: z.ZodOptional<z.ZodArray<z.ZodString>>; alsoAllow: z.ZodOptional<z.ZodArray<z.ZodString>>; deny: z.ZodOptional<z.ZodArray<z.ZodString>>; }, z.core.$strict>>;","entrypoint":"channel-config-schema","exportName":"ToolPolicySchema","importSpecifier":"openclaw/plugin-sdk/channel-config-schema","kind":"const","recordType":"export","sourceLine":253,"sourcePath":"src/config/zod-schema.agent-runtime.ts"}
{"category":"channel","entrypoint":"channel-contract","importSpecifier":"openclaw/plugin-sdk/channel-contract","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/channel-contract.ts"}
{"declaration":"export type BaseProbeResult = BaseProbeResult<TError>;","entrypoint":"channel-contract","exportName":"BaseProbeResult","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":558,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type BaseTokenResolution = BaseTokenResolution;","entrypoint":"channel-contract","exportName":"BaseTokenResolution","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":564,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type BaseProbeResult = BaseProbeResult<TError>;","entrypoint":"channel-contract","exportName":"BaseProbeResult","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":559,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type BaseTokenResolution = BaseTokenResolution;","entrypoint":"channel-contract","exportName":"BaseTokenResolution","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":565,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelAccountSnapshot = ChannelAccountSnapshot;","entrypoint":"channel-contract","exportName":"ChannelAccountSnapshot","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":144,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelAgentTool = ChannelAgentTool;","entrypoint":"channel-contract","exportName":"ChannelAgentTool","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":18,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelGroupContext = ChannelGroupContext;","entrypoint":"channel-contract","exportName":"ChannelGroupContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":216,"sourcePath":"src/channels/plugins/types.core.ts"}
@@ -165,43 +165,43 @@
{"declaration":"export function waitUntilAbort(signal?: AbortSignal | undefined, onAbort?: (() => void | Promise<void>) | undefined): Promise<void>;","entrypoint":"channel-runtime","exportName":"waitUntilAbort","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":38,"sourcePath":"src/plugin-sdk/channel-lifecycle.ts"}
{"declaration":"export const CHANNEL_MESSAGE_ACTION_NAMES: readonly [\"send\", \"broadcast\", \"poll\", \"poll-vote\", \"react\", \"reactions\", \"read\", \"edit\", \"unsend\", \"reply\", \"sendWithEffect\", \"renameGroup\", \"setGroupIcon\", \"addParticipant\", \"removeParticipant\", \"leaveGroup\", \"sendAttachment\", \"delete\", \"pin\", \"unpin\", \"list-pins\", \"permissions\", \"thread-create\", \"thread-list\", \"thread-reply\", \"search\", \"sticker\", \"sticker-search\", \"member-info\", \"role-info\", \"emoji-list\", \"emoji-upload\", \"sticker-upload\", \"role-add\", \"role-remove\", \"channel-info\", \"channel-list\", \"channel-create\", \"channel-edit\", \"channel-delete\", \"channel-move\", \"category-create\", \"category-edit\", \"category-delete\", \"topic-create\", \"topic-edit\", \"voice-status\", \"event-list\", \"event-create\", \"timeout\", \"kick\", \"ban\", \"set-profile\", \"set-presence\", \"set-profile\", \"download-file\"];","entrypoint":"channel-runtime","exportName":"CHANNEL_MESSAGE_ACTION_NAMES","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"const","recordType":"export","sourceLine":1,"sourcePath":"src/channels/plugins/message-action-names.ts"}
{"declaration":"export const CHANNEL_MESSAGE_CAPABILITIES: readonly [\"interactive\", \"buttons\", \"cards\", \"components\", \"blocks\"];","entrypoint":"channel-runtime","exportName":"CHANNEL_MESSAGE_CAPABILITIES","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"const","recordType":"export","sourceLine":1,"sourcePath":"src/channels/plugins/message-capabilities.ts"}
{"declaration":"export type BaseProbeResult = BaseProbeResult<TError>;","entrypoint":"channel-runtime","exportName":"BaseProbeResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":558,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type BaseTokenResolution = BaseTokenResolution;","entrypoint":"channel-runtime","exportName":"BaseTokenResolution","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":564,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type BaseProbeResult = BaseProbeResult<TError>;","entrypoint":"channel-runtime","exportName":"BaseProbeResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":559,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type BaseTokenResolution = BaseTokenResolution;","entrypoint":"channel-runtime","exportName":"BaseTokenResolution","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":565,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelAccountSnapshot = ChannelAccountSnapshot;","entrypoint":"channel-runtime","exportName":"ChannelAccountSnapshot","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":144,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelAccountState = ChannelAccountState;","entrypoint":"channel-runtime","exportName":"ChannelAccountState","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":108,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelAgentPromptAdapter = ChannelAgentPromptAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAgentPromptAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":463,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelAgentTool = ChannelAgentTool;","entrypoint":"channel-runtime","exportName":"ChannelAgentTool","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":18,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelAgentToolFactory = ChannelAgentToolFactory;","entrypoint":"channel-runtime","exportName":"ChannelAgentToolFactory","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":23,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelAllowlistAdapter = ChannelAllowlistAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAllowlistAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":497,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelAuthAdapter = ChannelAuthAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAuthAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":362,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelAllowlistAdapter = ChannelAllowlistAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAllowlistAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":498,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelAuthAdapter = ChannelAuthAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAuthAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":363,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelCapabilities = ChannelCapabilities;","entrypoint":"channel-runtime","exportName":"ChannelCapabilities","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":230,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelCapabilitiesDiagnostics = ChannelCapabilitiesDiagnostics;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDiagnostics","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":47,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelCapabilitiesDisplayLine = ChannelCapabilitiesDisplayLine;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayLine","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":42,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelCapabilitiesDisplayTone = ChannelCapabilitiesDisplayTone;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayTone","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":40,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelCommandAdapter = ChannelCommandAdapter;","entrypoint":"channel-runtime","exportName":"ChannelCommandAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":444,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelCommandAdapter = ChannelCommandAdapter;","entrypoint":"channel-runtime","exportName":"ChannelCommandAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":445,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfigAdapter = ChannelConfigAdapter<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelConfigAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":91,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":553,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":558,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":562,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelDirectoryAdapter = ChannelDirectoryAdapter;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":406,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":554,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":559,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":563,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelDirectoryAdapter = ChannelDirectoryAdapter;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":407,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelDirectoryEntry = ChannelDirectoryEntry;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryEntry","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":469,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelDirectoryEntryKind = ChannelDirectoryEntryKind;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryEntryKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":467,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelElevatedAdapter = ChannelElevatedAdapter;","entrypoint":"channel-runtime","exportName":"ChannelElevatedAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":437,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelExecApprovalAdapter = ChannelExecApprovalAdapter;","entrypoint":"channel-runtime","exportName":"ChannelExecApprovalAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":463,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelElevatedAdapter = ChannelElevatedAdapter;","entrypoint":"channel-runtime","exportName":"ChannelElevatedAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":438,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelExecApprovalAdapter = ChannelExecApprovalAdapter;","entrypoint":"channel-runtime","exportName":"ChannelExecApprovalAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":464,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelExecApprovalForwardTarget = ChannelExecApprovalForwardTarget;","entrypoint":"channel-runtime","exportName":"ChannelExecApprovalForwardTarget","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelExecApprovalInitiatingSurfaceState = ChannelExecApprovalInitiatingSurfaceState;","entrypoint":"channel-runtime","exportName":"ChannelExecApprovalInitiatingSurfaceState","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":27,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelGatewayAdapter = ChannelGatewayAdapter<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelGatewayAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":346,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":238,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelGatewayAdapter = ChannelGatewayAdapter<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelGatewayAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":347,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":239,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelGroupAdapter = ChannelGroupAdapter;","entrypoint":"channel-runtime","exportName":"ChannelGroupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":122,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelGroupContext = ChannelGroupContext;","entrypoint":"channel-runtime","exportName":"ChannelGroupContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":216,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelHeartbeatAdapter = ChannelHeartbeatAdapter;","entrypoint":"channel-runtime","exportName":"ChannelHeartbeatAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":372,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelHeartbeatAdapter = ChannelHeartbeatAdapter;","entrypoint":"channel-runtime","exportName":"ChannelHeartbeatAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":373,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelHeartbeatDeps = ChannelHeartbeatDeps;","entrypoint":"channel-runtime","exportName":"ChannelHeartbeatDeps","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":116,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelId = ChannelId;","entrypoint":"channel-runtime","exportName":"ChannelId","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":13,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelLifecycleAdapter = ChannelLifecycleAdapter;","entrypoint":"channel-runtime","exportName":"ChannelLifecycleAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":449,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelLoginWithQrStartResult = ChannelLoginWithQrStartResult;","entrypoint":"channel-runtime","exportName":"ChannelLoginWithQrStartResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":317,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelLoginWithQrWaitResult = ChannelLoginWithQrWaitResult;","entrypoint":"channel-runtime","exportName":"ChannelLoginWithQrWaitResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":322,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelLogoutContext = ChannelLogoutContext<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelLogoutContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":327,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelLogoutResult = ChannelLogoutResult;","entrypoint":"channel-runtime","exportName":"ChannelLogoutResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":311,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelLifecycleAdapter = ChannelLifecycleAdapter;","entrypoint":"channel-runtime","exportName":"ChannelLifecycleAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":450,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelLoginWithQrStartResult = ChannelLoginWithQrStartResult;","entrypoint":"channel-runtime","exportName":"ChannelLoginWithQrStartResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":318,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelLoginWithQrWaitResult = ChannelLoginWithQrWaitResult;","entrypoint":"channel-runtime","exportName":"ChannelLoginWithQrWaitResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":323,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelLogoutContext = ChannelLogoutContext<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelLogoutContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":328,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelLogoutResult = ChannelLogoutResult;","entrypoint":"channel-runtime","exportName":"ChannelLogoutResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":312,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelLogSink = ChannelLogSink;","entrypoint":"channel-runtime","exportName":"ChannelLogSink","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":209,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelMentionAdapter = ChannelMentionAdapter;","entrypoint":"channel-runtime","exportName":"ChannelMentionAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":260,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"channel-runtime","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":516,"sourcePath":"src/channels/plugins/types.core.ts"}
@@ -213,22 +213,22 @@
{"declaration":"export type ChannelMessageToolSchemaContribution = ChannelMessageToolSchemaContribution;","entrypoint":"channel-runtime","exportName":"ChannelMessageToolSchemaContribution","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":51,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelMessagingAdapter = ChannelMessagingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelMessagingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":395,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelMeta = ChannelMeta;","entrypoint":"channel-runtime","exportName":"ChannelMeta","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":122,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelOutboundAdapter = ChannelOutboundAdapter;","entrypoint":"channel-runtime","exportName":"ChannelOutboundAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":154,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelOutboundAdapter = ChannelOutboundAdapter;","entrypoint":"channel-runtime","exportName":"ChannelOutboundAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":155,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelOutboundContext = ChannelOutboundContext;","entrypoint":"channel-runtime","exportName":"ChannelOutboundContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":128,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelOutboundTargetMode = ChannelOutboundTargetMode;","entrypoint":"channel-runtime","exportName":"ChannelOutboundTargetMode","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":15,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelPairingAdapter = ChannelPairingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelPairingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":335,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelPairingAdapter = ChannelPairingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelPairingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":336,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelPlugin = ChannelPlugin<ResolvedAccount, Probe, Audit>;","entrypoint":"channel-runtime","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":55,"sourcePath":"src/channels/plugins/types.plugin.ts"}
{"declaration":"export type ChannelPollContext = ChannelPollContext;","entrypoint":"channel-runtime","exportName":"ChannelPollContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":547,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelPollResult = ChannelPollResult;","entrypoint":"channel-runtime","exportName":"ChannelPollResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":538,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelResolveKind = ChannelResolveKind;","entrypoint":"channel-runtime","exportName":"ChannelResolveKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":417,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelResolverAdapter = ChannelResolverAdapter;","entrypoint":"channel-runtime","exportName":"ChannelResolverAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":427,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelResolveResult = ChannelResolveResult;","entrypoint":"channel-runtime","exportName":"ChannelResolveResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":419,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelSecurityAdapter = ChannelSecurityAdapter<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelSecurityAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":575,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelResolveKind = ChannelResolveKind;","entrypoint":"channel-runtime","exportName":"ChannelResolveKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":418,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelResolverAdapter = ChannelResolverAdapter;","entrypoint":"channel-runtime","exportName":"ChannelResolverAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":428,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelResolveResult = ChannelResolveResult;","entrypoint":"channel-runtime","exportName":"ChannelResolveResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":420,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelSecurityAdapter = ChannelSecurityAdapter<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelSecurityAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":576,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelSecurityContext = ChannelSecurityContext<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelSecurityContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":254,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelSecurityDmPolicy = ChannelSecurityDmPolicy;","entrypoint":"channel-runtime","exportName":"ChannelSecurityDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":245,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"channel-runtime","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":56,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelSetupInput = ChannelSetupInput;","entrypoint":"channel-runtime","exportName":"ChannelSetupInput","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":63,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelStatusAdapter = ChannelStatusAdapter<ResolvedAccount, Probe, Audit>;","entrypoint":"channel-runtime","exportName":"ChannelStatusAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":184,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelStatusAdapter = ChannelStatusAdapter<ResolvedAccount, Probe, Audit>;","entrypoint":"channel-runtime","exportName":"ChannelStatusAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":185,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelStatusIssue = ChannelStatusIssue;","entrypoint":"channel-runtime","exportName":"ChannelStatusIssue","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":100,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelStreamingAdapter = ChannelStreamingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelStreamingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":279,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelStructuredComponents = ChannelStructuredComponents;","entrypoint":"channel-runtime","exportName":"ChannelStructuredComponents","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":288,"sourcePath":"src/channels/plugins/types.core.ts"}
@@ -258,12 +258,12 @@
{"declaration":"export type ChannelSetupWizard = ChannelSetupWizard;","entrypoint":"channel-setup","exportName":"ChannelSetupWizard","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":247,"sourcePath":"src/channels/plugins/setup-wizard.ts"}
{"declaration":"export type OptionalChannelSetupSurface = OptionalChannelSetupSurface;","entrypoint":"channel-setup","exportName":"OptionalChannelSetupSurface","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":29,"sourcePath":"src/plugin-sdk/channel-setup.ts"}
{"category":"channel","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/command-auth.ts"}
{"declaration":"export function buildCommandsMessage(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandsMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":961,"sourcePath":"src/auto-reply/status.ts"}
{"declaration":"export function buildCommandsMessagePaginated(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): CommandsMessageResult;","entrypoint":"command-auth","exportName":"buildCommandsMessagePaginated","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":970,"sourcePath":"src/auto-reply/status.ts"}
{"declaration":"export function buildCommandsPaginationKeyboard(currentPage: number, totalPages: number, agentId?: string | undefined): { text: string; callback_data: string; }[][];","entrypoint":"command-auth","exportName":"buildCommandsPaginationKeyboard","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":89,"sourcePath":"src/auto-reply/reply/commands-info.ts"}
{"declaration":"export function buildCommandsMessage(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandsMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":1049,"sourcePath":"src/auto-reply/status.ts"}
{"declaration":"export function buildCommandsMessagePaginated(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): CommandsMessageResult;","entrypoint":"command-auth","exportName":"buildCommandsMessagePaginated","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":1058,"sourcePath":"src/auto-reply/status.ts"}
{"declaration":"export function buildCommandsPaginationKeyboard(currentPage: number, totalPages: number, agentId?: string | undefined): { text: string; callback_data: string; }[][];","entrypoint":"command-auth","exportName":"buildCommandsPaginationKeyboard","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":196,"sourcePath":"src/auto-reply/reply/commands-info.ts"}
{"declaration":"export function buildCommandText(commandName: string, args?: string | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandText","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":199,"sourcePath":"src/auto-reply/commands-registry.ts"}
{"declaration":"export function buildCommandTextFromArgs(command: ChatCommandDefinition, args?: CommandArgs | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandTextFromArgs","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":291,"sourcePath":"src/auto-reply/commands-registry.ts"}
{"declaration":"export function buildHelpMessage(cfg?: OpenClawConfig | undefined): string;","entrypoint":"command-auth","exportName":"buildHelpMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":841,"sourcePath":"src/auto-reply/status.ts"}
{"declaration":"export function buildHelpMessage(cfg?: OpenClawConfig | undefined): string;","entrypoint":"command-auth","exportName":"buildHelpMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":844,"sourcePath":"src/auto-reply/status.ts"}
{"declaration":"export function buildModelsProviderData(cfg: OpenClawConfig, agentId?: string | undefined): Promise<ModelsProviderData>;","entrypoint":"command-auth","exportName":"buildModelsProviderData","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":37,"sourcePath":"src/auto-reply/reply/commands-models.ts"}
{"declaration":"export function createPreCryptoDirectDmAuthorizer(params: { resolveAccess: (senderId: string) => Promise<ResolvedInboundDirectDmAccess | Pick<ResolvedInboundDirectDmAccess, \"access\">>; issuePairingChallenge?: ((params: { ...; }) => Promise<...>) | undefined; onBlocked?: ((params: { ...; }) => void) | undefined; }): (input: { ...; }) => Promise<...>;","entrypoint":"command-auth","exportName":"createPreCryptoDirectDmAuthorizer","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":105,"sourcePath":"src/plugin-sdk/direct-dm.ts"}
{"declaration":"export function findCommandByNativeName(name: string, provider?: string | undefined): ChatCommandDefinition | undefined;","entrypoint":"command-auth","exportName":"findCommandByNativeName","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":187,"sourcePath":"src/auto-reply/commands-registry.ts"}
@@ -330,21 +330,21 @@
{"declaration":"export function applyAccountNameToChannelSection(params: { cfg: OpenClawConfig; channelKey: string; accountId: string; name?: string | undefined; alwaysUseAccounts?: boolean | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"applyAccountNameToChannelSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":33,"sourcePath":"src/channels/plugins/setup-helpers.ts"}
{"declaration":"export function buildAgentSessionKey(params: { agentId: string; channel: string; accountId?: string | null | undefined; peer?: RoutePeer | null | undefined; dmScope?: \"main\" | \"per-peer\" | \"per-channel-peer\" | \"per-account-channel-peer\" | undefined; identityLinks?: Record<...> | undefined; }): string;","entrypoint":"core","exportName":"buildAgentSessionKey","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":91,"sourcePath":"src/routing/resolve-route.ts"}
{"declaration":"export function buildChannelConfigSchema(schema: ZodType<unknown, unknown, $ZodTypeInternals<unknown, unknown>>): ChannelConfigSchema;","entrypoint":"core","exportName":"buildChannelConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":35,"sourcePath":"src/channels/plugins/config-schema.ts"}
{"declaration":"export function buildChannelOutboundSessionRoute(params: { cfg: OpenClawConfig; agentId: string; channel: string; accountId?: string | null | undefined; peer: { kind: \"direct\" | \"group\" | \"channel\"; id: string; }; chatType: \"direct\" | \"group\" | \"channel\"; from: string; to: string; threadId?: string | ... 1 more ... | undefined; }): ChannelOutboundSessionRoute;","entrypoint":"core","exportName":"buildChannelOutboundSessionRoute","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":162,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function buildChannelOutboundSessionRoute(params: { cfg: OpenClawConfig; agentId: string; channel: string; accountId?: string | null | undefined; peer: { kind: \"direct\" | \"group\" | \"channel\"; id: string; }; chatType: \"direct\" | \"group\" | \"channel\"; from: string; to: string; threadId?: string | ... 1 more ... | undefined; }): ChannelOutboundSessionRoute;","entrypoint":"core","exportName":"buildChannelOutboundSessionRoute","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":163,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function channelTargetSchema(options?: { description?: string | undefined; } | undefined): TString;","entrypoint":"core","exportName":"channelTargetSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":38,"sourcePath":"src/agents/schema/typebox.ts"}
{"declaration":"export function channelTargetsSchema(options?: { description?: string | undefined; } | undefined): TArray<TString>;","entrypoint":"core","exportName":"channelTargetsSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":44,"sourcePath":"src/agents/schema/typebox.ts"}
{"declaration":"export function clearAccountEntryFields<TAccountEntry extends object>(params: { accounts?: Record<string, TAccountEntry> | undefined; accountId: string; fields: string[]; isValueSet?: ((value: unknown) => boolean) | undefined; markClearedOnFieldPresence?: boolean | undefined; }): { ...; };","entrypoint":"core","exportName":"clearAccountEntryFields","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":122,"sourcePath":"src/channels/plugins/config-helpers.ts"}
{"declaration":"export function createChannelPluginBase<TResolvedAccount>(params: CreateChannelPluginBaseOptions<TResolvedAccount>): CreatedChannelPluginBase<TResolvedAccount>;","entrypoint":"core","exportName":"createChannelPluginBase","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":436,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function createChatChannelPlugin<TResolvedAccount extends { accountId?: string | null; }, Probe = unknown, Audit = unknown>(params: { base: ChatChannelPluginBase<TResolvedAccount, Probe, Audit>; security?: ChannelSecurityAdapter<TResolvedAccount> | ChatChannelSecurityOptions<...> | undefined; pairing?: ChannelPairingAdapter | ... 1 more ... | undefined; threading?: ChannelThreadingAdapter | ... 1 more ... | undefined; outbound?: ChannelOutboundAdapter | ... 1 more ... | undefined; }): ChannelPlugin<...>;","entrypoint":"core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":413,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function defineChannelPluginEntry<TPlugin>({ id, name, description, plugin, configSchema, setRuntime, registerFull, }: DefineChannelPluginEntryOptions<TPlugin>): DefinedPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":245,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function createChannelPluginBase<TResolvedAccount>(params: CreateChannelPluginBaseOptions<TResolvedAccount>): CreatedChannelPluginBase<TResolvedAccount>;","entrypoint":"core","exportName":"createChannelPluginBase","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":437,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function createChatChannelPlugin<TResolvedAccount extends { accountId?: string | null; }, Probe = unknown, Audit = unknown>(params: { base: ChatChannelPluginBase<TResolvedAccount, Probe, Audit>; security?: ChannelSecurityAdapter<TResolvedAccount> | ChatChannelSecurityOptions<...> | undefined; pairing?: ChannelPairingAdapter | ... 1 more ... | undefined; threading?: ChannelThreadingAdapter | ... 1 more ... | undefined; outbound?: ChannelOutboundAdapter | ... 1 more ... | undefined; }): ChannelPlugin<...>;","entrypoint":"core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":414,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function defineChannelPluginEntry<TPlugin>({ id, name, description, plugin, configSchema, setRuntime, registerFull, }: DefineChannelPluginEntryOptions<TPlugin>): DefinedPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":246,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"core","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":88,"sourcePath":"src/plugin-sdk/plugin-entry.ts"}
{"declaration":"export function defineSetupPluginEntry<TPlugin>(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":276,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function defineSetupPluginEntry<TPlugin>(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":277,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function delegateCompactionToRuntime(params: { sessionId: string; sessionKey?: string | undefined; sessionFile: string; tokenBudget?: number | undefined; force?: boolean | undefined; currentTokenCount?: number | undefined; compactionTarget?: \"budget\" | ... 1 more ... | undefined; customInstructions?: string | undefined; runtimeContext?: ContextEngineRuntimeContext | undefined; }): Promise<...>;","entrypoint":"core","exportName":"delegateCompactionToRuntime","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/context-engine/delegate.ts"}
{"declaration":"export function deleteAccountFromConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; accountId: string; clearBaseFields?: string[] | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"deleteAccountFromConfigSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":60,"sourcePath":"src/channels/plugins/config-helpers.ts"}
{"declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"core","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":13,"sourcePath":"src/plugins/config-schema.ts"}
{"declaration":"export function enqueueKeyedTask<T>(params: { tails: Map<string, Promise<void>>; key: string; task: () => Promise<T>; hooks?: KeyedAsyncQueueHooks | undefined; }): Promise<...>;","entrypoint":"core","exportName":"enqueueKeyedTask","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":7,"sourcePath":"src/plugin-sdk/keyed-async-queue.ts"}
{"declaration":"export function formatPairingApproveHint(channelId: string): string;","entrypoint":"core","exportName":"formatPairingApproveHint","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":17,"sourcePath":"src/channels/plugins/helpers.ts"}
{"declaration":"export function getChatChannelMeta(id: \"telegram\" | \"whatsapp\" | \"discord\" | \"irc\" | \"googlechat\" | \"slack\" | \"signal\" | \"imessage\" | \"line\"): ChannelMeta;","entrypoint":"core","exportName":"getChatChannelMeta","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":158,"sourcePath":"src/channels/registry.ts"}
{"declaration":"export function getChatChannelMeta(id: \"telegram\" | \"whatsapp\" | \"discord\" | \"irc\" | \"googlechat\" | \"slack\" | \"signal\" | \"imessage\" | \"line\"): ChannelMeta;","entrypoint":"core","exportName":"getChatChannelMeta","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":155,"sourcePath":"src/channels/registry.ts"}
{"declaration":"export function isSecretRef(value: unknown): value is SecretRef;","entrypoint":"core","exportName":"isSecretRef","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":34,"sourcePath":"src/config/types.secrets.ts"}
{"declaration":"export function loadSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): SecretFileReadResult;","entrypoint":"core","exportName":"loadSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":29,"sourcePath":"src/infra/secret-file.ts"}
{"declaration":"export function migrateBaseNameToDefaultAccount(params: { cfg: OpenClawConfig; channelKey: string; alwaysUseAccounts?: boolean | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"migrateBaseNameToDefaultAccount","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":92,"sourcePath":"src/channels/plugins/setup-helpers.ts"}
@@ -355,12 +355,13 @@
{"declaration":"export function parseOptionalDelimitedEntries(value?: string | undefined): string[] | undefined;","entrypoint":"core","exportName":"parseOptionalDelimitedEntries","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":23,"sourcePath":"src/channels/plugins/helpers.ts"}
{"declaration":"export function readSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): string;","entrypoint":"core","exportName":"readSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":118,"sourcePath":"src/infra/secret-file.ts"}
{"declaration":"export function resolveGatewayBindUrl(params: { bind?: string | undefined; customBindHost?: string | undefined; scheme: \"ws\" | \"wss\"; port: number; pickTailnetHost: () => string | null; pickLanHost: () => string | null; }): GatewayBindUrlResult;","entrypoint":"core","exportName":"resolveGatewayBindUrl","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":11,"sourcePath":"src/shared/gateway-bind-url.ts"}
{"declaration":"export function resolveGatewayPort(cfg?: OpenClawConfig | undefined, env?: ProcessEnv): number;","entrypoint":"core","exportName":"resolveGatewayPort","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":285,"sourcePath":"src/config/paths.ts"}
{"declaration":"export function resolveTailnetHostWithRunner(runCommandWithTimeout?: TailscaleStatusCommandRunner | undefined): Promise<string | null>;","entrypoint":"core","exportName":"resolveTailnetHostWithRunner","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":43,"sourcePath":"src/shared/tailscale-status.ts"}
{"declaration":"export function resolveThreadSessionKeys(params: { baseSessionKey: string; threadId?: string | null | undefined; parentSessionKey?: string | undefined; useSuffix?: boolean | undefined; normalizeThreadId?: ((threadId: string) => string) | undefined; }): { ...; };","entrypoint":"core","exportName":"resolveThreadSessionKeys","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":234,"sourcePath":"src/routing/session-key.ts"}
{"declaration":"export function setAccountEnabledInConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; accountId: string; enabled: boolean; allowTopLevel?: boolean | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"setAccountEnabledInConfigSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/channels/plugins/config-helpers.ts"}
{"declaration":"export function stringEnum<T extends readonly string[]>(values: T, options?: StringEnumOptions<T>): TUnsafe<T[number]>;","entrypoint":"core","exportName":"stringEnum","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":15,"sourcePath":"src/agents/schema/typebox.ts"}
{"declaration":"export function stripChannelTargetPrefix(raw: string, ...providers: string[]): string;","entrypoint":"core","exportName":"stripChannelTargetPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":142,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function stripTargetKindPrefix(raw: string): string;","entrypoint":"core","exportName":"stripTargetKindPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":154,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function stripChannelTargetPrefix(raw: string, ...providers: string[]): string;","entrypoint":"core","exportName":"stripChannelTargetPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":143,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function stripTargetKindPrefix(raw: string): string;","entrypoint":"core","exportName":"stripTargetKindPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":155,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function tryReadSecretFileSync(filePath: string | undefined, label: string, options?: SecretFileReadOptions): string | undefined;","entrypoint":"core","exportName":"tryReadSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":130,"sourcePath":"src/infra/secret-file.ts"}
{"declaration":"export const DEFAULT_ACCOUNT_ID: \"default\";","entrypoint":"core","exportName":"DEFAULT_ACCOUNT_ID","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export","sourceLine":3,"sourcePath":"src/routing/account-id.ts"}
{"declaration":"export const DEFAULT_SECRET_FILE_MAX_BYTES: number;","entrypoint":"core","exportName":"DEFAULT_SECRET_FILE_MAX_BYTES","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export","sourceLine":5,"sourcePath":"src/infra/secret-file.ts"}
@@ -368,7 +369,7 @@
{"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"core","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":482,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelMessagingAdapter = ChannelMessagingAdapter;","entrypoint":"core","exportName":"ChannelMessagingAdapter","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":395,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelOutboundSessionRoute = ChannelOutboundSessionRoute;","entrypoint":"core","exportName":"ChannelOutboundSessionRoute","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":309,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelOutboundSessionRouteParams = { cfg: OpenClawConfig; agentId: string; accountId?: string | null; target: string; resolvedTarget?: { to: string; kind: import(\"src/channels/plugins/types.core\").ChannelDirectoryEntryKind | \"channel\"; display?: string; source: \"normalized\" | \"directory\"; }; replyToId?: string | null; threadId?: string | number | null;};","entrypoint":"core","exportName":"ChannelOutboundSessionRouteParams","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":137,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export type ChannelOutboundSessionRouteParams = { cfg: OpenClawConfig; agentId: string; accountId?: string | null; target: string; resolvedTarget?: { to: string; kind: import(\"src/channels/plugins/types.core\").ChannelDirectoryEntryKind | \"channel\"; display?: string; source: \"normalized\" | \"directory\"; }; replyToId?: string | null; threadId?: string | number | null;};","entrypoint":"core","exportName":"ChannelOutboundSessionRouteParams","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":138,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export type ChannelPlugin = ChannelPlugin<ResolvedAccount, Probe, Audit>;","entrypoint":"core","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":55,"sourcePath":"src/channels/plugins/types.plugin.ts"}
{"declaration":"export type GatewayBindUrlResult = GatewayBindUrlResult;","entrypoint":"core","exportName":"GatewayBindUrlResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1,"sourcePath":"src/shared/gateway-bind-url.ts"}
{"declaration":"export type GatewayRequestHandlerOptions = GatewayRequestHandlerOptions;","entrypoint":"core","exportName":"GatewayRequestHandlerOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":112,"sourcePath":"src/gateway/server-methods/types.ts"}
@@ -536,7 +537,7 @@
{"declaration":"export function removeAckReactionAfterReply(params: { removeAfterReply: boolean; ackReactionPromise: Promise<boolean> | null; ackReactionValue: string | null; remove: () => Promise<void>; onError?: ((err: unknown) => void) | undefined; }): void;","entrypoint":"testing","exportName":"removeAckReactionAfterReply","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":81,"sourcePath":"src/channels/ack-reactions.ts"}
{"declaration":"export function shouldAckReaction(params: AckReactionGateParams): boolean;","entrypoint":"testing","exportName":"shouldAckReaction","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/channels/ack-reactions.ts"}
{"declaration":"export type ChannelAccountSnapshot = ChannelAccountSnapshot;","entrypoint":"testing","exportName":"ChannelAccountSnapshot","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":144,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext<ResolvedAccount>;","entrypoint":"testing","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":238,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext<ResolvedAccount>;","entrypoint":"testing","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":239,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type MockFn = MockFn<T>;","entrypoint":"testing","exportName":"MockFn","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":5,"sourcePath":"src/test-utils/vitest-mock-fn.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"testing","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"testing","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/plugins/runtime/types.ts"}

View File

@@ -275,6 +275,68 @@ Triggered when the gateway starts:
- **`gateway:startup`**: After channels start and hooks are loaded
### Session Patch Events
Triggered when session properties are modified:
- **`session:patch`**: When a session is updated
#### Session Event Context
Session events include rich context about the session and changes:
```typescript
{
sessionEntry: SessionEntry, // The complete updated session entry
patch: { // The patch object (only changed fields)
// Session identity & labeling
label?: string | null, // Human-readable session label
// AI model configuration
model?: string | null, // Model override (e.g., "claude-opus-4-5")
thinkingLevel?: string | null, // Thinking level ("off"|"low"|"med"|"high")
verboseLevel?: string | null, // Verbose output level
reasoningLevel?: string | null, // Reasoning mode override
elevatedLevel?: string | null, // Elevated mode override
responseUsage?: "off" | "tokens" | "full" | null, // Usage display mode
// Tool execution settings
execHost?: string | null, // Exec host (sandbox|gateway|node)
execSecurity?: string | null, // Security mode (deny|allowlist|full)
execAsk?: string | null, // Approval mode (off|on-miss|always)
execNode?: string | null, // Node ID for host=node
// Subagent coordination
spawnedBy?: string | null, // Parent session key (for subagents)
spawnDepth?: number | null, // Nesting depth (0 = root)
// Communication policies
sendPolicy?: "allow" | "deny" | null, // Message send policy
groupActivation?: "mention" | "always" | null, // Group chat activation
},
cfg: OpenClawConfig // Current gateway config
}
```
**Security note:** Only privileged clients (including the Control UI) can trigger `session:patch` events. Standard WebChat clients are blocked from patching sessions (see PR #20800), so the hook will not fire from those connections.
See `SessionsPatchParamsSchema` in `src/gateway/protocol/schema/sessions.ts` for the complete type definition.
#### Example: Session Patch Logger Hook
```typescript
const handler = async (event) => {
if (event.type !== "session" || event.action !== "patch") {
return;
}
const { patch } = event.context;
console.log(`[session-patch] Session updated: ${event.sessionKey}`);
console.log(`[session-patch] Changes:`, patch);
};
export default handler;
```
### Message Events
Triggered when messages are received or sent:

View File

@@ -316,41 +316,43 @@ After approval, you can chat normally.
**1. Group policy** (`channels.feishu.groupPolicy`):
- `"open"` = allow everyone in groups (default)
- `"open"` = allow everyone in groups
- `"allowlist"` = only allow `groupAllowFrom`
- `"disabled"` = disable group messages
**2. Mention requirement** (`channels.feishu.groups.<chat_id>.requireMention`):
Default: `allowlist`
- `true` = require @mention (default)
- `false` = respond without mentions
**2. Mention requirement** (`channels.feishu.requireMention`, overridable via `channels.feishu.groups.<chat_id>.requireMention`):
- explicit `true` = require @mention
- explicit `false` = respond without mentions
- when unset and `groupPolicy: "open"` = default to `false`
- when unset and `groupPolicy` is not `"open"` = default to `true`
---
## Group configuration examples
### Allow all groups, require @mention (default)
### Allow all groups, no @mention required (default for open groups)
```json5
{
channels: {
feishu: {
groupPolicy: "open",
// Default requireMention: true
},
},
}
```
### Allow all groups, no @mention required
### Allow all groups, but still require @mention
```json5
{
channels: {
feishu: {
groups: {
oc_xxx: { requireMention: false },
},
groupPolicy: "open",
requireMention: true,
},
},
}
@@ -680,9 +682,10 @@ Key options:
| `channels.feishu.accounts.<id>.domain` | Per-account API domain override | `feishu` |
| `channels.feishu.dmPolicy` | DM policy | `pairing` |
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - |
| `channels.feishu.groupPolicy` | Group policy | `open` |
| `channels.feishu.groupPolicy` | Group policy | `allowlist` |
| `channels.feishu.groupAllowFrom` | Group allowlist | - |
| `channels.feishu.groups.<chat_id>.requireMention` | Require @mention | `true` |
| `channels.feishu.requireMention` | Default require @mention | conditional |
| `channels.feishu.groups.<chat_id>.requireMention` | Per-group require @mention override | inherited |
| `channels.feishu.groups.<chat_id>.enabled` | Enable group | `true` |
| `channels.feishu.textChunkLimit` | Message chunk size | `2000` |
| `channels.feishu.mediaMaxMb` | Media size limit | `30` |

View File

@@ -33,6 +33,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately).
- [Voice Call](/plugins/voice-call) — Telephony via Plivo or Twilio (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
- [WeChat](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin) — Tencent iLink Bot plugin via QR login; private chats only.
- [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing.
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).

View File

@@ -74,7 +74,7 @@ If you see logs like:
Example (allow anyone in `#tuirc-dev` to talk to the bot):
```json55
```json5
{
channels: {
irc: {
@@ -95,7 +95,7 @@ That means you may see logs like `drop channel … (missing-mention)` unless the
To make the bot reply in an IRC channel **without needing a mention**, disable mention gating for that channel:
```json55
```json5
{
channels: {
irc: {
@@ -113,7 +113,7 @@ To make the bot reply in an IRC channel **without needing a mention**, disable m
Or to allow **all** IRC channels (no per-channel allowlist) and still reply without mentions:
```json55
```json5
{
channels: {
irc: {
@@ -133,7 +133,7 @@ To reduce risk, restrict tools for that channel.
### Same tools for everyone in the channel
```json55
```json5
{
channels: {
irc: {
@@ -154,7 +154,7 @@ To reduce risk, restrict tools for that channel.
Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your nick:
```json55
```json5
{
channels: {
irc: {

View File

@@ -36,7 +36,7 @@ openclaw pairing list telegram
openclaw pairing approve telegram <CODE>
```
Supported channels: `bluebubbles`, `discord`, `feishu`, `googlechat`, `imessage`, `irc`, `line`, `matrix`, `mattermost`, `msteams`, `nextcloud-talk`, `nostr`, `signal`, `slack`, `synology-chat`, `telegram`, `twitch`, `whatsapp`, `zalo`, `zalouser`.
Supported channels: `bluebubbles`, `discord`, `feishu`, `googlechat`, `imessage`, `irc`, `line`, `matrix`, `mattermost`, `msteams`, `nextcloud-talk`, `nostr`, `openclaw-weixin`, `signal`, `slack`, `synology-chat`, `telegram`, `twitch`, `whatsapp`, `zalo`, `zalouser`.
### Where the state lives

View File

@@ -1,5 +1,5 @@
---
summary: "CLI reference for `openclaw config` (get/set/unset/file/validate)"
summary: "CLI reference for `openclaw config` (get/set/unset/file/schema/validate)"
read_when:
- You want to read or edit config non-interactively
title: "config"
@@ -7,7 +7,7 @@ title: "config"
# `openclaw config`
Config helpers for non-interactive edits in `openclaw.json`: get/set/unset/validate
Config helpers for non-interactive edits in `openclaw.json`: get/set/unset/file/schema/validate
values by path and print the active config file. Run without a subcommand to
open the configure wizard (same as `openclaw configure`).
@@ -15,6 +15,7 @@ open the configure wizard (same as `openclaw configure`).
```bash
openclaw config file
openclaw config schema
openclaw config get browser.executablePath
openclaw config set browser.executablePath "/usr/bin/google-chrome"
openclaw config set agents.defaults.heartbeat.every "2h"
@@ -27,7 +28,21 @@ openclaw config validate
openclaw config validate --json
```
## Paths
### `config schema`
Print the generated JSON schema for `openclaw.json` to stdout as plain text.
```bash
openclaw config schema
```
Pipe it into a file when you want to inspect or validate it with other tools:
```bash
openclaw config schema > openclaw.schema.json
```
### Paths
Paths use dot or bracket notation:

View File

@@ -396,7 +396,7 @@ Interactive configuration wizard (models, channels, skills, gateway).
### `config`
Non-interactive config helpers (get/set/unset/file/validate). Running `openclaw config` with no
Non-interactive config helpers (get/set/unset/file/schema/validate). Running `openclaw config` with no
subcommand launches the wizard.
Subcommands:
@@ -413,6 +413,7 @@ Subcommands:
- `config set --strict-json`: require JSON5 parsing for path/value input. `--json` remains a legacy alias for strict parsing outside dry-run output mode.
- `config unset <path>`: remove a value.
- `config file`: print the active config file path.
- `config schema`: print the generated JSON schema for `openclaw.json`.
- `config validate`: validate the current config against the schema without starting the gateway.
- `config validate --json`: emit machine-readable JSON output.

View File

@@ -92,6 +92,13 @@ These run inside the agent loop or gateway pipeline:
- **`session_start` / `session_end`**: session lifecycle boundaries.
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
Hook decision rules for outbound/tool guards:
- `before_tool_call`: `{ block: true }` is terminal and stops lower-priority handlers.
- `before_tool_call`: `{ block: false }` is a no-op and does not clear a prior block.
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
- `message_sending`: `{ cancel: false }` is a no-op and does not clear a prior cancel.
See [Plugin hooks](/plugins/architecture#provider-runtime-hooks) for the hook API and registration details.
## Streaming + partial replies

View File

@@ -70,11 +70,35 @@ Default mode is `gateway.reload.mode="hybrid"`.
- One always-on process for routing, control plane, and channel connections.
- Single multiplexed port for:
- WebSocket control/RPC
- HTTP APIs (OpenAI-compatible, Responses, tools invoke)
- HTTP APIs, OpenAI compatible (`/v1/models`, `/v1/embeddings`, `/v1/chat/completions`, `/v1/responses`, `/tools/invoke`)
- Control UI and hooks
- Default bind mode: `loopback`.
- Auth is required by default (`gateway.auth.token` / `gateway.auth.password`, or `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`).
## OpenAI-compatible endpoints
OpenClaws highest-leverage compatibility surface is now:
- `GET /v1/models`
- `GET /v1/models/{id}`
- `POST /v1/embeddings`
- `POST /v1/chat/completions`
- `POST /v1/responses`
Why this set matters:
- Most Open WebUI, LobeChat, and LibreChat integrations probe `/v1/models` first.
- Many RAG and memory pipelines expect `/v1/embeddings`.
- Agent-native clients increasingly prefer `/v1/responses`.
Planning note:
- `/v1/models` is agent-first: it returns `openclaw`, `openclaw/default`, and `openclaw/<agentId>`.
- `openclaw/default` is the stable alias that always maps to the configured default agent.
- Use `x-openclaw-model` when you want a backend provider/model override; otherwise the selected agent's normal model and embedding setup stays in control.
All of these run on the main Gateway port and use the same trusted operator auth boundary as the rest of the Gateway HTTP API.
### Port and bind precedence
| Setting | Resolution order |

View File

@@ -14,6 +14,13 @@ This endpoint is **disabled by default**. Enable it in config first.
- `POST /v1/chat/completions`
- Same port as the Gateway (WS + HTTP multiplex): `http://<gateway-host>:<port>/v1/chat/completions`
When the Gateways OpenAI-compatible HTTP surface is enabled, it also serves:
- `GET /v1/models`
- `GET /v1/models/{id}`
- `POST /v1/embeddings`
- `POST /v1/responses`
Under the hood, requests are executed as a normal Gateway agent run (same codepath as `openclaw agent`), so routing/permissions/config match your Gateway.
## Authentication
@@ -41,20 +48,25 @@ Treat this endpoint as a **full operator-access** surface for the gateway instan
See [Security](/gateway/security) and [Remote access](/gateway/remote).
## Choosing an agent
## Agent-first model contract
No custom headers required: encode the agent id in the OpenAI `model` field:
OpenClaw treats the OpenAI `model` field as an **agent target**, not a raw provider model id.
- `model: "openclaw:<agentId>"` (example: `"openclaw:main"`, `"openclaw:beta"`)
- `model: "agent:<agentId>"` (alias)
- `model: "openclaw"` routes to the configured default agent.
- `model: "openclaw/default"` also routes to the configured default agent.
- `model: "openclaw/<agentId>"` routes to a specific agent.
Or target a specific OpenClaw agent by header:
Optional request headers:
- `x-openclaw-agent-id: <agentId>` (default: `main`)
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent.
- `x-openclaw-agent-id: <agentId>` remains supported as a compatibility override.
- `x-openclaw-session-key: <sessionKey>` fully controls session routing.
- `x-openclaw-message-channel: <channel>` sets the synthetic ingress channel context for channel-aware prompts and policies.
Advanced:
Compatibility aliases still accepted:
- `x-openclaw-session-key: <sessionKey>` to fully control session routing.
- `model: "openclaw:<agentId>"`
- `model: "agent:<agentId>"`
## Enabling the endpoint
@@ -94,6 +106,57 @@ By default the endpoint is **stateless per request** (a new session key is gener
If the request includes an OpenAI `user` string, the Gateway derives a stable session key from it, so repeated calls can share an agent session.
## Why this surface matters
This is the highest-leverage compatibility set for self-hosted frontends and tooling:
- Most Open WebUI, LobeChat, and LibreChat setups expect `/v1/models`.
- Many RAG systems expect `/v1/embeddings`.
- Existing OpenAI chat clients can usually start with `/v1/chat/completions`.
- More agent-native clients increasingly prefer `/v1/responses`.
## Model list and agent routing
<AccordionGroup>
<Accordion title="What does `/v1/models` return?">
An OpenClaw agent-target list.
The returned ids are `openclaw`, `openclaw/default`, and `openclaw/<agentId>` entries.
Use them directly as OpenAI `model` values.
</Accordion>
<Accordion title="Does `/v1/models` list agents or sub-agents?">
It lists top-level agent targets, not backend provider models and not sub-agents.
Sub-agents remain internal execution topology. They do not appear as pseudo-models.
</Accordion>
<Accordion title="Why is `openclaw/default` included?">
`openclaw/default` is the stable alias for the configured default agent.
That means clients can keep using one predictable id even if the real default agent id changes between environments.
</Accordion>
<Accordion title="How do I override the backend model?">
Use `x-openclaw-model`.
Examples:
`x-openclaw-model: openai/gpt-5.4`
`x-openclaw-model: gpt-5.4`
If you omit it, the selected agent runs with its normal configured model choice.
</Accordion>
<Accordion title="How do embeddings fit this contract?">
`/v1/embeddings` uses the same agent-target `model` ids.
Use `model: "openclaw/default"` or `model: "openclaw/<agentId>"`.
When you need a specific embedding model, send it in `x-openclaw-model`.
Without that header, the request passes through to the selected agent's normal embedding setup.
</Accordion>
</AccordionGroup>
## Streaming (SSE)
Set `stream: true` to receive Server-Sent Events (SSE):
@@ -102,6 +165,30 @@ Set `stream: true` to receive Server-Sent Events (SSE):
- Each event line is `data: <json>`
- Stream ends with `data: [DONE]`
## Open WebUI quick setup
For a basic Open WebUI connection:
- Base URL: `http://127.0.0.1:18789/v1`
- Docker on macOS base URL: `http://host.docker.internal:18789/v1`
- API key: your Gateway bearer token
- Model: `openclaw/default`
Expected behavior:
- `GET /v1/models` should list `openclaw/default`
- Open WebUI should use `openclaw/default` as the chat model id
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model`
Quick smoke:
```bash
curl -sS http://127.0.0.1:18789/v1/models \
-H 'Authorization: Bearer YOUR_TOKEN'
```
If that returns `openclaw/default`, most Open WebUI setups can connect with the same base URL and token.
## Examples
Non-streaming:
@@ -110,9 +197,8 @@ Non-streaming:
curl -sS http://127.0.0.1:18789/v1/chat/completions \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-openclaw-agent-id: main' \
-d '{
"model": "openclaw",
"model": "openclaw/default",
"messages": [{"role":"user","content":"hi"}]
}'
```
@@ -123,10 +209,44 @@ Streaming:
curl -N http://127.0.0.1:18789/v1/chat/completions \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-openclaw-agent-id: main' \
-H 'x-openclaw-model: openai/gpt-5.4' \
-d '{
"model": "openclaw",
"model": "openclaw/research",
"stream": true,
"messages": [{"role":"user","content":"hi"}]
}'
```
List models:
```bash
curl -sS http://127.0.0.1:18789/v1/models \
-H 'Authorization: Bearer YOUR_TOKEN'
```
Fetch one model:
```bash
curl -sS http://127.0.0.1:18789/v1/models/openclaw%2Fdefault \
-H 'Authorization: Bearer YOUR_TOKEN'
```
Create embeddings:
```bash
curl -sS http://127.0.0.1:18789/v1/embeddings \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-openclaw-model: openai/text-embedding-3-small' \
-d '{
"model": "openclaw/default",
"input": ["alpha", "beta"]
}'
```
Notes:
- `/v1/models` returns OpenClaw agent targets, not raw provider catalogs.
- `openclaw/default` is always present so one stable id works across environments.
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field.
- `/v1/embeddings` supports `input` as a string or array of strings.

View File

@@ -24,11 +24,22 @@ Operational behavior matches [OpenAI Chat Completions](/gateway/openai-http-api)
- use `Authorization: Bearer <token>` with the normal Gateway auth config
- treat the endpoint as full operator access for the gateway instance
- select agents with `model: "openclaw:<agentId>"`, `model: "agent:<agentId>"`, or `x-openclaw-agent-id`
- select agents with `model: "openclaw"`, `model: "openclaw/default"`, `model: "openclaw/<agentId>"`, or `x-openclaw-agent-id`
- use `x-openclaw-model` when you want to override the selected agent's backend model
- use `x-openclaw-session-key` for explicit session routing
- use `x-openclaw-message-channel` when you want a non-default synthetic ingress channel context
Enable or disable this endpoint with `gateway.http.endpoints.responses.enabled`.
The same compatibility surface also includes:
- `GET /v1/models`
- `GET /v1/models/{id}`
- `POST /v1/embeddings`
- `POST /v1/chat/completions`
For the canonical explanation of how agent-target models, `openclaw/default`, embeddings pass-through, and backend model overrides fit together, see [OpenAI Chat Completions](/gateway/openai-http-api#agent-first-model-contract) and [Model list and agent routing](/gateway/openai-http-api#model-list-and-agent-routing).
## Session behavior
By default the endpoint is **stateless per request** (a new session key is generated each call).
@@ -54,9 +65,12 @@ Accepted but **currently ignored**:
- `reasoning`
- `metadata`
- `store`
- `previous_response_id`
- `truncation`
Supported:
- `previous_response_id`: OpenClaw reuses the earlier response session when the request stays within the same agent/user/requested-session scope.
## Items (input)
### `message`

View File

@@ -181,6 +181,13 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- `source`: `core` or `plugin`
- `pluginId`: plugin owner when `source="plugin"`
- `optional`: whether a plugin tool is optional
- Operators may call `tools.effective` (`operator.read`) to fetch the runtime-effective tool
inventory for a session.
- `sessionKey` is required.
- The gateway derives trusted runtime context from the session server-side instead of accepting
caller-supplied auth or delivery context.
- The response is session-scoped and reflects what the active conversation can use right now,
including core, plugin, and channel tools.
## Exec approvals

View File

@@ -2153,9 +2153,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
1. Upgrade to a current OpenClaw release (or run from source `main`), then restart the gateway.
2. Make sure MiniMax is configured (wizard or JSON), or that a MiniMax API key
exists in env/auth profiles so the provider can be injected.
3. Use the exact model id (case-sensitive): `minimax/MiniMax-M2.7`,
`minimax/MiniMax-M2.7-highspeed`, `minimax/MiniMax-M2.5`, or
`minimax/MiniMax-M2.5-highspeed`.
3. Use the exact model id (case-sensitive): `minimax/MiniMax-M2.7` or
`minimax/MiniMax-M2.7-highspeed`.
4. Run:
```bash

View File

@@ -424,10 +424,16 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
## Docker runners (optional "works in Linux" checks)
These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted). They also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:
These Docker runners split into two buckets:
- Live-model runners: `test:docker:live-models` and `test:docker:live-gateway` run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted).
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:gateway-network`, and `test:docker:plugins` boot one or more real containers and verify higher-level integration paths.
The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:
- Direct models: `pnpm test:docker:live-models` (script: `scripts/test-live-models-docker.sh`)
- Gateway + dev agent: `pnpm test:docker:live-gateway` (script: `scripts/test-live-gateway-models-docker.sh`)
- Open WebUI live smoke: `pnpm test:docker:openwebui` (script: `scripts/e2e/openwebui-docker.sh`)
- Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`)
- Gateway networking (two containers, WS auth + health): `pnpm test:docker:gateway-network` (script: `scripts/e2e/gateway-network-docker.sh`)
- Plugins (install smoke + `/plugin` alias + Claude-bundle restart semantics): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`)
@@ -440,6 +446,17 @@ real Telegram/Discord/etc. channel workers inside the container.
`test:docker:live-models` still runs `pnpm test:live`, so pass through
`OPENCLAW_LIVE_GATEWAY_*` as well when you need to narrow or exclude gateway
live coverage from that Docker lane.
`test:docker:openwebui` is a higher-level compatibility smoke: it starts an
OpenClaw gateway container with the OpenAI-compatible HTTP endpoints enabled,
starts a pinned Open WebUI container against that gateway, signs in through
Open WebUI, verifies `/api/models` exposes `openclaw/default`, then sends a
real chat request through Open WebUI's `/api/chat/completions` proxy.
The first run can be noticeably slower because Docker may need to pull the
Open WebUI image and Open WebUI may need to finish its own cold-start setup.
This lane expects a usable live model key, and `OPENCLAW_PROFILE_FILE`
(`~/.profile` by default) is the primary way to provide it in Dockerized runs.
Successful runs print a small JSON payload like `{ "ok": true, "model":
"openclaw/default", ... }`.
Manual ACP plain-language thread smoke (not CI):
@@ -458,6 +475,9 @@ Useful env vars:
- `OPENCLAW_LIVE_GATEWAY_MODELS=...` / `OPENCLAW_LIVE_MODELS=...` to narrow the run
- `OPENCLAW_LIVE_GATEWAY_PROVIDERS=...` / `OPENCLAW_LIVE_PROVIDERS=...` to filter providers in-container
- `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to ensure creds come from the profile store (not env)
- `OPENCLAW_OPENWEBUI_MODEL=...` to choose the model exposed by the gateway for the Open WebUI smoke
- `OPENCLAW_OPENWEBUI_PROMPT=...` to override the nonce-check prompt used by the Open WebUI smoke
- `OPENWEBUI_IMAGE=...` to override the pinned Open WebUI image tag
## Docs sanity

View File

@@ -54,7 +54,7 @@ OpenClaw is a **self-hosted gateway** that connects your favorite chat apps —
- **Agent-native**: built for coding agents with tool use, sessions, memory, and multi-agent routing
- **Open source**: MIT licensed, community-driven
**What do you need?** Node 24 (recommended), or Node 22 LTS (`22.16+`) for compatibility, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available.
**What do you need?** Node 24 (recommended), or Node 22 LTS (`22.14+`) for compatibility, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available.
## How it works

View File

@@ -48,7 +48,7 @@ The Ansible playbook installs and configures:
1. **Tailscale** -- mesh VPN for secure remote access
2. **UFW firewall** -- SSH + Tailscale ports only
3. **Docker CE + Compose V2** -- for agent sandboxes
4. **Node.js 24 + pnpm** -- runtime dependencies (Node 22 LTS, currently `22.16+`, remains supported)
4. **Node.js 24 + pnpm** -- runtime dependencies (Node 22 LTS, currently `22.14+`, remains supported)
5. **OpenClaw** -- host-based, not containerized
6. **Systemd service** -- auto-start with security hardening

View File

@@ -41,7 +41,7 @@ Bun is an optional local runtime for running TypeScript directly (`bun run ...`,
Bun blocks dependency lifecycle scripts unless explicitly trusted. For this repo, the commonly blocked scripts are not required:
- `@whiskeysockets/baileys` `preinstall` -- checks Node major >= 20 (OpenClaw defaults to Node 24 and still supports Node 22 LTS, currently `22.16+`)
- `@whiskeysockets/baileys` `preinstall` -- checks Node major >= 20 (OpenClaw defaults to Node 24 and still supports Node 22 LTS, currently `22.14+`)
- `protobufjs` `postinstall` -- emits warnings about incompatible version schemes (no build artifacts)
If you hit a runtime issue that requires these scripts, trust them explicitly:

View File

@@ -55,6 +55,10 @@ Docker is **optional**. Use it only if you want a containerized gateway or to va
- generate a gateway token and write it to `.env`
- start the gateway via Docker Compose
During setup, pre-start onboarding and config writes run through
`openclaw-gateway` directly. `openclaw-cli` is for commands you run after
the gateway container already exists.
</Step>
<Step title="Open the Control UI">
@@ -94,7 +98,15 @@ If you prefer to run each step yourself instead of using the setup script:
```bash
docker build -t openclaw:local -f Dockerfile .
docker compose run --rm openclaw-cli onboard
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
dist/index.js onboard --mode local --no-install-daemon
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
dist/index.js config set gateway.mode local
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
dist/index.js config set gateway.bind lan
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
dist/index.js config set gateway.controlUi.allowedOrigins \
'["http://localhost:18789","http://127.0.0.1:18789"]' --strict-json
docker compose up -d openclaw-gateway
```
@@ -104,6 +116,13 @@ or `OPENCLAW_HOME_VOLUME`, the setup script writes `docker-compose.extra.yml`;
include it with `-f docker-compose.yml -f docker-compose.extra.yml`.
</Note>
<Note>
Because `openclaw-cli` shares `openclaw-gateway`'s network namespace, it is a
post-start tool. Before `docker compose up -d openclaw-gateway`, run onboarding
and setup-time config writes through `openclaw-gateway` with
`--no-deps --entrypoint node`.
</Note>
### Environment variables
The setup script accepts these optional environment variables:

View File

@@ -45,7 +45,7 @@ For all flags and CI/automation options, see [Installer internals](/install/inst
## System requirements
- **Node 24** (recommended) or Node 22.16+ — the installer script handles this automatically
- **Node 24** (recommended) or Node 22.14+ — the installer script handles this automatically
- **macOS, Linux, or Windows** — both native Windows and WSL2 are supported; WSL2 is more stable. See [Windows](/platforms/windows).
- `pnpm` is only needed if you build from source

View File

@@ -71,7 +71,7 @@ Recommended for most interactive installs on macOS/Linux/WSL.
Supports macOS and Linux (including WSL). If macOS is detected, installs Homebrew if missing.
</Step>
<Step title="Ensure Node.js 24 by default">
Checks Node version and installs Node 24 if needed (Homebrew on macOS, NodeSource setup scripts on Linux apt/dnf/yum). OpenClaw still supports Node 22 LTS, currently `22.16+`, for compatibility.
Checks Node version and installs Node 24 if needed (Homebrew on macOS, NodeSource setup scripts on Linux apt/dnf/yum). OpenClaw still supports Node 22 LTS, currently `22.14+`, for compatibility.
</Step>
<Step title="Ensure Git">
Installs Git if missing.
@@ -257,7 +257,7 @@ Designed for environments where you want everything under a local prefix (defaul
Requires PowerShell 5+.
</Step>
<Step title="Ensure Node.js 24 by default">
If missing, attempts install via winget, then Chocolatey, then Scoop. Node 22 LTS, currently `22.16+`, remains supported for compatibility.
If missing, attempts install via winget, then Chocolatey, then Scoop. Node 22 LTS, currently `22.14+`, remains supported for compatibility.
</Step>
<Step title="Install OpenClaw">
- `npm` method (default): global npm install using selected `-Tag`

View File

@@ -9,7 +9,7 @@ read_when:
# Node.js
OpenClaw requires **Node 22.16 or newer**. **Node 24 is the default and recommended runtime** for installs, CI, and release workflows. Node 22 remains supported via the active LTS line. The [installer script](/install#alternative-install-methods) will detect and install Node automatically — this page is for when you want to set up Node yourself and make sure everything is wired up correctly (versions, PATH, global installs).
OpenClaw requires **Node 22.14 or newer**. **Node 24 is the default and recommended runtime** for installs, CI, and release workflows. Node 22 remains supported via the active LTS line. The [installer script](/install#alternative-install-methods) will detect and install Node automatically — this page is for when you want to set up Node yourself and make sure everything is wired up correctly (versions, PATH, global installs).
## Check your version
@@ -17,7 +17,7 @@ OpenClaw requires **Node 22.16 or newer**. **Node 24 is the default and recommen
node -v
```
If this prints `v24.x.x` or higher, you're on the recommended default. If it prints `v22.16.x` or higher, you're on the supported Node 22 LTS path, but we still recommend upgrading to Node 24 when convenient. If Node isn't installed or the version is too old, pick an install method below.
If this prints `v24.x.x` or higher, you're on the recommended default. If it prints `v22.14.x` or higher, you're on the supported Node 22 LTS path, but we still recommend upgrading to Node 24 when convenient. If Node isn't installed or the version is too old, pick an install method below.
## Install Node

View File

@@ -15,7 +15,7 @@ Native Linux companion apps are planned. Contributions are welcome if you want t
## Beginner quick path (VPS)
1. Install Node 24 (recommended; Node 22 LTS, currently `22.16+`, still works for compatibility)
1. Install Node 24 (recommended; Node 22 LTS, currently `22.14+`, still works for compatibility)
2. `npm i -g openclaw@latest`
3. `openclaw onboard --install-daemon`
4. From your laptop: `ssh -N -L 18789:127.0.0.1:18789 <user>@<host>`

View File

@@ -16,7 +16,7 @@ running (or attaches to an existing local Gateway if one is already running).
## Install the CLI (required for local mode)
Node 24 is the default runtime on the Mac. Node 22 LTS, currently `22.16+`, still works for compatibility. Then install `openclaw` globally:
Node 24 is the default runtime on the Mac. Node 22 LTS, currently `22.14+`, still works for compatibility. Then install `openclaw` globally:
```bash
npm install -g openclaw@<version>

View File

@@ -14,7 +14,7 @@ This guide covers the necessary steps to build and run the OpenClaw macOS applic
Before building the app, ensure you have the following installed:
1. **Xcode 26.2+**: Required for Swift development.
2. **Node.js 24 & pnpm**: Recommended for the gateway, CLI, and packaging scripts. Node 22 LTS, currently `22.16+`, remains supported for compatibility.
2. **Node.js 24 & pnpm**: Recommended for the gateway, CLI, and packaging scripts. Node 22 LTS, currently `22.14+`, remains supported for compatibility.
## 1. Install Dependencies

View File

@@ -14,7 +14,7 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com
- calls [`scripts/codesign-mac-app.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/codesign-mac-app.sh) to sign the main binary and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see [macOS permissions](/platforms/mac/permissions)).
- uses `CODESIGN_TIMESTAMP=auto` by default; it enables trusted timestamps for Developer ID signatures. Set `CODESIGN_TIMESTAMP=off` to skip timestamping (offline debug builds).
- inject build metadata into Info.plist: `OpenClawBuildTimestamp` (UTC) and `OpenClawGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
- **Packaging defaults to Node 24**: the script runs TS builds and the Control UI build. Node 22 LTS, currently `22.16+`, remains supported for compatibility.
- **Packaging defaults to Node 24**: the script runs TS builds and the Control UI build. Node 22 LTS, currently `22.14+`, remains supported for compatibility.
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
- runs a Team ID audit after signing and fails if any Mach-O inside the app bundle is signed by a different Team ID. Set `SKIP_TEAM_ID_CHECK=1` to bypass.

View File

@@ -144,6 +144,15 @@ A single plugin can register any number of capabilities via the `api` object:
For the full registration API, see [SDK Overview](/plugins/sdk-overview#registration-api).
Hook guard semantics to keep in mind:
- `before_tool_call`: `{ block: true }` is terminal and stops lower-priority handlers.
- `before_tool_call`: `{ block: false }` is treated as no decision.
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
- `message_sending`: `{ cancel: false }` is treated as no decision.
See [SDK Overview hook decision semantics](/plugins/sdk-overview#hook-decision-semantics) for details.
## Registering agent tools
Tools are typed functions the LLM can call. They can be required (always

View File

@@ -152,6 +152,13 @@ methods:
| `api.on(hookName, handler, opts?)` | Typed lifecycle hook |
| `api.onConversationBindingResolved(handler)` | Conversation binding callback |
### Hook decision semantics
- `before_tool_call`: returning `{ block: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
- `before_tool_call`: returning `{ block: false }` is treated as no decision (same as omitting `block`), not as an override.
- `message_sending`: returning `{ cancel: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
- `message_sending`: returning `{ cancel: false }` is treated as no decision (same as omitting `cancel`), not as an override.
### API object fields
| Field | Type | Description |

View File

@@ -8,16 +8,12 @@ title: "MiniMax"
# MiniMax
OpenClaw's MiniMax provider defaults to **MiniMax M2.7** and keeps
**MiniMax M2.5** in the catalog for compatibility.
OpenClaw's MiniMax provider defaults to **MiniMax M2.7**.
## Model lineup
- `MiniMax-M2.7`: default hosted text model.
- `MiniMax-M2.7-highspeed`: faster M2.7 text tier.
- `MiniMax-M2.5`: previous text model, still available in the MiniMax catalog.
- `MiniMax-M2.5-highspeed`: faster M2.5 text tier.
- `MiniMax-VL-01`: vision model for text + image inputs.
## Choose a setup
@@ -80,24 +76,6 @@ Configure via CLI:
contextWindow: 200000,
maxTokens: 8192,
},
{
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
reasoning: true,
input: ["text"],
cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.12 },
contextWindow: 200000,
maxTokens: 8192,
},
{
id: "MiniMax-M2.5-highspeed",
name: "MiniMax M2.5 Highspeed",
reasoning: true,
input: ["text"],
cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.12 },
contextWindow: 200000,
maxTokens: 8192,
},
],
},
},
@@ -128,46 +106,6 @@ Example below uses Opus as a concrete primary; swap to your preferred latest-gen
}
```
### Optional: Local via LM Studio (manual)
**Best for:** local inference with LM Studio.
We have seen strong results with MiniMax M2.5 on powerful hardware (e.g. a
desktop/server) using LM Studio's local server.
Configure manually via `openclaw.json`:
```json5
{
agents: {
defaults: {
model: { primary: "lmstudio/minimax-m2.5-gs32" },
models: { "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" } },
},
},
models: {
mode: "merge",
providers: {
lmstudio: {
baseUrl: "http://127.0.0.1:1234/v1",
apiKey: "lmstudio",
api: "openai-responses",
models: [
{
id: "minimax-m2.5-gs32",
name: "MiniMax M2.5 GS32",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 196608,
maxTokens: 8192,
},
],
},
},
},
}
```
## Configure via `openclaw configure`
Use the interactive config wizard to set MiniMax without editing JSON:
@@ -190,7 +128,7 @@ Use the interactive config wizard to set MiniMax without editing JSON:
- Model refs are `minimax/<model>`.
- Default text model: `MiniMax-M2.7`.
- Alternate text models: `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`.
- Alternate text model: `MiniMax-M2.7-highspeed`.
- Coding Plan usage API: `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains` (requires a coding plan key).
- Update pricing values in `models.json` if you need exact cost tracking.
- Referral link for MiniMax Coding Plan (10% off): [https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link](https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link)
@@ -214,8 +152,6 @@ Make sure the model id is **casesensitive**:
- `minimax/MiniMax-M2.7`
- `minimax/MiniMax-M2.7-highspeed`
- `minimax/MiniMax-M2.5`
- `minimax/MiniMax-M2.5-highspeed`
Then recheck with:

View File

@@ -26,6 +26,7 @@ title: "Tests"
- Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`.
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `forks` + adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=<n>` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs.
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip.
- `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`), pulls an external Open WebUI image, and is not expected to be CI-stable like the normal unit/e2e suites.
## Local PR gate

View File

@@ -14,7 +14,7 @@ and a working chat session.
## What you need
- **Node.js** — Node 24 recommended (Node 22.16+ also supported)
- **Node.js** — Node 24 recommended (Node 22.14+ also supported)
- **An API key** from a model provider (Anthropic, OpenAI, Google, etc.) — onboarding will prompt you
<Tip>

View File

@@ -21,7 +21,7 @@ For onboarding details, see [Onboarding (CLI)](/start/wizard).
## Prereqs (from source)
- Node 24 recommended (Node 22 LTS, currently `22.16+`, still supported)
- Node 24 recommended (Node 22 LTS, currently `22.14+`, still supported)
- `pnpm`
- Docker (optional; only for containerized setup/e2e — see [Docker](/install/docker))

View File

@@ -258,6 +258,15 @@ Common registration methods:
| `registerContextEngine` | Context engine |
| `registerService` | Background service |
Hook guard behavior for typed lifecycle hooks:
- `before_tool_call`: `{ block: true }` is terminal; lower-priority handlers are skipped.
- `before_tool_call`: `{ block: false }` is a no-op and does not clear an earlier block.
- `message_sending`: `{ cancel: true }` is terminal; lower-priority handlers are skipped.
- `message_sending`: `{ cancel: false }` is a no-op and does not clear an earlier cancel.
For full typed hook behavior, see [SDK Overview](/plugins/sdk-overview#hook-decision-semantics).
## Related
- [Building Plugins](/plugins/building-plugins) — create your own plugin

View File

@@ -75,6 +75,7 @@ Text + native (when enabled):
- `/help`
- `/commands`
- `/tools [compact|verbose]` (show what the current agent can use right now; `verbose` adds descriptions)
- `/skill <name> [input]` (run a skill by name)
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/allowlist` (list/add/remove allowlist entries)
@@ -157,6 +158,22 @@ Notes:
- Example: `/prose` (OpenProse plugin) — see [OpenProse](/prose).
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
## `/tools`
`/tools` answers a runtime question, not a config question: **what this agent can use right now in
this conversation**.
- Default `/tools` is compact and optimized for quick scanning.
- `/tools verbose` adds short descriptions.
- Native-command surfaces that support arguments expose the same mode switch as `compact|verbose`.
- Results are session-scoped, so changing agent, channel, thread, sender authorization, or model can
change the output.
- `/tools` includes tools that are actually reachable at runtime, including core tools, connected
plugin tools, and channel-owned tools.
For profile and override editing, use the Control UI Tools panel or config/catalog surfaces instead
of treating `/tools` as a static catalog.
## Usage surfaces (what shows where)
- **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` for the current model provider when usage tracking is enabled.

View File

@@ -10,7 +10,7 @@ title: "Text-to-Speech"
# Text-to-speech (TTS)
OpenClaw can convert outbound replies into audio using ElevenLabs, Microsoft, or OpenAI.
It works anywhere OpenClaw can send audio; Telegram gets a round voice-note bubble.
It works anywhere OpenClaw can send audio.
## Supported services
@@ -170,7 +170,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
}
```
### Only reply with audio after an inbound voice note
### Only reply with audio after an inbound voice message
```json5
{
@@ -203,7 +203,7 @@ Then run:
### Notes on fields
- `auto`: autoTTS mode (`off`, `always`, `inbound`, `tagged`).
- `inbound` only sends audio after an inbound voice note.
- `inbound` only sends audio after an inbound voice message.
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
@@ -319,18 +319,18 @@ These override `messages.tts.*` for that host.
## Output formats (fixed)
- **Telegram**: Opus voice note (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble.
- **Feishu / Matrix / Telegram / WhatsApp**: Opus voice message (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
- 48kHz / 64kbps is a good voice message tradeoff.
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
- 44.1kHz / 128kbps is the default balance for speech clarity.
- **Microsoft**: uses `microsoft.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
- The bundled transport accepts an `outputFormat`, but not all formats are available from the service.
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus).
- Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need
guaranteed Opus voice notes. citeturn1search1
guaranteed Opus voice messages.
- If the configured Microsoft output format fails, OpenClaw retries with MP3.
OpenAI/ElevenLabs formats are fixed; Telegram expects Opus for voice-note UX.
OpenAI/ElevenLabs output formats are fixed per channel (see above).
## Auto-TTS behavior
@@ -391,8 +391,8 @@ Notes:
## Agent tool
The `tts` tool converts text to speech and returns an audio attachment for
reply delivery. When the result is Telegram-compatible, OpenClaw marks it for
voice-bubble delivery.
reply delivery. When the channel is Feishu, Matrix, Telegram, or WhatsApp,
the audio is delivered as a voice message rather than a file attachment.
## Gateway RPC

View File

@@ -10,7 +10,7 @@ title: "Text-to-Speech (legacy path)"
# Text-to-speech (TTS)
OpenClaw can convert outbound replies into audio using ElevenLabs, Microsoft, or OpenAI.
It works anywhere OpenClaw can send audio; Telegram gets a round voice-note bubble.
It works anywhere OpenClaw can send audio.
## Supported services
@@ -170,7 +170,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
}
```
### Only reply with audio after an inbound voice note
### Only reply with audio after an inbound voice message
```json5
{
@@ -203,7 +203,7 @@ Then run:
### Notes on fields
- `auto`: autoTTS mode (`off`, `always`, `inbound`, `tagged`).
- `inbound` only sends audio after an inbound voice note.
- `inbound` only sends audio after an inbound voice message.
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
@@ -319,18 +319,18 @@ These override `messages.tts.*` for that host.
## Output formats (fixed)
- **Telegram**: Opus voice note (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble.
- **Feishu / Matrix / Telegram / WhatsApp**: Opus voice message (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
- 48kHz / 64kbps is a good voice message tradeoff.
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
- 44.1kHz / 128kbps is the default balance for speech clarity.
- **Microsoft**: uses `microsoft.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
- The bundled transport accepts an `outputFormat`, but not all formats are available from the service.
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus).
- Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need
guaranteed Opus voice notes. citeturn1search1
guaranteed Opus voice messages.
- If the configured Microsoft output format fails, OpenClaw retries with MP3.
OpenAI/ElevenLabs formats are fixed; Telegram expects Opus for voice-note UX.
OpenAI/ElevenLabs output formats are fixed per channel (see above).
## Auto-TTS behavior
@@ -391,8 +391,8 @@ Notes:
## Agent tool
The `tts` tool converts text to speech and returns an audio attachment for
reply delivery. When the result is Telegram-compatible, OpenClaw marks it for
voice-bubble delivery.
reply delivery. When the channel is Feishu, Matrix, Telegram, or WhatsApp,
the audio is delivered as a voice message rather than a file attachment.
## Gateway RPC

View File

@@ -33,10 +33,14 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
## Control UI agents tools panel
- The Control UI `/agents` Tools panel fetches a runtime catalog via `tools.catalog` and labels each
tool as `core` or `plugin:<id>` (plus `optional` for optional plugin tools).
- If `tools.catalog` is unavailable, the panel falls back to a built-in static list.
- The panel edits profile and override config, but effective runtime access still follows policy
- The Control UI `/agents` Tools panel has two separate views:
- **Available Right Now** uses `tools.effective(sessionKey=...)` and shows what the current
session can actually use at runtime, including core, plugin, and channel-owned tools.
- **Tool Configuration** uses `tools.catalog` and stays focused on profiles, overrides, and
catalog semantics.
- Runtime availability is session-scoped. Switching sessions on the same agent can change the
**Available Right Now** list.
- The config editor does not imply runtime availability; effective access still follows policy
precedence (`allow`/`deny`, per-agent and provider/channel overrides).
## Remote use

View File

@@ -38,6 +38,53 @@ afterAll(async () => {
await cleanupMockRuntimeFixtures();
});
async function expectSessionEnsureFallback(params: {
sessionKey: string;
env?: Record<string, string>;
expectNewAfterStatus: boolean;
expectedRecordId?: string;
}) {
const previousEnv = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(params.env ?? {})) {
previousEnv.set(key, process.env[key]);
process.env[key] = value;
}
try {
const { runtime, logPath } = await createMockRuntimeFixture();
const handle = await runtime.ensureSession({
sessionKey: params.sessionKey,
agent: "codex",
mode: "persistent",
});
expect(handle.backend).toBe("acpx");
if (params.expectedRecordId) {
expect(handle.acpxRecordId).toBe(params.expectedRecordId);
}
const logs = await readMockRuntimeLogEntries(logPath);
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
const newIndex = logs.findIndex((entry) => entry.kind === "new");
expect(ensureIndex).toBeGreaterThanOrEqual(0);
expect(statusIndex).toBeGreaterThan(ensureIndex);
if (params.expectNewAfterStatus) {
expect(newIndex).toBeGreaterThan(statusIndex);
} else {
expect(newIndex).toBe(-1);
}
} finally {
for (const [key, value] of previousEnv.entries()) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
describe("AcpxRuntime", () => {
it("passes the shared ACP adapter contract suite", async () => {
const fixture = await createMockRuntimeFixture();
@@ -155,87 +202,38 @@ describe("AcpxRuntime", () => {
});
it("replaces dead named sessions returned by sessions ensure", async () => {
process.env.MOCK_ACPX_STATUS_STATUS = "dead";
process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable";
try {
const { runtime, logPath } = await createMockRuntimeFixture();
const sessionKey = "agent:codex:acp:dead-session";
const handle = await runtime.ensureSession({
sessionKey,
agent: "codex",
mode: "persistent",
});
expect(handle.backend).toBe("acpx");
const logs = await readMockRuntimeLogEntries(logPath);
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
const newIndex = logs.findIndex((entry) => entry.kind === "new");
expect(ensureIndex).toBeGreaterThanOrEqual(0);
expect(statusIndex).toBeGreaterThan(ensureIndex);
expect(newIndex).toBeGreaterThan(statusIndex);
} finally {
delete process.env.MOCK_ACPX_STATUS_STATUS;
delete process.env.MOCK_ACPX_STATUS_SUMMARY;
}
await expectSessionEnsureFallback({
sessionKey: "agent:codex:acp:dead-session",
env: {
MOCK_ACPX_STATUS_STATUS: "dead",
MOCK_ACPX_STATUS_SUMMARY: "queue owner unavailable",
},
expectNewAfterStatus: true,
});
});
it("reuses a live named session when sessions ensure exits before returning identifiers", async () => {
process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1";
process.env.MOCK_ACPX_STATUS_STATUS = "alive";
try {
const { runtime, logPath } = await createMockRuntimeFixture();
const sessionKey = "agent:codex:acp:ensure-fallback-alive";
const handle = await runtime.ensureSession({
sessionKey,
agent: "codex",
mode: "persistent",
});
expect(handle.backend).toBe("acpx");
expect(handle.acpxRecordId).toBe("rec-" + sessionKey);
const logs = await readMockRuntimeLogEntries(logPath);
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
const newIndex = logs.findIndex((entry) => entry.kind === "new");
expect(ensureIndex).toBeGreaterThanOrEqual(0);
expect(statusIndex).toBeGreaterThan(ensureIndex);
expect(newIndex).toBe(-1);
} finally {
delete process.env.MOCK_ACPX_ENSURE_EXIT_1;
delete process.env.MOCK_ACPX_STATUS_STATUS;
}
await expectSessionEnsureFallback({
sessionKey: "agent:codex:acp:ensure-fallback-alive",
env: {
MOCK_ACPX_ENSURE_EXIT_1: "1",
MOCK_ACPX_STATUS_STATUS: "alive",
},
expectNewAfterStatus: false,
expectedRecordId: "rec-agent:codex:acp:ensure-fallback-alive",
});
});
it("creates a fresh named session when sessions ensure exits and status is dead", async () => {
process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1";
process.env.MOCK_ACPX_STATUS_STATUS = "dead";
process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable";
try {
const { runtime, logPath } = await createMockRuntimeFixture();
const sessionKey = "agent:codex:acp:ensure-fallback-dead";
const handle = await runtime.ensureSession({
sessionKey,
agent: "codex",
mode: "persistent",
});
expect(handle.backend).toBe("acpx");
const logs = await readMockRuntimeLogEntries(logPath);
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
const newIndex = logs.findIndex((entry) => entry.kind === "new");
expect(ensureIndex).toBeGreaterThanOrEqual(0);
expect(statusIndex).toBeGreaterThan(ensureIndex);
expect(newIndex).toBeGreaterThan(statusIndex);
} finally {
delete process.env.MOCK_ACPX_ENSURE_EXIT_1;
delete process.env.MOCK_ACPX_STATUS_STATUS;
delete process.env.MOCK_ACPX_STATUS_SUMMARY;
}
await expectSessionEnsureFallback({
sessionKey: "agent:codex:acp:ensure-fallback-dead",
env: {
MOCK_ACPX_ENSURE_EXIT_1: "1",
MOCK_ACPX_STATUS_STATUS: "dead",
MOCK_ACPX_STATUS_SUMMARY: "queue owner unavailable",
},
expectNewAfterStatus: true,
});
});
it("serializes text plus image attachments into ACP prompt blocks", async () => {

View File

@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveBlueBubblesAccount } from "./accounts.js";
describe("resolveBlueBubblesAccount", () => {
it("treats SecretRef passwords as configured when serverUrl exists", () => {
const resolved = resolveBlueBubblesAccount({
cfg: {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://localhost:1234",
password: {
source: "env",
provider: "default",
id: "BLUEBUBBLES_PASSWORD",
},
},
},
},
});
expect(resolved.configured).toBe(true);
expect(resolved.baseUrl).toBe("http://localhost:1234");
});
});

View File

@@ -0,0 +1,69 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
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 { BlueBubblesConfigSchema } from "./config-schema.js";
import type { ChannelPlugin } from "./runtime-api.js";
import { normalizeBlueBubblesHandle } from "./targets.js";
export const bluebubblesMeta = {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles (macOS app)",
detailLabel: "BlueBubbles",
docsPath: "/channels/bluebubbles",
docsLabel: "bluebubbles",
blurb: "iMessage via the BlueBubbles mac app + REST API.",
systemImage: "bubble.left.and.text.bubble.right",
aliases: ["bb"],
order: 75,
preferOver: ["imessage"],
};
export const bluebubblesCapabilities: ChannelPlugin<ResolvedBlueBubblesAccount>["capabilities"] = {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
edit: true,
unsend: true,
reply: true,
effects: true,
groupManagement: true,
};
export const bluebubblesReload = { configPrefixes: ["channels.bluebubbles"] };
export const bluebubblesConfigSchema = buildChannelConfigSchema(BlueBubblesConfigSchema);
export const bluebubblesConfigAdapter =
createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({
sectionKey: "bluebubbles",
listAccountIds: listBlueBubblesAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount),
defaultAccountId: resolveDefaultBlueBubblesAccountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
}),
});
export function describeBlueBubblesAccount(account: ResolvedBlueBubblesAccount) {
return describeAccountSnapshot({
account,
configured: account.configured,
extra: {
baseUrl: account.baseUrl,
},
});
}

View File

@@ -1,81 +1,31 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { type ResolvedBlueBubblesAccount } from "./accounts.js";
import {
listBlueBubblesAccountIds,
type ResolvedBlueBubblesAccount,
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
bluebubblesCapabilities,
bluebubblesConfigAdapter,
bluebubblesConfigSchema,
bluebubblesMeta,
bluebubblesReload,
describeBlueBubblesAccount,
} from "./channel-shared.js";
import { blueBubblesSetupAdapter } from "./setup-core.js";
import { blueBubblesSetupWizard } from "./setup-surface.js";
import { normalizeBlueBubblesHandle } from "./targets.js";
const meta = {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles (macOS app)",
detailLabel: "BlueBubbles",
docsPath: "/channels/bluebubbles",
docsLabel: "bluebubbles",
blurb: "iMessage via the BlueBubbles mac app + REST API.",
systemImage: "bubble.left.and.text.bubble.right",
aliases: ["bb"],
order: 75,
preferOver: ["imessage"],
} as const;
const bluebubblesConfigAdapter = createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({
sectionKey: "bluebubbles",
listAccountIds: listBlueBubblesAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount),
defaultAccountId: resolveDefaultBlueBubblesAccountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
}),
});
export const bluebubblesSetupPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
id: "bluebubbles",
meta: {
...meta,
aliases: [...meta.aliases],
preferOver: [...meta.preferOver],
...bluebubblesMeta,
aliases: [...bluebubblesMeta.aliases],
preferOver: [...bluebubblesMeta.preferOver],
},
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
edit: true,
unsend: true,
reply: true,
effects: true,
groupManagement: true,
},
reload: { configPrefixes: ["channels.bluebubbles"] },
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
capabilities: bluebubblesCapabilities,
reload: bluebubblesReload,
configSchema: bluebubblesConfigSchema,
setupWizard: blueBubblesSetupWizard,
config: {
...bluebubblesConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
extra: {
baseUrl: account.baseUrl,
},
}),
describeAccount: (account) => describeBlueBubblesAccount(account),
},
setup: blueBubblesSetupAdapter,
};

View File

@@ -1,10 +1,4 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import {
@@ -25,15 +19,21 @@ import {
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { bluebubblesMessageActions } from "./actions.js";
import {
bluebubblesCapabilities,
bluebubblesConfigAdapter,
bluebubblesConfigSchema,
bluebubblesMeta as meta,
bluebubblesReload,
describeBlueBubblesAccount,
} from "./channel-shared.js";
import type { BlueBubblesProbe } from "./channel.runtime.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./group-policy.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "./runtime-api.js";
import {
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
collectBlueBubblesStatusIssues,
DEFAULT_ACCOUNT_ID,
@@ -57,20 +57,6 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport(
"blueBubblesChannelRuntime",
);
const bluebubblesConfigAdapter = createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({
sectionKey: "bluebubbles",
listAccountIds: listBlueBubblesAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount),
defaultAccountId: resolveDefaultBlueBubblesAccountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
}),
});
const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBubblesAccount>({
channelKey: "bluebubbles",
resolvePolicy: (account) => account.config.dmPolicy,
@@ -90,53 +76,23 @@ const collectBlueBubblesSecurityWarnings =
mentionGated: false,
});
const meta = {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles (macOS app)",
detailLabel: "BlueBubbles",
docsPath: "/channels/bluebubbles",
docsLabel: "bluebubbles",
blurb: "iMessage via the BlueBubbles mac app + REST API.",
systemImage: "bubble.left.and.text.bubble.right",
aliases: ["bb"],
order: 75,
preferOver: ["imessage"],
};
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBubblesProbe> =
createChatChannelPlugin<ResolvedBlueBubblesAccount, BlueBubblesProbe>({
base: {
id: "bluebubbles",
meta,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
edit: true,
unsend: true,
reply: true,
effects: true,
groupManagement: true,
},
capabilities: bluebubblesCapabilities,
groups: {
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
resolveToolPolicy: resolveBlueBubblesGroupToolPolicy,
},
reload: { configPrefixes: ["channels.bluebubbles"] },
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
reload: bluebubblesReload,
configSchema: bluebubblesConfigSchema,
setupWizard: blueBubblesSetupWizard,
config: {
...bluebubblesConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account): ChannelAccountSnapshot =>
describeAccountSnapshot({
account,
configured: account.configured,
extra: {
baseUrl: account.baseUrl,
},
}),
describeAccount: (account): ChannelAccountSnapshot => describeBlueBubblesAccount(account),
},
actions: bluebubblesMessageActions,
messaging: {

View File

@@ -1,67 +0,0 @@
import { describe, expect, it } from "vitest";
import { BlueBubblesConfigSchema } from "./config-schema.js";
describe("BlueBubblesConfigSchema", () => {
it("accepts account config when serverUrl and password are both set", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
password: "secret", // pragma: allowlist secret
});
expect(parsed.success).toBe(true);
});
it("accepts SecretRef password when serverUrl is set", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
password: {
source: "env",
provider: "default",
id: "BLUEBUBBLES_PASSWORD",
},
});
expect(parsed.success).toBe(true);
});
it("requires password when top-level serverUrl is configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
});
expect(parsed.success).toBe(false);
if (parsed.success) {
return;
}
expect(parsed.error.issues[0]?.path).toEqual(["password"]);
expect(parsed.error.issues[0]?.message).toBe(
"password is required when serverUrl is configured",
);
});
it("requires password when account serverUrl is configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
accounts: {
work: {
serverUrl: "http://localhost:1234",
},
},
});
expect(parsed.success).toBe(false);
if (parsed.success) {
return;
}
expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]);
expect(parsed.error.issues[0]?.message).toBe(
"password is required when serverUrl is configured",
);
});
it("allows password omission when serverUrl is not configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
accounts: {
work: {
name: "Work iMessage",
},
},
});
expect(parsed.success).toBe(true);
});
});

View File

@@ -49,6 +49,9 @@ const bluebubblesAccountSchema = z
mediaMaxMb: z.number().int().positive().optional(),
mediaLocalRoots: z.array(z.string()).optional(),
sendReadReceipts: z.boolean().optional(),
sendConfirmationTimeoutMs: z.number().int().positive().optional(),
sendRetryCount: z.number().int().min(0).max(3).optional(),
sendRetryBaseDelayMs: z.number().int().positive().optional(),
allowPrivateNetwork: z.boolean().optional(),
blockStreaming: z.boolean().optional(),
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),

View File

@@ -1,36 +0,0 @@
import { describe, expect, it } from "vitest";
import {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./group-policy.js";
describe("bluebubbles group policy", () => {
it("uses generic channel group policy helpers", () => {
const cfg = {
channels: {
bluebubbles: {
groups: {
"chat:primary": {
requireMention: false,
tools: { deny: ["exec"] },
},
"*": {
requireMention: true,
tools: { allow: ["message.send"] },
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false);
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({
deny: ["exec"],
});
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
allow: ["message.send"],
});
});
});

View File

@@ -33,6 +33,7 @@ import type {
BlueBubblesRuntimeEnv,
WebhookTarget,
} from "./monitor-shared.js";
import { confirmBlueBubblesOutboundMessage } from "./outbound-confirmation.js";
import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
import type { OpenClawConfig } from "./runtime-api.js";
@@ -521,6 +522,14 @@ export async function processMessage(
if (message.fromMe) {
// Cache from-me messages so reply context can resolve sender/body.
cacheInboundMessage();
confirmBlueBubblesOutboundMessage({
accountId: account.accountId,
chatGuid: message.chatGuid,
chatIdentifier: message.chatIdentifier,
chatId: message.chatId,
messageId: cacheMessageId,
body: rawBody,
});
const confirmedAssistantOutbound =
confirmedOutboundCacheEntry?.senderLabel === "me" &&
normalizeSnippet(confirmedOutboundCacheEntry.body ?? "") === normalizeSnippet(rawBody);

View File

@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearBlueBubblesOutboundConfirmations,
confirmBlueBubblesOutboundMessage,
waitForBlueBubblesOutboundConfirmation,
} from "./outbound-confirmation.js";
afterEach(() => {
clearBlueBubblesOutboundConfirmations();
vi.useRealTimers();
});
describe("outbound confirmation", () => {
it("matches a pending confirmation by chat and message id", async () => {
const pendingPromise = waitForBlueBubblesOutboundConfirmation({
accountId: "default",
chatGuid: "iMessage;-;+15551234567",
messageId: "msg-123",
body: "Hello there",
timeoutMs: 1_000,
});
expect(
confirmBlueBubblesOutboundMessage({
accountId: "default",
chatGuid: "iMessage;-;+15551234567",
messageId: "msg-123",
body: "Hello there",
}),
).toBe(true);
await expect(pendingPromise).resolves.toEqual({
messageId: "msg-123",
source: "webhook",
});
});
it("falls back to normalized body matching", async () => {
const pendingPromise = waitForBlueBubblesOutboundConfirmation({
accountId: "default",
chatGuid: "iMessage;-;+15551234567",
body: "**Hello** there",
timeoutMs: 1_000,
});
expect(
confirmBlueBubblesOutboundMessage({
accountId: "default",
chatGuid: "iMessage;-;+15551234567",
body: "Hello there",
}),
).toBe(true);
await expect(pendingPromise).resolves.toEqual({
messageId: undefined,
source: "webhook",
});
});
it("returns null when confirmation times out", async () => {
vi.useFakeTimers();
const pendingPromise = waitForBlueBubblesOutboundConfirmation({
accountId: "default",
chatGuid: "iMessage;-;+15551234567",
body: "Hello there",
timeoutMs: 250,
});
await vi.advanceTimersByTimeAsync(250);
await expect(pendingPromise).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,165 @@
import { stripMarkdown } from "./runtime-api.js";
const PENDING_CONFIRMATION_TTL_MS = 2 * 60 * 1000;
export type BlueBubblesOutboundConfirmation = {
messageId?: string;
source: "webhook";
};
type PendingBlueBubblesOutboundConfirmation = {
id: number;
accountId: string;
chatGuid?: string;
chatIdentifier?: string;
chatId?: number;
messageId?: string;
bodyNorm: string;
createdAt: number;
resolve: (value: BlueBubblesOutboundConfirmation | null) => void;
timeout: ReturnType<typeof setTimeout>;
};
const pendingConfirmations: PendingBlueBubblesOutboundConfirmation[] = [];
let nextPendingConfirmationId = 0;
function trimOrUndefined(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function normalizeBody(value: string): string {
return stripMarkdown(value).replace(/\s+/g, " ").trim().toLowerCase();
}
function prunePendingConfirmations(now = Date.now()): void {
const cutoff = now - PENDING_CONFIRMATION_TTL_MS;
for (let i = pendingConfirmations.length - 1; i >= 0; i--) {
if (pendingConfirmations[i].createdAt < cutoff) {
const [entry] = pendingConfirmations.splice(i, 1);
clearTimeout(entry.timeout);
entry.resolve(null);
}
}
}
function removePendingConfirmation(id: number): PendingBlueBubblesOutboundConfirmation | null {
const index = pendingConfirmations.findIndex((entry) => entry.id === id);
if (index < 0) {
return null;
}
const [entry] = pendingConfirmations.splice(index, 1);
clearTimeout(entry.timeout);
return entry ?? null;
}
function chatsMatch(
pending: Pick<PendingBlueBubblesOutboundConfirmation, "chatGuid" | "chatIdentifier" | "chatId">,
candidate: { chatGuid?: string; chatIdentifier?: string; chatId?: number },
): boolean {
const pendingGuid = trimOrUndefined(pending.chatGuid);
const candidateGuid = trimOrUndefined(candidate.chatGuid);
if (pendingGuid && candidateGuid) {
return pendingGuid === candidateGuid;
}
const pendingIdentifier = trimOrUndefined(pending.chatIdentifier);
const candidateIdentifier = trimOrUndefined(candidate.chatIdentifier);
if (pendingIdentifier && candidateIdentifier) {
return pendingIdentifier === candidateIdentifier;
}
const pendingChatId = typeof pending.chatId === "number" ? pending.chatId : undefined;
const candidateChatId = typeof candidate.chatId === "number" ? candidate.chatId : undefined;
if (pendingChatId !== undefined && candidateChatId !== undefined) {
return pendingChatId === candidateChatId;
}
return false;
}
export async function waitForBlueBubblesOutboundConfirmation(params: {
accountId: string;
chatGuid?: string;
chatIdentifier?: string;
chatId?: number;
messageId?: string;
body: string;
timeoutMs: number;
}): Promise<BlueBubblesOutboundConfirmation | null> {
prunePendingConfirmations();
const normalizedTimeoutMs = Math.max(0, Math.floor(params.timeoutMs));
const normalizedBody = normalizeBody(params.body);
if (!normalizedBody || normalizedTimeoutMs === 0) {
return null;
}
return await new Promise<BlueBubblesOutboundConfirmation | null>((resolve) => {
nextPendingConfirmationId += 1;
const pendingId = nextPendingConfirmationId;
const timeout = setTimeout(() => {
const entry = removePendingConfirmation(pendingId);
entry?.resolve(null);
}, normalizedTimeoutMs);
pendingConfirmations.push({
id: pendingId,
accountId: params.accountId,
chatGuid: trimOrUndefined(params.chatGuid),
chatIdentifier: trimOrUndefined(params.chatIdentifier),
chatId: typeof params.chatId === "number" ? params.chatId : undefined,
messageId: trimOrUndefined(params.messageId),
bodyNorm: normalizedBody,
createdAt: Date.now(),
resolve,
timeout,
});
});
}
export function confirmBlueBubblesOutboundMessage(params: {
accountId: string;
chatGuid?: string;
chatIdentifier?: string;
chatId?: number;
messageId?: string;
body: string;
}): boolean {
prunePendingConfirmations();
const normalizedBody = normalizeBody(params.body);
const normalizedMessageId = trimOrUndefined(params.messageId);
for (let i = 0; i < pendingConfirmations.length; i++) {
const pending = pendingConfirmations[i];
if (pending.accountId !== params.accountId) {
continue;
}
if (!chatsMatch(pending, params)) {
continue;
}
if (normalizedMessageId && pending.messageId && normalizedMessageId === pending.messageId) {
pendingConfirmations.splice(i, 1);
clearTimeout(pending.timeout);
pending.resolve({ messageId: normalizedMessageId, source: "webhook" });
return true;
}
if (!normalizedBody || pending.bodyNorm !== normalizedBody) {
continue;
}
pendingConfirmations.splice(i, 1);
clearTimeout(pending.timeout);
pending.resolve({ messageId: normalizedMessageId, source: "webhook" });
return true;
}
return false;
}
export function clearBlueBubblesOutboundConfirmations(): void {
while (pendingConfirmations.length > 0) {
const entry = pendingConfirmations.pop();
if (!entry) {
continue;
}
clearTimeout(entry.timeout);
entry.resolve(null);
}
}

View File

@@ -1,5 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
const waitForBlueBubblesOutboundConfirmationMock = vi.hoisted(() => vi.fn());
vi.mock("./outbound-confirmation.js", () => ({
waitForBlueBubblesOutboundConfirmation: waitForBlueBubblesOutboundConfirmationMock,
}));
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import type { PluginRuntime } from "./runtime-api.js";
import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js";
@@ -19,6 +26,13 @@ installBlueBubblesFetchTestHooks({
privateApiStatusMock,
});
beforeEach(() => {
waitForBlueBubblesOutboundConfirmationMock.mockReset();
waitForBlueBubblesOutboundConfirmationMock.mockResolvedValue({
source: "webhook",
});
});
function mockResolvedHandleTarget(
guid: string = "iMessage;-;+15551234567",
address: string = "+15551234567",
@@ -780,6 +794,75 @@ describe("send", () => {
expect(typeof body.tempGuid).toBe("string");
expect(body.tempGuid.length).toBeGreaterThan(0);
});
it("throws when BlueBubbles returns an error envelope with HTTP 200", async () => {
mockResolvedHandleTarget();
mockSendResponse({
status: 500,
message: "Message Send Error",
error: { message: "Transaction timeout", type: "iMessage Error" },
});
await expect(
sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("Transaction timeout");
});
it("retries once when BlueBubbles accepts the send but no confirmation arrives", async () => {
waitForBlueBubblesOutboundConfirmationMock
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ source: "webhook" });
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-first-attempt" } });
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
mockSendResponse({ data: { guid: "msg-second-attempt" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
deliveryRetryBaseDelayMs: 1,
});
expect(result.messageId).toBe("msg-second-attempt");
expect(waitForBlueBubblesOutboundConfirmationMock).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledTimes(4);
});
it("uses recent history as delivery confirmation before retrying", async () => {
waitForBlueBubblesOutboundConfirmationMock.mockResolvedValueOnce(null);
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-api-response" } });
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "msg-history-confirmed",
text: "Hello from history",
is_from_me: true,
},
],
}),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello from history", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("msg-history-confirmed");
expect(waitForBlueBubblesOutboundConfirmationMock).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledTimes(3);
});
});
describe("createChatForHandle", () => {

View File

@@ -1,5 +1,7 @@
import crypto from "node:crypto";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { fetchBlueBubblesHistory } from "./history.js";
import { waitForBlueBubblesOutboundConfirmation } from "./outbound-confirmation.js";
import {
getCachedBlueBubblesPrivateApiStatus,
isBlueBubblesPrivateApiStatusEnabled,
@@ -28,6 +30,12 @@ export type BlueBubblesSendOpts = {
replyToPartIndex?: number;
/** Effect ID or short name for message effects (e.g., "slam", "balloons") */
effectId?: string;
/** Wait for webhook or history confirmation before treating the send as delivered. */
deliveryConfirmationTimeoutMs?: number;
/** Retries to attempt when BlueBubbles accepts a send but no delivery confirmation arrives. */
deliveryRetryCount?: number;
/** Base delay between retry attempts; exponential backoff is applied from this value. */
deliveryRetryBaseDelayMs?: number;
};
export type BlueBubblesSendResult = {
@@ -108,16 +116,233 @@ function resolvePrivateApiDecision(params: {
};
}
async function parseBlueBubblesMessageResponse(res: Response): Promise<BlueBubblesSendResult> {
const DEFAULT_DELIVERY_CONFIRMATION_TIMEOUT_MS = 8_000;
const DEFAULT_DELIVERY_RETRY_COUNT = 1;
const DEFAULT_DELIVERY_RETRY_BASE_DELAY_MS = 1_500;
const DELIVERY_HISTORY_CHECK_LIMIT = 8;
type BlueBubblesDeliveryConfig = {
timeoutMs: number;
retryCount: number;
retryBaseDelayMs: number;
};
type ParsedBlueBubblesMessageResponse = BlueBubblesSendResult & {
chatGuid?: string | null;
};
type BlueBubblesApiRecord = Record<string, unknown>;
function asBlueBubblesRecord(value: unknown): BlueBubblesApiRecord | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as BlueBubblesApiRecord)
: null;
}
function extractBlueBubblesResponseError(
payload: unknown,
): { status?: number; message: string } | null {
const record = asBlueBubblesRecord(payload);
if (!record) {
return null;
}
const status =
typeof record.status === "number" && Number.isFinite(record.status) ? record.status : undefined;
const errorRecord = asBlueBubblesRecord(record.error);
const explicitMessage =
(typeof errorRecord?.message === "string" && errorRecord.message) ||
(typeof errorRecord?.error === "string" && errorRecord.error) ||
(typeof record.message === "string" &&
status !== undefined &&
status >= 400 &&
record.message) ||
"";
if (status !== undefined && status >= 400) {
return { status, message: explicitMessage || `status ${status}` };
}
if (errorRecord) {
return {
status,
message:
explicitMessage ||
(typeof errorRecord.type === "string" && errorRecord.type) ||
"unknown error",
};
}
if (record.success === false) {
return {
status,
message: explicitMessage || "request was not accepted by BlueBubbles",
};
}
return null;
}
function extractBlueBubblesResponseChatGuid(payload: unknown): string | null {
const record = asBlueBubblesRecord(payload);
if (!record) {
return null;
}
const data = asBlueBubblesRecord(record.data);
const result = asBlueBubblesRecord(record.result);
const payloadRecord = asBlueBubblesRecord(record.payload);
const roots = [record, data, result, payloadRecord];
for (const root of roots) {
if (!root) {
continue;
}
const candidates = [root.chatGuid, root.chat_guid, root.guid, root.chatIdentifier];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
}
}
return null;
}
function clampPositiveNumber(value: number | undefined, fallback: number, max: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
const normalized = Math.floor(value);
if (normalized <= 0) {
return fallback;
}
return Math.min(normalized, max);
}
function resolveDeliveryConfig(
opts: BlueBubblesSendOpts,
account: ReturnType<typeof resolveBlueBubblesAccount>,
): BlueBubblesDeliveryConfig {
return {
timeoutMs: clampPositiveNumber(
opts.deliveryConfirmationTimeoutMs ?? account.config.sendConfirmationTimeoutMs,
DEFAULT_DELIVERY_CONFIRMATION_TIMEOUT_MS,
30_000,
),
retryCount: Math.min(
clampPositiveNumber(
opts.deliveryRetryCount ?? account.config.sendRetryCount,
DEFAULT_DELIVERY_RETRY_COUNT,
3,
),
3,
),
retryBaseDelayMs: clampPositiveNumber(
opts.deliveryRetryBaseDelayMs ?? account.config.sendRetryBaseDelayMs,
DEFAULT_DELIVERY_RETRY_BASE_DELAY_MS,
30_000,
),
};
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeConfirmationBody(text: string): string {
return stripMarkdown(text).replace(/\s+/g, " ").trim().toLowerCase();
}
async function isMessageVisibleInRecentHistory(params: {
baseUrl: string;
password: string;
accountId: string;
chatGuid: string;
body: string;
timeoutMs?: number;
}): Promise<string | null> {
const bodyNorm = normalizeConfirmationBody(params.body);
if (!bodyNorm) {
return null;
}
const history = await fetchBlueBubblesHistory(params.chatGuid, DELIVERY_HISTORY_CHECK_LIMIT, {
serverUrl: params.baseUrl,
password: params.password,
accountId: params.accountId,
timeoutMs: params.timeoutMs,
});
for (let i = history.entries.length - 1; i >= 0; i--) {
const entry = history.entries[i];
if (entry.sender !== "me") {
continue;
}
if (normalizeConfirmationBody(entry.body) !== bodyNorm) {
continue;
}
const messageId = entry.messageId?.trim();
return messageId || "ok";
}
return null;
}
async function waitForDeliveryConfirmation(params: {
baseUrl: string;
password: string;
accountId: string;
chatGuid: string;
messageId: string;
body: string;
confirmationTimeoutMs: number;
timeoutMs?: number;
}): Promise<{ confirmed: boolean; messageId: string }> {
const webhookConfirmation = await waitForBlueBubblesOutboundConfirmation({
accountId: params.accountId,
chatGuid: params.chatGuid,
messageId: params.messageId,
body: params.body,
timeoutMs: params.confirmationTimeoutMs,
});
if (webhookConfirmation) {
return {
confirmed: true,
messageId: webhookConfirmation.messageId?.trim() || params.messageId,
};
}
const historyMessageId = await isMessageVisibleInRecentHistory({
baseUrl: params.baseUrl,
password: params.password,
accountId: params.accountId,
chatGuid: params.chatGuid,
body: params.body,
timeoutMs: params.timeoutMs,
});
if (historyMessageId) {
return { confirmed: true, messageId: historyMessageId };
}
return { confirmed: false, messageId: params.messageId };
}
async function parseBlueBubblesMessageResponse(
res: Response,
): Promise<ParsedBlueBubblesMessageResponse> {
const body = await res.text();
if (!body) {
return { messageId: "ok" };
return { messageId: "ok", chatGuid: null };
}
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
const responseError = extractBlueBubblesResponseError(parsed);
if (responseError) {
const statusSuffix = responseError.status ? ` (${responseError.status})` : "";
throw new Error(
`BlueBubbles send failed${statusSuffix}: ${responseError.message || "unknown"}`,
);
}
return {
messageId: extractBlueBubblesMessageId(parsed),
chatGuid: extractBlueBubblesResponseChatGuid(parsed),
};
} catch (error) {
if (error instanceof Error && error.message.startsWith("BlueBubbles send failed")) {
throw error;
}
return { messageId: "ok", chatGuid: null };
}
}
@@ -397,25 +622,135 @@ export async function createChatForHandle(params: {
return { chatGuid, messageId };
}
async function sendTextMessageOnce(params: {
baseUrl: string;
password: string;
payload: Record<string, unknown>;
timeoutMs?: number;
}): Promise<ParsedBlueBubblesMessageResponse> {
const url = buildBlueBubblesApiUrl({
baseUrl: params.baseUrl,
path: "/api/v1/message/text",
password: params.password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params.payload),
},
params.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text();
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
}
return parseBlueBubblesMessageResponse(res);
}
async function sendMessageWithDeliveryRetries(params: {
accountId: string;
baseUrl: string;
password: string;
message: string;
confirmationChatGuid?: string | null;
resolveConfirmationChatGuid?: () => Promise<string | null>;
timeoutMs?: number;
delivery: BlueBubblesDeliveryConfig;
send: () => Promise<ParsedBlueBubblesMessageResponse>;
}): Promise<BlueBubblesSendResult> {
const maxAttempts = Math.max(1, params.delivery.retryCount + 1);
let resolvedChatGuid = params.confirmationChatGuid?.trim() || null;
let lastMessageId = "ok";
for (let attemptIndex = 0; attemptIndex < maxAttempts; attemptIndex++) {
const sendResult = await params.send();
lastMessageId = sendResult.messageId;
resolvedChatGuid = sendResult.chatGuid?.trim() || resolvedChatGuid;
if (!resolvedChatGuid && params.resolveConfirmationChatGuid) {
resolvedChatGuid = await params.resolveConfirmationChatGuid();
}
if (!resolvedChatGuid) {
warnBlueBubbles(
"send accepted by BlueBubbles, but no chatGuid was available to verify delivery. Returning the API result without confirmation.",
);
return { messageId: lastMessageId };
}
const confirmation = await waitForDeliveryConfirmation({
baseUrl: params.baseUrl,
password: params.password,
accountId: params.accountId,
chatGuid: resolvedChatGuid,
messageId: sendResult.messageId,
body: params.message,
confirmationTimeoutMs: params.delivery.timeoutMs,
timeoutMs: params.timeoutMs,
});
if (confirmation.confirmed) {
return { messageId: confirmation.messageId || sendResult.messageId };
}
const attemptsLeft = maxAttempts - attemptIndex - 1;
const messageIdHint = sendResult.messageId?.trim();
const attemptLabel = `attempt ${attemptIndex + 1}/${maxAttempts}`;
if (attemptsLeft <= 0) {
throw new Error(
`BlueBubbles send could not be confirmed after ${maxAttempts} attempts. The API accepted the request${messageIdHint && messageIdHint !== "ok" && messageIdHint !== "unknown" ? ` (messageId=${messageIdHint})` : ""}, but no webhook or history confirmation arrived within ${params.delivery.timeoutMs}ms.`,
);
}
warnBlueBubbles(
`send delivery not confirmed for ${attemptLabel}; retrying in ${params.delivery.retryBaseDelayMs * 2 ** attemptIndex}ms.`,
);
await sleep(params.delivery.retryBaseDelayMs * 2 ** attemptIndex);
}
return { messageId: lastMessageId };
}
/**
* Creates a new chat (DM) and sends an initial message.
* Requires Private API to be enabled in BlueBubbles.
*/
async function createNewChatWithMessage(params: {
accountId: string;
baseUrl: string;
password: string;
address: string;
message: string;
timeoutMs?: number;
delivery: BlueBubblesDeliveryConfig;
}): Promise<BlueBubblesSendResult> {
const result = await createChatForHandle({
return await sendMessageWithDeliveryRetries({
accountId: params.accountId,
baseUrl: params.baseUrl,
password: params.password,
address: params.address,
message: params.message,
timeoutMs: params.timeoutMs,
delivery: params.delivery,
resolveConfirmationChatGuid: async () =>
await resolveChatGuidForTarget({
baseUrl: params.baseUrl,
password: params.password,
timeoutMs: params.timeoutMs,
target: {
kind: "handle",
address: params.address,
},
}),
send: async () => {
const result = await createChatForHandle({
baseUrl: params.baseUrl,
password: params.password,
address: params.address,
message: params.message,
timeoutMs: params.timeoutMs,
});
return { messageId: result.messageId, chatGuid: result.chatGuid };
},
});
return { messageId: result.messageId };
}
export async function sendMessageBlueBubbles(
@@ -450,6 +785,7 @@ export async function sendMessageBlueBubbles(
throw new Error("BlueBubbles password is required");
}
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);
const delivery = resolveDeliveryConfig(opts, account);
const target = resolveBlueBubblesSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({
@@ -463,11 +799,13 @@ export async function sendMessageBlueBubbles(
// auto-create a new DM chat using the /api/v1/chat/new endpoint
if (target.kind === "handle") {
return createNewChatWithMessage({
accountId: account.accountId,
baseUrl,
password,
address: target.address,
message: strippedText,
timeoutMs: opts.timeoutMs,
delivery,
});
}
throw new Error(
@@ -490,43 +828,40 @@ export async function sendMessageBlueBubbles(
if (privateApiDecision.warningMessage) {
warnBlueBubbles(privateApiDecision.warningMessage);
}
const payload: Record<string, unknown> = {
chatGuid,
tempGuid: crypto.randomUUID(),
message: strippedText,
};
if (privateApiDecision.canUsePrivateApi) {
payload.method = "private-api";
}
// Add reply threading support
if (wantsReplyThread && privateApiDecision.canUsePrivateApi) {
payload.selectedMessageGuid = opts.replyToMessageGuid;
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
}
// Add message effects support
if (effectId && privateApiDecision.canUsePrivateApi) {
payload.effectId = effectId;
}
const url = buildBlueBubblesApiUrl({
return await sendMessageWithDeliveryRetries({
accountId: account.accountId,
baseUrl,
path: "/api/v1/message/text",
password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
message: strippedText,
confirmationChatGuid: chatGuid,
timeoutMs: opts.timeoutMs,
delivery,
send: async () => {
const payload: Record<string, unknown> = {
chatGuid,
tempGuid: crypto.randomUUID(),
message: strippedText,
};
if (privateApiDecision.canUsePrivateApi) {
payload.method = "private-api";
}
if (wantsReplyThread && privateApiDecision.canUsePrivateApi) {
payload.selectedMessageGuid = opts.replyToMessageGuid;
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
}
if (effectId && privateApiDecision.canUsePrivateApi) {
payload.effectId = effectId;
}
return await sendTextMessageOnce({
baseUrl,
password,
payload,
timeoutMs: opts.timeoutMs,
});
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text();
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
}
return parseBlueBubblesMessageResponse(res);
});
}

View File

@@ -8,6 +8,20 @@ import {
type WizardPrompter,
} from "../../../test/helpers/extensions/setup-wizard.js";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./group-policy.js";
import {
inferBlueBubblesTargetChatType,
isAllowedBlueBubblesSender,
looksLikeBlueBubblesExplicitTargetId,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesMessagingTarget,
parseBlueBubblesAllowTarget,
parseBlueBubblesTarget,
} from "./targets.js";
import { DEFAULT_WEBHOOK_PATH } from "./webhook-shared.js";
async function createBlueBubblesConfigureAdapter() {
@@ -138,3 +152,337 @@ describe("bluebubbles setup surface", () => {
expect(next?.channels?.bluebubbles?.enabled).toBe(false);
});
});
describe("resolveBlueBubblesAccount", () => {
it("treats SecretRef passwords as configured when serverUrl exists", () => {
const resolved = resolveBlueBubblesAccount({
cfg: {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://localhost:1234",
password: {
source: "env",
provider: "default",
id: "BLUEBUBBLES_PASSWORD",
},
},
},
},
});
expect(resolved.configured).toBe(true);
expect(resolved.baseUrl).toBe("http://localhost:1234");
});
});
describe("BlueBubblesConfigSchema", () => {
it("accepts account config when serverUrl and password are both set", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
password: "secret", // pragma: allowlist secret
});
expect(parsed.success).toBe(true);
});
it("accepts SecretRef password when serverUrl is set", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
password: {
source: "env",
provider: "default",
id: "BLUEBUBBLES_PASSWORD",
},
});
expect(parsed.success).toBe(true);
});
it("requires password when top-level serverUrl is configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
});
expect(parsed.success).toBe(false);
if (parsed.success) {
return;
}
expect(parsed.error.issues[0]?.path).toEqual(["password"]);
expect(parsed.error.issues[0]?.message).toBe(
"password is required when serverUrl is configured",
);
});
it("requires password when account serverUrl is configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
accounts: {
work: {
serverUrl: "http://localhost:1234",
},
},
});
expect(parsed.success).toBe(false);
if (parsed.success) {
return;
}
expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]);
expect(parsed.error.issues[0]?.message).toBe(
"password is required when serverUrl is configured",
);
});
it("allows password omission when serverUrl is not configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
accounts: {
work: {
name: "Work iMessage",
},
},
});
expect(parsed.success).toBe(true);
});
});
describe("bluebubbles group policy", () => {
it("uses generic channel group policy helpers", () => {
const cfg = {
channels: {
bluebubbles: {
groups: {
"chat:primary": {
requireMention: false,
tools: { deny: ["exec"] },
},
"*": {
requireMention: true,
tools: { allow: ["message.send"] },
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false);
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({
deny: ["exec"],
});
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
allow: ["message.send"],
});
});
});
describe("normalizeBlueBubblesMessagingTarget", () => {
it("normalizes chat_guid targets", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
});
it("normalizes group numeric targets to chat_id", () => {
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
});
it("strips provider prefix and normalizes handles", () => {
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
"imessage:user@example.com",
);
});
it("extracts handle from DM chat_guid for cross-context matching", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
"+19257864429",
);
expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
"+15551234567",
);
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
"user@example.com",
);
});
it("preserves group chat_guid format", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
"chat_guid:iMessage;+;chat123456789",
);
});
it("normalizes raw chat_guid values", () => {
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
"chat_guid:iMessage;+;chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
});
it("normalizes chat<digits> pattern to chat_identifier format", () => {
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
"chat_identifier:chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
});
it("normalizes UUID/hex chat identifiers", () => {
expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
"chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
);
expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
"chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
);
});
});
describe("looksLikeBlueBubblesTargetId", () => {
it("accepts chat targets", () => {
expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
});
it("accepts email handles", () => {
expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
});
it("accepts phone numbers with punctuation", () => {
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
});
it("accepts raw chat_guid values", () => {
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
});
it("accepts chat<digits> pattern as chat_id", () => {
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
});
it("accepts UUID/hex chat identifiers", () => {
expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
});
it("rejects display names", () => {
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
});
});
describe("looksLikeBlueBubblesExplicitTargetId", () => {
it("treats explicit chat targets as immediate ids", () => {
expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true);
expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true);
});
it("prefers directory fallback for bare handles and phone numbers", () => {
expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false);
expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false);
});
});
describe("inferBlueBubblesTargetChatType", () => {
it("infers direct chat for handles and dm chat_guids", () => {
expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct");
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct");
});
it("infers group chat for explicit group targets", () => {
expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group");
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group");
});
});
describe("parseBlueBubblesTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
});
expect(parseBlueBubblesTarget("chat123")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat123",
});
expect(parseBlueBubblesTarget("Chat456789")).toEqual({
kind: "chat_identifier",
chatIdentifier: "Chat456789",
});
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
kind: "handle",
to: "+19257864429",
service: "auto",
});
});
it("parses raw chat_guid format", () => {
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
kind: "chat_guid",
chatGuid: "iMessage;+;chat660250192681427962",
});
});
});
describe("parseBlueBubblesAllowTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
});
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat123",
});
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
kind: "handle",
handle: "+19257864429",
});
});
});
describe("isAllowedBlueBubblesSender", () => {
it("denies when allowFrom is empty", () => {
const allowed = isAllowedBlueBubblesSender({
allowFrom: [],
sender: "+15551234567",
});
expect(allowed).toBe(false);
});
it("allows wildcard entries", () => {
const allowed = isAllowedBlueBubblesSender({
allowFrom: ["*"],
sender: "+15551234567",
});
expect(allowed).toBe(true);
});
});

View File

@@ -1,228 +0,0 @@
import { describe, expect, it } from "vitest";
import {
inferBlueBubblesTargetChatType,
looksLikeBlueBubblesExplicitTargetId,
isAllowedBlueBubblesSender,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesMessagingTarget,
parseBlueBubblesTarget,
parseBlueBubblesAllowTarget,
} from "./targets.js";
describe("normalizeBlueBubblesMessagingTarget", () => {
it("normalizes chat_guid targets", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
});
it("normalizes group numeric targets to chat_id", () => {
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
});
it("strips provider prefix and normalizes handles", () => {
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
"imessage:user@example.com",
);
});
it("extracts handle from DM chat_guid for cross-context matching", () => {
// DM format: service;-;handle
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
"+19257864429",
);
expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
"+15551234567",
);
// Email handles
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
"user@example.com",
);
});
it("preserves group chat_guid format", () => {
// Group format: service;+;groupId
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
"chat_guid:iMessage;+;chat123456789",
);
});
it("normalizes raw chat_guid values", () => {
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
"chat_guid:iMessage;+;chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
});
it("normalizes chat<digits> pattern to chat_identifier format", () => {
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
"chat_identifier:chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
});
it("normalizes UUID/hex chat identifiers", () => {
expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
"chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
);
expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
"chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
);
});
});
describe("looksLikeBlueBubblesTargetId", () => {
it("accepts chat targets", () => {
expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
});
it("accepts email handles", () => {
expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
});
it("accepts phone numbers with punctuation", () => {
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
});
it("accepts raw chat_guid values", () => {
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
});
it("accepts chat<digits> pattern as chat_id", () => {
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
});
it("accepts UUID/hex chat identifiers", () => {
expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
});
it("rejects display names", () => {
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
});
});
describe("looksLikeBlueBubblesExplicitTargetId", () => {
it("treats explicit chat targets as immediate ids", () => {
expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true);
expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true);
});
it("prefers directory fallback for bare handles and phone numbers", () => {
expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false);
expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false);
});
});
describe("inferBlueBubblesTargetChatType", () => {
it("infers direct chat for handles and dm chat_guids", () => {
expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct");
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct");
});
it("infers group chat for explicit group targets", () => {
expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group");
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group");
});
});
describe("parseBlueBubblesTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
});
expect(parseBlueBubblesTarget("chat123")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat123",
});
expect(parseBlueBubblesTarget("Chat456789")).toEqual({
kind: "chat_identifier",
chatIdentifier: "Chat456789",
});
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
kind: "handle",
to: "+19257864429",
service: "auto",
});
});
it("parses raw chat_guid format", () => {
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
kind: "chat_guid",
chatGuid: "iMessage;+;chat660250192681427962",
});
});
});
describe("parseBlueBubblesAllowTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
});
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat123",
});
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
kind: "handle",
handle: "+19257864429",
});
});
});
describe("isAllowedBlueBubblesSender", () => {
it("denies when allowFrom is empty", () => {
const allowed = isAllowedBlueBubblesSender({
allowFrom: [],
sender: "+15551234567",
});
expect(allowed).toBe(false);
});
it("allows wildcard entries", () => {
const allowed = isAllowedBlueBubblesSender({
allowFrom: ["*"],
sender: "+15551234567",
});
expect(allowed).toBe(true);
});
});

View File

@@ -53,6 +53,12 @@ export type BlueBubblesAccountConfig = {
mediaLocalRoots?: string[];
/** Send read receipts for incoming messages (default: true). */
sendReadReceipts?: boolean;
/** Wait this long for webhook or history confirmation before treating an outbound send as unconfirmed. */
sendConfirmationTimeoutMs?: number;
/** Retry count when BlueBubbles accepts a send but no confirmation arrives. */
sendRetryCount?: number;
/** Base delay between send retries; exponential backoff is applied from this value. */
sendRetryBaseDelayMs?: number;
/** Allow fetching from private/internal IP addresses (e.g. localhost). Required for same-host BlueBubbles setups. */
allowPrivateNetwork?: boolean;
/** Per-group configuration keyed by chat GUID or identifier. */

View File

@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import { __testing } from "./brave-web-search-provider.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { __testing, createBraveWebSearchProvider } from "./brave-web-search-provider.js";
describe("brave web search provider", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("normalizes brave language parameters and swaps reversed ui/search inputs", () => {
expect(
__testing.normalizeBraveLanguageParams({
@@ -49,4 +53,29 @@ describe("brave web search provider", () => {
},
]);
});
it("returns validation errors for invalid date ranges", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "BSA...",
brave: { apiKey: "BSA..." },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({
query: "latest gpu news",
date_after: "2026-03-20",
date_before: "2026-03-01",
});
expect(result).toMatchObject({
error: "invalid_date_range",
});
});
});

View File

@@ -6,7 +6,7 @@ import {
formatCliCommand,
mergeScopedSearchConfig,
normalizeFreshness,
normalizeToIsoDate,
parseIsoDateRange,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
@@ -478,29 +478,17 @@ function createBraveToolDefinition(
docs: "https://docs.openclaw.ai/tools/web",
};
}
const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined;
if (rawDateAfter && !dateAfter) {
return {
error: "invalid_date",
message: "date_after must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined;
if (rawDateBefore && !dateBefore) {
return {
error: "invalid_date",
message: "date_before must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (dateAfter && dateBefore && dateAfter > dateBefore) {
return {
error: "invalid_date_range",
message: "date_after must be before date_before.",
docs: "https://docs.openclaw.ai/tools/web",
};
const parsedDateRange = parseIsoDateRange({
rawDateAfter,
rawDateBefore,
invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
invalidDateRangeMessage: "date_after must be before date_before.",
});
if ("error" in parsedDateRange) {
return parsedDateRange;
}
const { dateAfter, dateBefore } = parsedDateRange;
const cacheKey = buildSearchCacheKey([
"brave",

View File

@@ -8,7 +8,11 @@ export {
type DeviceBootstrapProfile,
} from "openclaw/plugin-sdk/device-bootstrap";
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
export { resolveGatewayBindUrl, resolveTailnetHostWithRunner } from "openclaw/plugin-sdk/core";
export {
resolveGatewayBindUrl,
resolveGatewayPort,
resolveTailnetHostWithRunner,
} from "openclaw/plugin-sdk/core";
export {
resolvePreferredOpenClawTmpDir,
runPluginCommandWithTimeout,

View File

@@ -8,6 +8,7 @@ import type {
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
import type { OpenClawPluginApi } from "./api.js";
import type { PendingPairingRequest } from "./notify.ts";
const pluginApiMocks = vi.hoisted(() => ({
clearDeviceBootstrapTokens: vi.fn(async () => ({ removed: 2 })),
@@ -17,6 +18,7 @@ const pluginApiMocks = vi.hoisted(() => ({
})),
revokeDeviceBootstrapToken: vi.fn(async () => ({ removed: true })),
renderQrPngBase64: vi.fn(async () => "ZmFrZXBuZw=="),
resolveGatewayPort: vi.fn(() => 18789),
resolvePreferredOpenClawTmpDir: vi.fn(() => path.join(os.tmpdir(), "openclaw-device-pair-tests")),
}));
@@ -35,6 +37,7 @@ vi.mock("./api.js", () => {
revokeDeviceBootstrapToken: pluginApiMocks.revokeDeviceBootstrapToken,
resolvePreferredOpenClawTmpDir: pluginApiMocks.resolvePreferredOpenClawTmpDir,
resolveGatewayBindUrl: vi.fn(),
resolveGatewayPort: pluginApiMocks.resolveGatewayPort,
resolveTailnetHostWithRunner: vi.fn(),
runPluginCommandWithTimeout: vi.fn(),
};
@@ -383,6 +386,49 @@ describe("device-pair /pair qr", () => {
});
});
describe("device-pair notify pending formatting", () => {
it("includes role and scopes for pending requests", async () => {
const { formatPendingRequests } =
await vi.importActual<typeof import("./notify.ts")>("./notify.ts");
const pending: PendingPairingRequest[] = [
{
requestId: "req-1",
deviceId: "device-1",
displayName: "dev one",
platform: "ios",
role: "operator",
scopes: ["operator.admin", "operator.read"],
remoteIp: "198.51.100.2",
},
];
const text = formatPendingRequests(pending);
expect(text).toContain("Pending device pairing requests:");
expect(text).toContain("name=dev one");
expect(text).toContain("platform=ios");
expect(text).toContain("role=operator");
expect(text).toContain("scopes=operator.admin, operator.read");
expect(text).toContain("ip=198.51.100.2");
});
it("falls back to roles list and no scopes when role/scopes are absent", async () => {
const { formatPendingRequests } =
await vi.importActual<typeof import("./notify.ts")>("./notify.ts");
const pending: PendingPairingRequest[] = [
{
requestId: "req-2",
deviceId: "device-2",
roles: ["node", "operator"],
scopes: [],
},
];
const text = formatPendingRequests(pending);
expect(text).toContain("role=node, operator");
expect(text).toContain("scopes=none");
});
});
describe("device-pair /pair approve", () => {
it("rejects internal gateway callers without operator.pairing", async () => {
vi.mocked(listDevicePairing).mockResolvedValueOnce({

View File

@@ -11,9 +11,10 @@ import {
renderQrPngBase64,
revokeDeviceBootstrapToken,
resolveGatewayBindUrl,
resolveGatewayPort,
resolvePreferredOpenClawTmpDir,
resolveTailnetHostWithRunner,
runPluginCommandWithTimeout,
resolveTailnetHostWithRunner,
type OpenClawPluginApi,
} from "./api.js";
import {
@@ -43,8 +44,6 @@ function formatDurationMinutes(expiresAtMs: number): string {
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
}
const DEFAULT_GATEWAY_PORT = 18789;
type DevicePairPluginConfig = {
publicUrl?: string;
};
@@ -175,26 +174,6 @@ function parseNormalizedGatewayUrl(raw: string): string | null {
}
}
function parsePositiveInteger(raw: string | undefined): number | null {
if (!raw) {
return null;
}
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function resolveGatewayPort(cfg: OpenClawPluginApi["config"]): number {
const envPort = parsePositiveInteger(process.env.OPENCLAW_GATEWAY_PORT?.trim());
if (envPort) {
return envPort;
}
const configPort = cfg.gateway?.port;
if (typeof configPort === "number" && Number.isFinite(configPort) && configPort > 0) {
return configPort;
}
return DEFAULT_GATEWAY_PORT;
}
function resolveScheme(
cfg: OpenClawPluginApi["config"],
opts?: { forceSecure?: boolean },

View File

@@ -1,41 +0,0 @@
import { describe, expect, it } from "vitest";
import { formatPendingRequests, type PendingPairingRequest } from "./notify.ts";
describe("device-pair notify pending formatting", () => {
it("includes role and scopes for pending requests", () => {
const pending: PendingPairingRequest[] = [
{
requestId: "req-1",
deviceId: "device-1",
displayName: "dev one",
platform: "ios",
role: "operator",
scopes: ["operator.admin", "operator.read"],
remoteIp: "198.51.100.2",
},
];
const text = formatPendingRequests(pending);
expect(text).toContain("Pending device pairing requests:");
expect(text).toContain("name=dev one");
expect(text).toContain("platform=ios");
expect(text).toContain("role=operator");
expect(text).toContain("scopes=operator.admin, operator.read");
expect(text).toContain("ip=198.51.100.2");
});
it("falls back to roles list and no scopes when role/scopes are absent", () => {
const pending: PendingPairingRequest[] = [
{
requestId: "req-2",
deviceId: "device-2",
roles: ["node", "operator"],
scopes: [],
},
];
const text = formatPendingRequests(pending);
expect(text).toContain("role=node, operator");
expect(text).toContain("scopes=none");
});
});

View File

@@ -1,54 +1 @@
import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime";
import QRCodeModule from "qrcode-terminal/vendor/QRCode/index.js";
import QRErrorCorrectLevelModule from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js";
type QRCodeConstructor = new (
typeNumber: number,
errorCorrectLevel: unknown,
) => {
addData: (data: string) => void;
make: () => void;
getModuleCount: () => number;
isDark: (row: number, col: number) => boolean;
};
const QRCode = QRCodeModule as QRCodeConstructor;
const QRErrorCorrectLevel = QRErrorCorrectLevelModule;
function createQrMatrix(input: string) {
const qr = new QRCode(-1, QRErrorCorrectLevel.L);
qr.addData(input);
qr.make();
return qr;
}
export async function renderQrPngBase64(
input: string,
opts: { scale?: number; marginModules?: number } = {},
): Promise<string> {
const { scale = 6, marginModules = 4 } = opts;
const qr = createQrMatrix(input);
const modules = qr.getModuleCount();
const size = (modules + marginModules * 2) * scale;
const buf = Buffer.alloc(size * size * 4, 255);
for (let row = 0; row < modules; row += 1) {
for (let col = 0; col < modules; col += 1) {
if (!qr.isDark(row, col)) {
continue;
}
const startX = (col + marginModules) * scale;
const startY = (row + marginModules) * scale;
for (let y = 0; y < scale; y += 1) {
const pixelY = startY + y;
for (let x = 0; x < scale; x += 1) {
const pixelX = startX + x;
fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255);
}
}
}
}
const png = encodePngRgba(buf, size, size);
return png.toString("base64");
}
export { renderQrPngBase64 } from "openclaw/plugin-sdk/media-runtime";

View File

@@ -1,140 +0,0 @@
import type { IncomingMessage } from "node:http";
import { describe, expect, it, vi } from "vitest";
import { createMockServerResponse } from "../../test/helpers/extensions/mock-http-response.js";
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "./api.js";
import plugin from "./index.js";
describe("diffs plugin registration", () => {
it("registers the tool, http route, and system-prompt guidance hook", async () => {
const registerTool = vi.fn();
const registerHttpRoute = vi.fn();
const on = vi.fn();
plugin.register?.(
createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {},
runtime: {} as never,
registerTool,
registerHttpRoute,
on,
}),
);
expect(registerTool).toHaveBeenCalledTimes(1);
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({
path: "/plugins/diffs",
auth: "plugin",
match: "prefix",
});
expect(on).toHaveBeenCalledTimes(1);
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
const beforePromptBuild = on.mock.calls[0]?.[1];
const result = await beforePromptBuild?.({}, {});
expect(result).toMatchObject({
prependSystemContext: expect.stringContaining("prefer the `diffs` tool"),
});
expect(result?.prependContext).toBeUndefined();
});
it("applies plugin-config defaults through registered tool and viewer handler", async () => {
type RegisteredTool = {
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
};
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
let registeredToolFactory:
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
| undefined;
let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined;
const api = createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
pluginConfig: {
defaults: {
mode: "view",
theme: "light",
background: false,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
lineSpacing: 2,
},
},
runtime: {} as never,
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
},
registerHttpRoute(params: RegisteredHttpRouteParams) {
registeredHttpRouteHandler = params.handler;
},
});
plugin.register?.(api as unknown as OpenClawPluginApi);
const registeredTool = registeredToolFactory?.({
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
}) as RegisteredTool | undefined;
const result = await registeredTool?.execute?.("tool-1", {
before: "one\n",
after: "two\n",
});
const viewerPath = String(
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
);
const res = createMockServerResponse();
const handled = await registeredHttpRouteHandler?.(
localReq({
method: "GET",
url: viewerPath,
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain('body data-theme="light"');
expect(String(res.body)).toContain('"backgroundEnabled":false');
expect(String(res.body)).toContain('"diffStyle":"split"');
expect(String(res.body)).toContain('"disableLineNumbers":true');
expect(String(res.body)).toContain('"diffIndicators":"classic"');
expect(String(res.body)).toContain("--diffs-line-height: 30px;");
expect((result as { details?: Record<string, unknown> } | undefined)?.details?.context).toEqual(
{
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
},
);
});
});
function localReq(input: {
method: string;
url: string;
headers?: IncomingMessage["headers"];
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
}

View File

@@ -1,13 +1,21 @@
import fs from "node:fs/promises";
import type { IncomingMessage } from "node:http";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
import { createTestPluginApi } from "../../../test/helpers/extensions/plugin-api.js";
import type { OpenClawConfig } from "../api.js";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
import plugin from "../index.js";
import { createTempDiffRoot } from "./test-helpers.js";
const { launchMock } = vi.hoisted(() => ({
launchMock: vi.fn(),
}));
let PlaywrightDiffScreenshotter: typeof import("./browser.js").PlaywrightDiffScreenshotter;
let resetSharedBrowserStateForTests: typeof import("./browser.js").resetSharedBrowserStateForTests;
vi.mock("playwright-core", () => ({
chromium: {
launch: launchMock,
@@ -19,18 +27,22 @@ describe("PlaywrightDiffScreenshotter", () => {
let outputPath: string;
let cleanupRootDir: () => Promise<void>;
beforeAll(async () => {
vi.resetModules();
({ PlaywrightDiffScreenshotter, resetSharedBrowserStateForTests } =
await import("./browser.js"));
});
beforeEach(async () => {
vi.useFakeTimers();
({ rootDir, cleanup: cleanupRootDir } = await createTempDiffRoot("openclaw-diffs-browser-"));
outputPath = path.join(rootDir, "preview.png");
launchMock.mockReset();
const browserModule = await import("./browser.js");
await browserModule.resetSharedBrowserStateForTests();
await resetSharedBrowserStateForTests();
});
afterEach(async () => {
const browserModule = await import("./browser.js");
await browserModule.resetSharedBrowserStateForTests();
await resetSharedBrowserStateForTests();
vi.useRealTimers();
await cleanupRootDir();
});
@@ -131,8 +143,6 @@ describe("PlaywrightDiffScreenshotter", () => {
boundingBox: { x: 40, y: 40, width: 960, height: 60_000 },
});
launchMock.mockResolvedValue(browser);
const { PlaywrightDiffScreenshotter } = await import("./browser.js");
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,
@@ -182,6 +192,128 @@ describe("PlaywrightDiffScreenshotter", () => {
});
});
describe("diffs plugin registration", () => {
it("registers the tool, http route, and system-prompt guidance hook", async () => {
const registerTool = vi.fn();
const registerHttpRoute = vi.fn();
const on = vi.fn();
plugin.register?.(
createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {},
runtime: {} as never,
registerTool,
registerHttpRoute,
on,
}),
);
expect(registerTool).toHaveBeenCalledTimes(1);
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({
path: "/plugins/diffs",
auth: "plugin",
match: "prefix",
});
expect(on).toHaveBeenCalledTimes(1);
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
const beforePromptBuild = on.mock.calls[0]?.[1];
const result = await beforePromptBuild?.({}, {});
expect(result).toMatchObject({
prependSystemContext: expect.stringContaining("prefer the `diffs` tool"),
});
expect(result?.prependContext).toBeUndefined();
});
it("applies plugin-config defaults through registered tool and viewer handler", async () => {
type RegisteredTool = {
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
};
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
let registeredToolFactory:
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
| undefined;
let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined;
const api = createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
pluginConfig: {
defaults: {
mode: "view",
theme: "light",
background: false,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
lineSpacing: 2,
},
},
runtime: {} as never,
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
},
registerHttpRoute(params: RegisteredHttpRouteParams) {
registeredHttpRouteHandler = params.handler;
},
});
plugin.register?.(api as unknown as OpenClawPluginApi);
const registeredTool = registeredToolFactory?.({
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
}) as RegisteredTool | undefined;
const result = await registeredTool?.execute?.("tool-1", {
before: "one\n",
after: "two\n",
});
const viewerPath = String(
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
);
const res = createMockServerResponse();
const handled = await registeredHttpRouteHandler?.(
localReq({
method: "GET",
url: viewerPath,
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain('body data-theme="light"');
expect(String(res.body)).toContain('"backgroundEnabled":false');
expect(String(res.body)).toContain('"diffStyle":"split"');
expect(String(res.body)).toContain('"disableLineNumbers":true');
expect(String(res.body)).toContain('"diffIndicators":"classic"');
expect(String(res.body)).toContain("--diffs-line-height: 30px;");
expect((result as { details?: Record<string, unknown> } | undefined)?.details?.context).toEqual(
{
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
},
);
});
});
function createConfig(): OpenClawConfig {
return {
browser: {
@@ -190,6 +322,18 @@ function createConfig(): OpenClawConfig {
} as OpenClawConfig;
}
function localReq(input: {
method: string;
url: string;
headers?: IncomingMessage["headers"];
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
}
async function createScreenshotterHarness(options?: {
boundingBox?: { x: number; y: number; width: number; height: number };
}) {
@@ -200,7 +344,6 @@ async function createScreenshotterHarness(options?: {
}> = [];
const browser = createMockBrowser(pages, options);
launchMock.mockResolvedValue(browser);
const { PlaywrightDiffScreenshotter } = await import("./browser.js");
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,

View File

@@ -8,6 +8,10 @@ import {
resolveDiffsPluginDefaults,
resolveDiffsPluginSecurity,
} from "./config.js";
import { renderDiffDocument } from "./render.js";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js";
import { parseViewerPayloadJson } from "./viewer-payload.js";
const FULL_DEFAULTS = {
fontFamily: "JetBrains Mono",
@@ -177,3 +181,232 @@ describe("diffs plugin schema surfaces", () => {
expect(diffsPluginConfigSchema.jsonSchema).toEqual(manifest.configSchema);
});
});
describe("diffs viewer URL helpers", () => {
it("defaults to loopback for lan/tailnet bind modes", () => {
expect(
buildViewerUrl({
config: { gateway: { bind: "lan", port: 18789 } },
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token");
expect(
buildViewerUrl({
config: { gateway: { bind: "tailnet", port: 24444 } },
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
});
it("uses custom bind host when provided", () => {
expect(
buildViewerUrl({
config: {
gateway: {
bind: "custom",
customBindHost: "gateway.example.com",
port: 443,
tls: { enabled: true },
},
},
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("https://gateway.example.com/plugins/diffs/view/id/token");
});
it("joins viewer path under baseUrl pathname", () => {
expect(
buildViewerUrl({
config: {},
baseUrl: "https://example.com/openclaw",
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
});
it("rejects base URLs with query/hash", () => {
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
"baseUrl must not include query/hash",
);
expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow(
"baseUrl must not include query/hash",
);
});
});
describe("renderDiffDocument", () => {
it("renders before/after input into a complete viewer document", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "const value = 1;\n",
after: "const value = 2;\n",
path: "src/example.ts",
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
);
expect(rendered.title).toBe("src/example.ts");
expect(rendered.fileCount).toBe(1);
expect(rendered.html).toContain("data-openclaw-diff-root");
expect(rendered.html).toContain("src/example.ts");
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js");
expect(rendered.imageHtml).toContain("max-width: 960px;");
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
expect(rendered.html).toContain("min-height: 100vh;");
expect(rendered.html).toContain('"diffIndicators":"bars"');
expect(rendered.html).toContain('"disableLineNumbers":false');
expect(rendered.html).toContain("--diffs-line-height: 24px;");
expect(rendered.html).toContain("--diffs-font-size: 15px;");
expect(rendered.html).not.toContain("fonts.googleapis.com");
});
it("renders multi-file patch input", async () => {
const patch = [
"diff --git a/a.ts b/a.ts",
"--- a/a.ts",
"+++ b/a.ts",
"@@ -1 +1 @@",
"-const a = 1;",
"+const a = 2;",
"diff --git a/b.ts b/b.ts",
"--- a/b.ts",
"+++ b/b.ts",
"@@ -1 +1 @@",
"-const b = 1;",
"+const b = 2;",
].join("\n");
const rendered = await renderDiffDocument(
{
kind: "patch",
patch,
title: "Workspace patch",
},
{
presentation: {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
layout: "split",
theme: "dark",
},
image: resolveDiffImageRenderOptions({
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
fileQuality: "hq",
fileMaxWidth: 1180,
}),
expandUnchanged: true,
},
);
expect(rendered.title).toBe("Workspace patch");
expect(rendered.fileCount).toBe(2);
expect(rendered.html).toContain("Workspace patch");
expect(rendered.imageHtml).toContain("max-width: 1180px;");
});
it("rejects patches that exceed file-count limits", async () => {
const patch = Array.from({ length: 129 }, (_, i) => {
return [
`diff --git a/f${i}.ts b/f${i}.ts`,
`--- a/f${i}.ts`,
`+++ b/f${i}.ts`,
"@@ -1 +1 @@",
"-const x = 1;",
"+const x = 2;",
].join("\n");
}).join("\n");
await expect(
renderDiffDocument(
{
kind: "patch",
patch,
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
),
).rejects.toThrow("too many files");
});
});
describe("viewer assets", () => {
it("serves a stable loader that points at the current runtime bundle", async () => {
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`);
});
it("serves the runtime bundle body", async () => {
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
expect(String(runtime?.body)).toContain("openclawDiffsReady");
});
it("returns null for unknown asset paths", async () => {
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
});
});
describe("parseViewerPayloadJson", () => {
function buildValidPayload(): Record<string, unknown> {
return {
prerenderedHTML: "<div>ok</div>",
langs: ["text"],
oldFile: {
name: "README.md",
contents: "before",
},
newFile: {
name: "README.md",
contents: "after",
},
options: {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: "unified",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "dark",
backgroundEnabled: true,
overflow: "wrap",
unsafeCSS: ":host{}",
},
};
}
it("accepts valid payload JSON", () => {
const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload()));
expect(parsed.options.diffStyle).toBe("unified");
expect(parsed.options.diffIndicators).toBe("bars");
});
it("rejects payloads with invalid shape", () => {
const broken = buildValidPayload();
broken.options = {
...(broken.options as Record<string, unknown>),
diffIndicators: "invalid",
};
expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow(
"Diff payload has invalid shape.",
);
});
it("rejects invalid JSON", () => {
expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON.");
});
});

View File

@@ -1,206 +0,0 @@
import type { IncomingMessage } from "node:http";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
import { createDiffsHttpHandler } from "./http.js";
import { DiffArtifactStore } from "./store.js";
import { createDiffStoreHarness } from "./test-helpers.js";
describe("createDiffsHttpHandler", () => {
let store: DiffArtifactStore;
let cleanupRootDir: () => Promise<void>;
async function handleLocalGet(url: string) {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url,
}),
res,
);
return { handled, res };
}
beforeEach(async () => {
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-"));
});
afterEach(async () => {
await cleanupRootDir();
});
it("serves a stored diff document", async () => {
const artifact = await createViewerArtifact(store);
const { handled, res } = await handleLocalGet(artifact.viewerPath);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("<html>viewer</html>");
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
});
it("rejects invalid tokens", async () => {
const artifact = await createViewerArtifact(store);
const { handled, res } = await handleLocalGet(
artifact.viewerPath.replace(artifact.token, "bad-token"),
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("rejects malformed artifact ids before reading from disk", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("serves the shared viewer asset", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/assets/viewer.js",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v=");
});
it("serves the shared viewer runtime asset", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/assets/viewer-runtime.js",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("openclawDiffsReady");
});
it.each([
{
name: "blocks non-loopback viewer access by default",
request: remoteReq,
allowRemoteViewer: false,
expectedStatusCode: 404,
},
{
name: "blocks loopback requests that carry proxy forwarding headers by default",
request: localReq,
headers: { "x-forwarded-for": "203.0.113.10" },
allowRemoteViewer: false,
expectedStatusCode: 404,
},
{
name: "allows remote access when allowRemoteViewer is enabled",
request: remoteReq,
allowRemoteViewer: true,
expectedStatusCode: 200,
},
{
name: "allows proxied loopback requests when allowRemoteViewer is enabled",
request: localReq,
headers: { "x-forwarded-for": "203.0.113.10" },
allowRemoteViewer: true,
expectedStatusCode: 200,
},
])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => {
const artifact = await createViewerArtifact(store);
const handler = createDiffsHttpHandler({ store, allowRemoteViewer });
const res = createMockServerResponse();
const handled = await handler(
request({
method: "GET",
url: artifact.viewerPath,
headers,
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(expectedStatusCode);
if (expectedStatusCode === 200) {
expect(res.body).toBe("<html>viewer</html>");
}
});
it("rate-limits repeated remote misses", async () => {
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
for (let i = 0; i < 40; i++) {
const miss = createMockServerResponse();
await handler(
remoteReq({
method: "GET",
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
}),
miss,
);
expect(miss.statusCode).toBe(404);
}
const limited = createMockServerResponse();
await handler(
remoteReq({
method: "GET",
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
}),
limited,
);
expect(limited.statusCode).toBe(429);
});
});
async function createViewerArtifact(store: DiffArtifactStore) {
return await store.createArtifact({
html: "<html>viewer</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
}
function localReq(input: {
method: string;
url: string;
headers?: Record<string, string>;
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
}
function remoteReq(input: {
method: string;
url: string;
headers?: Record<string, string>;
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "203.0.113.10" },
} as unknown as IncomingMessage;
}

View File

@@ -1,106 +0,0 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
import { renderDiffDocument } from "./render.js";
describe("renderDiffDocument", () => {
it("renders before/after input into a complete viewer document", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "const value = 1;\n",
after: "const value = 2;\n",
path: "src/example.ts",
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
);
expect(rendered.title).toBe("src/example.ts");
expect(rendered.fileCount).toBe(1);
expect(rendered.html).toContain("data-openclaw-diff-root");
expect(rendered.html).toContain("src/example.ts");
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js");
expect(rendered.imageHtml).toContain("max-width: 960px;");
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
expect(rendered.html).toContain("min-height: 100vh;");
expect(rendered.html).toContain('"diffIndicators":"bars"');
expect(rendered.html).toContain('"disableLineNumbers":false');
expect(rendered.html).toContain("--diffs-line-height: 24px;");
expect(rendered.html).toContain("--diffs-font-size: 15px;");
expect(rendered.html).not.toContain("fonts.googleapis.com");
});
it("renders multi-file patch input", async () => {
const patch = [
"diff --git a/a.ts b/a.ts",
"--- a/a.ts",
"+++ b/a.ts",
"@@ -1 +1 @@",
"-const a = 1;",
"+const a = 2;",
"diff --git a/b.ts b/b.ts",
"--- a/b.ts",
"+++ b/b.ts",
"@@ -1 +1 @@",
"-const b = 1;",
"+const b = 2;",
].join("\n");
const rendered = await renderDiffDocument(
{
kind: "patch",
patch,
title: "Workspace patch",
},
{
presentation: {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
layout: "split",
theme: "dark",
},
image: resolveDiffImageRenderOptions({
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
fileQuality: "hq",
fileMaxWidth: 1180,
}),
expandUnchanged: true,
},
);
expect(rendered.title).toBe("Workspace patch");
expect(rendered.fileCount).toBe(2);
expect(rendered.html).toContain("Workspace patch");
expect(rendered.imageHtml).toContain("max-width: 1180px;");
});
it("rejects patches that exceed file-count limits", async () => {
const patch = Array.from({ length: 129 }, (_, i) => {
return [
`diff --git a/f${i}.ts b/f${i}.ts`,
`--- a/f${i}.ts`,
`+++ b/f${i}.ts`,
"@@ -1 +1 @@",
"-const x = 1;",
"+const x = 2;",
].join("\n");
}).join("\n");
await expect(
renderDiffDocument(
{
kind: "patch",
patch,
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
),
).rejects.toThrow("too many files");
});
});

View File

@@ -1,6 +1,9 @@
import fs from "node:fs/promises";
import type { IncomingMessage } from "node:http";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
import { createDiffsHttpHandler } from "./http.js";
import { DiffArtifactStore } from "./store.js";
import { createDiffStoreHarness } from "./test-helpers.js";
@@ -211,3 +214,203 @@ describe("DiffArtifactStore", () => {
expect(cleanupSpy).toHaveBeenCalledTimes(2);
});
});
describe("createDiffsHttpHandler", () => {
let store: DiffArtifactStore;
let cleanupRootDir: () => Promise<void>;
async function handleLocalGet(url: string) {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url,
}),
res,
);
return { handled, res };
}
beforeEach(async () => {
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-"));
});
afterEach(async () => {
await cleanupRootDir();
});
it("serves a stored diff document", async () => {
const artifact = await createViewerArtifact(store);
const { handled, res } = await handleLocalGet(artifact.viewerPath);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("<html>viewer</html>");
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
});
it("rejects invalid tokens", async () => {
const artifact = await createViewerArtifact(store);
const { handled, res } = await handleLocalGet(
artifact.viewerPath.replace(artifact.token, "bad-token"),
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("rejects malformed artifact ids before reading from disk", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("serves the shared viewer asset", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/assets/viewer.js",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v=");
});
it("serves the shared viewer runtime asset", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/assets/viewer-runtime.js",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("openclawDiffsReady");
});
it.each([
{
name: "blocks non-loopback viewer access by default",
request: remoteReq,
allowRemoteViewer: false,
expectedStatusCode: 404,
},
{
name: "blocks loopback requests that carry proxy forwarding headers by default",
request: localReq,
headers: { "x-forwarded-for": "203.0.113.10" },
allowRemoteViewer: false,
expectedStatusCode: 404,
},
{
name: "allows remote access when allowRemoteViewer is enabled",
request: remoteReq,
allowRemoteViewer: true,
expectedStatusCode: 200,
},
{
name: "allows proxied loopback requests when allowRemoteViewer is enabled",
request: localReq,
headers: { "x-forwarded-for": "203.0.113.10" },
allowRemoteViewer: true,
expectedStatusCode: 200,
},
])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => {
const artifact = await createViewerArtifact(store);
const handler = createDiffsHttpHandler({ store, allowRemoteViewer });
const res = createMockServerResponse();
const handled = await handler(
request({
method: "GET",
url: artifact.viewerPath,
headers,
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(expectedStatusCode);
if (expectedStatusCode === 200) {
expect(res.body).toBe("<html>viewer</html>");
}
});
it("rate-limits repeated remote misses", async () => {
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
for (let i = 0; i < 40; i++) {
const miss = createMockServerResponse();
await handler(
remoteReq({
method: "GET",
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
}),
miss,
);
expect(miss.statusCode).toBe(404);
}
const limited = createMockServerResponse();
await handler(
remoteReq({
method: "GET",
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
}),
limited,
);
expect(limited.statusCode).toBe(429);
});
});
async function createViewerArtifact(store: DiffArtifactStore) {
return await store.createArtifact({
html: "<html>viewer</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
}
function localReq(input: {
method: string;
url: string;
headers?: Record<string, string>;
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
}
function remoteReq(input: {
method: string;
url: string;
headers?: Record<string, string>;
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "203.0.113.10" },
} as unknown as IncomingMessage;
}

View File

@@ -1,55 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
describe("diffs viewer URL helpers", () => {
it("defaults to loopback for lan/tailnet bind modes", () => {
expect(
buildViewerUrl({
config: { gateway: { bind: "lan", port: 18789 } },
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token");
expect(
buildViewerUrl({
config: { gateway: { bind: "tailnet", port: 24444 } },
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
});
it("uses custom bind host when provided", () => {
expect(
buildViewerUrl({
config: {
gateway: {
bind: "custom",
customBindHost: "gateway.example.com",
port: 443,
tls: { enabled: true },
},
},
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("https://gateway.example.com/plugins/diffs/view/id/token");
});
it("joins viewer path under baseUrl pathname", () => {
expect(
buildViewerUrl({
config: {},
baseUrl: "https://example.com/openclaw",
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
});
it("rejects base URLs with query/hash", () => {
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
"baseUrl must not include query/hash",
);
expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow(
"baseUrl must not include query/hash",
);
});
});

View File

@@ -1,22 +0,0 @@
import { describe, expect, it } from "vitest";
import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js";
describe("viewer assets", () => {
it("serves a stable loader that points at the current runtime bundle", async () => {
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`);
});
it("serves the runtime bundle body", async () => {
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
expect(String(runtime?.body)).toContain("openclawDiffsReady");
});
it("returns null for unknown asset paths", async () => {
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
});
});

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