Compare commits

..

128 Commits

Author SHA1 Message Date
Tak Hoffman
c51af21281 Fix outbound channel selection fast paths 2026-03-19 00:36:39 -05:00
Tak Hoffman
92a1ba88bd Fix Windows hook path containment 2026-03-19 00:00:50 -05:00
Tak Hoffman
53a34c39f6 Fix windows ACL os mock typing 2026-03-18 23:49:53 -05:00
Tak Hoffman
3261a2a0b1 Tighten bug report grounding guidance 2026-03-18 23:46:45 -05:00
Tak Hoffman
74b9ad010a test: preserve node os exports in windows acl mock 2026-03-18 23:38:25 -05:00
Josh Avant
a2a9a553e1 Stabilize plugin loader and Docker extension smoke (#50058)
* Plugins: stabilize Area 6 loader and Docker smoke

* Docker: fail fast on extension npm install errors

* Tests: stabilize loader non-native Jiti boundary CI timeout

* Tests: stabilize plugin loader Jiti source-runtime coverage

* Docker: keep extension deps on lockfile graph

* Tests: cover tsx-cache renamed package cwd fallback

* Tests: stabilize plugin-sdk export subpath assertions

* Plugins: align tsx-cache alias fallback with subpath fallback

* Tests: normalize guardrail path checks for Windows

* Plugins: restrict plugin-sdk cwd fallback to trusted roots

* Tests: exempt outbound-session from extension import guard

* Tests: tighten guardrails and cli-entry trust coverage

* Tests: guard optional loader fixture exports

* Tests: make loader fixture package exports null-safe

* Tests: make loader fixture package exports null-safe

* Tests: make loader fixture package exports null-safe

* changelog

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

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-18 23:35:32 -05:00
Tak Hoffman
3abffe0967 fix: stabilize windows temp and path handling 2026-03-18 23:29:14 -05:00
Gustavo Madeira Santana
afa95fade0 Tests: align fixtures with current gateway and model types 2026-03-19 00:25:24 -04:00
Gustavo Madeira Santana
83d284610c Diffs: route plugin context through artifacts 2026-03-19 00:24:00 -04:00
Tak Hoffman
a98ffa41d0 build: make whatsapp plugin publishable 2026-03-18 23:22:44 -05:00
Tak Hoffman
16567ba4e7 test: align whatsapp expectations with current contracts 2026-03-18 23:17:48 -05:00
Tak Hoffman
b8b1e2cf50 AGENTS.md: split GHSA advisory workflow into its own skill 2026-03-18 23:11:18 -05:00
Tak Hoffman
f6c57edd5c Tests: tighten channel import guardrails 2026-03-18 23:08:02 -05:00
Tak Hoffman
79e13e0a5e AGENTS.md: forbid merge commits on main 2026-03-18 23:01:22 -05:00
Tak Hoffman
5b7b5529f1 Plugins: remove shared extension boundary debt 2026-03-18 22:58:40 -05:00
Tak Hoffman
126839380c Tests: fix current check failures 2026-03-18 22:58:40 -05:00
Tak Hoffman
74756b91b7 AGENTS.md: block test-baseline silencing edits 2026-03-18 22:55:27 -05:00
Tak Hoffman
f7675eca6b AGENTS.md: split local and safety notes 2026-03-18 22:55:27 -05:00
Tak Hoffman
59269f3534 AGENTS.md: extract repo workflows into skills 2026-03-18 22:55:27 -05:00
Peter Steinberger
25015161fe refactor: install optional channel capabilities on demand 2026-03-19 03:39:15 +00:00
Peter Steinberger
19126033dd build: regenerate protocol swift models 2026-03-19 03:38:35 +00:00
Peter Steinberger
b7ca56f662 refactor: install heavy plugins on demand 2026-03-19 03:37:30 +00:00
Peter Steinberger
83c5bc946d fix: restore full gate stability 2026-03-19 03:36:03 +00:00
lixuankai
c86de678f3 feat(android): support android node sms.search (#48299)
* feat(android): support android node sms.search

* feat(android): support android node sms.search

* fix(android): split sms search permissions

* fix: document android sms.search landing (#48299) (thanks @lixuankai)

---------

Co-authored-by: lixuankai <lixuankai@oppo.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-19 08:52:15 +05:30
Peter Steinberger
58cf9b865f refactor: route extension seams through public apis 2026-03-19 03:20:10 +00:00
Tak Hoffman
8404f56841 Docs: trialing stronger AGENTS.md rules 2026-03-18 22:18:52 -05:00
Peter Steinberger
30a94dfd3b refactor: untangle whatsapp runtime boundary 2026-03-19 03:13:48 +00:00
Peter Steinberger
510f4276b5 refactor: tighten sdk reply pipeline contract 2026-03-19 03:13:15 +00:00
clay-datacurve
7b61ca1b06 Session management improvements and dashboard API (#50101)
* fix: make cleanup "keep" persist subagent sessions indefinitely

* feat: expose subagent session metadata in sessions list

* fix: include status and timing in sessions_list tool

* fix: hide injected timestamp prefixes in chat ui

* feat: push session list updates over websocket

* feat: expose child subagent sessions in subagents list

* feat: add admin http endpoint to kill sessions

* Emit session.message websocket events for transcript updates

* Estimate session costs in sessions list

* Add direct session history HTTP and SSE endpoints

* Harden dashboard session events and history APIs

* Add session lifecycle gateway methods

* Add dashboard session API improvements

* Add dashboard session model and parent linkage support

* fix: tighten dashboard session API metadata

* Fix dashboard session cost metadata

* Persist accumulated session cost

* fix: stop followup queue drain cfg crash

* Fix dashboard session create and model metadata

* fix: stop guessing session model costs

* Gateway: cache OpenRouter pricing for configured models

* Gateway: add timeout session status

* Fix subagent spawn test config loading

* Gateway: preserve operator scopes without device identity

* Emit user message transcript events and deduplicate plugin warnings

* feat: emit sessions.changed lifecycle event on subagent spawn

Adds a session-lifecycle-events module (similar to transcript-events)
that emits create events when subagents are spawned. The gateway
server.impl.ts listens for these events and broadcasts sessions.changed
with reason=create to SSE subscribers, so dashboards can pick up new
subagent sessions without polling.

* Gateway: allow persistent dashboard orchestrator sessions

* fix: preserve operator scopes for token-authenticated backend clients

Backend clients (like agent-dashboard) that authenticate with a valid gateway
token but don't present a device identity were getting their scopes stripped.
The scope-clearing logic ran before checking the device identity decision,
so even when evaluateMissingDeviceIdentity returned 'allow' (because
roleCanSkipDeviceIdentity passed for token-authed operators), scopes were
already cleared.

Fix: also check decision.kind before clearing scopes, so token-authenticated
operators keep their requested scopes.

* Gateway: allow operator-token session kills

* Fix stale active subagent status after follow-up runs

* Fix dashboard image attachments in sessions send

* Fix completed session follow-up status updates

* feat: stream session tool events to operator UIs

* Add sessions.steer gateway coverage

* Persist subagent timing in session store

* Fix subagent session transcript event keys

* Fix active subagent session status in gateway

* bump session label max to 512

* Fix gateway send session reactivation

* fix: publish terminal session lifecycle state

* feat: change default session reset to effectively never

- Change DEFAULT_RESET_MODE from "daily" to "idle"
- Change DEFAULT_IDLE_MINUTES from 60 to 0 (0 = disabled/never)
- Allow idleMinutes=0 through normalization (don't clamp to 1)
- Treat idleMinutes=0 as "no idle expiry" in evaluateSessionFreshness
- Default behavior: mode "idle" + idleMinutes 0 = sessions never auto-reset
- Update test assertion for new default mode

* fix: prep session management followups (#50101) (thanks @clay-datacurve)

---------

Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
2026-03-19 12:12:30 +09:00
Tak Hoffman
a837ebdd67 Docs: update AGENTS.md import boundaries 2026-03-18 22:06:44 -05:00
Tyler Yust
a290f5e50f fix: persist outbound sends and skip stale cron deliveries (#50092)
* fix(bluebubbles): auto-create chats for new numbers, persist outbound messages to session transcripts

Two fixes for BlueBubbles message tool behavior:

1. **Attachment sends to new phone numbers**: sendBlueBubblesAttachment now
   auto-creates a new DM chat (via /api/v1/chat/new) when no existing chat
   is found for a handle target, matching the behavior already present in
   sendMessageBlueBubbles for text sends. The existing createNewChatWithMessage
   is refactored into a reusable createChatForHandle that returns the chatGuid.

2. **Outbound message session persistence**: Ensures outbound messages sent
   via the message tool are reliably tracked in session transcripts:
   - ensureOutboundSessionEntry now falls back to directly creating a session
     store entry when recordSessionMetaFromInbound returns null, guaranteeing
     a sessionId exists for the subsequent mirror append.
   - appendAssistantMessageToSessionTranscript now normalizes the session key
     (lowercased) when looking up the store, preventing case mismatches
     between the store keys and the mirror sessionKey.

Tests added for all changes.

* test(slack): verify outbound session tracking and new target sends for Slack

The shared infrastructure changes from the BlueBubbles fix (session key
normalization in transcript.ts and fallback session entry creation in
outbound-session.ts) already cover Slack. Slack's sendMessageSlack uses
conversations.open to auto-create DM channels for new user targets.

Add tests confirming:
- Slack user DM and channel session route resolution (outbound.test.ts)
- Slack session key normalization for transcript append (sessions.test.ts)
- Slack outbound sendText/sendMedia to new user and channel targets (channel.test.ts)

* fix(cron): skip stale delayed deliveries

* fix: prep PR #50092
2026-03-19 11:40:34 +09:00
Tyler Yust
ffc1d5459c fix: resolve failing tests on main (warning filter + slack mocks) 2026-03-18 19:31:12 -07:00
clawdia
6ae68faf5f fix(whatsapp): use globalThis singleton for active-listener Map (#47433)
Merged via squash.

Prepared head SHA: 1c43dbff39
Co-authored-by: clawdia67 <261743618+clawdia67@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-03-18 22:16:31 -03:00
Josh Avant
0f0cecd2e8 Discord: enforce strict DM component allowlist auth (#49997)
* Discord: enforce strict DM component allowlist auth

* Discord: align model picker fallback routing

* changelog

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

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-18 20:11:47 -05:00
Peter Steinberger
7b151afeeb test: align plugin-sdk subpath guardrail with current exports (#49249) 2026-03-18 18:02:44 -07:00
Peter Steinberger
371b3d22f5 fix: export imessage-core plugin-sdk subpath (#49249) 2026-03-18 18:02:44 -07:00
Peter Steinberger
42b9212eb2 fix: preserve interactive Ollama model selection (#49249) (thanks @BruceMacD) 2026-03-18 18:02:44 -07:00
Bruce MacDonald
f8c70bf1f1 fix(ollama): don't auto-pull glm-4.7-flash during Local mode onboarding 2026-03-18 18:02:44 -07:00
Vincent Koc
de86e25fd4 fix(ci): skip extension lanes with no tests 2026-03-18 17:52:28 -07:00
Vincent Koc
8884643f40 fix(plugin-sdk): restore imessage-core export 2026-03-18 17:49:51 -07:00
Peter Steinberger
002cc07322 refactor: tighten plugin sdk channel surfaces 2026-03-19 00:46:36 +00:00
Vincent Koc
f19cb738af fix(plugin-sdk): restore public runtime subpaths 2026-03-18 17:38:49 -07:00
Peter Steinberger
4cc0bb07c1 refactor: unify plugin sdk pairing flows 2026-03-19 00:31:03 +00:00
Vincent Koc
b736a92e19 fix(ci): gate extension relative package escapes 2026-03-18 17:27:57 -07:00
Peter Steinberger
c70837f07d refactor: converge plugin sdk channel helpers 2026-03-19 00:25:19 +00:00
Peter Steinberger
62b7b350c9 refactor: move bundled channel deps to plugin packages 2026-03-19 00:24:44 +00:00
Vincent Koc
9a9db87952 fix(release): isolate config doc surfaces and sdk exports 2026-03-18 17:14:15 -07:00
Peter Steinberger
60a55c9cbe fix(committer): accept argv and shell path blobs 2026-03-19 00:10:25 +00:00
Peter Steinberger
d7018aaf19 refactor: move bundled extension deps to plugin packages 2026-03-19 00:04:50 +00:00
Peter Steinberger
07d9f725b6 refactor: unify plugin sdk primitives 2026-03-18 23:58:56 +00:00
Vincent Koc
bea90b72e6 docs: update development-channels with --tag, --dry-run, status, and main warning 2026-03-18 16:41:43 -07:00
Vincent Koc
5f97645382 docs: update development-channels with --tag, --dry-run, and status sections 2026-03-18 16:41:43 -07:00
Peter Steinberger
46f49eb6eb refactor: shrink plugin sdk public surface 2026-03-18 23:31:08 +00:00
Peter Steinberger
6e044ace28 fix: keep bundled runtime deps out of release pack 2026-03-18 23:18:36 +00:00
Peter Steinberger
b9c4db1a77 test: fix stale boundary guardrails 2026-03-18 23:09:59 +00:00
Vincent Koc
a996f60f11 fix(release): isolate config docs child env 2026-03-18 16:05:40 -07:00
Vincent Koc
757c2cc2de fix(release): isolate bundled config docs loading 2026-03-18 16:01:43 -07:00
Vincent Koc
7d8d3d9d77 docs: merge duplicate OpenRouter entry, fix broken plugin anchor links 2026-03-18 16:00:46 -07:00
Vincent Koc
67da67b61a docs: fix tools nav A-Z, split plugin page, consolidate sandbox docs, add OpenShell page (#50055)
* docs: fix A-Z built-in tools nav, split plugin page, consolidate sandbox docs

* docs: add dedicated OpenShell sandbox backend page

* style: format markdown tables

* docs: trim plugin page, restructure available plugins into table + categories
2026-03-18 15:44:08 -07:00
Josh Avant
2661de384f Matrix: make onboarding status runtime-safe (#49995)
* Matrix: make onboarding status runtime-safe

* Matrix tests: mock reply dispatch in BodyForAgent coverage

* changelog

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

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-18 17:33:42 -05:00
Josh Avant
859889aae9 WhatsApp: stabilize inbound monitor and setup tests (#50007) 2026-03-18 17:08:57 -05:00
Vincent Koc
91d37ccfc3 fix(auth): lazy-load provider oauth helpers 2026-03-18 13:40:28 -07:00
Vincent Koc
6ebcd853be fix(plugin-sdk): isolate provider entry surfaces 2026-03-18 13:20:46 -07:00
Vincent Koc
b526098eb2 docs: restore original Credits heading, disambiguate H1 2026-03-18 12:38:46 -07:00
Vincent Koc
c749957c93 docs: fix duplicate Credits heading in credits.md 2026-03-18 12:34:37 -07:00
Vincent Koc
e5a1185796 docs: add extensions section to docs hubs 2026-03-18 12:29:02 -07:00
Vincent Koc
be3f4a7966 docs: add Building Extensions guide and nav entry 2026-03-18 12:28:19 -07:00
Vincent Koc
198de10523 docs: add missing H1 headings and fix HEARTBEAT template 2026-03-18 12:27:07 -07:00
Vincent Koc
63e09f8267 chore(changelog): remove fragment workflow drift 2026-03-18 12:26:56 -07:00
Vincent Koc
2797ae1583 docs: add missing voice-call CLI commands and contract test section to testing 2026-03-18 12:26:18 -07:00
Vincent Koc
cc5bd57bd7 docs: add missing provider pages (google, modelstudio, perplexity, volcengine) and nav entries 2026-03-18 12:26:01 -07:00
Vincent Koc
e9903c9133 Tests: align unit sharding with unit config 2026-03-18 12:16:07 -07:00
Josh Avant
e6911f0448 Tests: restore deterministic plugins CLI coverage (#49955)
* Tests: restore deterministic plugins CLI coverage

* CLI: preserve plugins exit control-flow narrowing

* Tests: fix plugins CLI mock typing for tsgo

* Tests: fix provider usage mock typing in key normalization
2026-03-18 14:05:04 -05:00
Vincent Koc
ef1346e503 Plugin SDK: route reply payload through public subpath 2026-03-18 12:01:15 -07:00
Vincent Koc
ecfa79ee4c Tests: fix provider auth plugin mock spread 2026-03-18 12:01:05 -07:00
Tak Hoffman
600f57c979 test: add architecture smell detector 2026-03-18 13:28:13 -05:00
darkamenosa
4b5487ee85 LINE: avoid runtime lookup during onboarding (#49960) 2026-03-19 01:27:21 +07:00
Onur
8f0727d75c Delete CNAME 2026-03-18 19:22:17 +01:00
Peter Steinberger
1746e130f9 test: fix imessage extension CI mocks 2026-03-18 18:20:04 +00:00
Peter Steinberger
a0d3dc94d0 perf: reduce unit test hot path overhead 2026-03-18 18:19:40 +00:00
Vincent Koc
fa52d122c4 Plugin SDK: route provider metadata through public models subpath 2026-03-18 11:18:04 -07:00
Peter Steinberger
62edfdffbd refactor: deduplicate reply payload handling 2026-03-18 18:14:57 +00:00
Vincent Koc
152d179302 Plugin SDK: add public WhatsApp runtime subpaths 2026-03-18 11:13:19 -07:00
Vincent Koc
8240fd900a Plugin SDK: route core channel runtimes through public subpaths 2026-03-18 11:00:58 -07:00
Josh Lehman
505d140aeb fix: stabilize build dependency resolution (#49928)
* build: mirror uuid for msteams

Add uuid to both the msteams bundled extension and the root package so the workspace build can resolve @microsoft/agents-hosting during tsdown while standalone extension installs also have the runtime dependency available.

Regeneration-Prompt: |
  pnpm build failed because @microsoft/agents-hosting 1.3.1 requires uuid in its published JS but does not declare it in its package manifest. The msteams extension dynamically imports that package, and the workspace build resolves it from the root dependency graph. Mirror uuid into the root package for workspace builds and keep it in extensions/msteams/package.json so standalone plugin installs also resolve it. Update the lockfile to match the manifest changes.

* build: prune stale plugin dist symlinks

Remove stale dist and dist-runtime plugin node_modules symlinks before tsdown runs. These links point back into extension installs, and tsdown's clean step can traverse them on rebuilds and hollow out the active pnpm dependency tree before plugin-sdk declaration generation runs.

Regeneration-Prompt: |
  pnpm build was intermittently failing in the plugin-sdk:dts phase after earlier build steps had already run. The symptom looked like missing root packages such as zod, ajv, commander, and undici even though a fresh install briefly fixed the problem. Investigate the build pipeline step by step rather than patching TypeScript errors. Confirm whether rebuilds mutate node_modules, identify the first step that does it, and preserve existing runtime-postbuild behavior.
  The key constraint is that dist and dist-runtime plugin node_modules links are intentional for runtime packaging, so do not remove that feature globally. Instead, make rebuilds safe by deleting only stale symlinks left in generated output before invoking tsdown, so tsdown cleanup cannot recurse back into the live pnpm install tree. Verify with repeated pnpm build runs.
2026-03-18 10:55:25 -07:00
Vincent Koc
ea74123ab2 Slack: fix directory test runtime stub 2026-03-18 10:54:00 -07:00
Vincent Koc
7d08070dd7 Plugins: generate bundled auth env metadata 2026-03-18 10:53:48 -07:00
Peter Steinberger
8d73bc77fa refactor: deduplicate reply payload helpers 2026-03-18 17:30:25 +00:00
scoootscooob
656679e6e0 Slack: remove duplicate directory imports (#49935) 2026-03-18 10:28:59 -07:00
scoootscooob
b49946a67e Slack: import directory helpers (#49930)
import the config-backed Slack directory helpers into the Slack channel plugin so directory.listPeers and directory.listGroups no longer throw at runtime, and add a regression test covering configured DM peer listing
2026-03-18 10:24:17 -07:00
Vincent Koc
ff326e90c3 Build: use hoisted pnpm linker 2026-03-18 10:14:53 -07:00
Vincent Koc
467ec4d5f3 Types: fix optional cluster check follow-ups 2026-03-18 10:02:40 -07:00
Peter Steinberger
05b1cdec3c test: make runner scheduling timing-driven 2026-03-18 16:57:38 +00:00
Vincent Koc
891e2a3da8 Build: isolate optional bundled plugin-sdk clusters 2026-03-18 09:54:22 -07:00
Vincent Koc
b4f16bad32 Plugin SDK: export windows spawn and temp path 2026-03-18 09:46:24 -07:00
Vincent Koc
a02bfd30c5 Plugin SDK: use public utility subpaths 2026-03-18 09:43:46 -07:00
Vincent Koc
f187e8bac4 Plugin SDK: use public slack subpath 2026-03-18 09:40:57 -07:00
Vincent Koc
e64cc1983f Plugin SDK: use public discord subpath 2026-03-18 09:39:12 -07:00
Vincent Koc
b3ca855283 Plugin SDK: use public whatsapp subpath 2026-03-18 09:37:54 -07:00
Peter Steinberger
27f655ed11 refactor: deduplicate channel runtime helpers 2026-03-18 16:37:27 +00:00
Vincent Koc
3e02635df3 Plugin SDK: use public telegram subpath 2026-03-18 09:33:21 -07:00
Vincent Koc
382640e674 Channels: trim optional bundled plugin defaults 2026-03-18 09:30:54 -07:00
Vincent Koc
d8008a9a67 Tools: classify optional bundled clusters 2026-03-18 09:26:39 -07:00
Peter Steinberger
3d8afb96bd fix: use transpiled jiti for source plugin shims 2026-03-18 16:24:45 +00:00
liyuan97
b64f4e313d MiniMax: add M2.7 models and update default to M2.7 (#49691)
* MiniMax: add M2.7 models and update default to M2.7

- Add MiniMax-M2.7 and MiniMax-M2.7-highspeed to provider catalog and model definitions
- Update default model from MiniMax-M2.5 to MiniMax-M2.7 across onboard, portal, and provider configs
- Update isModernMiniMaxModel to recognize M2.7 prefix
- Update all test fixtures to reflect M2.7 as default

Made-with: Cursor

* MiniMax: add extension test for model definitions

* update 2.7

* feat: add MiniMax M2.7 models and update default (#49691) (thanks @liyuan97)

---------

Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-03-18 09:24:37 -07:00
Chris Kimpton
823a09acbe docs: clarify that CI test-fix-only PRs are handled by maintainers (#49679)
Co-authored-by: Shadow <shadow@openclaw.ai>
2026-03-18 11:21:46 -05:00
Peter Steinberger
10dc4d65d1 test: refresh plugin extension boundary baseline 2026-03-18 16:16:31 +00:00
Peter Steinberger
5fd482d6b0 test: align acp session mode list 2026-03-18 16:14:14 +00:00
Vincent Koc
73539ac787 Core: move web media seam out of plugin sdk 2026-03-18 09:12:23 -07:00
Vincent Koc
947dac48f2 Tests: cap shards for explicit file lanes 2026-03-18 08:59:37 -07:00
Vincent Koc
cfdc0fdbe1 Plugins: include fal in image-generation contract registry 2026-03-18 08:59:00 -07:00
Vincent Koc
22fc5a5442 Contracts: narrow codex catalog hint return type 2026-03-18 08:54:01 -07:00
Peter Steinberger
49b248a333 fix: skip plugin sdk dts in docker builds 2026-03-18 15:48:15 +00:00
Vincent Koc
ebb10c0852 Contracts: fix codex catalog hint assertion 2026-03-18 08:46:58 -07:00
Vincent Koc
6a381e80bc Contracts: stabilize provider plugin test imports 2026-03-18 08:44:47 -07:00
Peter Steinberger
a0e7a2fcc1 fix: repair rebased contract gate 2026-03-18 15:43:24 +00:00
Peter Steinberger
f6928617b7 test: stabilize gate regressions 2026-03-18 15:36:32 +00:00
Peter Steinberger
7943e83c6c fix: restore rebased full gate 2026-03-18 15:36:18 +00:00
Peter Steinberger
c0c3c4824d fix: checkpoint gate fixes before rebase 2026-03-18 15:36:18 +00:00
Peter Steinberger
e9b19ca1d1 fix: restore full gate after web-search rebase 2026-03-18 15:35:27 +00:00
Peter Steinberger
861fcb1575 fix: restore rebased full gate 2026-03-18 15:34:27 +00:00
Peter Steinberger
b5d2123156 fix: stabilize rebased full gate 2026-03-18 15:34:27 +00:00
Peter Steinberger
0cddb5fb7c fix: restore full gate 2026-03-18 15:34:27 +00:00
Tak Hoffman
ea476de1e4 Add plugin-sdk seam audit script 2026-03-18 10:16:21 -05:00
Tak Hoffman
5d41fd4497 test: extend plugin contract setup timeouts 2026-03-18 09:42:52 -05:00
Tak Hoffman
ca13256913 Deps: restore known-good tlon api install source 2026-03-18 08:50:02 -05:00
Tak Hoffman
4a44ca8f79 fix llm-task invalid thinking timeout 2026-03-18 08:33:40 -05:00
Tak Hoffman
c2402e48c9 Build: narrow tsdown unresolved import guard 2026-03-18 08:32:41 -05:00
886 changed files with 33593 additions and 11553 deletions

View File

@@ -0,0 +1,87 @@
---
name: openclaw-ghsa-maintainer
description: Maintainer workflow for OpenClaw GitHub Security Advisories (GHSA). Use when Codex needs to inspect, patch, validate, or publish a repo advisory, verify private-fork state, prepare advisory Markdown or JSON payloads safely, handle GHSA API-specific publish constraints, or confirm advisory publish success.
---
# OpenClaw GHSA Maintainer
Use this skill for repo security advisory workflow only. Keep general release work in `openclaw-release-maintainer`.
## Respect advisory guardrails
- Before reviewing or publishing a repo advisory, read `SECURITY.md`.
- Ask permission before any publish action.
- Treat this skill as GHSA-only. Do not use it for stable or beta release work.
## Fetch and inspect advisory state
Fetch the current advisory and the latest published npm version:
```bash
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
npm view openclaw version --userconfig "$(mktemp)"
```
Use the fetch output to confirm the advisory state, linked private fork, and vulnerability payload shape before patching.
## Verify private fork PRs are closed
Before publishing, verify that the advisory's private fork has no open PRs:
```bash
fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)
gh pr list -R "$fork" --state open
```
The PR list must be empty before publish.
## Prepare advisory Markdown and JSON safely
- Write advisory Markdown via heredoc to a temp file. Do not use escaped `\n` strings.
- Build PATCH payload JSON with `jq`, not hand-escaped shell JSON.
Example pattern:
```bash
cat > /tmp/ghsa.desc.md <<'EOF'
<markdown description>
EOF
jq -n --rawfile desc /tmp/ghsa.desc.md \
'{summary,severity,description:$desc,vulnerabilities:[...]}' \
> /tmp/ghsa.patch.json
```
## Apply PATCH calls in the correct sequence
- Do not set `severity` and `cvss_vector_string` in the same PATCH call.
- Use separate calls when the advisory requires both fields.
- Publish by PATCHing the advisory and setting `"state":"published"`. There is no separate `/publish` endpoint.
Example shape:
```bash
gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> \
--input /tmp/ghsa.patch.json
```
## Publish and verify success
After publish, re-fetch the advisory and confirm:
- `state=published`
- `published_at` is set
- the description does not contain literal escaped `\\n`
Verification pattern:
```bash
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
jq -r .description < /tmp/ghsa.refetch.json | rg '\\\\n'
```
## Common GHSA footguns
- Publishing fails with HTTP 422 if required fields are missing or the private fork still has open PRs.
- A payload that looks correct in shell can still be wrong if Markdown was assembled with escaped newline strings.
- Advisory PATCH sequencing matters; separate field updates when GHSA API constraints require it.

View File

@@ -0,0 +1,58 @@
---
name: openclaw-parallels-smoke
description: End-to-end Parallels smoke, upgrade, and rerun workflow for OpenClaw across macOS, Windows, and Linux guests. Use when Codex needs to run, rerun, debug, or interpret VM-based install, onboarding, gateway smoke tests, latest-release-to-main upgrade checks, fresh snapshot retests, or optional Discord roundtrip verification under Parallels.
---
# OpenClaw Parallels Smoke
Use this skill for Parallels guest workflows and smoke interpretation. Do not load it for normal repo work.
## Global rules
- Use the snapshot most closely matching the requested fresh baseline.
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc` unless the stable version being checked does not support it yet.
- Stable `2026.3.12` pre-upgrade diagnostics may require a plain `gateway status --deep` fallback.
- Treat `precheck=latest-ref-fail` on that stable pre-upgrade lane as baseline, not automatically a regression.
- Pass `--json` for machine-readable summaries.
- Per-phase logs land under `/tmp/openclaw-parallels-*`.
- Do not run local and gateway agent turns in parallel on the same fresh workspace or session.
## macOS flow
- Preferred entrypoint: `pnpm test:parallels:macos`
- Target the snapshot closest to `macOS 26.3.1 fresh`.
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
- On the fresh Tahoe snapshot, `brew` exists but `node` may be missing from PATH in noninteractive exec. Use `/opt/homebrew/bin/node` when needed.
- Fresh host-served tgz installs should install as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
- Root-installed tgz smoke can log plugin blocks for world-writable `extensions/*`; do not treat that as an onboarding or gateway failure unless plugin loading is the task.
## Windows flow
- Preferred entrypoint: `pnpm test:parallels:windows`
- Use the snapshot closest to `pre-openclaw-native-e2e-2026-03-12`.
- Always use `prlctl exec --current-user`; plain `prlctl exec` lands in `NT AUTHORITY\\SYSTEM`.
- Prefer explicit `npm.cmd` and `openclaw.cmd`.
- Use PowerShell only as the transport with `-ExecutionPolicy Bypass`, then call the `.cmd` shims from inside it.
- Keep onboarding and status output ASCII-clean in logs; fancy punctuation becomes mojibake in current capture paths.
## Linux flow
- Preferred entrypoint: `pnpm test:parallels:linux`
- Use the snapshot closest to fresh `Ubuntu 24.04.3 ARM64`.
- Use plain `prlctl exec`; `--current-user` is not the right transport on this snapshot.
- Fresh snapshots may be missing `curl`, and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates`.
- Fresh `main` tgz smoke still needs the latest-release installer first because the snapshot has no Node or npm before bootstrap.
- This snapshot does not have a usable `systemd --user` session; managed daemon install is unsupported.
- `prlctl exec` reaps detached Linux child processes on this snapshot, so detached background gateway runs are not trustworthy smoke signals.
## Discord roundtrip
- Discord roundtrip is optional and should be enabled with:
- `--discord-token-env`
- `--discord-guild-id`
- `--discord-channel-id`
- Keep the Discord token only in a host env var.
- Use installed `openclaw message send/read`, not `node openclaw.mjs message ...`.
- Set `channels.discord.guilds` as one JSON object, not dotted config paths with snowflakes.
- Avoid long `prlctl enter` or expect-driven Discord config scripts; prefer `prlctl exec --current-user /bin/sh -lc ...` with short commands.
- For a narrower macOS-only Discord proof run, the existing `parallels-discord-roundtrip` skill is the deep-dive companion.

View File

@@ -0,0 +1,75 @@
---
name: openclaw-pr-maintainer
description: Maintainer workflow for reviewing, triaging, preparing, closing, or landing OpenClaw pull requests and related issues. Use when Codex needs to validate bug-fix claims, search for related issues or PRs, apply or recommend close/reason labels, prepare GitHub comments safely, check review-thread follow-up, or perform maintainer-style PR decision making before merge or closure.
---
# OpenClaw PR Maintainer
Use this skill for maintainer-facing GitHub workflow, not for ordinary code changes.
## Apply close and triage labels correctly
- If an issue or PR matches an auto-close reason, apply the label and let `.github/workflows/auto-response.yml` handle the comment/close/lock flow.
- Do not manually close plus manually comment for these reasons.
- `r:*` labels can be used on both issues and PRs.
- Current reasons:
- `r: skill`
- `r: support`
- `r: no-ci-pr`
- `r: too-many-prs`
- `r: testflight`
- `r: third-party-extension`
- `r: moltbook`
- `r: spam`
- `invalid`
- `dirty` for PRs only
## Enforce the bug-fix evidence bar
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
- Before landing, require:
1. symptom evidence such as a repro, logs, or a failing test
2. a verified root cause in code with file/line
3. a fix that touches the implicated code path
4. a regression test when feasible, or explicit manual verification plus a reason no test was added
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.
- If the linked issue appears outdated or incorrect, correct triage first. Do not merge a speculative fix.
## Handle GitHub text safely
- For issue comments and PR comments, use literal multiline strings or `-F - <<'EOF'` for real newlines. Never embed `\n`.
- Do not use `gh issue/pr comment -b "..."` when the body contains backticks or shell characters. Prefer a single-quoted heredoc.
- Do not wrap issue or PR refs like `#24643` in backticks when you want auto-linking.
- PR landing comments should include clickable full commit links for landed and source SHAs when present.
## Search broadly before deciding
- Prefer targeted keyword search before proposing new work or closing something as duplicate.
- Use `--repo openclaw/openclaw` with `--match title,body` first.
- Add `--match comments` when triaging follow-up discussion.
- Do not stop at the first 500 results when the task requires a full search.
Examples:
```bash
gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"
gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"
gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
--json number,title,state,url,updatedAt -- "auto update" \
--jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'
```
## Follow PR review and landing hygiene
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
- When landing or merging any PR, follow the global `/landpr` process.
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
- Keep commit messages concise and action-oriented.
- Group related changes; avoid bundling unrelated refactors.
- Use `.github/pull_request_template.md` for PR submissions and `.github/ISSUE_TEMPLATE/` for issues.
## Extra safety
- If a close or reopen action would affect more than 5 PRs, ask for explicit confirmation with the exact count and target query first.
- `sync` means: if the tree is dirty, commit all changes with a sensible Conventional Commit message, then `git pull --rebase`, then `git push`. Stop if rebase conflicts cannot be resolved safely.

View File

@@ -0,0 +1,74 @@
---
name: openclaw-release-maintainer
description: Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.
---
# OpenClaw Release Maintainer
Use this skill for release and publish-time workflow. Keep ordinary development changes and GHSA-specific advisory work outside this skill.
## Respect release guardrails
- Do not change version numbers without explicit operator approval.
- Ask permission before any npm publish or release step.
- Use the private maintainer release docs for the actual runbook and `docs/reference/RELEASING.md` for public policy.
## Keep release channel naming aligned
- `stable`: tagged releases only, with npm dist-tag `latest`
- `beta`: prerelease tags like `vYYYY.M.D-beta.N`, with npm dist-tag `beta`
- Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes
- `dev`: moving head on `main`
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
## Handle versions and release files consistently
- Version locations include:
- `package.json`
- `apps/android/app/build.gradle.kts`
- `apps/ios/Sources/Info.plist`
- `apps/ios/Tests/Info.plist`
- `apps/macos/Sources/OpenClaw/Resources/Info.plist`
- `docs/install/updating.md`
- Peekaboo Xcode project and plist version fields
- “Bump version everywhere” means all version locations above except `appcast.xml`.
- Release signing and notary credentials live outside the repo in the private maintainer docs.
## Build changelog-backed release notes
- Changelog entries should be user-facing, not internal release-process notes.
- When cutting a mac release with a beta GitHub prerelease:
- tag `vYYYY.M.D-beta.N` from the release commit
- create a prerelease titled `openclaw YYYY.M.D-beta.N`
- use release notes from the matching `CHANGELOG.md` version section
- attach at least the zip and dSYM zip, plus dmg if available
- Keep the top version entries in `CHANGELOG.md` sorted by impact:
- `### Changes` first
- `### Fixes` deduped with user-facing fixes first
## Run publish-time validation
Before tagging or publishing, run:
```bash
node --import tsx scripts/release-check.ts
pnpm release:check
pnpm test:install:smoke
```
For a non-root smoke path:
```bash
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
```
## Use the right auth flow
- Core `openclaw` publish uses GitHub trusted publishing.
- Do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
## GHSA advisory work
- Use `openclaw-ghsa-maintainer` for GHSA advisory inspection, patch/publish flow, private-fork validation, and GHSA API-specific publish checks.

View File

@@ -7,7 +7,8 @@ body:
- type: markdown
attributes:
value: |
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
Thanks for filing this report. Keep every answer concise, reproducible, and grounded in observed evidence.
Do not speculate or infer beyond the evidence. If a narrative section cannot be answered from the available evidence, respond with exactly `NOT_ENOUGH_INFO`.
- type: dropdown
id: bug_type
attributes:
@@ -23,35 +24,35 @@ body:
id: summary
attributes:
label: Summary
description: One-sentence statement of what is broken.
placeholder: After upgrading to <version>, <channel> behavior regressed from <prior version>.
description: One-sentence statement of what is broken, based only on observed evidence. If the evidence is insufficient, respond with exactly `NOT_ENOUGH_INFO`.
placeholder: After upgrading from 2026.2.10 to 2026.2.17, Telegram thread replies stopped posting; reproduced twice and confirmed by gateway logs.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Provide the shortest deterministic repro path.
description: Provide the shortest deterministic repro path supported by direct observation. If the repro path cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
placeholder: |
1. Configure channel X.
2. Send message Y.
3. Run command Z.
1. Start OpenClaw 2026.2.17 with the attached config.
2. Send a Telegram thread reply in the affected chat.
3. Observe no reply and confirm the attached `reply target not found` log line.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should happen if the bug does not exist.
placeholder: Agent posts a reply in the same thread.
description: State the expected result using a concrete reference such as prior observed behavior, attached docs, or a known-good version. If no grounded reference exists, respond with exactly `NOT_ENOUGH_INFO`.
placeholder: In 2026.2.10, the agent posted replies in the same Telegram thread under the same workflow.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What happened instead, including user-visible errors.
placeholder: No reply is posted; gateway logs "reply target not found".
description: Describe only the observed result, including user-visible errors and cited evidence. If the observed result cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
placeholder: No reply is posted in the thread; the attached gateway log shows `reply target not found` at 14:23:08 UTC.
validations:
required: true
- type: input
@@ -92,12 +93,6 @@ body:
placeholder: openclaw -> cloudflare-ai-gateway -> minimax
validations:
required: true
- type: input
id: config_location
attributes:
label: Config file / key location
description: Optional. Relevant config source or key path if this bug depends on overrides or custom provider setup. Redact secrets.
placeholder: ~/.openclaw/openclaw.json ; models.providers.cloudflare-ai-gateway.baseUrl ; ~/.openclaw/agents/<agentId>/agent/models.json
- type: textarea
id: provider_setup_details
attributes:
@@ -111,27 +106,28 @@ body:
id: logs
attributes:
label: Logs, screenshots, and evidence
description: Include redacted logs/screenshots/recordings that prove the behavior.
description: Include the redacted logs, screenshots, recordings, docs, or version comparisons that support the grounded answers above.
render: shell
- type: textarea
id: impact
attributes:
label: Impact and severity
description: |
Explain who is affected, how severe it is, how often it happens, and the practical consequence.
Explain who is affected, how severe it is, how often it happens, and the practical consequence using only observed evidence.
If any part cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
Include:
- Affected users/systems/channels
- Severity (annoying, blocks workflow, data risk, etc.)
- Frequency (always/intermittent/edge case)
- Consequence (missed messages, failed onboarding, extra cost, etc.)
placeholder: |
Affected: Telegram group users on <version>
Severity: High (blocks replies)
Frequency: 100% repro
Consequence: Agents cannot respond in threads
Affected: Telegram group users on 2026.2.17
Severity: High (blocks thread replies)
Frequency: 4/4 observed attempts
Consequence: Agents do not respond in the affected threads
- type: textarea
id: additional_information
attributes:
label: Additional information
description: Add any context that helps triage but does not fit above. If this is a regression, include the last known good and first known bad versions.
placeholder: Last known good version <...>, first known bad version <...>, temporary workaround is ...
description: Add any remaining grounded context that helps triage but does not fit above. If this is a regression, include the last known good and first known bad versions when observed. If there is not enough evidence, respond with exactly `NOT_ENOUGH_INFO`.
placeholder: Last known good version 2026.2.10, first known bad version 2026.2.17, temporary workaround is sending a top-level message instead of a thread reply.

View File

@@ -62,24 +62,57 @@ jobs:
run: |
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
# This smoke only validates that the build-arg path preinstalls selected
# extension deps without breaking image build or basic CLI startup. It
# does not exercise runtime loading/registration of diagnostics-otel.
# This smoke validates that the build-arg path preinstalls selected
# extension deps and that matrix plugin discovery stays healthy in the
# final runtime image.
- name: Build extension Dockerfile smoke image
uses: useblacksmith/build-push-action@v2
with:
context: .
file: ./Dockerfile
build-args: |
OPENCLAW_EXTENSIONS=diagnostics-otel
OPENCLAW_EXTENSIONS=matrix
tags: openclaw-ext-smoke:local
load: true
push: false
provenance: false
- name: Smoke test Dockerfile with extension build arg
- name: Smoke test Dockerfile with matrix extension build arg
run: |
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc 'which openclaw && openclaw --version'
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc '
which openclaw &&
openclaw --version &&
node -e "
const Module = require(\"node:module\");
const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
requireFromMatrix.resolve(\"@vector-im/matrix-bot-sdk/package.json\");
requireFromMatrix.resolve(\"@matrix-org/matrix-sdk-crypto-nodejs/package.json\");
const { spawnSync } = require(\"node:child_process\");
const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" });
if (run.status !== 0) {
process.stderr.write(run.stderr || run.stdout || \"plugins list failed\\n\");
process.exit(run.status ?? 1);
}
const parsed = JSON.parse(run.stdout);
const matrix = (parsed.plugins || []).find((entry) => entry.id === \"matrix\");
if (!matrix) {
throw new Error(\"matrix plugin missing from bundled plugin list\");
}
const matrixDiag = (parsed.diagnostics || []).filter(
(diag) =>
typeof diag.source === \"string\" &&
diag.source.includes(\"/extensions/matrix\") &&
typeof diag.message === \"string\" &&
diag.message.includes(\"extension entry escapes package directory\"),
);
if (matrixDiag.length > 0) {
throw new Error(
\"unexpected matrix diagnostics: \" +
matrixDiag.map((diag) => diag.message).join(\"; \"),
);
}
"
'
- name: Build installer smoke image
uses: useblacksmith/build-push-action@v2

5
.gitignore vendored
View File

@@ -100,8 +100,6 @@ USER.md
/local/
package-lock.json
.claude/
.agents/
.agents
.agent/
skills-lock.json
@@ -135,3 +133,6 @@ ui/src/ui/__screenshots__
ui/src/ui/views/__screenshots__
ui/.vitest-attachments
docs/superpowers
# Deprecated changelog fragment workflow
changelog/fragments/

3
.npmrc
View File

@@ -1 +1,4 @@
# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies.
# TS 7 native-preview fails to resolve packages reliably from pnpm's isolated linker.
# Keep the workspace on a hoisted layout so pnpm check/build stay stable.
node-linker=hoisted

190
AGENTS.md
View File

@@ -2,45 +2,8 @@
- Repo: https://github.com/openclaw/openclaw
- In chat replies, file references must be repo-root relative only (example: `extensions/bluebubbles/src/channel.ts:80`); never absolute paths or `~/...`.
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption.
- GitHub linking footgun: dont wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL).
- PR landing comments: always make commit SHAs clickable with full commit links (both landed SHA + source SHA when present).
- PR review conversations: if a bot leaves review conversations on your PR, address them and resolve those conversations yourself once fixed. Leave a conversation unresolved only when reviewer or maintainer judgment is still needed; do not leave bot-conversation cleanup to maintainers.
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
## Auto-close labels (issues and PRs)
- If an issue/PR matches one of the reasons below, apply the label and let `.github/workflows/auto-response.yml` handle comment/close/lock.
- Do not manually close + manually comment for these reasons.
- Why: keeps wording consistent, preserves automation behavior (`state_reason`, locking), and keeps triage/reporting searchable by label.
- `r:*` labels can be used on both issues and PRs.
- `r: skill`: close with guidance to publish skills on Clawhub.
- `r: support`: close with redirect to Discord support + stuck FAQ.
- `r: no-ci-pr`: close test-fix-only PRs for failing `main` CI and post the standard explanation.
- `r: too-many-prs`: close when author exceeds active PR limit.
- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies.
- `r: third-party-extension`: close with guidance to ship as third-party plugin.
- `r: moltbook`: close + lock as off-topic (not affiliated).
- `r: spam`: close + lock as spam (`lock_reason: spam`).
- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed).
- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label).
## PR truthfulness and bug-fix validation
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
- Before `/landpr`, run `/reviewpr` and require explicit evidence for bug-fix claims.
- Minimum merge gate for bug-fix PRs:
1. symptom evidence (repro/log/failing test),
2. verified root cause in code with file/line,
3. fix touches the implicated code path,
4. regression test (fail before/pass after) when feasible; if not feasible, include manual verification proof and why no test was added.
- If claim is unsubstantiated or likely hallucinated/BS: do not merge. Request evidence/changes, or close with `invalid` when appropriate.
- If linked issue appears wrong/outdated, correct triage first; do not merge speculative fixes.
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
@@ -48,6 +11,7 @@
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias).
- Import boundaries: extension production code should treat `openclaw/plugin-sdk/*` plus local `api.ts` / `runtime-api.ts` barrels as the public surface. Do not import core `src/**`, `src/plugin-sdk-internal/**`, or another extension's `src/**` directly.
- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/`
@@ -106,6 +70,10 @@
- Format check: `pnpm format` (oxfmt --check)
- Format fix: `pnpm format:fix` (oxfmt --write)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
- Hard gate: before any commit, `pnpm check` MUST be run and MUST pass for the change being committed.
- Hard gate: before any push to `main`, `pnpm check` MUST be run and MUST pass, and `pnpm test` MUST be run and MUST pass.
- Hard gate: if the change can affect build output, packaging, lazy-loading/module boundaries, or published surfaces, `pnpm build` MUST be run and MUST pass before pushing `main`.
- Hard gate: do not commit or push with failing format, lint, type, build, or required test checks.
## Coding Style & Naming Conventions
@@ -115,6 +83,8 @@
- Dynamic import guardrail: do not mix `await import("x")` and static `import ... from "x"` for the same module in production code paths. If you need lazy loading, create a dedicated `*.runtime.ts` boundary (that re-exports from `x`) and dynamically import that boundary from lazy callers only.
- Dynamic import verification: after refactors that touch lazy-loading/module boundaries, run `pnpm build` and check for `[INEFFECTIVE_DYNAMIC_IMPORT]` warnings before submitting.
- Extension SDK self-import guardrail: inside an extension package, do not import that same extension via `openclaw/plugin-sdk/<extension>` from production files. Route internal imports through a local barrel such as `./api.ts` or `./runtime-api.ts`, and keep the `plugin-sdk/<extension>` path as the external contract only.
- Extension package boundary guardrail: inside `extensions/<id>/**`, do not use relative imports/exports that resolve outside that same `extensions/<id>` package root. If shared code belongs in the plugin SDK, import `openclaw/plugin-sdk/<subpath>` instead of reaching into `src/plugin-sdk/**` or other repo paths via `../`.
- Extension API surface rule: `openclaw/plugin-sdk/<subpath>` is the only public cross-package contract for extension-facing SDK code. If an extension needs a new seam, add a public subpath first; do not reach into `src/plugin-sdk/**` by relative path.
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.
@@ -124,18 +94,18 @@
- Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys.
- Written English: use American spelling and grammar in code, comments, docs, and UI strings (e.g. "color" not "colour", "behavior" not "behaviour", "analyze" not "analyse").
## Release Channels (Naming)
## Release / Advisory Workflows
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-<patch>` and `vYYYY.M.D.beta.N` remain recognized.
- dev: moving head on `main` (no tag; git checkout main).
- Use `$openclaw-release-maintainer` at `.agents/skills/openclaw-release-maintainer/SKILL.md` for release naming, version coordination, release auth, and changelog-backed release-note workflows.
- Use `$openclaw-ghsa-maintainer` at `.agents/skills/openclaw-ghsa-maintainer/SKILL.md` for GHSA advisory inspection, patch/publish flow, private-fork checks, and GHSA API validation.
- Release and publish remain explicit-approval actions even when using the skill.
## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- 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.
- 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.
@@ -149,7 +119,9 @@
## Commit & Pull Request Guidelines
**Full maintainer PR workflow (optional):** If you want the repo's end-to-end maintainer workflow (triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the `review-pr` > `prepare-pr` > `merge-pr` pipeline), see `.agents/skills/PR_WORKFLOW.md`. Maintainers may use other workflows; when a maintainer specifies a workflow, follow that. If no workflow is specified, default to PR_WORKFLOW.
- Use `$openclaw-pr-maintainer` at `.agents/skills/openclaw-pr-maintainer/SKILL.md` for maintainer PR triage, review, close, search, and landing workflows.
- This includes auto-close labels, bug-fix evidence gates, GitHub comment/search footguns, and maintainer PR decision flow.
- For the repo's end-to-end maintainer PR workflow, use `$openclaw-pr-maintainer` at `.agents/skills/openclaw-pr-maintainer/SKILL.md`.
- `/landpr` lives in the global Codex prompts (`~/.codex/prompts/landpr.md`); when landing or merging any PR, always follow that `/landpr` process.
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
@@ -158,105 +130,30 @@
- PR submission template (canonical): `.github/pull_request_template.md`
- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/`
## Shorthand Commands
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
## Git Notes
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
- Agents MUST NOT create or push merge commits on `main`. If `main` has advanced, rebase local commits onto the latest `origin/main` before pushing.
- Bulk PR close/reopen safety: if a close action would affect more than 5 PRs, first ask for explicit user confirmation with the exact PR count and target scope/query.
## GitHub Search (`gh`)
- Prefer targeted keyword search before proposing new work or duplicating fixes.
- Use `--repo openclaw/openclaw` + `--match title,body` first; add `--match comments` when triaging follow-up threads.
- PRs: `gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
- Issues: `gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
- Structured output example:
`gh search issues --repo openclaw/openclaw --match title,body --limit 50 --json number,title,state,url,updatedAt -- "auto update" --jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'`
## Security & Configuration Tips
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook; use `docs/reference/RELEASING.md` for the public release policy.
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook, `docs/reference/RELEASING.md` for the public release policy, and `$openclaw-release-maintainer` for the maintainership workflow.
## GHSA (Repo Advisory) Patch/Publish
- Before reviewing security advisories, read `SECURITY.md`.
- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`
- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"`
- Private fork PRs must be closed:
`fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)`
`gh pr list -R "$fork" --state open` (must be empty)
- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings)
- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json`
- GHSA API footgun: cannot set `severity` and `cvss_vector_string` in the same PATCH; do separate calls.
- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint)
- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs
- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing
## Troubleshooting
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
## Agent-Specific Notes
## Local Runtime / Platform Notes
- Vocabulary: "makeup" = "mac app".
- Parallels macOS retests: use the snapshot most closely named like `macOS 26.3.1 fresh` when the user asks for a clean/fresh macOS rerun; avoid older Tahoe snapshots unless explicitly requested.
- Parallels beta smoke: use `--target-package-spec openclaw@<beta-version>` for the beta artifact, and pin the stable side with both `--install-version <stable-version>` and `--latest-version <stable-version>` for upgrade runs. npm dist-tags can move mid-run.
- Parallels beta smoke, Windows nuance: old stable `2026.3.12` still prints the Unicode Windows onboarding banner, so mojibake during the stable precheck log is expected there. Judge the beta package by the post-upgrade lane.
- Parallels macOS smoke playbook:
- `prlctl exec` is fine for deterministic repo commands, but it can misrepresent interactive shell behavior (`PATH`, `HOME`, `curl | bash`, shebang resolution). For installer parity or shell-sensitive repros, prefer the guest Terminal or `prlctl enter`.
- Fresh Tahoe snapshot current reality: `brew` exists, `node` may not be on `PATH` in noninteractive guest exec. Use absolute `/opt/homebrew/bin/node` for repo/CLI runs when needed.
- Preferred automation entrypoint: `pnpm test:parallels:macos`. It restores the snapshot most closely matching `macOS 26.3.1 fresh`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
- Discord roundtrip smoke is opt-in. Pass `--discord-token-env <VAR> --discord-guild-id <guild> --discord-channel-id <channel>`; the harness will configure Discord in-guest, post a guest message, verify host-side visibility via the Discord REST API, post a fresh host-side message back into the channel, then verify `openclaw message read` sees it in-guest.
- Keep the Discord token in a host env var only. For Peters Mac Studio bot, fetch it into a temp env var from `~/.openclaw/openclaw.json` over SSH instead of hardcoding it in repo files/shell history.
- For Discord smoke on this snapshot: use `openclaw message send/read` via the installed wrapper, not `node openclaw.mjs message ...`; lazy `message` subcommands do not resolve the same way through the direct module entrypoint.
- For Discord guild allowlists: set `channels.discord.guilds` as one JSON object. Do not use dotted `config set channels.discord.guilds.<snowflake>...` paths; numeric snowflakes get treated as array indexes.
- Avoid `prlctl enter` / expect for the Discord config phase; long lines get mangled. Use `prlctl exec --current-user /bin/sh -lc ...` with short commands or temp files.
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero.
- Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded.
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-smoke.*`.
- All-OS parallel runs should share the host `dist` build via `/tmp/openclaw-parallels-build.lock` instead of rebuilding three times.
- Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails.
- Fresh host-served tgz install: restore fresh snapshot, install tgz as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
- For `openclaw onboard --non-interactive --secret-input-mode ref --install-daemon`, expect env-backed auth-profile refs (for example `OPENAI_API_KEY`) to be copied into the service env at install time; this path was fixed and should stay green.
- Dont run local + gateway agent turns in parallel on the same fresh workspace/session; they can collide on the session lock. Run sequentially.
- Root-installed tarball smoke on Tahoe can still log plugin blocks for world-writable `extensions/*` under `/opt/homebrew/lib/node_modules/openclaw`; treat that as separate from onboarding/gateway health unless the task is plugin loading.
- Parallels Windows smoke playbook:
- Preferred automation entrypoint: `pnpm test:parallels:windows`. It restores the snapshot most closely matching `pre-openclaw-native-e2e-2026-03-12`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero.
- Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded.
- Always use `prlctl exec --current-user` for Windows guest runs; plain `prlctl exec` lands in `NT AUTHORITY\SYSTEM` and does not match the real desktop-user install path.
- Prefer explicit `npm.cmd` / `openclaw.cmd`. Bare `npm` / `openclaw` in PowerShell can hit the `.ps1` shim and fail under restrictive execution policy.
- Use PowerShell only as the transport (`powershell.exe -NoProfile -ExecutionPolicy Bypass`) and call the `.cmd` shims explicitly from inside it.
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-windows.*`.
- Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails.
- Keep Windows onboarding/status text ASCII-clean in logs. Fancy punctuation in banners shows up as mojibake through the current guest PowerShell capture path.
- Parallels Linux smoke playbook:
- Preferred automation entrypoint: `pnpm test:parallels:linux`. It restores the snapshot most closely matching `fresh` on `Ubuntu 24.04.3 ARM64`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
- Use plain `prlctl exec` on this snapshot. `--current-user` is not the right transport there.
- Fresh snapshot reality: `curl` is missing and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates` before testing installer paths.
- Fresh `main` tgz smoke on Linux still needs the latest-release installer first, because this snapshot has no Node/npm before bootstrap. The harness does stable bootstrap first, then overlays current `main`.
- This snapshot does not have a usable `systemd --user` session. Treat managed daemon install as unsupported here; use `--skip-health`, then verify with direct `openclaw gateway run --bind loopback --port 18789 --force`.
- Env-backed auth refs are still fine, but any direct shell launch (`openclaw gateway run`, `openclaw agent --local`, Linux `gateway status --deep` against that direct run) must inherit the referenced env vars in the same shell.
- `prlctl exec` reaps detached Linux child processes on this snapshot, so a background `openclaw gateway run` launched from automation is not a trustworthy smoke path. The harness verifies installer + `agent --local`; do direct gateway checks only from an interactive guest shell when needed.
- When you do run Linux gateway checks manually from an interactive guest shell, use `openclaw gateway status --deep --require-rpc` so an RPC miss is a hard failure.
- Prefer direct argv guest commands for fetch/install steps (`curl`, `npm install -g`, `openclaw ...`) over nested `bash -lc` quoting; Linux guest quoting through Parallels was the flaky part.
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-linux.*`.
- Current expected outcome on Linux smoke: fresh + upgrade should pass installer and `agent --local`; gateway remains `skipped-no-detached-linux-gateway` on this snapshot and should not be treated as a regression by itself.
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
- Use `$openclaw-parallels-smoke` at `.agents/skills/openclaw-parallels-smoke/SKILL.md` for Parallels smoke, rerun, upgrade, debug, and result-interpretation workflows across macOS, Windows, and Linux guests.
- For the macOS Discord roundtrip deep dive, use the narrower `.agents/skills/parallels-discord-roundtrip/SKILL.md` companion skill.
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
- If you need local-only `.agents` ignores, use `.git/info/exclude` instead of repo `.gitignore`.
- When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`).
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); dont hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the OpenClaw Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep openclaw` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
@@ -271,6 +168,20 @@
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
- Release signing/notary credentials are managed outside the repo; maintainers keep that setup in the private [maintainer release docs](https://github.com/openclaw/maintainers/tree/main/release).
- Lobster palette: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
- Voice wake forwarding tips:
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Dont add extra quotes.
- launchd PATH is minimal; ensure the apps launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
## Collaboration / Safety Notes
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
@@ -281,41 +192,12 @@
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
- Only ask when changes are semantic (logic/data/behavior).
- Lobster palette: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
- Voice wake forwarding tips:
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Dont add extra quotes.
- launchd PATH is minimal; ensure the apps launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
- For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tools escaping.
- Release guardrails: do not change version numbers without operators explicit consent; always ask permission before running any npm publish/release step.
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
## Release Auth
- Core `openclaw` publish uses GitHub trusted publishing; do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
- Separate `@openclaw/*` plugin publishes use a different maintainer-only auth flow.
- Plugin scope: only publish already-on-npm `@openclaw/*` plugins. Bundled disk-tree-only plugins stay out.
- Maintainers: private 1Password item names, tmux rules, plugin publish helpers, and local mac signing/notary setup live in the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md).
## Changelog Release Notes
- When cutting a mac release with beta GitHub prerelease:
- Tag `vYYYY.M.D-beta.N` from the release commit (example: `v2026.2.15-beta.1`).
- Create prerelease with title `openclaw YYYY.M.D-beta.N`.
- Use release notes from `CHANGELOG.md` version section (`Changes` + `Fixes`, no title duplicate).
- Attach at least `OpenClaw-YYYY.M.D.zip` and `OpenClaw-YYYY.M.D.dSYM.zip`; include `.dmg` if available.
- Keep top version entries in `CHANGELOG.md` sorted by impact:
- `### Changes` first.
- `### Fixes` deduped and ranked with user-facing fixes first.
- Before tagging/publishing, run:
- `node --import tsx scripts/release-check.ts`
- `pnpm release:check`
- `pnpm test:install:smoke` or `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` for non-root smoke path.

View File

@@ -23,7 +23,8 @@ Docs: https://docs.openclaw.ai
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) Thanks @Takhoffman.
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) Thanks @day253.
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lixuankai.
- Android/nodes: add `sms.search` plus shared SMS permission wiring so Android nodes can search device text messages through the gateway. (#48299) Thanks @lixuankai.
- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility.
- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
- Telegram/error replies: add a default-off `channels.telegram.silentErrorReplies` setting so bot error replies can be delivered silently across regular replies, native commands, and fallback sends. (#19776) Thanks @ImLukeF.
@@ -42,9 +43,11 @@ Docs: https://docs.openclaw.ai
- Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev.
- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev.
- Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman.
- Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97.
### Fixes
- CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD.
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
@@ -129,6 +132,9 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468.
- Models/chat commands: keep `/model ...@YYYYMMDD` version suffixes intact by default, but still honor matching stored numeric auth-profile overrides for the same provider. (#48896) Thanks @Alix-007.
- Gateway/channels: serialize per-account channel startup so overlapping starts do not boot the same provider twice, preventing MS Teams `EADDRINUSE` crash loops during startup and restart. (#49583) Thanks @sudie-codes.
- Tests/OpenAI Codex auth: align login expectations with the default `gpt-5.4` model so CI coverage stays consistent with the current OpenAI Codex default. (#44367) Thanks @jrrcdev.
- Discord: enforce strict DM component allowlist auth (#49997) Thanks @joshavant.
- Stabilize plugin loader and Docker extension smoke (#50058) Thanks @joshavant.
### Fixes
@@ -149,6 +155,9 @@ Docs: https://docs.openclaw.ai
- xAI/web search: add missing Grok credential metadata so the bundled provider registration type-checks again. (#49472) thanks @scoootscooob.
- Signal/runtime API: re-export `SignalAccountConfig` so Signal account resolution type-checks again. (#49470) Thanks @scoootscooob.
- Google Chat/runtime API: thin the private runtime barrel onto the curated public SDK surface while keeping public Google Chat exports intact. (#49504) Thanks @scoootscooob.
- WhatsApp: stabilize inbound monitor and setup tests (#50007) Thanks @joshavant.
- Matrix: make onboarding status runtime-safe (#49995) Thanks @joshavant.
- WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67.
### Breaking
@@ -236,6 +245,7 @@ Docs: https://docs.openclaw.ai
- Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar.
- Deps/audit: bump the pinned `fast-xml-parser` override to the first patched release so `pnpm audit --prod --audit-level=high` no longer fails on the AWS Bedrock XML builder path. Thanks @vincentkoc.
- Hooks/after_compaction: forward `sessionFile` for direct/manual compaction events and add `sessionFile` plus `sessionKey` to wired auto-compaction hook context so plugins receive the session metadata already declared in the hook types. (#40781) Thanks @jarimustonen.
- Sessions/BlueBubbles/cron: persist outbound session routing and transcript mirroring for new targets, auto-create BlueBubbles chats before attachment sends, and only suppress isolated cron deliveries when the run started hours late instead of merely finishing late. (#50092)
### Breaking

View File

@@ -83,7 +83,8 @@ Welcome to the lobster tank! 🦞
1. **Bugs & small fixes** → Open a PR!
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first
3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
3. **Test/CI-only PRs for known `main` failures** → Don't open a PR, the Maintainer team is already tracking it and such PRs will be closed automatically. If you've spotted a _new_ regression not yet shown in main CI, report it as an issue first.
4. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
## Before You PR
@@ -96,6 +97,7 @@ Welcome to the lobster tank! 🦞
- For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins`
- If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
- Do not submit test or CI-config fixes for failures already red on `main` CI. If a failure is already visible in the [main branch CI runs](https://github.com/openclaw/openclaw/actions), it's a known issue the Maintainer team is tracking, and a PR that only addresses those failures will be closed automatically. If you spot a _new_ regression not yet shown in main CI, report it as an issue first.
- Ensure CI checks pass
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
- Describe what & why

View File

@@ -146,6 +146,10 @@ COPY --from=runtime-assets --chown=node:node /app/extensions ./extensions
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
# In npm-installed Docker images, prefer the copied source extension tree for
# bundled discovery so package metadata that points at source entries stays valid.
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/extensions
# Keep pnpm available in the runtime image for container-local workflows.
# Use a shared Corepack home so the non-root `node` user does not need a
# first-run network fetch when invoking pnpm.

View File

@@ -176,6 +176,45 @@ More details: `docs/platforms/android.md`.
- `CAMERA` for `camera.snap` and `camera.clip`
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
## Google Play Restricted Permissions
As of March 19, 2026, these manifest permissions are the main Google Play policy risk for this app:
- `READ_SMS`
- `SEND_SMS`
- `READ_CALL_LOG`
Why these matter:
- Google Play treats SMS and Call Log access as highly restricted. In most cases, Play only allows them for the default SMS app, default Phone app, default Assistant, or a narrow policy exception.
- Review usually involves a `Permissions Declaration Form`, policy justification, and demo video evidence in Play Console.
- If we want a Play-safe build, these should be the first permissions removed behind a dedicated product flavor / variant.
Current OpenClaw Android implication:
- APK / sideload build can keep SMS and Call Log features.
- Google Play build should exclude SMS send/search and Call Log search unless the product is intentionally positioned and approved as a default-handler exception case.
Policy links:
- [Google Play SMS and Call Log policy](https://support.google.com/googleplay/android-developer/answer/10208820?hl=en)
- [Google Play sensitive permissions policy hub](https://support.google.com/googleplay/android-developer/answer/16558241)
- [Android default handlers guide](https://developer.android.com/guide/topics/permissions/default-handlers)
Other Play-restricted surfaces to watch if added later:
- `ACCESS_BACKGROUND_LOCATION`
- `MANAGE_EXTERNAL_STORAGE`
- `QUERY_ALL_PACKAGES`
- `REQUEST_INSTALL_PACKAGES`
- `AccessibilityService`
Reference links:
- [Background location policy](https://support.google.com/googleplay/android-developer/answer/9799150)
- [AccessibilityService policy](https://support.google.com/googleplay/android-developer/answer/10964491?hl=en-GB)
- [Photo and Video Permissions policy](https://support.google.com/googleplay/android-developer/answer/14594990)
## Integration Capability Test (Preconditioned)
This suite assumes setup is already done manually. It does **not** install/run/pair automatically.

View File

@@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission

View File

@@ -137,7 +137,8 @@ class NodeRuntime(
voiceWakeMode = { VoiceWakeMode.Off },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
smsAvailable = { sms.canSendSms() },
sendSmsAvailable = { sms.canSendSms() },
readSmsAvailable = { sms.canReadSms() },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
)
@@ -160,7 +161,8 @@ class NodeRuntime(
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
smsAvailable = { sms.canSendSms() },
sendSmsAvailable = { sms.canSendSms() },
readSmsAvailable = { sms.canReadSms() },
debugBuild = { BuildConfig.DEBUG },
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
onCanvasA2uiPush = {

View File

@@ -17,7 +17,8 @@ class ConnectionManager(
private val voiceWakeMode: () -> VoiceWakeMode,
private val motionActivityAvailable: () -> Boolean,
private val motionPedometerAvailable: () -> Boolean,
private val smsAvailable: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean,
) {
@@ -78,7 +79,8 @@ class ConnectionManager(
NodeRuntimeFlags(
cameraEnabled = cameraEnabled(),
locationEnabled = locationMode() != LocationMode.Off,
smsAvailable = smsAvailable(),
sendSmsAvailable = sendSmsAvailable(),
readSmsAvailable = readSmsAvailable(),
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
motionActivityAvailable = motionActivityAvailable(),
motionPedometerAvailable = motionPedometerAvailable(),

View File

@@ -18,7 +18,8 @@ import ai.openclaw.app.protocol.OpenClawSystemCommand
data class NodeRuntimeFlags(
val cameraEnabled: Boolean,
val locationEnabled: Boolean,
val smsAvailable: Boolean,
val sendSmsAvailable: Boolean,
val readSmsAvailable: Boolean,
val voiceWakeEnabled: Boolean,
val motionActivityAvailable: Boolean,
val motionPedometerAvailable: Boolean,
@@ -29,7 +30,8 @@ enum class InvokeCommandAvailability {
Always,
CameraEnabled,
LocationEnabled,
SmsAvailable,
SendSmsAvailable,
ReadSmsAvailable,
MotionActivityAvailable,
MotionPedometerAvailable,
DebugBuild,
@@ -187,7 +189,11 @@ object InvokeCommandRegistry {
),
InvokeCommandSpec(
name = OpenClawSmsCommand.Send.rawValue,
availability = InvokeCommandAvailability.SmsAvailable,
availability = InvokeCommandAvailability.SendSmsAvailable,
),
InvokeCommandSpec(
name = OpenClawSmsCommand.Search.rawValue,
availability = InvokeCommandAvailability.ReadSmsAvailable,
),
InvokeCommandSpec(
name = OpenClawCallLogCommand.Search.rawValue,
@@ -213,7 +219,7 @@ object InvokeCommandRegistry {
NodeCapabilityAvailability.Always -> true
NodeCapabilityAvailability.CameraEnabled -> flags.cameraEnabled
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable
NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
}
@@ -228,7 +234,8 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.Always -> true
InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable
InvokeCommandAvailability.SendSmsAvailable -> flags.sendSmsAvailable
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
InvokeCommandAvailability.DebugBuild -> flags.debugBuild

View File

@@ -32,7 +32,8 @@ class InvokeDispatcher(
private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean,
private val smsAvailable: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val debugBuild: () -> Boolean,
private val refreshNodeCanvasCapability: suspend () -> Boolean,
private val onCanvasA2uiPush: () -> Unit,
@@ -162,6 +163,7 @@ class InvokeDispatcher(
// SMS command
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
OpenClawSmsCommand.Search.rawValue -> smsHandler.handleSmsSearch(paramsJson)
// CallLog command
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
@@ -256,8 +258,17 @@ class InvokeDispatcher(
message = "PEDOMETER_UNAVAILABLE: step counter not available",
)
}
InvokeCommandAvailability.SmsAvailable ->
if (smsAvailable()) {
InvokeCommandAvailability.SendSmsAvailable ->
if (sendSmsAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "SMS_UNAVAILABLE",
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
InvokeCommandAvailability.ReadSmsAvailable ->
if (readSmsAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(

View File

@@ -16,4 +16,16 @@ class SmsHandler(
return GatewaySession.InvokeResult.error(code = code, message = error)
}
}
suspend fun handleSmsSearch(paramsJson: String?): GatewaySession.InvokeResult {
val res = sms.search(paramsJson)
if (res.ok) {
return GatewaySession.InvokeResult.ok(res.payloadJson)
} else {
val error = res.error ?: "SMS_SEARCH_FAILED"
val idx = error.indexOf(':')
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEARCH_FAILED"
return GatewaySession.InvokeResult.error(code = code, message = error)
}
}
}

View File

@@ -3,19 +3,27 @@ package ai.openclaw.app.node
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.provider.ContactsContract
import android.provider.Telephony
import android.telephony.SmsManager as AndroidSmsManager
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.encodeToString
import kotlinx.serialization.Serializable
import ai.openclaw.app.PermissionRequester
/**
* Sends SMS messages via the Android SMS API.
* Requires SEND_SMS permission to be granted.
*
* Also provides SMS query functionality with READ_SMS permission.
*/
class SmsManager(private val context: Context) {
@@ -30,6 +38,30 @@ class SmsManager(private val context: Context) {
val payloadJson: String,
)
/**
* Represents a single SMS message
*/
@Serializable
data class SmsMessage(
val id: Long,
val threadId: Long,
val address: String?,
val person: String?,
val date: Long,
val dateSent: Long,
val read: Boolean,
val type: Int,
val body: String?,
val status: Int,
)
data class SearchResult(
val ok: Boolean,
val messages: List<SmsMessage>,
val error: String? = null,
val payloadJson: String,
)
internal data class ParsedParams(
val to: String,
val message: String,
@@ -44,12 +76,30 @@ class SmsManager(private val context: Context) {
) : ParseResult()
}
internal data class QueryParams(
val startTime: Long? = null,
val endTime: Long? = null,
val contactName: String? = null,
val phoneNumber: String? = null,
val keyword: String? = null,
val type: Int? = null,
val isRead: Boolean? = null,
val limit: Int = DEFAULT_SMS_LIMIT,
val offset: Int = 0,
)
internal sealed class QueryParseResult {
data class Ok(val params: QueryParams) : QueryParseResult()
data class Error(val error: String) : QueryParseResult()
}
internal data class SendPlan(
val parts: List<String>,
val useMultipart: Boolean,
)
companion object {
private const val DEFAULT_SMS_LIMIT = 25
internal val JsonConfig = Json { ignoreUnknownKeys = true }
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
@@ -88,6 +138,52 @@ class SmsManager(private val context: Context) {
return ParseResult.Ok(ParsedParams(to = to, message = message))
}
internal fun parseQueryParams(paramsJson: String?, json: Json = JsonConfig): QueryParseResult {
val params = paramsJson?.trim().orEmpty()
if (params.isEmpty()) {
return QueryParseResult.Ok(QueryParams())
}
val obj = try {
json.parseToJsonElement(params).jsonObject
} catch (_: Throwable) {
return QueryParseResult.Error("INVALID_REQUEST: expected JSON object")
}
val startTime = (obj["startTime"] as? JsonPrimitive)?.content?.toLongOrNull()
val endTime = (obj["endTime"] as? JsonPrimitive)?.content?.toLongOrNull()
val contactName = (obj["contactName"] as? JsonPrimitive)?.content?.trim()
val phoneNumber = (obj["phoneNumber"] as? JsonPrimitive)?.content?.trim()
val keyword = (obj["keyword"] as? JsonPrimitive)?.content?.trim()
val type = (obj["type"] as? JsonPrimitive)?.content?.toIntOrNull()
val isRead = (obj["isRead"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull()
val limit = ((obj["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_SMS_LIMIT)
.coerceIn(1, 200)
val offset = ((obj["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
.coerceAtLeast(0)
// Validate time range
if (startTime != null && endTime != null && startTime > endTime) {
return QueryParseResult.Error("INVALID_REQUEST: startTime must be less than or equal to endTime")
}
return QueryParseResult.Ok(QueryParams(
startTime = startTime,
endTime = endTime,
contactName = contactName,
phoneNumber = phoneNumber,
keyword = keyword,
type = type,
isRead = isRead,
limit = limit,
offset = offset,
))
}
private fun normalizePhoneNumber(phone: String): String {
return phone.replace(Regex("""[\s\-()]"""), "")
}
internal fun buildSendPlan(
message: String,
divider: (String) -> List<String>,
@@ -112,6 +208,25 @@ class SmsManager(private val context: Context) {
}
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
}
internal fun buildQueryPayloadJson(
json: Json = JsonConfig,
ok: Boolean,
messages: List<SmsMessage>,
error: String? = null,
): String {
val messagesArray = json.encodeToString(messages)
val messagesElement = json.parseToJsonElement(messagesArray)
val payload = mutableMapOf<String, JsonElement>(
"ok" to JsonPrimitive(ok),
"count" to JsonPrimitive(messages.size),
"messages" to messagesElement
)
if (!ok && error != null) {
payload["error"] = JsonPrimitive(error)
}
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
}
}
fun hasSmsPermission(): Boolean {
@@ -121,10 +236,28 @@ class SmsManager(private val context: Context) {
) == PackageManager.PERMISSION_GRANTED
}
fun hasReadSmsPermission(): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_SMS
) == PackageManager.PERMISSION_GRANTED
}
fun hasReadContactsPermission(): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_CONTACTS
) == PackageManager.PERMISSION_GRANTED
}
fun canSendSms(): Boolean {
return hasSmsPermission() && hasTelephonyFeature()
}
fun canReadSms(): Boolean {
return hasReadSmsPermission() && hasTelephonyFeature()
}
fun hasTelephonyFeature(): Boolean {
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
@@ -208,6 +341,20 @@ class SmsManager(private val context: Context) {
return results[Manifest.permission.SEND_SMS] == true
}
private suspend fun ensureReadSmsPermission(): Boolean {
if (hasReadSmsPermission()) return true
val requester = permissionRequester ?: return false
val results = requester.requestIfMissing(listOf(Manifest.permission.READ_SMS))
return results[Manifest.permission.READ_SMS] == true
}
private suspend fun ensureReadContactsPermission(): Boolean {
if (hasReadContactsPermission()) return true
val requester = permissionRequester ?: return false
val results = requester.requestIfMissing(listOf(Manifest.permission.READ_CONTACTS))
return results[Manifest.permission.READ_CONTACTS] == true
}
private fun okResult(to: String, message: String): SendResult {
return SendResult(
ok = true,
@@ -227,4 +374,240 @@ class SmsManager(private val context: Context) {
payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error),
)
}
/**
* search SMS messages with the specified parameters.
*
* @param paramsJson JSON with optional fields:
* - startTime (Long): Start time in milliseconds
* - endTime (Long): End time in milliseconds
* - contactName (String): Contact name to search
* - phoneNumber (String): Phone number to search (supports partial matching)
* - keyword (String): Keyword to search in message body
* - type (Int): SMS type (1=Inbox, 2=Sent, 3=Draft, etc.)
* - isRead (Boolean): Read status
* - limit (Int): Number of records to return (default: 25, range: 1-200)
* - offset (Int): Number of records to skip (default: 0)
* @return SearchResult containing the list of SMS messages or an error
*/
suspend fun search(paramsJson: String?): SearchResult = withContext(Dispatchers.IO) {
if (!hasTelephonyFeature()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_UNAVAILABLE: telephony not available",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_UNAVAILABLE: telephony not available")
)
}
if (!ensureReadSmsPermission()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission")
)
}
val parseResult = parseQueryParams(paramsJson, json)
if (parseResult is QueryParseResult.Error) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = parseResult.error,
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = parseResult.error)
)
}
val params = (parseResult as QueryParseResult.Ok).params
return@withContext try {
// Get phone numbers from contact name if provided
val phoneNumbers = if (!params.contactName.isNullOrEmpty()) {
if (!ensureReadContactsPermission()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission")
)
}
getPhoneNumbersFromContactName(params.contactName)
} else {
emptyList()
}
val messages = querySmsMessages(params, phoneNumbers)
SearchResult(
ok = true,
messages = messages,
error = null,
payloadJson = buildQueryPayloadJson(json, ok = true, messages = messages)
)
} catch (e: SecurityException) {
SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_PERMISSION_REQUIRED: ${e.message}",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: ${e.message}")
)
} catch (e: Throwable) {
SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}")
)
}
}
/**
* Get all phone numbers associated with a contact name
*/
private fun getPhoneNumbersFromContactName(contactName: String): List<String> {
val phoneNumbers = mutableListOf<String>()
val selection = "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ?"
val selectionArgs = arrayOf("%$contactName%")
val cursor = context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER),
selection,
selectionArgs,
null
)
cursor?.use {
val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
while (it.moveToNext()) {
val number = it.getString(numberIndex)
if (!number.isNullOrBlank()) {
phoneNumbers.add(normalizePhoneNumber(number))
}
}
}
return phoneNumbers
}
/**
* Query SMS messages based on the provided parameters
*/
private fun querySmsMessages(params: QueryParams, phoneNumbers: List<String>): List<SmsMessage> {
val messages = mutableListOf<SmsMessage>()
// Build selection and selectionArgs
val selections = mutableListOf<String>()
val selectionArgs = mutableListOf<String>()
// Time range
if (params.startTime != null) {
selections.add("${Telephony.Sms.DATE} >= ?")
selectionArgs.add(params.startTime.toString())
}
if (params.endTime != null) {
selections.add("${Telephony.Sms.DATE} <= ?")
selectionArgs.add(params.endTime.toString())
}
// Phone numbers (from contact name or direct phone number)
val allPhoneNumbers = if (!params.phoneNumber.isNullOrEmpty()) {
phoneNumbers + normalizePhoneNumber(params.phoneNumber)
} else {
phoneNumbers
}
if (allPhoneNumbers.isNotEmpty()) {
val addressSelection = allPhoneNumbers.joinToString(" OR ") {
"${Telephony.Sms.ADDRESS} LIKE ?"
}
selections.add("($addressSelection)")
allPhoneNumbers.forEach {
selectionArgs.add("%$it%")
}
}
// Keyword in body
if (!params.keyword.isNullOrEmpty()) {
selections.add("${Telephony.Sms.BODY} LIKE ?")
selectionArgs.add("%${params.keyword}%")
}
// Type
if (params.type != null) {
selections.add("${Telephony.Sms.TYPE} = ?")
selectionArgs.add(params.type.toString())
}
// Read status
if (params.isRead != null) {
selections.add("${Telephony.Sms.READ} = ?")
selectionArgs.add(if (params.isRead) "1" else "0")
}
val selection = if (selections.isNotEmpty()) {
selections.joinToString(" AND ")
} else {
null
}
val selectionArgsArray = if (selectionArgs.isNotEmpty()) {
selectionArgs.toTypedArray()
} else {
null
}
// Query SMS with SQL-level LIMIT and OFFSET to avoid loading all matching rows
val sortOrder = "${Telephony.Sms.DATE} DESC LIMIT ${params.limit} OFFSET ${params.offset}"
val cursor = context.contentResolver.query(
Telephony.Sms.CONTENT_URI,
arrayOf(
Telephony.Sms._ID,
Telephony.Sms.THREAD_ID,
Telephony.Sms.ADDRESS,
Telephony.Sms.PERSON,
Telephony.Sms.DATE,
Telephony.Sms.DATE_SENT,
Telephony.Sms.READ,
Telephony.Sms.TYPE,
Telephony.Sms.BODY,
Telephony.Sms.STATUS
),
selection,
selectionArgsArray,
sortOrder
)
cursor?.use {
val idIndex = it.getColumnIndex(Telephony.Sms._ID)
val threadIdIndex = it.getColumnIndex(Telephony.Sms.THREAD_ID)
val addressIndex = it.getColumnIndex(Telephony.Sms.ADDRESS)
val personIndex = it.getColumnIndex(Telephony.Sms.PERSON)
val dateIndex = it.getColumnIndex(Telephony.Sms.DATE)
val dateSentIndex = it.getColumnIndex(Telephony.Sms.DATE_SENT)
val readIndex = it.getColumnIndex(Telephony.Sms.READ)
val typeIndex = it.getColumnIndex(Telephony.Sms.TYPE)
val bodyIndex = it.getColumnIndex(Telephony.Sms.BODY)
val statusIndex = it.getColumnIndex(Telephony.Sms.STATUS)
var count = 0
while (it.moveToNext() && count < params.limit) {
val message = SmsMessage(
id = it.getLong(idIndex),
threadId = it.getLong(threadIdIndex),
address = it.getString(addressIndex),
person = it.getString(personIndex),
date = it.getLong(dateIndex),
dateSent = it.getLong(dateSentIndex),
read = it.getInt(readIndex) == 1,
type = it.getInt(typeIndex),
body = it.getString(bodyIndex),
status = it.getInt(statusIndex)
)
messages.add(message)
count++
}
}
return messages
}
}

View File

@@ -53,6 +53,7 @@ enum class OpenClawCameraCommand(val rawValue: String) {
enum class OpenClawSmsCommand(val rawValue: String) {
Send("sms.send"),
Search("sms.search"),
;
companion object {

View File

@@ -287,7 +287,11 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
var enableSms by
rememberSaveable {
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
mutableStateOf(
smsAvailable &&
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS)
)
}
var enableCallLog by
rememberSaveable {
@@ -336,7 +340,9 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
!motionPermissionRequired ||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
PermissionToggle.Sms ->
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
!smsAvailable ||
(isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS))
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
}
@@ -698,7 +704,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
requestPermissionToggle(
PermissionToggle.Sms,
checked,
listOf(Manifest.permission.SEND_SMS),
listOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS),
)
}
},
@@ -1437,9 +1443,11 @@ private fun PermissionsStep(
InlineDivider()
PermissionToggleRow(
title = "SMS",
subtitle = "Send text messages via the gateway",
subtitle = "Send and search text messages via the gateway",
checked = enableSms,
granted = isPermissionGranted(context, Manifest.permission.SEND_SMS),
granted =
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS),
onCheckedChange = onSmsChange,
)
}

View File

@@ -247,12 +247,16 @@ fun SettingsSheet(viewModel: MainViewModel) {
remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
PackageManager.PERMISSION_GRANTED,
)
}
val smsPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
smsPermissionGranted = granted
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
val sendOk = perms[Manifest.permission.SEND_SMS] == true
val readOk = perms[Manifest.permission.READ_SMS] == true
smsPermissionGranted = sendOk && readOk
viewModel.refreshGatewayConnection()
}
@@ -287,6 +291,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED
smsPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
PackageManager.PERMISSION_GRANTED
}
}
@@ -507,7 +513,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
colors = listItemColors,
headlineContent = { Text("SMS", style = mobileHeadline) },
supportingContent = {
Text("Send SMS from this device.", style = mobileCallout)
Text("Send and search SMS from this device.", style = mobileCallout)
},
trailingContent = {
Button(
@@ -515,7 +521,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
if (smsPermissionGranted) {
openAppSettings(context)
} else {
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
smsPermissionLauncher.launch(arrayOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS))
}
},
colors = settingsPrimaryButtonColors(),

View File

@@ -64,6 +64,7 @@ class InvokeCommandRegistryTest {
OpenClawMotionCommand.Activity.rawValue,
OpenClawMotionCommand.Pedometer.rawValue,
OpenClawSmsCommand.Send.rawValue,
OpenClawSmsCommand.Search.rawValue,
)
private val debugCommands = setOf("debug.logs", "debug.ed25519")
@@ -83,7 +84,8 @@ class InvokeCommandRegistryTest {
defaultFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
sendSmsAvailable = true,
readSmsAvailable = true,
voiceWakeEnabled = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
@@ -108,7 +110,8 @@ class InvokeCommandRegistryTest {
defaultFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
sendSmsAvailable = true,
readSmsAvailable = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
debugBuild = true,
@@ -125,7 +128,8 @@ class InvokeCommandRegistryTest {
NodeRuntimeFlags(
cameraEnabled = false,
locationEnabled = false,
smsAvailable = false,
sendSmsAvailable = false,
readSmsAvailable = false,
voiceWakeEnabled = false,
motionActivityAvailable = true,
motionPedometerAvailable = false,
@@ -137,10 +141,43 @@ class InvokeCommandRegistryTest {
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
}
@Test
fun advertisedCommands_splitsSmsSendAndSearchAvailability() {
val readOnlyCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(readSmsAvailable = true),
)
val sendOnlyCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(sendSmsAvailable = true),
)
assertTrue(readOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
assertFalse(readOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
assertTrue(sendOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(sendOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
}
@Test
fun advertisedCapabilities_includeSmsWhenEitherSmsPathIsAvailable() {
val readOnlyCapabilities =
InvokeCommandRegistry.advertisedCapabilities(
defaultFlags(readSmsAvailable = true),
)
val sendOnlyCapabilities =
InvokeCommandRegistry.advertisedCapabilities(
defaultFlags(sendSmsAvailable = true),
)
assertTrue(readOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
assertTrue(sendOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
}
private fun defaultFlags(
cameraEnabled: Boolean = false,
locationEnabled: Boolean = false,
smsAvailable: Boolean = false,
sendSmsAvailable: Boolean = false,
readSmsAvailable: Boolean = false,
voiceWakeEnabled: Boolean = false,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
@@ -149,7 +186,8 @@ class InvokeCommandRegistryTest {
NodeRuntimeFlags(
cameraEnabled = cameraEnabled,
locationEnabled = locationEnabled,
smsAvailable = smsAvailable,
sendSmsAvailable = sendSmsAvailable,
readSmsAvailable = readSmsAvailable,
voiceWakeEnabled = voiceWakeEnabled,
motionActivityAvailable = motionActivityAvailable,
motionPedometerAvailable = motionPedometerAvailable,

View File

@@ -88,4 +88,95 @@ class SmsManagerTest {
assertFalse(plan.useMultipart)
assertEquals(listOf("hello"), plan.parts)
}
@Test
fun parseQueryParamsAcceptsEmptyPayload() {
val result = SmsManager.parseQueryParams(null, json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(25, ok.params.limit)
assertEquals(0, ok.params.offset)
}
@Test
fun parseQueryParamsRejectsInvalidJson() {
val result = SmsManager.parseQueryParams("not-json", json)
assertTrue(result is SmsManager.QueryParseResult.Error)
val error = result as SmsManager.QueryParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseQueryParamsRejectsNonObjectJson() {
val result = SmsManager.parseQueryParams("[]", json)
assertTrue(result is SmsManager.QueryParseResult.Error)
val error = result as SmsManager.QueryParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseQueryParamsParsesLimitAndOffset() {
val result = SmsManager.parseQueryParams("{\"limit\":10,\"offset\":5}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(10, ok.params.limit)
assertEquals(5, ok.params.offset)
}
@Test
fun parseQueryParamsClampsLimitRange() {
val result = SmsManager.parseQueryParams("{\"limit\":300}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(200, ok.params.limit)
}
@Test
fun parseQueryParamsParsesPhoneNumber() {
val result = SmsManager.parseQueryParams("{\"phoneNumber\":\"+1234567890\"}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals("+1234567890", ok.params.phoneNumber)
}
@Test
fun parseQueryParamsParsesContactName() {
val result = SmsManager.parseQueryParams("{\"contactName\":\"lixuankai\"}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals("lixuankai", ok.params.contactName)
}
@Test
fun parseQueryParamsParsesKeyword() {
val result = SmsManager.parseQueryParams("{\"keyword\":\"test\"}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals("test", ok.params.keyword)
}
@Test
fun parseQueryParamsParsesTimeRange() {
val result = SmsManager.parseQueryParams("{\"startTime\":1000,\"endTime\":2000}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(1000L, ok.params.startTime)
assertEquals(2000L, ok.params.endTime)
}
@Test
fun parseQueryParamsParsesType() {
val result = SmsManager.parseQueryParams("{\"type\":1}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(1, ok.params.type)
}
@Test
fun parseQueryParamsParsesReadStatus() {
val result = SmsManager.parseQueryParams("{\"isRead\":true}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(true, ok.params.isRead)
}
}

View File

@@ -90,4 +90,9 @@ class OpenClawProtocolConstantsTest {
fun callLogCommandsUseStableStrings() {
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
}
@Test
fun smsCommandsUseStableStrings() {
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
}
}

View File

@@ -1326,6 +1326,124 @@ public struct SessionsResolveParams: Codable, Sendable {
}
}
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?
public let label: String?
public let model: String?
public let parentsessionkey: String?
public let task: String?
public let message: String?
public init(
key: String?,
agentid: String?,
label: String?,
model: String?,
parentsessionkey: String?,
task: String?,
message: String?)
{
self.key = key
self.agentid = agentid
self.label = label
self.model = model
self.parentsessionkey = parentsessionkey
self.task = task
self.message = message
}
private enum CodingKeys: String, CodingKey {
case key
case agentid = "agentId"
case label
case model
case parentsessionkey = "parentSessionKey"
case task
case message
}
}
public struct SessionsSendParams: Codable, Sendable {
public let key: String
public let message: String
public let thinking: String?
public let attachments: [AnyCodable]?
public let timeoutms: Int?
public let idempotencykey: String?
public init(
key: String,
message: String,
thinking: String?,
attachments: [AnyCodable]?,
timeoutms: Int?,
idempotencykey: String?)
{
self.key = key
self.message = message
self.thinking = thinking
self.attachments = attachments
self.timeoutms = timeoutms
self.idempotencykey = idempotencykey
}
private enum CodingKeys: String, CodingKey {
case key
case message
case thinking
case attachments
case timeoutms = "timeoutMs"
case idempotencykey = "idempotencyKey"
}
}
public struct SessionsMessagesSubscribeParams: Codable, Sendable {
public let key: String
public init(
key: String)
{
self.key = key
}
private enum CodingKeys: String, CodingKey {
case key
}
}
public struct SessionsMessagesUnsubscribeParams: Codable, Sendable {
public let key: String
public init(
key: String)
{
self.key = key
}
private enum CodingKeys: String, CodingKey {
case key
}
}
public struct SessionsAbortParams: Codable, Sendable {
public let key: String
public let runid: String?
public init(
key: String,
runid: String?)
{
self.key = key
self.runid = runid
}
private enum CodingKeys: String, CodingKey {
case key
case runid = "runId"
}
}
public struct SessionsPatchParams: Codable, Sendable {
public let key: String
public let label: AnyCodable?

View File

@@ -1326,6 +1326,124 @@ public struct SessionsResolveParams: Codable, Sendable {
}
}
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?
public let label: String?
public let model: String?
public let parentsessionkey: String?
public let task: String?
public let message: String?
public init(
key: String?,
agentid: String?,
label: String?,
model: String?,
parentsessionkey: String?,
task: String?,
message: String?)
{
self.key = key
self.agentid = agentid
self.label = label
self.model = model
self.parentsessionkey = parentsessionkey
self.task = task
self.message = message
}
private enum CodingKeys: String, CodingKey {
case key
case agentid = "agentId"
case label
case model
case parentsessionkey = "parentSessionKey"
case task
case message
}
}
public struct SessionsSendParams: Codable, Sendable {
public let key: String
public let message: String
public let thinking: String?
public let attachments: [AnyCodable]?
public let timeoutms: Int?
public let idempotencykey: String?
public init(
key: String,
message: String,
thinking: String?,
attachments: [AnyCodable]?,
timeoutms: Int?,
idempotencykey: String?)
{
self.key = key
self.message = message
self.thinking = thinking
self.attachments = attachments
self.timeoutms = timeoutms
self.idempotencykey = idempotencykey
}
private enum CodingKeys: String, CodingKey {
case key
case message
case thinking
case attachments
case timeoutms = "timeoutMs"
case idempotencykey = "idempotencyKey"
}
}
public struct SessionsMessagesSubscribeParams: Codable, Sendable {
public let key: String
public init(
key: String)
{
self.key = key
}
private enum CodingKeys: String, CodingKey {
case key
}
}
public struct SessionsMessagesUnsubscribeParams: Codable, Sendable {
public let key: String
public init(
key: String)
{
self.key = key
}
private enum CodingKeys: String, CodingKey {
case key
}
}
public struct SessionsAbortParams: Codable, Sendable {
public let key: String
public let runid: String?
public init(
key: String,
runid: String?)
{
self.key = key
self.runid = runid
}
private enum CodingKeys: String, CodingKey {
case key
case runid = "runId"
}
}
public struct SessionsPatchParams: Codable, Sendable {
public let key: String
public let label: AnyCodable?

View File

@@ -1 +0,0 @@
- tests: align OpenAI Codex auth login expectations with the `gpt-5.4` default model to prevent stale CI failures. (#44367) thanks @jrrcdev

View File

@@ -0,0 +1,3 @@
### Fixes
- Gateway/session history: return `404` for unknown session history lookups, unsubscribe session lifecycle listeners during shutdown, add coverage for the new transcript and lifecycle helpers, and tighten session history plus live transcript tests so the Control UI session surfaces stay stable under restart and follow mode.

View File

@@ -1 +0,0 @@
- runner: infer canonical tool names from malformed `toolCallId` variants (e.g. `functionsread3`, `functionswrite4`) when allowlist is present, preventing `Tool not found` regressions in strict routers.

View File

@@ -15230,7 +15230,7 @@
"network"
],
"label": "Feishu",
"help": "飞书/Lark enterprise messaging.",
"help": "飞书/Lark enterprise messaging with doc/wiki/drive tools.",
"hasChildren": true
},
{
@@ -17232,7 +17232,7 @@
"network"
],
"label": "Google Chat",
"help": "Google Workspace Chat app with HTTP webhook.",
"help": "Google Workspace Chat app via HTTP webhooks.",
"hasChildren": true
},
{
@@ -22069,7 +22069,7 @@
"network"
],
"label": "Matrix",
"help": "open protocol; configure a homeserver + access token.",
"help": "open protocol; install the plugin to enable.",
"hasChildren": true
},
{
@@ -26190,7 +26190,7 @@
"network"
],
"label": "Nostr",
"help": "Decentralized DMs via Nostr relays (NIP-04)",
"help": "Decentralized protocol; encrypted DMs via NIP-04.",
"hasChildren": true
},
{
@@ -30798,7 +30798,7 @@
"network"
],
"label": "Synology Chat",
"help": "Connect your Synology NAS Chat to OpenClaw",
"help": "Connect your Synology NAS Chat to OpenClaw with full agent capabilities.",
"hasChildren": true
},
{
@@ -34814,7 +34814,7 @@
"network"
],
"label": "Tlon",
"help": "Decentralized messaging on Urbit",
"help": "decentralized messaging on Urbit; install the plugin to enable.",
"hasChildren": true
},
{
@@ -44903,6 +44903,16 @@
"tags": [],
"hasChildren": false
},
{
"path": "models.providers.*.models.*.compat.nativeWebSearchTool",
"kind": "core",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "models.providers.*.models.*.compat.requiresAssistantAfterToolResult",
"kind": "core",
@@ -45023,6 +45033,26 @@
"tags": [],
"hasChildren": false
},
{
"path": "models.providers.*.models.*.compat.toolCallArgumentsEncoding",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "models.providers.*.models.*.compat.toolSchemaProfile",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "models.providers.*.models.*.contextWindow",
"kind": "core",
@@ -46155,6 +46185,52 @@
],
"label": "@openclaw/brave-plugin Config",
"help": "Plugin-defined config payload for brave.",
"hasChildren": true
},
{
"path": "plugins.entries.brave.config.webSearch",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "plugins.entries.brave.config.webSearch.apiKey",
"kind": "plugin",
"type": [
"object",
"string"
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"security"
],
"label": "Brave Search API Key",
"help": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
"hasChildren": false
},
{
"path": "plugins.entries.brave.config.webSearch.mode",
"kind": "plugin",
"type": "string",
"required": false,
"enumValues": [
"web",
"llm-context"
],
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Brave Search Mode",
"help": "Brave Search mode: web or llm-context.",
"hasChildren": false
},
{
@@ -47690,6 +47766,127 @@
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
"hasChildren": false
},
{
"path": "plugins.entries.fal",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/fal-provider",
"help": "OpenClaw fal provider plugin (plugin: fal)",
"hasChildren": true
},
{
"path": "plugins.entries.fal.config",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/fal-provider Config",
"help": "Plugin-defined config payload for fal.",
"hasChildren": false
},
{
"path": "plugins.entries.fal.enabled",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Enable @openclaw/fal-provider",
"hasChildren": false
},
{
"path": "plugins.entries.fal.hooks",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Hook Policy",
"help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.",
"hasChildren": true
},
{
"path": "plugins.entries.fal.hooks.allowPromptInjection",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Allow Prompt Injection Hooks",
"help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
"hasChildren": false
},
{
"path": "plugins.entries.fal.subagent",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Subagent Policy",
"help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",
"hasChildren": true
},
{
"path": "plugins.entries.fal.subagent.allowedModels",
"kind": "plugin",
"type": "array",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Plugin Subagent Allowed Models",
"help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.",
"hasChildren": true
},
{
"path": "plugins.entries.fal.subagent.allowedModels.*",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "plugins.entries.fal.subagent.allowModelOverride",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Allow Plugin Subagent Model Override",
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
"hasChildren": false
},
{
"path": "plugins.entries.feishu",
"kind": "plugin",
@@ -47837,6 +48034,48 @@
],
"label": "@openclaw/firecrawl-plugin Config",
"help": "Plugin-defined config payload for firecrawl.",
"hasChildren": true
},
{
"path": "plugins.entries.firecrawl.config.webSearch",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "plugins.entries.firecrawl.config.webSearch.apiKey",
"kind": "plugin",
"type": [
"object",
"string"
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"security"
],
"label": "Firecrawl Search API Key",
"help": "Firecrawl API key for web search (fallback: FIRECRAWL_API_KEY env var).",
"hasChildren": false
},
{
"path": "plugins.entries.firecrawl.config.webSearch.baseUrl",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Firecrawl Search Base URL",
"help": "Firecrawl Search base URL override.",
"hasChildren": false
},
{
@@ -48079,6 +48318,48 @@
],
"label": "@openclaw/google-plugin Config",
"help": "Plugin-defined config payload for google.",
"hasChildren": true
},
{
"path": "plugins.entries.google.config.webSearch",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "plugins.entries.google.config.webSearch.apiKey",
"kind": "plugin",
"type": [
"object",
"string"
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"security"
],
"label": "Gemini Search API Key",
"help": "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).",
"hasChildren": false
},
{
"path": "plugins.entries.google.config.webSearch.model",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"models"
],
"label": "Gemini Search Model",
"help": "Gemini model override for web search grounding.",
"hasChildren": false
},
{
@@ -50456,6 +50737,62 @@
],
"label": "@openclaw/moonshot-provider Config",
"help": "Plugin-defined config payload for moonshot.",
"hasChildren": true
},
{
"path": "plugins.entries.moonshot.config.webSearch",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "plugins.entries.moonshot.config.webSearch.apiKey",
"kind": "plugin",
"type": [
"object",
"string"
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"security"
],
"label": "Kimi Search API Key",
"help": "Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).",
"hasChildren": false
},
{
"path": "plugins.entries.moonshot.config.webSearch.baseUrl",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Kimi Search Base URL",
"help": "Kimi base URL override.",
"hasChildren": false
},
{
"path": "plugins.entries.moonshot.config.webSearch.model",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"models"
],
"label": "Kimi Search Model",
"help": "Kimi model override.",
"hasChildren": false
},
{
@@ -52075,6 +52412,62 @@
],
"label": "@openclaw/perplexity-plugin Config",
"help": "Plugin-defined config payload for perplexity.",
"hasChildren": true
},
{
"path": "plugins.entries.perplexity.config.webSearch",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "plugins.entries.perplexity.config.webSearch.apiKey",
"kind": "plugin",
"type": [
"object",
"string"
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"security"
],
"label": "Perplexity API Key",
"help": "Perplexity or OpenRouter API key for web search.",
"hasChildren": false
},
{
"path": "plugins.entries.perplexity.config.webSearch.baseUrl",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Perplexity Base URL",
"help": "Optional Perplexity/OpenRouter chat-completions base URL override.",
"hasChildren": false
},
{
"path": "plugins.entries.perplexity.config.webSearch.model",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"models"
],
"label": "Perplexity Model",
"help": "Optional Sonar/OpenRouter model override.",
"hasChildren": false
},
{
@@ -56010,6 +56403,62 @@
],
"label": "@openclaw/xai-plugin Config",
"help": "Plugin-defined config payload for xai.",
"hasChildren": true
},
{
"path": "plugins.entries.xai.config.webSearch",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "plugins.entries.xai.config.webSearch.apiKey",
"kind": "plugin",
"type": [
"object",
"string"
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"security"
],
"label": "Grok Search API Key",
"help": "xAI API key for Grok web search (fallback: XAI_API_KEY env var).",
"hasChildren": false
},
{
"path": "plugins.entries.xai.config.webSearch.inlineCitations",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Inline Citations",
"help": "Include inline markdown citations in Grok responses.",
"hasChildren": false
},
{
"path": "plugins.entries.xai.config.webSearch.model",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"models"
],
"label": "Grok Search Model",
"help": "Grok model override for web search.",
"hasChildren": false
},
{
@@ -62780,8 +63229,6 @@
"security",
"tools"
],
"label": "Brave Search API Key",
"help": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
"hasChildren": true
},
{
@@ -62824,6 +63271,63 @@
"tags": [],
"hasChildren": true
},
{
"path": "tools.web.search.brave.apiKey",
"kind": "core",
"type": [
"object",
"string"
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"security",
"tools"
],
"hasChildren": true
},
{
"path": "tools.web.search.brave.apiKey.id",
"kind": "core",
"type": "string",
"required": true,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "tools.web.search.brave.apiKey.provider",
"kind": "core",
"type": "string",
"required": true,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "tools.web.search.brave.apiKey.source",
"kind": "core",
"type": "string",
"required": true,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "tools.web.search.brave.baseUrl",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "tools.web.search.brave.mode",
"kind": "core",
@@ -62831,11 +63335,17 @@
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"tools"
],
"label": "Brave Search Mode",
"help": "Brave Search mode: \"web\" (URL results) or \"llm-context\" (pre-extracted page content for LLM grounding).",
"tags": [],
"hasChildren": false
},
{
"path": "tools.web.search.brave.model",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
@@ -62893,8 +63403,6 @@
"security",
"tools"
],
"label": "Firecrawl Search API Key",
"help": "Firecrawl API key for web search (fallback: FIRECRAWL_API_KEY env var).",
"hasChildren": true
},
{
@@ -62934,11 +63442,17 @@
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"tools"
],
"label": "Firecrawl Search Base URL",
"help": "Firecrawl Search base URL override (default: \"https://api.firecrawl.dev\").",
"tags": [],
"hasChildren": false
},
{
"path": "tools.web.search.firecrawl.model",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
@@ -62966,8 +63480,6 @@
"security",
"tools"
],
"label": "Gemini Search API Key",
"help": "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).",
"hasChildren": true
},
{
@@ -63000,6 +63512,16 @@
"tags": [],
"hasChildren": false
},
{
"path": "tools.web.search.gemini.baseUrl",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "tools.web.search.gemini.model",
"kind": "core",
@@ -63007,12 +63529,7 @@
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"models",
"tools"
],
"label": "Gemini Search Model",
"help": "Gemini model override (default: \"gemini-2.5-flash\").",
"tags": [],
"hasChildren": false
},
{
@@ -63040,8 +63557,6 @@
"security",
"tools"
],
"label": "Grok Search API Key",
"help": "Grok (xAI) API key (fallback: XAI_API_KEY env var).",
"hasChildren": true
},
{
@@ -63074,6 +63589,16 @@
"tags": [],
"hasChildren": false
},
{
"path": "tools.web.search.grok.baseUrl",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "tools.web.search.grok.inlineCitations",
"kind": "core",
@@ -63091,12 +63616,7 @@
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"models",
"tools"
],
"label": "Grok Search Model",
"help": "Grok model override (default: \"grok-4-1-fast\").",
"tags": [],
"hasChildren": false
},
{
@@ -63124,8 +63644,6 @@
"security",
"tools"
],
"label": "Kimi Search API Key",
"help": "Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).",
"hasChildren": true
},
{
@@ -63165,11 +63683,7 @@
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"tools"
],
"label": "Kimi Search Base URL",
"help": "Kimi base URL override (default: \"https://api.moonshot.ai/v1\").",
"tags": [],
"hasChildren": false
},
{
@@ -63179,12 +63693,7 @@
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"models",
"tools"
],
"label": "Kimi Search Model",
"help": "Kimi model override (default: \"moonshot-v1-128k\").",
"tags": [],
"hasChildren": false
},
{
@@ -63227,8 +63736,6 @@
"security",
"tools"
],
"label": "Perplexity API Key",
"help": "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var). Direct Perplexity keys default to the Search API; OpenRouter keys use Sonar chat completions.",
"hasChildren": true
},
{
@@ -63268,11 +63775,7 @@
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"tools"
],
"label": "Perplexity Base URL",
"help": "Optional Perplexity/OpenRouter chat-completions base URL override. Setting this opts Perplexity into the legacy Sonar/OpenRouter compatibility path.",
"tags": [],
"hasChildren": false
},
{
@@ -63282,12 +63785,7 @@
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"models",
"tools"
],
"label": "Perplexity Model",
"help": "Optional Sonar/OpenRouter model override (default: \"perplexity/sonar-pro\"). Setting this opts Perplexity into the legacy chat-completions compatibility path.",
"tags": [],
"hasChildren": false
},
{
@@ -63301,7 +63799,7 @@
"tools"
],
"label": "Web Search Provider",
"help": "Search provider (\"brave\", \"firecrawl\", \"gemini\", \"grok\", \"kimi\", or \"perplexity\"). Auto-detected from available API keys if omitted.",
"help": "Search provider id. Auto-detected from available API keys if omitted.",
"hasChildren": false
},
{
@@ -63386,7 +63884,7 @@
"advanced"
],
"label": "Accent Color",
"help": "Primary accent/seam color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.",
"help": "Primary accent color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.",
"hasChildren": false
},
{

View File

@@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5476}
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5518}
{"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}
@@ -1352,7 +1352,7 @@
{"recordType":"path","path":"channels.discord.voice.tts.provider","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.voice.tts.summaryModel","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.voice.tts.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Feishu","help":"飞书/Lark enterprise messaging.","hasChildren":true}
{"recordType":"path","path":"channels.feishu","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Feishu","help":"飞书/Lark enterprise messaging with doc/wiki/drive tools.","hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.feishu.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
@@ -1532,7 +1532,7 @@
{"recordType":"path","path":"channels.feishu.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/feishu/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.googlechat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Google Chat","help":"Google Workspace Chat app with HTTP webhook.","hasChildren":true}
{"recordType":"path","path":"channels.googlechat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Google Chat","help":"Google Workspace Chat app via HTTP webhooks.","hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.googlechat.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
@@ -1980,7 +1980,7 @@
{"recordType":"path","path":"channels.line.secretFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.line.tokenFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.line.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.matrix","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Matrix","help":"open protocol; configure a homeserver + access token.","hasChildren":true}
{"recordType":"path","path":"channels.matrix","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Matrix","help":"open protocol; install the plugin to enable.","hasChildren":true}
{"recordType":"path","path":"channels.matrix.accessToken","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.matrix.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.matrix.accounts.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2362,7 +2362,7 @@
{"recordType":"path","path":"channels.nextcloud-talk.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.nextcloud-talk.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.nextcloud-talk.webhookPublicUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.nostr","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Nostr","help":"Decentralized DMs via Nostr relays (NIP-04)","hasChildren":true}
{"recordType":"path","path":"channels.nostr","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Nostr","help":"Decentralized protocol; encrypted DMs via NIP-04.","hasChildren":true}
{"recordType":"path","path":"channels.nostr.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.nostr.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.nostr.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2779,7 +2779,7 @@
{"recordType":"path","path":"channels.slack.userToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.userTokenReadOnly","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":["auth","channels","network","security"],"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes.","hasChildren":false}
{"recordType":"path","path":"channels.slack.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/slack/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.synology-chat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Synology Chat","help":"Connect your Synology NAS Chat to OpenClaw","hasChildren":true}
{"recordType":"path","path":"channels.synology-chat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Synology Chat","help":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","hasChildren":true}
{"recordType":"path","path":"channels.synology-chat.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram","help":"simplest way to get started — register a bot with @BotFather and get going.","hasChildren":true}
{"recordType":"path","path":"channels.telegram.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
@@ -3139,7 +3139,7 @@
{"recordType":"path","path":"channels.telegram.webhookSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.webhookSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.webhookUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.tlon","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Tlon","help":"Decentralized messaging on Urbit","hasChildren":true}
{"recordType":"path","path":"channels.tlon","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Tlon","help":"decentralized messaging on Urbit; install the plugin to enable.","hasChildren":true}
{"recordType":"path","path":"channels.tlon.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.tlon.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.tlon.accounts.*.allowPrivateNetwork","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -3986,6 +3986,7 @@
{"recordType":"path","path":"models.providers.*.models.*.api","kind":"core","type":"string","required":false,"enumValues":["openai-completions","openai-responses","openai-codex-responses","anthropic-messages","google-generative-ai","github-copilot","bedrock-converse-stream","ollama"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"models.providers.*.models.*.compat","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"models.providers.*.models.*.compat.maxTokensField","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"models.providers.*.models.*.compat.nativeWebSearchTool","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"models.providers.*.models.*.compat.requiresAssistantAfterToolResult","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"models.providers.*.models.*.compat.requiresMistralToolIds","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"models.providers.*.models.*.compat.requiresOpenAiAnthropicToolPayload","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -3998,6 +3999,8 @@
{"recordType":"path","path":"models.providers.*.models.*.compat.supportsTools","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"models.providers.*.models.*.compat.supportsUsageInStreaming","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"models.providers.*.models.*.compat.thinkingFormat","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"models.providers.*.models.*.compat.toolCallArgumentsEncoding","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"models.providers.*.models.*.compat.toolSchemaProfile","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"models.providers.*.models.*.contextWindow","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"models.providers.*.models.*.cost","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"models.providers.*.models.*.cost.cacheRead","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -4086,7 +4089,10 @@
{"recordType":"path","path":"plugins.entries.bluebubbles.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.bluebubbles.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.brave","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/brave-plugin","help":"OpenClaw Brave plugin (plugin: brave)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.brave.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/brave-plugin Config","help":"Plugin-defined config payload for brave.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.brave.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/brave-plugin Config","help":"Plugin-defined config payload for brave.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.brave.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"plugins.entries.brave.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Brave Search API Key","help":"Brave Search API key (fallback: BRAVE_API_KEY env var).","hasChildren":false}
{"recordType":"path","path":"plugins.entries.brave.config.webSearch.mode","kind":"plugin","type":"string","required":false,"enumValues":["web","llm-context"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Brave Search Mode","help":"Brave Search mode: web or llm-context.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.brave.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/brave-plugin","hasChildren":false}
{"recordType":"path","path":"plugins.entries.brave.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.brave.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
@@ -4198,6 +4204,15 @@
{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.fal","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/fal-provider","help":"OpenClaw fal provider plugin (plugin: fal)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.fal.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/fal-provider Config","help":"Plugin-defined config payload for fal.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.fal.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/fal-provider","hasChildren":false}
{"recordType":"path","path":"plugins.entries.fal.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.fal.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.fal.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.fal.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.fal.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.fal.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.feishu","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/feishu","help":"OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng) (plugin: feishu)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.feishu.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/feishu Config","help":"Plugin-defined config payload for feishu.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.feishu.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/feishu","hasChildren":false}
@@ -4208,7 +4223,10 @@
{"recordType":"path","path":"plugins.entries.feishu.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.feishu.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.firecrawl","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/firecrawl-plugin","help":"OpenClaw Firecrawl plugin (plugin: firecrawl)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.firecrawl.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/firecrawl-plugin Config","help":"Plugin-defined config payload for firecrawl.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.firecrawl.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/firecrawl-plugin Config","help":"Plugin-defined config payload for firecrawl.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.firecrawl.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"plugins.entries.firecrawl.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Firecrawl Search API Key","help":"Firecrawl API key for web search (fallback: FIRECRAWL_API_KEY env var).","hasChildren":false}
{"recordType":"path","path":"plugins.entries.firecrawl.config.webSearch.baseUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Firecrawl Search Base URL","help":"Firecrawl Search base URL override.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.firecrawl.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/firecrawl-plugin","hasChildren":false}
{"recordType":"path","path":"plugins.entries.firecrawl.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.firecrawl.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
@@ -4226,7 +4244,10 @@
{"recordType":"path","path":"plugins.entries.github-copilot.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.github-copilot.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.google","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-plugin","help":"OpenClaw Google plugin (plugin: google)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.google.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-plugin Config","help":"Plugin-defined config payload for google.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.google.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-plugin Config","help":"Plugin-defined config payload for google.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.google.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"plugins.entries.google.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Gemini Search API Key","help":"Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).","hasChildren":false}
{"recordType":"path","path":"plugins.entries.google.config.webSearch.model","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models"],"label":"Gemini Search Model","help":"Gemini model override for web search grounding.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.google.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/google-plugin","hasChildren":false}
{"recordType":"path","path":"plugins.entries.google.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.google.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
@@ -4404,7 +4425,11 @@
{"recordType":"path","path":"plugins.entries.modelstudio.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.modelstudio.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.moonshot","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/moonshot-provider","help":"OpenClaw Moonshot provider plugin (plugin: moonshot)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.moonshot.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/moonshot-provider Config","help":"Plugin-defined config payload for moonshot.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.moonshot.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/moonshot-provider Config","help":"Plugin-defined config payload for moonshot.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.moonshot.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"plugins.entries.moonshot.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Kimi Search API Key","help":"Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).","hasChildren":false}
{"recordType":"path","path":"plugins.entries.moonshot.config.webSearch.baseUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Kimi Search Base URL","help":"Kimi base URL override.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.moonshot.config.webSearch.model","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models"],"label":"Kimi Search Model","help":"Kimi model override.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.moonshot.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/moonshot-provider","hasChildren":false}
{"recordType":"path","path":"plugins.entries.moonshot.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.moonshot.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
@@ -4524,7 +4549,11 @@
{"recordType":"path","path":"plugins.entries.openshell.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.openshell.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.perplexity","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/perplexity-plugin","help":"OpenClaw Perplexity plugin (plugin: perplexity)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.perplexity.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/perplexity-plugin Config","help":"Plugin-defined config payload for perplexity.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.perplexity.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/perplexity-plugin Config","help":"Plugin-defined config payload for perplexity.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.perplexity.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"plugins.entries.perplexity.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Perplexity API Key","help":"Perplexity or OpenRouter API key for web search.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.perplexity.config.webSearch.baseUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Perplexity Base URL","help":"Optional Perplexity/OpenRouter chat-completions base URL override.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.perplexity.config.webSearch.model","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models"],"label":"Perplexity Model","help":"Optional Sonar/OpenRouter model override.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.perplexity.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/perplexity-plugin","hasChildren":false}
{"recordType":"path","path":"plugins.entries.perplexity.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.perplexity.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
@@ -4832,7 +4861,11 @@
{"recordType":"path","path":"plugins.entries.whatsapp.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.whatsapp.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.xai","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xai-plugin","help":"OpenClaw xAI plugin (plugin: xai)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.xai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xai-plugin Config","help":"Plugin-defined config payload for xai.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.xai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xai-plugin Config","help":"Plugin-defined config payload for xai.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.xai.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"plugins.entries.xai.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Grok Search API Key","help":"xAI API key for Grok web search (fallback: XAI_API_KEY env var).","hasChildren":false}
{"recordType":"path","path":"plugins.entries.xai.config.webSearch.inlineCitations","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Inline Citations","help":"Include inline markdown citations in Grok responses.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.xai.config.webSearch.model","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models"],"label":"Grok Search Model","help":"Grok model override for web search.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.xai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/xai-plugin","hasChildren":false}
{"recordType":"path","path":"plugins.entries.xai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.xai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
@@ -5403,55 +5436,64 @@
{"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}
{"recordType":"path","path":"tools.web.search","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"tools.web.search.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"Brave Search API Key","help":"Brave Search API key (fallback: BRAVE_API_KEY env var).","hasChildren":true}
{"recordType":"path","path":"tools.web.search.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
{"recordType":"path","path":"tools.web.search.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.brave","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"tools.web.search.brave.mode","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Brave Search Mode","help":"Brave Search mode: \"web\" (URL results) or \"llm-context\" (pre-extracted page content for LLM grounding).","hasChildren":false}
{"recordType":"path","path":"tools.web.search.brave.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
{"recordType":"path","path":"tools.web.search.brave.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.brave.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.brave.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.brave.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.brave.mode","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.brave.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.cacheTtlMinutes","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["performance","storage","tools"],"label":"Web Search Cache TTL (min)","help":"Cache TTL in minutes for web_search results.","hasChildren":false}
{"recordType":"path","path":"tools.web.search.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Enable Web Search Tool","help":"Enable the web_search tool (requires a provider API key).","hasChildren":false}
{"recordType":"path","path":"tools.web.search.firecrawl","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"tools.web.search.firecrawl.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"Firecrawl Search API Key","help":"Firecrawl API key for web search (fallback: FIRECRAWL_API_KEY env var).","hasChildren":true}
{"recordType":"path","path":"tools.web.search.firecrawl.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
{"recordType":"path","path":"tools.web.search.firecrawl.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.firecrawl.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.firecrawl.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.firecrawl.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Firecrawl Search Base URL","help":"Firecrawl Search base URL override (default: \"https://api.firecrawl.dev\").","hasChildren":false}
{"recordType":"path","path":"tools.web.search.firecrawl.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.firecrawl.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.gemini","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"tools.web.search.gemini.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"Gemini Search API Key","help":"Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).","hasChildren":true}
{"recordType":"path","path":"tools.web.search.gemini.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
{"recordType":"path","path":"tools.web.search.gemini.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.gemini.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.gemini.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.gemini.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models","tools"],"label":"Gemini Search Model","help":"Gemini model override (default: \"gemini-2.5-flash\").","hasChildren":false}
{"recordType":"path","path":"tools.web.search.gemini.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.gemini.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.grok","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"tools.web.search.grok.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"Grok Search API Key","help":"Grok (xAI) API key (fallback: XAI_API_KEY env var).","hasChildren":true}
{"recordType":"path","path":"tools.web.search.grok.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
{"recordType":"path","path":"tools.web.search.grok.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.grok.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.grok.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.grok.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.grok.inlineCitations","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.grok.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models","tools"],"label":"Grok Search Model","help":"Grok model override (default: \"grok-4-1-fast\").","hasChildren":false}
{"recordType":"path","path":"tools.web.search.grok.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.kimi","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"tools.web.search.kimi.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"Kimi Search API Key","help":"Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).","hasChildren":true}
{"recordType":"path","path":"tools.web.search.kimi.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
{"recordType":"path","path":"tools.web.search.kimi.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.kimi.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.kimi.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.kimi.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Kimi Search Base URL","help":"Kimi base URL override (default: \"https://api.moonshot.ai/v1\").","hasChildren":false}
{"recordType":"path","path":"tools.web.search.kimi.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models","tools"],"label":"Kimi Search Model","help":"Kimi model override (default: \"moonshot-v1-128k\").","hasChildren":false}
{"recordType":"path","path":"tools.web.search.kimi.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.kimi.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.maxResults","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Search Max Results","help":"Number of results to return (1-10).","hasChildren":false}
{"recordType":"path","path":"tools.web.search.perplexity","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"tools.web.search.perplexity.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"Perplexity API Key","help":"Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var). Direct Perplexity keys default to the Search API; OpenRouter keys use Sonar chat completions.","hasChildren":true}
{"recordType":"path","path":"tools.web.search.perplexity.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
{"recordType":"path","path":"tools.web.search.perplexity.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.perplexity.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.perplexity.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.perplexity.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Perplexity Base URL","help":"Optional Perplexity/OpenRouter chat-completions base URL override. Setting this opts Perplexity into the legacy Sonar/OpenRouter compatibility path.","hasChildren":false}
{"recordType":"path","path":"tools.web.search.perplexity.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models","tools"],"label":"Perplexity Model","help":"Optional Sonar/OpenRouter model override (default: \"perplexity/sonar-pro\"). Setting this opts Perplexity into the legacy chat-completions compatibility path.","hasChildren":false}
{"recordType":"path","path":"tools.web.search.provider","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Web Search Provider","help":"Search provider (\"brave\", \"firecrawl\", \"gemini\", \"grok\", \"kimi\", or \"perplexity\"). Auto-detected from available API keys if omitted.","hasChildren":false}
{"recordType":"path","path":"tools.web.search.perplexity.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.perplexity.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.search.provider","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Web Search Provider","help":"Search provider id. Auto-detected from available API keys if omitted.","hasChildren":false}
{"recordType":"path","path":"tools.web.search.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Search Timeout (sec)","help":"Timeout in seconds for web_search requests.","hasChildren":false}
{"recordType":"path","path":"ui","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"UI","help":"UI presentation settings for accenting and assistant identity shown in control surfaces. Use this for branding and readability customization without changing runtime behavior.","hasChildren":true}
{"recordType":"path","path":"ui.assistant","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Assistant Appearance","help":"Assistant display identity settings for name and avatar shown in UI surfaces. Keep these values aligned with your operator-facing persona and support expectations.","hasChildren":true}
{"recordType":"path","path":"ui.assistant.avatar","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Assistant Avatar","help":"Assistant avatar image source used in UI surfaces (URL, path, or data URI depending on runtime support). Use trusted assets and consistent branding dimensions for clean rendering.","hasChildren":false}
{"recordType":"path","path":"ui.assistant.name","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Assistant Name","help":"Display name shown for the assistant in UI views, chat chrome, and status contexts. Keep this stable so operators can reliably identify which assistant persona is active.","hasChildren":false}
{"recordType":"path","path":"ui.seamColor","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Accent Color","help":"Primary accent/seam color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.","hasChildren":false}
{"recordType":"path","path":"ui.seamColor","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Accent Color","help":"Primary accent color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.","hasChildren":false}
{"recordType":"path","path":"update","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Updates","help":"Update-channel and startup-check behavior for keeping OpenClaw runtime versions current. Use conservative channels in production and more experimental channels only in controlled environments.","hasChildren":true}
{"recordType":"path","path":"update.auto","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"update.auto.betaCheckIntervalHours","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Auto Update Beta Check Interval (hours)","help":"How often beta-channel checks run in hours (default: 1).","hasChildren":false}

View File

@@ -1 +0,0 @@
docs.openclaw.ai

View File

@@ -17,7 +17,7 @@ Hooks are small scripts that run when something happens. There are two kinds:
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
- **Webhooks**: external HTTP webhooks that let other systems trigger work in OpenClaw. See [Webhook Hooks](/automation/webhook) or use `openclaw webhooks` for Gmail helper commands.
Hooks can also be bundled inside plugins; see [Plugins](/tools/plugin#plugin-hooks).
Hooks can also be bundled inside plugins; see [Plugin hooks](/plugins/architecture#provider-runtime-hooks).
Common uses:

View File

@@ -7,6 +7,8 @@ read_when:
- You are configuring IRC allowlists, group policy, or mention gating
---
# IRC
Use IRC when you want OpenClaw in classic channels (`#room`) and direct messages.
IRC ships as an extension plugin, but it is configured in the main config under `channels.irc`.

View File

@@ -9,6 +9,21 @@ title: "WhatsApp"
Status: production-ready via WhatsApp Web (Baileys). Gateway owns linked session(s).
## Install (on demand)
- Onboarding (`openclaw onboard`) and `openclaw channels add --channel whatsapp`
prompt to install the WhatsApp plugin the first time you select it.
- `openclaw channels login --channel whatsapp` also offers the install flow when
the plugin is not present yet.
- Dev channel + git checkout: defaults to the local plugin path.
- Stable/Beta: defaults to the npm package `@openclaw/whatsapp`.
Manual install stays available:
```bash
openclaw plugins install @openclaw/whatsapp
```
<CardGroup cols={3}>
<Card title="Pairing" icon="link" href="/channels/pairing">
Default DM policy is pairing for unknown senders.

View File

@@ -13,7 +13,7 @@ Manage agent hooks (event-driven automations for commands like `/new`, `/reset`,
Related:
- Hooks: [Hooks](/automation/hooks)
- Plugin hooks: [Plugins](/tools/plugin#plugin-hooks)
- Plugin hooks: [Plugin hooks](/plugins/architecture#provider-runtime-hooks)
## List All Hooks

View File

@@ -168,7 +168,7 @@ Each plugin is classified by what it actually registers at runtime:
- **hook-only** — only hooks, no capabilities or surfaces
- **non-capability** — tools/commands/services but no capabilities
See [Plugins](/tools/plugin#plugin-shapes) for more on the capability model.
See [Plugin shapes](/plugins/architecture#plugin-shapes) for more on the capability model.
The `--json` flag outputs a machine-readable report suitable for scripting and
auditing.

View File

@@ -92,7 +92,7 @@ These run inside the agent loop or gateway pipeline:
- **`session_start` / `session_end`**: session lifecycle boundaries.
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
See [Plugins](/tools/plugin#plugin-hooks) for the hook API and registration details.
See [Plugin hooks](/plugins/architecture#provider-runtime-hooks) for the hook API and registration details.
## Streaming + partial replies

View File

@@ -5,6 +5,8 @@ read_when:
title: "Features"
---
# Features
## Highlights
<Columns>

View File

@@ -34,7 +34,7 @@ For model selection rules, see [/concepts/models](/concepts/models).
`fetchUsageSnapshot`.
- Note: provider runtime `capabilities` is shared runner metadata (provider
family, transcript/tooling quirks, transport/cache hints). It is not the
same as the [public capability model](/tools/plugin#public-capability-model)
same as the [public capability model](/plugins/architecture#public-capability-model)
which describes what a plugin registers (text inference, speech, etc.).
## Plugin-owned provider behavior

View File

@@ -75,6 +75,25 @@ Behavior:
- Returns messages array in the raw transcript format.
- When given a `sessionId`, OpenClaw resolves it to the corresponding session key (missing ids error).
## Gateway session history and live transcript APIs
Control UI and gateway clients can use the lower level history and live transcript surfaces directly.
HTTP:
- `GET /sessions/{sessionKey}/history`
- Query params: `limit`, `cursor`, `includeTools=1`, `follow=1`
- Unknown sessions return HTTP `404` with `error.type = "not_found"`
- `follow=1` upgrades the response to an SSE stream of transcript updates for that session
WebSocket:
- `sessions.subscribe` subscribes to all session lifecycle and transcript events visible to the client
- `sessions.messages.subscribe { key }` subscribes only to `session.message` events for one session
- `sessions.messages.unsubscribe { key }` removes that targeted transcript subscription
- `session.message` carries appended transcript messages plus live usage metadata when available
- `sessions.changed` emits `phase: "message"` for transcript appends so session lists can refresh counters and previews
## sessions_send
Send a message into another session.

View File

@@ -990,9 +990,8 @@
"pages": [
"tools/apply-patch",
"brave-search",
"perplexity",
"tools/btw",
"tools/diffs",
"tools/pdf",
"tools/elevated",
"tools/exec",
"tools/exec-approvals",
@@ -1000,10 +999,11 @@
"tools/llm-task",
"tools/lobster",
"tools/loop-detection",
"tools/pdf",
"perplexity",
"tools/reactions",
"tools/thinking",
"tools/web",
"tools/btw"
"tools/web"
]
},
{
@@ -1037,6 +1037,8 @@
{
"group": "Extensions",
"pages": [
"plugins/building-extensions",
"plugins/architecture",
"plugins/community",
"plugins/bundles",
"plugins/voice-call",
@@ -1101,11 +1103,13 @@
"providers/claude-max-api-proxy",
"providers/deepgram",
"providers/github-copilot",
"providers/google",
"providers/huggingface",
"providers/kilocode",
"providers/litellm",
"providers/glm",
"providers/minimax",
"providers/modelstudio",
"providers/moonshot",
"providers/mistral",
"providers/nvidia",
@@ -1114,13 +1118,17 @@
"providers/opencode-go",
"providers/opencode",
"providers/openrouter",
"providers/perplexity-provider",
"providers/qianfan",
"providers/qwen",
"providers/sglang",
"providers/synthetic",
"providers/together",
"providers/vercel-ai-gateway",
"providers/venice",
"providers/vllm",
"providers/volcengine",
"providers/xai",
"providers/xiaomi",
"providers/zai"
]
@@ -1201,6 +1209,7 @@
"pages": [
"gateway/security/index",
"gateway/sandboxing",
"gateway/openshell",
"gateway/sandbox-vs-tool-policy-vs-elevated"
]
},
@@ -1574,13 +1583,13 @@
"pages": [
"zh-CN/tools/apply-patch",
"zh-CN/brave-search",
"zh-CN/perplexity",
"zh-CN/tools/elevated",
"zh-CN/tools/exec",
"zh-CN/tools/exec-approvals",
"zh-CN/tools/firecrawl",
"zh-CN/tools/llm-task",
"zh-CN/tools/lobster",
"zh-CN/perplexity",
"zh-CN/tools/reactions",
"zh-CN/tools/thinking",
"zh-CN/tools/web"
@@ -1616,6 +1625,7 @@
{
"group": "扩展",
"pages": [
"zh-CN/plugins/architecture",
"zh-CN/plugins/voice-call",
"zh-CN/plugins/zalouser",
"zh-CN/plugins/manifest",

View File

@@ -5,6 +5,8 @@ read_when:
title: "Network model"
---
# Network Model
Most operations flow through the Gateway (`openclaw gateway`), a single long-running
process that owns channel connections and the WebSocket control plane.

307
docs/gateway/openshell.md Normal file
View File

@@ -0,0 +1,307 @@
---
title: OpenShell
summary: "Use OpenShell as a managed sandbox backend for OpenClaw agents"
read_when:
- You want cloud-managed sandboxes instead of local Docker
- You are setting up the OpenShell plugin
- You need to choose between mirror and remote workspace modes
---
# OpenShell
OpenShell is a managed sandbox backend for OpenClaw. Instead of running Docker
containers locally, OpenClaw delegates sandbox lifecycle to the `openshell` CLI,
which provisions remote environments with SSH-based command execution.
The OpenShell plugin reuses the same core SSH transport and remote filesystem
bridge as the generic [SSH backend](/gateway/sandboxing#ssh-backend). It adds
OpenShell-specific lifecycle (`sandbox create/get/delete`, `sandbox ssh-config`)
and an optional `mirror` workspace mode.
## Prerequisites
- The `openshell` CLI installed and on `PATH` (or set a custom path via
`plugins.entries.openshell.config.command`)
- An OpenShell account with sandbox access
- OpenClaw Gateway running on the host
## Quick start
1. Enable the plugin and set the sandbox backend:
```json5
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "openshell",
scope: "session",
workspaceAccess: "rw",
},
},
},
plugins: {
entries: {
openshell: {
enabled: true,
config: {
from: "openclaw",
mode: "remote",
},
},
},
},
}
```
2. Restart the Gateway. On the next agent turn, OpenClaw creates an OpenShell
sandbox and routes tool execution through it.
3. Verify:
```bash
openclaw sandbox list
openclaw sandbox explain
```
## Workspace modes
This is the most important decision when using OpenShell.
### `mirror`
Use `plugins.entries.openshell.config.mode: "mirror"` when you want the **local
workspace to stay canonical**.
Behavior:
- Before `exec`, OpenClaw syncs the local workspace into the OpenShell sandbox.
- After `exec`, OpenClaw syncs the remote workspace back to the local workspace.
- File tools still operate through the sandbox bridge, but the local workspace
remains the source of truth between turns.
Best for:
- You edit files locally outside OpenClaw and want those changes visible in the
sandbox automatically.
- You want the OpenShell sandbox to behave as much like the Docker backend as
possible.
- You want the host workspace to reflect sandbox writes after each exec turn.
Tradeoff: extra sync cost before and after each exec.
### `remote`
Use `plugins.entries.openshell.config.mode: "remote"` when you want the
**OpenShell workspace to become canonical**.
Behavior:
- When the sandbox is first created, OpenClaw seeds the remote workspace from
the local workspace once.
- After that, `exec`, `read`, `write`, `edit`, and `apply_patch` operate
directly against the remote OpenShell workspace.
- OpenClaw does **not** sync remote changes back into the local workspace.
- Prompt-time media reads still work because file and media tools read through
the sandbox bridge.
Best for:
- The sandbox should live primarily on the remote side.
- You want lower per-turn sync overhead.
- You do not want host-local edits to silently overwrite remote sandbox state.
Important: if you edit files on the host outside OpenClaw after the initial seed,
the remote sandbox does **not** see those changes. Use
`openclaw sandbox recreate` to re-seed.
### Choosing a mode
| | `mirror` | `remote` |
| ------------------------ | -------------------------- | ------------------------- |
| **Canonical workspace** | Local host | Remote OpenShell |
| **Sync direction** | Bidirectional (each exec) | One-time seed |
| **Per-turn overhead** | Higher (upload + download) | Lower (direct remote ops) |
| **Local edits visible?** | Yes, on next exec | No, until recreate |
| **Best for** | Development workflows | Long-running agents, CI |
## Configuration reference
All OpenShell config lives under `plugins.entries.openshell.config`:
| Key | Type | Default | Description |
| ------------------------- | ------------------------ | ------------- | ----------------------------------------------------- |
| `mode` | `"mirror"` or `"remote"` | `"mirror"` | Workspace sync mode |
| `command` | `string` | `"openshell"` | Path or name of the `openshell` CLI |
| `from` | `string` | `"openclaw"` | Sandbox source for first-time create |
| `gateway` | `string` | — | OpenShell gateway name (`--gateway`) |
| `gatewayEndpoint` | `string` | — | OpenShell gateway endpoint URL (`--gateway-endpoint`) |
| `policy` | `string` | — | OpenShell policy ID for sandbox creation |
| `providers` | `string[]` | `[]` | Provider names to attach when sandbox is created |
| `gpu` | `boolean` | `false` | Request GPU resources |
| `autoProviders` | `boolean` | `true` | Pass `--auto-providers` during sandbox create |
| `remoteWorkspaceDir` | `string` | `"/sandbox"` | Primary writable workspace inside the sandbox |
| `remoteAgentWorkspaceDir` | `string` | `"/agent"` | Agent workspace mount path (for read-only access) |
| `timeoutSeconds` | `number` | `120` | Timeout for `openshell` CLI operations |
Sandbox-level settings (`mode`, `scope`, `workspaceAccess`) are configured under
`agents.defaults.sandbox` as with any backend. See
[Sandboxing](/gateway/sandboxing) for the full matrix.
## Examples
### Minimal remote setup
```json5
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "openshell",
},
},
},
plugins: {
entries: {
openshell: {
enabled: true,
config: {
from: "openclaw",
mode: "remote",
},
},
},
},
}
```
### Mirror mode with GPU
```json5
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "openshell",
scope: "agent",
workspaceAccess: "rw",
},
},
},
plugins: {
entries: {
openshell: {
enabled: true,
config: {
from: "openclaw",
mode: "mirror",
gpu: true,
providers: ["openai"],
timeoutSeconds: 180,
},
},
},
},
}
```
### Per-agent OpenShell with custom gateway
```json5
{
agents: {
defaults: {
sandbox: { mode: "off" },
},
list: [
{
id: "researcher",
sandbox: {
mode: "all",
backend: "openshell",
scope: "agent",
workspaceAccess: "rw",
},
},
],
},
plugins: {
entries: {
openshell: {
enabled: true,
config: {
from: "openclaw",
mode: "remote",
gateway: "lab",
gatewayEndpoint: "https://lab.example",
policy: "strict",
},
},
},
},
}
```
## Lifecycle management
OpenShell sandboxes are managed through the normal sandbox CLI:
```bash
# List all sandbox runtimes (Docker + OpenShell)
openclaw sandbox list
# Inspect effective policy
openclaw sandbox explain
# Recreate (deletes remote workspace, re-seeds on next use)
openclaw sandbox recreate --all
```
For `remote` mode, **recreate is especially important**: it deletes the canonical
remote workspace for that scope. The next use seeds a fresh remote workspace from
the local workspace.
For `mirror` mode, recreate mainly resets the remote execution environment because
the local workspace remains canonical.
### When to recreate
Recreate after changing any of these:
- `agents.defaults.sandbox.backend`
- `plugins.entries.openshell.config.from`
- `plugins.entries.openshell.config.mode`
- `plugins.entries.openshell.config.policy`
```bash
openclaw sandbox recreate --all
```
## Current limitations
- Sandbox browser is not supported on the OpenShell backend.
- `sandbox.docker.binds` does not apply to OpenShell.
- Docker-specific runtime knobs under `sandbox.docker.*` apply only to the Docker
backend.
## How it works
1. OpenClaw calls `openshell sandbox create` (with `--from`, `--gateway`,
`--policy`, `--providers`, `--gpu` flags as configured).
2. OpenClaw calls `openshell sandbox ssh-config <name>` to get SSH connection
details for the sandbox.
3. Core writes the SSH config to a temp file and opens an SSH session using the
same remote filesystem bridge as the generic SSH backend.
4. In `mirror` mode: sync local to remote before exec, run, sync back after exec.
5. In `remote` mode: seed once on create, then operate directly on the remote
workspace.
## See also
- [Sandboxing](/gateway/sandboxing) -- modes, scopes, and backend comparison
- [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) -- debugging blocked tools
- [Multi-Agent Sandbox and Tools](/tools/multi-agent-sandbox-tools) -- per-agent overrides
- [Sandbox CLI](/cli/sandbox) -- `openclaw sandbox` commands

View File

@@ -126,3 +126,9 @@ Fix-it keys (pick one):
### "I thought this was main, why is it sandboxed?"
In `"non-main"` mode, group/channel keys are _not_ main. Use the main session key (shown by `sandbox explain`) or switch mode to `"off"`.
## See also
- [Sandboxing](/gateway/sandboxing) -- full sandbox reference (modes, scopes, backends, images)
- [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) -- per-agent overrides and precedence
- [Elevated Mode](/tools/elevated)

View File

@@ -65,6 +65,18 @@ Not sandboxed:
SSH-specific config lives under `agents.defaults.sandbox.ssh`.
OpenShell-specific config lives under `plugins.entries.openshell.config`.
### Choosing a backend
| | Docker | SSH | OpenShell |
| ------------------- | -------------------------------- | ------------------------------ | --------------------------------------------------- |
| **Where it runs** | Local container | Any SSH-accessible host | OpenShell managed sandbox |
| **Setup** | `scripts/sandbox-setup.sh` | SSH key + target host | OpenShell plugin enabled |
| **Workspace model** | Bind-mount or copy | Remote-canonical (seed once) | `mirror` or `remote` |
| **Network control** | `docker.network` (default: none) | Depends on remote host | Depends on OpenShell |
| **Browser sandbox** | Supported | Not supported | Not supported yet |
| **Bind mounts** | `docker.binds` | N/A | N/A |
| **Best for** | Local dev, full isolation | Offloading to a remote machine | Managed remote sandboxes with optional two-way sync |
### SSH backend
Use `backend: "ssh"` when you want OpenClaw to sandbox `exec`, file tools, and media reads on
@@ -120,6 +132,18 @@ Important consequences:
- Browser sandboxing is not supported on the SSH backend.
- `sandbox.docker.*` settings do not apply to the SSH backend.
### OpenShell backend
Use `backend: "openshell"` when you want OpenClaw to sandbox tools in an
OpenShell-managed remote environment. For the full setup guide, configuration
reference, and workspace mode comparison, see the dedicated
[OpenShell page](/gateway/openshell).
OpenShell reuses the same core SSH transport and remote filesystem bridge as the
generic SSH backend, and adds OpenShell-specific lifecycle
(`sandbox create/get/delete`, `sandbox ssh-config`) plus the optional `mirror`
workspace mode.
```json5
{
agents: {
@@ -153,9 +177,6 @@ OpenShell modes:
- `mirror` (default): local workspace stays canonical. OpenClaw syncs local files into OpenShell before exec and syncs the remote workspace back after exec.
- `remote`: OpenShell workspace is canonical after the sandbox is created. OpenClaw seeds the remote workspace once from the local workspace, then file tools and exec run directly against the remote sandbox without syncing changes back.
OpenShell reuses the same core SSH transport and remote filesystem bridge as the generic SSH backend.
The plugin adds OpenShell-specific lifecycle (`sandbox create/get/delete`, `sandbox ssh-config`) and the optional `mirror` mode.
Remote transport details:
- OpenClaw asks OpenShell for sandbox-specific SSH config via `openshell sandbox ssh-config <name>`.
@@ -168,11 +189,11 @@ Current OpenShell limitations:
- `sandbox.docker.binds` is not supported on the OpenShell backend
- Docker-specific runtime knobs under `sandbox.docker.*` still apply only to the Docker backend
## OpenShell workspace modes
#### Workspace modes
OpenShell has two workspace models. This is the part that matters most in practice.
### `mirror`
##### `mirror`
Use `plugins.entries.openshell.config.mode: "mirror"` when you want the **local workspace to stay canonical**.
@@ -192,7 +213,7 @@ Tradeoff:
- extra sync cost before and after exec
### `remote`
##### `remote`
Use `plugins.entries.openshell.config.mode: "remote"` when you want the **OpenShell workspace to become canonical**.
@@ -219,7 +240,7 @@ Use this when:
Choose `mirror` if you think of the sandbox as a temporary execution environment.
Choose `remote` if you think of the sandbox as the real workspace.
## OpenShell lifecycle
#### OpenShell lifecycle
OpenShell sandboxes are still managed through the normal sandbox lifecycle:
@@ -441,6 +462,8 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
## Related docs
- [OpenShell](/gateway/openshell) -- managed sandbox backend setup, workspace modes, and config reference
- [Sandbox Configuration](/gateway/configuration#agentsdefaults-sandbox)
- [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools)
- [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) -- debugging "why is this blocked?"
- [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) -- per-agent overrides and precedence
- [Security](/gateway/security)

View File

@@ -52,6 +52,10 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- Runs in CI
- No real keys required
- Should be fast and stable
- Scheduler note:
- `pnpm test` now keeps a small checked-in behavioral manifest for true pool/isolation overrides and a separate timing snapshot for the slowest unit files.
- Shared unit coverage stays on, but the wrapper peels the heaviest measured files into dedicated lanes instead of relying on a growing hand-maintained exclusion list.
- Refresh the timing snapshot with `pnpm test:perf:update-timings` after major suite shape changes.
- Embedded runner note:
- When you change message-tool discovery inputs or compaction runtime context,
keep both levels of coverage.
@@ -457,6 +461,55 @@ Future evals should stay deterministic first:
- A small suite of skill-focused scenarios (use vs avoid, gating, prompt injection).
- Optional live evals (opt-in, env-gated) only after the CI-safe suite is in place.
## Contract tests (plugin and channel shape)
Contract tests verify that every registered plugin and channel conforms to its
interface contract. They iterate over all discovered plugins and run a suite of
shape and behavior assertions.
### Commands
- All contracts: `pnpm test:contracts`
- Channel contracts only: `pnpm test:contracts:channels`
- Provider contracts only: `pnpm test:contracts:plugins`
### Channel contracts
Located in `src/channels/plugins/contracts/*.contract.test.ts`:
- **plugin** - Basic plugin shape (id, name, capabilities)
- **setup** - Setup wizard contract
- **session-binding** - Session binding behavior
- **outbound-payload** - Message payload structure
- **inbound** - Inbound message handling
- **actions** - Channel action handlers
- **threading** - Thread ID handling
- **directory** - Directory/roster API
- **group-policy** - Group policy enforcement
- **status** - Channel status probes
- **registry** - Plugin registry shape
### Provider contracts
Located in `src/plugins/contracts/*.contract.test.ts`:
- **auth** - Auth flow contract
- **auth-choice** - Auth choice/selection
- **catalog** - Model catalog API
- **discovery** - Plugin discovery
- **loader** - Plugin loading
- **runtime** - Provider runtime
- **shape** - Plugin shape/interface
- **wizard** - Setup wizard
### When to run
- After changing plugin-sdk exports or subpaths
- After adding or modifying a channel or provider plugin
- After refactoring plugin registration or discovery
Contract tests run in CI and do not require real API keys.
## Adding regressions (guidance)
When you fix a provider/model issue discovered in live:

View File

@@ -63,7 +63,7 @@ Example:
}
```
Reference: [/tools/plugin#distribution-npm](/tools/plugin#distribution-npm)
Reference: [Plugin architecture](/plugins/architecture)
## Decision tree

View File

@@ -1,77 +1,119 @@
---
summary: "Stable, beta, and dev channels: semantics, switching, and tagging"
summary: "Stable, beta, and dev channels: semantics, switching, pinning, and tagging"
read_when:
- You want to switch between stable/beta/dev
- You want to pin a specific version, tag, or SHA
- You are tagging or publishing prereleases
title: "Development Channels"
---
# Development channels
Last updated: 2026-01-21
OpenClaw ships three update channels:
- **stable**: npm dist-tag `latest`.
- **stable**: npm dist-tag `latest`. Recommended for most users.
- **beta**: npm dist-tag `beta` (builds under test).
- **dev**: moving head of `main` (git). npm dist-tag: `dev` (when published).
The `main` branch is for experimentation and active development. It may contain
incomplete features or breaking changes. Do not use it for production gateways.
We ship builds to **beta**, test them, then **promote a vetted build to `latest`**
without changing the version number dist-tags are the source of truth for npm installs.
without changing the version number -- dist-tags are the source of truth for npm installs.
## Switching channels
Git checkout:
```bash
openclaw update --channel stable
openclaw update --channel beta
openclaw update --channel dev
```
- `stable`/`beta` check out the latest matching tag (often the same tag).
- `dev` switches to `main` and rebases on the upstream.
`--channel` persists your choice in config (`update.channel`) and aligns the
install method:
npm/pnpm global install:
- **`stable`/`beta`** (package installs): updates via the matching npm dist-tag.
- **`stable`/`beta`** (git installs): checks out the latest matching git tag.
- **`dev`**: ensures a git checkout (default `~/openclaw`, override with
`OPENCLAW_GIT_DIR`), switches to `main`, rebases on upstream, builds, and
installs the global CLI from that checkout.
Tip: if you want stable + dev in parallel, keep two clones and point your
gateway at the stable one.
## One-off version or tag targeting
Use `--tag` to target a specific dist-tag, version, or package spec for a single
update **without** changing your persisted channel:
```bash
openclaw update --channel stable
openclaw update --channel beta
openclaw update --channel dev
# Install a specific version
openclaw update --tag 2026.3.14
# Install from the beta dist-tag (one-off, does not persist)
openclaw update --tag beta
# Install from GitHub main branch (npm tarball)
openclaw update --tag main
# Install a specific npm package spec
openclaw update --tag openclaw@2026.3.12
```
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
Notes:
When you **explicitly** switch channels with `--channel`, OpenClaw also aligns
the install method:
- `--tag` applies to **package (npm) installs only**. Git installs ignore it.
- The tag is not persisted. Your next `openclaw update` uses your configured
channel as usual.
- Downgrade protection: if the target version is older than your current version,
OpenClaw prompts for confirmation (skip with `--yes`).
- `dev` ensures a git checkout (default `~/openclaw`, override with `OPENCLAW_GIT_DIR`),
updates it, and installs the global CLI from that checkout.
- `stable`/`beta` installs from npm using the matching dist-tag.
## Dry run
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
Preview what `openclaw update` would do without making changes:
```bash
openclaw update --dry-run
openclaw update --channel beta --dry-run
openclaw update --tag 2026.3.14 --dry-run
openclaw update --dry-run --json
```
The dry run shows the effective channel, target version, planned actions, and
whether a downgrade confirmation would be required.
## Plugins and channels
When you switch channels with `openclaw update`, OpenClaw also syncs plugin sources:
When you switch channels with `openclaw update`, OpenClaw also syncs plugin
sources:
- `dev` prefers bundled plugins from the git checkout.
- `stable` and `beta` restore npm-installed plugin packages.
- npm-installed plugins are updated after the core update completes.
## Checking current status
```bash
openclaw update status
```
Shows the active channel, install kind (git or package), current version, and
source (config, git tag, git branch, or default).
## Tagging best practices
- Tag releases you want git checkouts to land on (`vYYYY.M.D` for stable, `vYYYY.M.D-beta.N` for beta).
- Tag releases you want git checkouts to land on (`vYYYY.M.D` for stable,
`vYYYY.M.D-beta.N` for beta).
- `vYYYY.M.D.beta.N` is also recognized for compatibility, but prefer `-beta.N`.
- Legacy `vYYYY.M.D-<patch>` tags are still recognized as stable (non-beta).
- Keep tags immutable: never move or reuse a tag.
- npm dist-tags remain the source of truth for npm installs:
- `latest` stable
- `beta` candidate build
- `dev` main snapshot (optional)
- `latest` -> stable
- `beta` -> candidate build
- `dev` -> main snapshot (optional)
## macOS app availability
Beta and dev builds may **not** include a macOS app release. Thats OK:
Beta and dev builds may **not** include a macOS app release. That is OK:
- The git tag and npm dist-tag can still be published.
- Call out no macOS build for this beta in release notes or changelog.
- Call out "no macOS build for this beta" in release notes or changelog.

View File

@@ -286,6 +286,7 @@ Available families:
- `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add`
- `callLog.search`
- `sms.search`
- `motion.activity`, `motion.pedometer`
Example invokes:

View File

@@ -164,4 +164,5 @@ See [Camera node](/nodes/camera) for parameters and CLI helpers.
- `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add`
- `callLog.search`
- `sms.search`
- `motion.activity`, `motion.pedometer`

1335
docs/plugins/architecture.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,205 @@
---
title: "Building Extensions"
summary: "Step-by-step guide for creating OpenClaw channel and provider extensions"
read_when:
- You want to create a new OpenClaw plugin or extension
- You need to understand the plugin SDK import patterns
- You are adding a new channel or provider to OpenClaw
---
# Building Extensions
This guide walks through creating an OpenClaw extension from scratch. Extensions
can add channels, model providers, tools, or other capabilities.
## Prerequisites
- OpenClaw repository cloned and dependencies installed (`pnpm install`)
- Familiarity with TypeScript (ESM)
## Extension structure
Every extension lives under `extensions/<name>/` and follows this layout:
```
extensions/my-channel/
├── package.json # npm metadata + openclaw config
├── index.ts # Entry point (defineChannelPluginEntry)
├── setup-entry.ts # Setup wizard (optional)
├── api.ts # Public contract barrel (optional)
├── runtime-api.ts # Internal runtime barrel (optional)
└── src/
├── channel.ts # Channel adapter implementation
├── runtime.ts # Runtime wiring
└── *.test.ts # Colocated tests
```
## Step 1: Create the package
Create `extensions/my-channel/package.json`:
```json
{
"name": "@openclaw/my-channel",
"version": "2026.1.1",
"description": "OpenClaw My Channel plugin",
"type": "module",
"dependencies": {},
"openclaw": {
"extensions": ["./index.ts"],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "my-channel",
"label": "My Channel",
"selectionLabel": "My Channel (plugin)",
"docsPath": "/channels/my-channel",
"docsLabel": "my-channel",
"blurb": "Short description of the channel.",
"order": 80
},
"install": {
"npmSpec": "@openclaw/my-channel",
"localPath": "extensions/my-channel"
}
}
}
```
The `openclaw` field tells the plugin system what your extension provides.
For provider plugins, use `providers` instead of `channel`.
## Step 2: Define the entry point
Create `extensions/my-channel/index.ts`:
```typescript
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
export default defineChannelPluginEntry({
id: "my-channel",
name: "My Channel",
description: "Connects OpenClaw to My Channel",
plugin: {
// Channel adapter implementation
},
});
```
For provider plugins, use `definePluginEntry` instead.
## Step 3: Import from focused subpaths
The plugin SDK exposes many focused subpaths. Always import from specific
subpaths rather than the monolithic root:
```typescript
// Correct: focused subpaths
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import { createOptionalChannelSetupSurface } from "openclaw/plugin-sdk/channel-setup";
import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
// Wrong: monolithic root (lint will reject this)
import { ... } from "openclaw/plugin-sdk";
```
Common subpaths:
| Subpath | Purpose |
| ----------------------------------- | ------------------------------------ |
| `plugin-sdk/core` | Plugin entry definitions, base types |
| `plugin-sdk/channel-setup` | Optional setup adapters/wizards |
| `plugin-sdk/channel-pairing` | DM pairing primitives |
| `plugin-sdk/channel-reply-pipeline` | Prefix + typing reply wiring |
| `plugin-sdk/channel-config-schema` | Config schema builders |
| `plugin-sdk/channel-policy` | Group/DM policy helpers |
| `plugin-sdk/secret-input` | Secret input parsing/helpers |
| `plugin-sdk/webhook-ingress` | Webhook request/target helpers |
| `plugin-sdk/runtime-store` | Persistent plugin storage |
| `plugin-sdk/allow-from` | Allowlist resolution |
| `plugin-sdk/reply-payload` | Message reply types |
| `plugin-sdk/provider-onboard` | Provider onboarding config patches |
| `plugin-sdk/testing` | Test utilities |
Use the narrowest primitive that matches the job. Reach for `channel-runtime`
or other larger helper barrels only when a dedicated subpath does not exist yet.
## Step 4: Use local barrels for internal imports
Within your extension, create barrel files for internal code sharing instead
of importing through the plugin SDK:
```typescript
// api.ts — public contract for this extension
export { MyChannelConfig } from "./src/config.js";
export { MyChannelRuntime } from "./src/runtime.js";
// runtime-api.ts — internal-only exports (not for production consumers)
export { internalHelper } from "./src/helpers.js";
```
**Self-import guardrail**: never import your own extension back through its
published SDK contract path from production files. Route internal imports
through `./api.ts` or `./runtime-api.ts` instead. The SDK contract is for
external consumers only.
## Step 5: Add a plugin manifest
Create `openclaw.plugin.json` in your extension root:
```json
{
"id": "my-channel",
"kind": "channel",
"channels": ["my-channel"],
"name": "My Channel Plugin",
"description": "Connects OpenClaw to My Channel"
}
```
See [Plugin manifest](/plugins/manifest) for the full schema.
## Step 6: Test with contract tests
OpenClaw runs contract tests against all registered plugins. After adding your
extension, run:
```bash
pnpm test:contracts:channels # channel plugins
pnpm test:contracts:plugins # provider plugins
```
Contract tests verify your plugin conforms to the expected interface (setup
wizard, session binding, message handling, group policy, etc.).
For unit tests, import test helpers from the public testing surface:
```typescript
import { createTestRuntime } from "openclaw/plugin-sdk/testing";
```
## Lint enforcement
Three scripts enforce SDK boundaries:
1. **No monolithic root imports**`openclaw/plugin-sdk` root is rejected
2. **No direct src/ imports** — extensions cannot import `../../src/` directly
3. **No self-imports** — extensions cannot import their own `plugin-sdk/<name>` subpath
Run `pnpm check` to verify all boundaries before committing.
## Checklist
Before submitting your extension:
- [ ] `package.json` has correct `openclaw` metadata
- [ ] Entry point uses `defineChannelPluginEntry` or `definePluginEntry`
- [ ] All imports use focused `plugin-sdk/<subpath>` paths
- [ ] Internal imports use local barrels, not SDK self-imports
- [ ] `openclaw.plugin.json` manifest is present and valid
- [ ] Contract tests pass (`pnpm test:contracts`)
- [ ] Unit tests colocated as `*.test.ts`
- [ ] `pnpm check` passes (lint + format)
- [ ] Doc page created under `docs/channels/` or `docs/plugins/`

View File

@@ -33,7 +33,7 @@ plugin errors and block config validation.
See the full plugin system guide: [Plugins](/tools/plugin).
For the native capability model and current external-compatibility guidance:
[Capability model](/tools/plugin#public-capability-model).
[Capability model](/plugins/architecture#public-capability-model).
## Required fields
@@ -135,7 +135,7 @@ See [Configuration reference](/configuration) for the full `plugins.*` schema.
`--auth-choice` resolution, preferred-provider mapping, and simple onboarding
CLI flag registration before provider runtime loads. For runtime wizard
metadata that requires provider code, see
[Provider runtime hooks](/tools/plugin#provider-runtime-hooks).
[Provider runtime hooks](/plugins/architecture#provider-runtime-hooks).
- Exclusive plugin kinds are selected through `plugins.slots.*`.
- `kind: "memory"` is selected by `plugins.slots.memory`.
- `kind: "context-engine"` is selected by `plugins.slots.contextEngine`

View File

@@ -312,14 +312,21 @@ Auto-responses use the agent system. Tune with:
```bash
openclaw voicecall call --to "+15555550123" --message "Hello from OpenClaw"
openclaw voicecall start --to "+15555550123" # alias for call
openclaw voicecall continue --call-id <id> --message "Any questions?"
openclaw voicecall speak --call-id <id> --message "One moment"
openclaw voicecall end --call-id <id>
openclaw voicecall status --call-id <id>
openclaw voicecall tail
openclaw voicecall latency # summarize turn latency from logs
openclaw voicecall expose --mode funnel
```
`latency` reads `calls.jsonl` from the default voice-call storage path. Use
`--file <path>` to point at a different log and `--last <n>` to limit analysis
to the last N records (default 200). Output includes p50/p90/p99 for turn
latency and listen-wait times.
## Agent tool
Tool name: `voice_call`

78
docs/providers/google.md Normal file
View File

@@ -0,0 +1,78 @@
---
title: "Google (Gemini)"
summary: "Google Gemini setup (API key + OAuth, image generation, media understanding, web search)"
read_when:
- You want to use Google Gemini models with OpenClaw
- You need the API key or OAuth auth flow
---
# Google (Gemini)
The Google plugin provides access to Gemini models through Google AI Studio, plus
image generation, media understanding (image/audio/video), and web search via
Gemini Grounding.
- Provider: `google`
- Auth: `GEMINI_API_KEY` or `GOOGLE_API_KEY`
- API: Google Gemini API
- Alternative provider: `google-gemini-cli` (OAuth)
## Quick start
1. Set the API key:
```bash
openclaw onboard --auth-choice google-api-key
```
2. Set a default model:
```json5
{
agents: {
defaults: {
model: { primary: "google/gemini-3.1-pro-preview" },
},
},
}
```
## Non-interactive example
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice google-api-key \
--gemini-api-key "$GEMINI_API_KEY"
```
## OAuth (Gemini CLI)
An alternative provider `google-gemini-cli` uses PKCE OAuth instead of an API
key. This is an unofficial integration; some users report account
restrictions. Use at your own risk.
Environment variables:
- `OPENCLAW_GEMINI_OAUTH_CLIENT_ID`
- `OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET`
(Or the `GEMINI_CLI_*` variants.)
## Capabilities
| Capability | Supported |
| ---------------------- | ----------------- |
| Chat completions | Yes |
| Image generation | Yes |
| Image understanding | Yes |
| Audio transcription | Yes |
| Video understanding | Yes |
| Web search (Grounding) | Yes |
| Thinking/reasoning | Yes (Gemini 3.1+) |
## Environment note
If the Gateway runs as a daemon (launchd/systemd), make sure `GEMINI_API_KEY`
is available to that process (for example, in `~/.openclaw/.env` or via
`env.shellEnv`).

View File

@@ -30,23 +30,28 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- [GLM models](/providers/glm)
- [Google (Gemini)](/providers/google)
- [Hugging Face (Inference)](/providers/huggingface)
- [Kilocode](/providers/kilocode)
- [LiteLLM (unified gateway)](/providers/litellm)
- [MiniMax](/providers/minimax)
- [Mistral](/providers/mistral)
- [Model Studio (Alibaba Cloud)](/providers/modelstudio)
- [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
- [NVIDIA](/providers/nvidia)
- [Ollama (cloud + local models)](/providers/ollama)
- [OpenAI (API + Codex)](/providers/openai)
- [OpenCode (Zen + Go)](/providers/opencode)
- [OpenRouter](/providers/openrouter)
- [Perplexity (web search)](/providers/perplexity-provider)
- [Qianfan](/providers/qianfan)
- [Qwen (OAuth)](/providers/qwen)
- [SGLang (local models)](/providers/sglang)
- [Together AI](/providers/together)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Venice (Venice AI, privacy-focused)](/providers/venice)
- [vLLM (local models)](/providers/vllm)
- [Volcengine (Doubao)](/providers/volcengine)
- [xAI](/providers/xai)
- [Xiaomi](/providers/xiaomi)
- [Z.AI](/providers/zai)

View File

@@ -0,0 +1,66 @@
---
title: "Model Studio"
summary: "Alibaba Cloud Model Studio setup (Coding Plan, dual region endpoints)"
read_when:
- You want to use Alibaba Cloud Model Studio with OpenClaw
- You need the API key env var for Model Studio
---
# Model Studio (Alibaba Cloud)
The Model Studio provider gives access to Alibaba Cloud Coding Plan models,
including Qwen and third-party models hosted on the platform.
- Provider: `modelstudio`
- Auth: `MODELSTUDIO_API_KEY`
- API: OpenAI-compatible
## Quick start
1. Set the API key:
```bash
openclaw onboard --auth-choice modelstudio-api-key
```
2. Set a default model:
```json5
{
agents: {
defaults: {
model: { primary: "modelstudio/qwen3.5-plus" },
},
},
}
```
## Region endpoints
Model Studio has two endpoints based on region:
| Region | Endpoint |
| ---------- | ------------------------------------ |
| China (CN) | `coding.dashscope.aliyuncs.com` |
| Global | `coding-intl.dashscope.aliyuncs.com` |
The provider auto-selects based on the auth choice (`modelstudio-api-key` for
global, `modelstudio-api-key-cn` for China). You can override with a custom
`baseUrl` in config.
## Available models
- **qwen3.5-plus** (default) - Qwen 3.5 Plus
- **qwen3-max** - Qwen 3 Max
- **qwen3-coder** series - Qwen coding models
- **GLM-5**, **GLM-4.7** - GLM models via Alibaba
- **Kimi K2.5** - Moonshot AI via Alibaba
- **MiniMax-M2.5** - MiniMax via Alibaba
Most models support image input. Context windows range from 200K to 1M tokens.
## Environment note
If the Gateway runs as a daemon (launchd/systemd), make sure
`MODELSTUDIO_API_KEY` is available to that process (for example, in
`~/.openclaw/.env` or via `env.shellEnv`).

View File

@@ -0,0 +1,56 @@
---
title: "Perplexity (Provider)"
summary: "Perplexity web search provider setup (API key, search modes, filtering)"
read_when:
- You want to configure Perplexity as a web search provider
- You need the Perplexity API key or OpenRouter proxy setup
---
# Perplexity (Web Search Provider)
The Perplexity plugin provides web search capabilities through the Perplexity
Search API or Perplexity Sonar via OpenRouter.
<Note>
This page covers the Perplexity **provider** setup. For the Perplexity
**tool** (how the agent uses it), see [Perplexity tool](/perplexity).
</Note>
- Type: web search provider (not a model provider)
- Auth: `PERPLEXITY_API_KEY` (direct) or `OPENROUTER_API_KEY` (via OpenRouter)
- Config path: `tools.web.search.perplexity.apiKey`
## Quick start
1. Set the API key:
```bash
openclaw config set tools.web.search.perplexity.apiKey "pplx-xxxxxxxxxxxx"
```
2. The agent will automatically use Perplexity for web searches when configured.
## Search modes
The plugin auto-selects the transport based on API key prefix:
| Key prefix | Transport | Features |
| ---------- | ---------------------------- | ------------------------------------------------ |
| `pplx-` | Native Perplexity Search API | Structured results, domain/language/date filters |
| `sk-or-` | OpenRouter (Sonar) | AI-synthesized answers with citations |
## Native API filtering
When using the native Perplexity API (`pplx-` key), searches support:
- **Country**: 2-letter country code
- **Language**: ISO 639-1 language code
- **Date range**: day, week, month, year
- **Domain filters**: allowlist/denylist (max 20 domains)
- **Content budget**: `max_tokens`, `max_tokens_per_page`
## Environment note
If the Gateway runs as a daemon (launchd/systemd), make sure
`PERPLEXITY_API_KEY` is available to that process (for example, in
`~/.openclaw/.env` or via `env.shellEnv`).

View File

@@ -0,0 +1,74 @@
---
title: "Volcengine (Doubao)"
summary: "Volcano Engine setup (Doubao models, general + coding endpoints)"
read_when:
- You want to use Volcano Engine or Doubao models with OpenClaw
- You need the Volcengine API key setup
---
# Volcengine (Doubao)
The Volcengine provider gives access to Doubao models and third-party models
hosted on Volcano Engine, with separate endpoints for general and coding
workloads.
- Providers: `volcengine` (general) + `volcengine-plan` (coding)
- Auth: `VOLCANO_ENGINE_API_KEY`
- API: OpenAI-compatible
## Quick start
1. Set the API key:
```bash
openclaw onboard --auth-choice volcengine-api-key
```
2. Set a default model:
```json5
{
agents: {
defaults: {
model: { primary: "volcengine-plan/ark-code-latest" },
},
},
}
```
## Non-interactive example
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice volcengine-api-key \
--volcengine-api-key "$VOLCANO_ENGINE_API_KEY"
```
## Providers and endpoints
| Provider | Endpoint | Use case |
| ----------------- | ----------------------------------------- | -------------- |
| `volcengine` | `ark.cn-beijing.volces.com/api/v3` | General models |
| `volcengine-plan` | `ark.cn-beijing.volces.com/api/coding/v3` | Coding models |
Both providers are configured from a single API key. Setup registers both
automatically.
## Available models
- **doubao-seed-1-8** - Doubao Seed 1.8 (general, default)
- **doubao-seed-code-preview** - Doubao coding model
- **ark-code-latest** - Coding plan default
- **Kimi K2.5** - Moonshot AI via Volcano Engine
- **GLM-4.7** - GLM via Volcano Engine
- **DeepSeek V3.2** - DeepSeek via Volcano Engine
Most models support text + image input. Context windows range from 128K to 256K
tokens.
## Environment note
If the Gateway runs as a daemon (launchd/systemd), make sure
`VOLCANO_ENGINE_API_KEY` is available to that process (for example, in
`~/.openclaw/.env` or via `env.shellEnv`).

View File

@@ -5,6 +5,8 @@ read_when:
title: "Credits"
---
# Credits and Acknowledgments
## The name
OpenClaw = CLAW + TARDIS, because every space lobster needs a time and space machine.

View File

@@ -38,6 +38,11 @@ Scope intent:
- `plugins.entries.moonshot.config.webSearch.apiKey`
- `plugins.entries.perplexity.config.webSearch.apiKey`
- `plugins.entries.firecrawl.config.webSearch.apiKey`
- `tools.web.search.apiKey`
- `tools.web.search.gemini.apiKey`
- `tools.web.search.grok.apiKey`
- `tools.web.search.kimi.apiKey`
- `tools.web.search.perplexity.apiKey`
- `gateway.auth.password`
- `gateway.auth.token`
- `gateway.remote.token`

View File

@@ -447,6 +447,48 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.brave.config.webSearch.apiKey",
"configFile": "openclaw.json",
"path": "plugins.entries.brave.config.webSearch.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.firecrawl.config.webSearch.apiKey",
"configFile": "openclaw.json",
"path": "plugins.entries.firecrawl.config.webSearch.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.google.config.webSearch.apiKey",
"configFile": "openclaw.json",
"path": "plugins.entries.google.config.webSearch.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.moonshot.config.webSearch.apiKey",
"configFile": "openclaw.json",
"path": "plugins.entries.moonshot.config.webSearch.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.perplexity.config.webSearch.apiKey",
"configFile": "openclaw.json",
"path": "plugins.entries.perplexity.config.webSearch.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.xai.config.webSearch.apiKey",
"configFile": "openclaw.json",
"path": "plugins.entries.xai.config.webSearch.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "skills.entries.*.apiKey",
"configFile": "openclaw.json",
@@ -476,44 +518,37 @@
"optIn": true
},
{
"id": "plugins.entries.brave.config.webSearch.apiKey",
"id": "tools.web.search.apiKey",
"configFile": "openclaw.json",
"path": "plugins.entries.brave.config.webSearch.apiKey",
"path": "tools.web.search.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.google.config.webSearch.apiKey",
"id": "tools.web.search.gemini.apiKey",
"configFile": "openclaw.json",
"path": "plugins.entries.google.config.webSearch.apiKey",
"path": "tools.web.search.gemini.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.xai.config.webSearch.apiKey",
"id": "tools.web.search.grok.apiKey",
"configFile": "openclaw.json",
"path": "plugins.entries.xai.config.webSearch.apiKey",
"path": "tools.web.search.grok.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.moonshot.config.webSearch.apiKey",
"id": "tools.web.search.kimi.apiKey",
"configFile": "openclaw.json",
"path": "plugins.entries.moonshot.config.webSearch.apiKey",
"path": "tools.web.search.kimi.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.perplexity.config.webSearch.apiKey",
"id": "tools.web.search.perplexity.apiKey",
"configFile": "openclaw.json",
"path": "plugins.entries.perplexity.config.webSearch.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.firecrawl.config.webSearch.apiKey",
"configFile": "openclaw.json",
"path": "plugins.entries.firecrawl.config.webSearch.apiKey",
"path": "tools.web.search.perplexity.apiKey",
"secretShape": "secret_input",
"optIn": true
}

View File

@@ -5,8 +5,10 @@ read_when:
- Bootstrapping a workspace manually
---
# HEARTBEAT.md
# HEARTBEAT.md Template
```markdown
# Keep this file empty (or with only comments) to skip heartbeat API calls.
# Add tasks below when you want the agent to check something periodically.
```

View File

@@ -12,9 +12,10 @@ title: "Tests"
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests dont collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
- `pnpm test` on Node 22, 23, and 24 uses Vitest `vmForks` by default for faster startup. Node 25+ falls back to `forks` until re-validated. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`.
- `pnpm test`: runs the fast core unit lane by default for quick local feedback.
- `pnpm test`: runs the full wrapper. It keeps only a small behavioral override manifest in git, then uses a checked-in timing snapshot to peel the heaviest measured unit files into dedicated lanes.
- `pnpm test:channels`: runs channel-heavy suites.
- `pnpm test:extensions`: runs extension/plugin suites.
- `pnpm test:perf:update-timings`: refreshes the checked-in slow-file timing snapshot used by `scripts/test-parallel.mjs`.
- 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 `vmForks` + 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.

View File

@@ -5,6 +5,8 @@ read_when:
title: "Docs directory"
---
# Docs Directory
<Note>
This page is a curated index. If you are new, start with [Getting Started](/start/getting-started).
For a complete map of the docs, see [Docs hubs](/start/hubs).

View File

@@ -162,6 +162,18 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [macOS skills](/platforms/mac/skills)
- [macOS Peekaboo](/platforms/mac/peekaboo)
## Extensions + plugins
- [Plugins overview](/tools/plugin)
- [Building extensions](/plugins/building-extensions)
- [Plugin manifest](/plugins/manifest)
- [Agent tools](/plugins/agent-tools)
- [Plugin bundles](/plugins/bundles)
- [Community plugins](/plugins/community)
- [Capability cookbook](/tools/capability-cookbook)
- [Voice call plugin](/plugins/voice-call)
- [Zalo user plugin](/plugins/zalouser)
## Workspace + templates
- [Skills](/tools/skills)

View File

@@ -111,6 +111,7 @@ All fields are optional unless noted:
- `lang` (`string`): language override hint for before and after mode.
- `title` (`string`): viewer title override.
- `mode` (`"view" | "file" | "both"`): output mode. Defaults to plugin default `defaults.mode`.
Deprecated alias: `"image"` behaves like `"file"` and is still accepted for backward compatibility.
- `theme` (`"light" | "dark"`): viewer theme. Defaults to plugin default `defaults.theme`.
- `layout` (`"unified" | "split"`): diff layout. Defaults to plugin default `defaults.layout`.
- `expandUnchanged` (`boolean`): expand unchanged sections when full context is available. Per-call option only (not a plugin default key).
@@ -150,9 +151,12 @@ Shared fields for modes that create a viewer:
- `inputKind`
- `fileCount`
- `mode`
- `context` (`agentId`, `sessionId`, `messageChannel`, `agentAccountId` when available)
File fields when PNG or PDF is rendered:
- `artifactId`
- `expiresAt`
- `filePath`
- `path` (same value as `filePath`, for message tool compatibility)
- `fileBytes`

View File

@@ -1,40 +1,25 @@
---
summary: "Per-agent sandbox + tool restrictions, precedence, and examples"
summary: Per-agent sandbox + tool restrictions, precedence, and examples
title: Multi-Agent Sandbox & Tools
read_when: "You want per-agent sandboxing or per-agent tool allow/deny policies in a multi-agent gateway."
read_when: You want per-agent sandboxing or per-agent tool allow/deny policies in a multi-agent gateway.
status: active
---
# Multi-Agent Sandbox & Tools Configuration
## Overview
Each agent in a multi-agent setup can override the global sandbox and tool
policy. This page covers per-agent configuration, precedence rules, and
examples.
Each agent in a multi-agent setup can now have its own:
- **Sandbox configuration** (`agents.list[].sandbox` overrides `agents.defaults.sandbox`)
- **Tool restrictions** (`tools.allow` / `tools.deny`, plus `agents.list[].tools`)
This allows you to run multiple agents with different security profiles:
- Personal assistant with full access
- Family/work agents with restricted tools
- Public-facing agents in sandboxes
`setupCommand` belongs under `sandbox.docker` (global or per-agent) and runs once
when the container is created.
Auth is per-agent: each agent reads from its own `agentDir` auth store at:
```
~/.openclaw/agents/<agentId>/agent/auth-profiles.json
```
- **Sandbox backends and modes**: see [Sandboxing](/gateway/sandboxing).
- **Debugging blocked tools**: see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) and `openclaw sandbox explain`.
- **Elevated exec**: see [Elevated Mode](/tools/elevated).
Auth is per-agent: each agent reads from its own `agentDir` auth store at
`~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
Credentials are **not** shared between agents. Never reuse `agentDir` across agents.
If you want to share creds, copy `auth-profiles.json` into the other agent's `agentDir`.
For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing).
For debugging “why is this blocked?”, see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) and `openclaw sandbox explain`.
---
## Configuration Examples
@@ -222,30 +207,9 @@ If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools`
If `agents.list[].tools.profile` is set, it overrides `tools.profile` for that agent.
Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.2`).
### Tool groups (shorthands)
Tool policies support `group:*` shorthands that expand to multiple tools. See [Tool groups](/gateway/sandbox-vs-tool-policy-vs-elevated#tool-groups-shorthands) for the full list.
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple concrete tools:
- `group:runtime`: `exec`, `bash`, `process`
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:openclaw`: all built-in OpenClaw tools (excludes provider plugins)
### Elevated Mode
`tools.elevated` is the global baseline (sender-based allowlist). `agents.list[].tools.elevated` can further restrict elevated for specific agents (both must allow).
Mitigation patterns:
- Deny `exec` for untrusted agents (`agents.list[].tools.deny: ["exec"]`)
- Avoid allowlisting senders that route to restricted agents
- Disable elevated globally (`tools.elevated.enabled: false`) if you only want sandboxed execution
- Disable elevated per agent (`agents.list[].tools.elevated.enabled: false`) for sensitive profiles
Per-agent elevated overrides (`agents.list[].tools.elevated`) can further restrict elevated exec for specific agents. See [Elevated Mode](/tools/elevated) for details.
---
@@ -390,8 +354,11 @@ After configuring multi-agent sandbox and tools:
---
## See Also
## See also
- [Sandboxing](/gateway/sandboxing) -- full sandbox reference (modes, scopes, backends, images)
- [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) -- debugging "why is this blocked?"
- [Elevated Mode](/tools/elevated)
- [Multi-Agent Routing](/concepts/multi-agent)
- [Sandbox Configuration](/gateway/configuration#agentsdefaults-sandbox)
- [Session Management](/concepts/session)

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,8 @@ import { spawn } from "node:child_process";
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createWindowsCmdShimFixture } from "openclaw/plugin-sdk/testing";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createWindowsCmdShimFixture } from "../../../shared/windows-cmd-shim-test-fixtures.js";
import {
resolveSpawnCommand,
spawnAndCollect,

View File

@@ -1,4 +1,3 @@
import type { AcpRuntime, OpenClawPluginServiceContext } from "openclaw/plugin-sdk/acpx";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AcpRuntimeError } from "../../../src/acp/runtime/errors.js";
import {
@@ -6,6 +5,7 @@ import {
getAcpRuntimeBackend,
requireAcpRuntimeBackend,
} from "../../../src/acp/runtime/registry.js";
import type { AcpRuntime, OpenClawPluginServiceContext } from "../runtime-api.js";
import { ACPX_BUNDLED_BIN, ACPX_PINNED_VERSION } from "./config.js";
import { createAcpxRuntimeService } from "./service.js";

View File

@@ -1,4 +1,4 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import {
createBedrockNoCacheWrapper,
isAnthropicBedrockModel,

View File

@@ -0,0 +1,4 @@
export {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./src/group-policy.js";

View File

@@ -1,4 +1,6 @@
import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
import { bluebubblesPlugin } from "./src/channel.js";
import { bluebubblesSetupPlugin } from "./src/channel.setup.js";
export default defineSetupPluginEntry(bluebubblesPlugin);
export { bluebubblesSetupPlugin } from "./src/channel.setup.js";
export default defineSetupPluginEntry(bluebubblesSetupPlugin);

View File

@@ -1,5 +1,6 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { createAccountListHelpers, type OpenClawConfig } from "./runtime-api.js";
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";

View File

@@ -484,4 +484,94 @@ describe("sendBlueBubblesAttachment", () => {
expect(bodyText).not.toContain('name="selectedMessageGuid"');
expect(bodyText).not.toContain('name="partIndex"');
});
it("auto-creates a new chat when sending to a phone number with no existing chat", async () => {
// First call: resolveChatGuidForTarget queries chats, returns empty (no match)
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
// Second call: createChatForHandle creates new chat
mockFetch.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { chatGuid: "iMessage;-;+15559876543", guid: "iMessage;-;+15559876543" },
}),
),
});
// Third call: actual attachment send
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "attach-msg-1" } })),
});
const result = await sendBlueBubblesAttachment({
to: "+15559876543",
buffer: new Uint8Array([1, 2, 3]),
filename: "photo.jpg",
contentType: "image/jpeg",
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
expect(result.messageId).toBe("attach-msg-1");
// Verify chat creation was called
const createCallBody = JSON.parse(mockFetch.mock.calls[1][1].body);
expect(createCallBody.addresses).toEqual(["+15559876543"]);
// Verify attachment was sent to the newly created chat
const attachBody = mockFetch.mock.calls[2][1]?.body as Uint8Array;
const attachText = decodeBody(attachBody);
expect(attachText).toContain("iMessage;-;+15559876543");
});
it("retries chatGuid resolution after creating a chat with no returned guid", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: {} })),
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [{ guid: "iMessage;-;+15557654321" }] }),
});
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "attach-msg-2" } })),
});
const result = await sendBlueBubblesAttachment({
to: "+15557654321",
buffer: new Uint8Array([4, 5, 6]),
filename: "photo.jpg",
contentType: "image/jpeg",
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
expect(result.messageId).toBe("attach-msg-2");
const createCallBody = JSON.parse(mockFetch.mock.calls[1][1].body);
expect(createCallBody.addresses).toEqual(["+15557654321"]);
const attachBody = mockFetch.mock.calls[3][1]?.body as Uint8Array;
const attachText = decodeBody(attachBody);
expect(attachText).toContain("iMessage;-;+15557654321");
});
it("still throws for non-handle targets when chatGuid is not found", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
await expect(
sendBlueBubblesAttachment({
to: "chat_id:999",
buffer: new Uint8Array([1, 2, 3]),
filename: "photo.jpg",
opts: { serverUrl: "http://localhost:1234", password: "test" },
}),
).rejects.toThrow("chatGuid not found");
});
});

View File

@@ -10,7 +10,7 @@ import { resolveRequestUrl } from "./request-url.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { resolveChatGuidForTarget } from "./send.js";
import { resolveChatGuidForTarget, createChatForHandle } from "./send.js";
import {
blueBubblesFetchWithTimeout,
buildBlueBubblesApiUrl,
@@ -180,16 +180,37 @@ export async function sendBlueBubblesAttachment(params: {
}
const target = resolveBlueBubblesSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({
let chatGuid = await resolveChatGuidForTarget({
baseUrl,
password,
timeoutMs: opts.timeoutMs,
target,
});
if (!chatGuid) {
throw new Error(
"BlueBubbles attachment send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
);
// For handle targets (phone numbers/emails), auto-create a new DM chat
if (target.kind === "handle") {
const created = await createChatForHandle({
baseUrl,
password,
address: target.address,
timeoutMs: opts.timeoutMs,
});
chatGuid = created.chatGuid;
// If we still don't have a chatGuid, try resolving again (chat was created server-side)
if (!chatGuid) {
chatGuid = await resolveChatGuidForTarget({
baseUrl,
password,
timeoutMs: opts.timeoutMs,
target,
});
}
}
if (!chatGuid) {
throw new Error(
"BlueBubbles attachment send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
);
}
}
const url = buildBlueBubblesApiUrl({

View File

@@ -0,0 +1,76 @@
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import { 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 {
listBlueBubblesAccountIds,
type ResolvedBlueBubblesAccount,
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { BlueBubblesConfigSchema } from "./config-schema.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: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }),
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],
},
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),
setupWizard: blueBubblesSetupWizard,
config: {
...bluebubblesConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
}),
},
setup: blueBubblesSetupAdapter,
};

View File

@@ -4,7 +4,15 @@ import {
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import { collectOpenGroupPolicyRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import {
createOpenGroupPolicyRestrictSendersWarningCollector,
projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
createAttachedChannelResultAdapter,
createPairingPrefixStripper,
createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import {
listBlueBubblesAccountIds,
@@ -68,6 +76,17 @@ const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBu
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
});
const collectBlueBubblesSecurityWarnings =
createOpenGroupPolicyRestrictSendersWarningCollector<ResolvedBlueBubblesAccount>({
resolveGroupPolicy: (account) => account.config.groupPolicy,
defaultGroupPolicy: "allowlist",
surface: "BlueBubbles groups",
openScope: "any member",
groupPolicyPath: "channels.bluebubbles.groupPolicy",
groupAllowFromPath: "channels.bluebubbles.groupAllowFrom",
mentionGated: false,
});
const meta = {
id: "bluebubbles",
label: "BlueBubbles",
@@ -123,17 +142,10 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
actions: bluebubblesMessageActions,
security: {
resolveDmPolicy: resolveBlueBubblesDmPolicy,
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
return collectOpenGroupPolicyRestrictSendersWarnings({
groupPolicy,
surface: "BlueBubbles groups",
openScope: "any member",
groupPolicyPath: "channels.bluebubbles.groupPolicy",
groupAllowFromPath: "channels.bluebubbles.groupAllowFrom",
mentionGated: false,
});
},
collectWarnings: projectWarningCollector(
({ account }: { account: ResolvedBlueBubblesAccount }) => account,
collectBlueBubblesSecurityWarnings,
),
},
messaging: {
normalizeTarget: normalizeBlueBubblesMessagingTarget,
@@ -226,17 +238,18 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
},
},
setup: blueBubblesSetupAdapter,
pairing: {
pairing: createTextPairingAdapter({
idLabel: "bluebubblesSenderId",
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
notifyApproval: async ({ cfg, id }) => {
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^bluebubbles:/i, normalizeBlueBubblesHandle),
notify: async ({ cfg, id, message }) => {
await (
await loadBlueBubblesChannelRuntime()
).sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
).sendMessageBlueBubbles(id, message, {
cfg: cfg,
});
},
},
}),
outbound: {
deliveryMode: "direct",
textChunkLimit: 4000,
@@ -250,46 +263,44 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const runtime = await loadBlueBubblesChannelRuntime();
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
// Resolve short ID (e.g., "5") to full UUID
const replyToMessageGuid = rawReplyToId
? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "";
const result = await runtime.sendMessageBlueBubbles(to, text, {
cfg: cfg,
accountId: accountId ?? undefined,
replyToMessageGuid: replyToMessageGuid || undefined,
});
return { channel: "bluebubbles", ...result };
},
sendMedia: async (ctx) => {
const runtime = await loadBlueBubblesChannelRuntime();
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
};
const resolvedCaption = caption ?? text;
const result = await runtime.sendBlueBubblesMedia({
cfg: cfg,
to,
mediaUrl,
mediaPath,
mediaBuffer,
contentType,
filename,
caption: resolvedCaption ?? undefined,
replyToId: replyToId ?? null,
accountId: accountId ?? undefined,
});
return { channel: "bluebubbles", ...result };
},
...createAttachedChannelResultAdapter({
channel: "bluebubbles",
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const runtime = await loadBlueBubblesChannelRuntime();
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
const replyToMessageGuid = rawReplyToId
? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "";
return await runtime.sendMessageBlueBubbles(to, text, {
cfg: cfg,
accountId: accountId ?? undefined,
replyToMessageGuid: replyToMessageGuid || undefined,
});
},
sendMedia: async (ctx) => {
const runtime = await loadBlueBubblesChannelRuntime();
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
};
return await runtime.sendBlueBubblesMedia({
cfg: cfg,
to,
mediaUrl,
mediaPath,
mediaBuffer,
contentType,
filename,
caption: caption ?? text ?? undefined,
replyToId: replyToId ?? null,
accountId: accountId ?? undefined,
});
},
}),
},
status: {
defaultRuntime: {

View File

@@ -1,4 +1,5 @@
import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "./runtime-api.js";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
type BlueBubblesConfigPatch = {
serverUrl?: string;

View File

@@ -3,9 +3,10 @@ import {
buildCatchallMultiAccountChannelSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
ToolPolicySchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "zod";
import { MarkdownConfigSchema, ToolPolicySchema } from "./runtime-api.js";
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
const bluebubblesActionSchema = z

View File

@@ -3,7 +3,7 @@ import {
resolveChannelGroupToolsPolicy,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
import type { OpenClawConfig } from "./runtime-api.js";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
type BlueBubblesGroupContext = {
cfg: OpenClawConfig;

View File

@@ -1,3 +1,8 @@
import {
resolveOutboundMediaUrls,
resolveTextChunksWithFallback,
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
import { downloadBlueBubblesAttachment } from "./attachments.js";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { fetchBlueBubblesHistory } from "./history.js";
@@ -33,10 +38,9 @@ import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./re
import type { OpenClawConfig } from "./runtime-api.js";
import {
DM_GROUP_ACCESS_REASON,
createScopedPairingAccess,
createReplyPrefixOptions,
createChannelPairingController,
createChannelReplyPipeline,
evictOldHistoryKeys,
issuePairingChallenge,
logAckFailure,
logInboundDrop,
logTypingFailure,
@@ -447,7 +451,7 @@ export async function processMessage(
target: WebhookTarget,
): Promise<void> {
const { account, config, runtime, core, statusSink } = target;
const pairing = createScopedPairingAccess({
const pairing = createChannelPairingController({
core,
channel: "bluebubbles",
accountId: account.accountId,
@@ -649,12 +653,10 @@ export async function processMessage(
}
if (accessDecision.decision === "pairing") {
await issuePairingChallenge({
channel: "bluebubbles",
await pairing.issueChallenge({
senderId: message.senderId,
senderIdLine: `Your BlueBubbles sender id: ${message.senderId}`,
meta: { name: message.senderName },
upsertPairingRequest: pairing.upsertPairingRequest,
onCreated: () => {
runtime.log?.(`[bluebubbles] pairing request sender=${message.senderId} created=true`);
logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
@@ -1223,17 +1225,47 @@ export async function processMessage(
}, typingRestartDelayMs);
};
try {
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({
cfg: config,
agentId: route.agentId,
channel: "bluebubbles",
accountId: account.accountId,
typingCallbacks: {
onReplyStart: async () => {
if (!chatGuidForActions) {
return;
}
if (!baseUrl || !password) {
return;
}
streamingActive = true;
clearTypingRestartTimer();
try {
await sendBlueBubblesTyping(chatGuidForActions, true, {
cfg: config,
accountId: account.accountId,
});
} catch (err) {
runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
}
},
onIdle: () => {
if (!chatGuidForActions) {
return;
}
if (!baseUrl || !password) {
return;
}
// Intentionally no-op for block streaming. We stop typing in finally
// after the run completes to avoid flicker between paragraph blocks.
},
},
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
...prefixOptions,
...replyPipeline,
deliver: async (payload, info) => {
const rawReplyToId =
privateApiEnabled && typeof payload.replyToId === "string"
@@ -1243,11 +1275,7 @@ export async function processMessage(
const replyToMessageGuid = rawReplyToId
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "";
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
const mediaList = resolveOutboundMediaUrls(payload);
if (mediaList.length > 0) {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
@@ -1257,43 +1285,44 @@ export async function processMessage(
const text = sanitizeReplyDirectiveText(
core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
);
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : undefined;
first = false;
const cachedBody = (caption ?? "").trim() || "<media:attachment>";
const pendingId = rememberPendingOutboundMessageId({
accountId: account.accountId,
sessionKey: route.sessionKey,
outboundTarget,
chatGuid: chatGuidForActions ?? chatGuid,
chatIdentifier,
chatId,
snippet: cachedBody,
});
let result: Awaited<ReturnType<typeof sendBlueBubblesMedia>>;
try {
result = await sendBlueBubblesMedia({
cfg: config,
to: outboundTarget,
mediaUrl,
caption: caption ?? undefined,
replyToId: replyToMessageGuid || null,
await sendMediaWithLeadingCaption({
mediaUrls: mediaList,
caption: text,
send: async ({ mediaUrl, caption }) => {
const cachedBody = (caption ?? "").trim() || "<media:attachment>";
const pendingId = rememberPendingOutboundMessageId({
accountId: account.accountId,
sessionKey: route.sessionKey,
outboundTarget,
chatGuid: chatGuidForActions ?? chatGuid,
chatIdentifier,
chatId,
snippet: cachedBody,
});
} catch (err) {
forgetPendingOutboundMessageId(pendingId);
throw err;
}
if (maybeEnqueueOutboundMessageId(result.messageId, cachedBody)) {
forgetPendingOutboundMessageId(pendingId);
}
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
if (info.kind === "block") {
restartTypingSoon();
}
}
let result: Awaited<ReturnType<typeof sendBlueBubblesMedia>>;
try {
result = await sendBlueBubblesMedia({
cfg: config,
to: outboundTarget,
mediaUrl,
caption: caption ?? undefined,
replyToId: replyToMessageGuid || null,
accountId: account.accountId,
});
} catch (err) {
forgetPendingOutboundMessageId(pendingId);
throw err;
}
if (maybeEnqueueOutboundMessageId(result.messageId, cachedBody)) {
forgetPendingOutboundMessageId(pendingId);
}
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
if (info.kind === "block") {
restartTypingSoon();
}
},
});
return;
}
@@ -1312,11 +1341,14 @@ export async function processMessage(
);
const chunks =
chunkMode === "newline"
? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
: core.channel.text.chunkMarkdownText(text, textLimit);
if (!chunks.length && text) {
chunks.push(text);
}
? resolveTextChunksWithFallback(
text,
core.channel.text.chunkTextWithMode(text, textLimit, chunkMode),
)
: resolveTextChunksWithFallback(
text,
core.channel.text.chunkMarkdownText(text, textLimit),
);
if (!chunks.length) {
return;
}
@@ -1351,34 +1383,8 @@ export async function processMessage(
}
}
},
onReplyStart: async () => {
if (!chatGuidForActions) {
return;
}
if (!baseUrl || !password) {
return;
}
streamingActive = true;
clearTypingRestartTimer();
try {
await sendBlueBubblesTyping(chatGuidForActions, true, {
cfg: config,
accountId: account.accountId,
});
} catch (err) {
runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
}
},
onIdle: async () => {
if (!chatGuidForActions) {
return;
}
if (!baseUrl || !password) {
return;
}
// Intentionally no-op for block streaming. We stop typing in finally
// after the run completes to avoid flicker between paragraph blocks.
},
onReplyStart: typingCallbacks?.onReplyStart,
onIdle: typingCallbacks?.onIdle,
onError: (err, info) => {
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
},
@@ -1442,7 +1448,7 @@ export async function processReaction(
target: WebhookTarget,
): Promise<void> {
const { account, config, runtime, core } = target;
const pairing = createScopedPairingAccess({
const pairing = createChannelPairingController({
core,
channel: "bluebubbles",
accountId: account.accountId,

View File

@@ -1,9 +1,12 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { normalizeWebhookPath, type OpenClawConfig } from "./runtime-api.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import type { BlueBubblesAccountConfig } from "./types.js";
export { normalizeWebhookPath };
export {
DEFAULT_WEBHOOK_PATH,
normalizeWebhookPath,
resolveWebhookPathFromConfig,
} from "./webhook-shared.js";
export type BlueBubblesRuntimeEnv = {
log?: (message: string) => void;
@@ -29,13 +32,3 @@ export type WebhookTarget = {
path: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
const raw = config?.webhookPath?.trim();
if (raw) {
return normalizeWebhookPath(raw);
}
return DEFAULT_WEBHOOK_PATH;
}

View File

@@ -1,13 +1,6 @@
import {
buildSecretInputSchema,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "./runtime-api.js";
export {
buildSecretInputSchema,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
};
} from "openclaw/plugin-sdk/secret-input";

View File

@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js";
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
import { sendMessageBlueBubbles, resolveChatGuidForTarget, createChatForHandle } from "./send.js";
import {
BLUE_BUBBLES_PRIVATE_API_STATUS,
installBlueBubblesFetchTestHooks,
@@ -781,4 +781,109 @@ describe("send", () => {
expect(body.tempGuid.length).toBeGreaterThan(0);
});
});
describe("createChatForHandle", () => {
it("creates a new chat and returns chatGuid from response", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "iMessage;-;+15559876543", chatGuid: "iMessage;-;+15559876543" },
}),
),
});
const result = await createChatForHandle({
baseUrl: "http://localhost:1234",
password: "test",
address: "+15559876543",
message: "Hello!",
});
expect(result.chatGuid).toBe("iMessage;-;+15559876543");
expect(result.messageId).toBeDefined();
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.addresses).toEqual(["+15559876543"]);
expect(body.message).toBe("Hello!");
});
it("creates a new chat without a message when message is omitted", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "iMessage;-;+15559876543" },
}),
),
});
const result = await createChatForHandle({
baseUrl: "http://localhost:1234",
password: "test",
address: "+15559876543",
});
expect(result.chatGuid).toBe("iMessage;-;+15559876543");
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.message).toBe("");
});
it.each([
["data.chatGuid", { data: { chatGuid: "shape-chat-guid" } }, "shape-chat-guid"],
["data.guid", { data: { guid: "shape-guid" } }, "shape-guid"],
[
"data.chats[0].guid",
{ data: { chats: [{ guid: "shape-array-guid" }] } },
"shape-array-guid",
],
["data.chat.guid", { data: { chat: { guid: "shape-object-guid" } } }, "shape-object-guid"],
])("extracts chatGuid from %s", async (_label, responseBody, expectedChatGuid) => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify(responseBody)),
});
const result = await createChatForHandle({
baseUrl: "http://localhost:1234",
password: "test",
address: "+15559876543",
});
expect(result.chatGuid).toBe(expectedChatGuid);
});
it("throws when Private API is not enabled", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
text: () => Promise.resolve("Private API not enabled"),
});
await expect(
createChatForHandle({
baseUrl: "http://localhost:1234",
password: "test",
address: "+15559876543",
}),
).rejects.toThrow("Private API must be enabled");
});
it("returns null chatGuid when response has no chat data", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: {} })),
});
const result = await createChatForHandle({
baseUrl: "http://localhost:1234",
password: "test",
address: "+15559876543",
message: "Hello",
});
expect(result.chatGuid).toBeNull();
});
});
});

View File

@@ -312,16 +312,20 @@ export async function resolveChatGuidForTarget(params: {
}
/**
* Creates a new chat (DM) and optionally sends an initial message.
* Creates a new DM chat for the given address and returns the chat GUID.
* Requires Private API to be enabled in BlueBubbles.
*
* If a `message` is provided it is sent as the initial message in the new chat;
* otherwise an empty-string message body is used (BlueBubbles still creates the
* chat but will not deliver a visible bubble).
*/
async function createNewChatWithMessage(params: {
export async function createChatForHandle(params: {
baseUrl: string;
password: string;
address: string;
message: string;
message?: string;
timeoutMs?: number;
}): Promise<BlueBubblesSendResult> {
}): Promise<{ chatGuid: string | null; messageId: string }> {
const url = buildBlueBubblesApiUrl({
baseUrl: params.baseUrl,
path: "/api/v1/chat/new",
@@ -329,7 +333,7 @@ async function createNewChatWithMessage(params: {
});
const payload = {
addresses: [params.address],
message: params.message,
message: params.message ?? "",
tempGuid: `temp-${crypto.randomUUID()}`,
};
const res = await blueBubblesFetchWithTimeout(
@@ -343,7 +347,6 @@ async function createNewChatWithMessage(params: {
);
if (!res.ok) {
const errorText = await res.text();
// Check for Private API not enabled error
if (
res.status === 400 ||
res.status === 403 ||
@@ -355,7 +358,64 @@ async function createNewChatWithMessage(params: {
}
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
}
return parseBlueBubblesMessageResponse(res);
const body = await res.text();
let messageId = "ok";
let chatGuid: string | null = null;
if (body) {
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
messageId = extractBlueBubblesMessageId(parsed);
// Extract chatGuid from the response data
const data = parsed.data as Record<string, unknown> | undefined;
if (data) {
chatGuid =
(typeof data.chatGuid === "string" && data.chatGuid) ||
(typeof data.guid === "string" && data.guid) ||
null;
// Also try nested chats array (some BB versions nest it)
if (!chatGuid) {
const chats = data.chats ?? data.chat;
if (Array.isArray(chats) && chats.length > 0) {
const first = chats[0] as Record<string, unknown> | undefined;
chatGuid =
(typeof first?.guid === "string" && first.guid) ||
(typeof first?.chatGuid === "string" && first.chatGuid) ||
null;
} else if (chats && typeof chats === "object" && !Array.isArray(chats)) {
const chatObj = chats as Record<string, unknown>;
chatGuid =
(typeof chatObj.guid === "string" && chatObj.guid) ||
(typeof chatObj.chatGuid === "string" && chatObj.chatGuid) ||
null;
}
}
}
} catch {
// ignore parse errors
}
}
return { chatGuid, messageId };
}
/**
* Creates a new chat (DM) and sends an initial message.
* Requires Private API to be enabled in BlueBubbles.
*/
async function createNewChatWithMessage(params: {
baseUrl: string;
password: string;
address: string;
message: string;
timeoutMs?: number;
}): Promise<BlueBubblesSendResult> {
const result = await createChatForHandle({
baseUrl: params.baseUrl,
password: params.password,
address: params.address,
message: params.message,
timeoutMs: params.timeoutMs,
});
return { messageId: result.messageId };
}
export async function sendMessageBlueBubbles(

View File

@@ -3,7 +3,7 @@ import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/chan
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { DEFAULT_WEBHOOK_PATH } from "./monitor-shared.js";
import { DEFAULT_WEBHOOK_PATH } from "./webhook-shared.js";
async function createBlueBubblesConfigureAdapter() {
const { blueBubblesSetupAdapter, blueBubblesSetupWizard } = await import("./setup-surface.js");

View File

@@ -14,7 +14,6 @@ import {
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
import { DEFAULT_WEBHOOK_PATH } from "./monitor-shared.js";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
import {
blueBubblesSetupAdapter,
@@ -23,6 +22,7 @@ import {
} from "./setup-core.js";
import { parseBlueBubblesAllowTarget } from "./targets.js";
import { normalizeBlueBubblesServerUrl } from "./types.js";
import { DEFAULT_WEBHOOK_PATH } from "./webhook-shared.js";
const channel = "bluebubbles" as const;
const CONFIGURE_CUSTOM_WEBHOOK_FLAG = "__bluebubblesConfigureCustomWebhookPath";

View File

@@ -1,11 +1,11 @@
import { isAllowedParsedChatSender } from "openclaw/plugin-sdk/allow-from";
import {
isAllowedParsedChatSender,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
type ParsedChatTarget,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
} from "./runtime-api.js";
} from "openclaw/plugin-sdk/imessage-core";
export type BlueBubblesService = "imessage" | "sms" | "auto";

View File

@@ -1,6 +1,6 @@
import type { DmPolicy, GroupPolicy } from "./runtime-api.js";
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/setup";
export type { DmPolicy, GroupPolicy } from "./runtime-api.js";
export type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/setup";
export type BlueBubblesGroupConfig = {
/** If true, only respond in this group when mentioned. */

View File

@@ -0,0 +1,14 @@
import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-path";
import type { BlueBubblesAccountConfig } from "./types.js";
export { normalizeWebhookPath };
export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
const raw = config?.webhookPath?.trim();
if (raw) {
return normalizeWebhookPath(raw);
}
return DEFAULT_WEBHOOK_PATH;
}

View File

@@ -11,13 +11,13 @@ import {
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
resolveProviderWebSearchPluginConfig,
setTopLevelCredentialValue,
setProviderWebSearchPluginConfigValue,
type OpenClawConfig,
type SearchConfigRecord,
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
@@ -92,7 +92,6 @@ const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
type BraveConfig = {
apiKey?: unknown;
mode?: string;
};
@@ -115,41 +114,18 @@ type BraveLlmContextResponse = {
sources?: { url?: string; hostname?: string; date?: string }[];
};
function resolveBraveConfig(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): BraveConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "brave");
if (pluginConfig) {
return pluginConfig as BraveConfig;
}
const scoped = (searchConfig as Record<string, unknown> | undefined)?.brave;
return scoped && typeof scoped === "object" && !Array.isArray(scoped)
? ({
...(scoped as BraveConfig),
apiKey: (searchConfig as Record<string, unknown> | undefined)?.apiKey,
} as BraveConfig)
: ({ apiKey: (searchConfig as Record<string, unknown> | undefined)?.apiKey } as BraveConfig);
function resolveBraveConfig(searchConfig?: SearchConfigRecord): BraveConfig {
const brave = searchConfig?.brave;
return brave && typeof brave === "object" && !Array.isArray(brave) ? (brave as BraveConfig) : {};
}
function resolveBraveMode(brave?: BraveConfig): "web" | "llm-context" {
return brave?.mode === "llm-context" ? "llm-context" : "web";
}
function resolveBraveApiKey(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): string | undefined {
const braveConfig = resolveBraveConfig(config, searchConfig);
function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined {
return (
readConfiguredSecretString(
braveConfig.apiKey,
"plugins.entries.brave.config.webSearch.apiKey",
) ??
readConfiguredSecretString(
(searchConfig as Record<string, unknown> | undefined)?.apiKey,
"tools.web.search.apiKey",
) ??
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
readProviderEnvValue(["BRAVE_API_KEY"])
);
}
@@ -410,10 +386,9 @@ function missingBraveKeyPayload() {
}
function createBraveToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
const braveConfig = resolveBraveConfig(config, searchConfig);
const braveConfig = resolveBraveConfig(searchConfig);
const braveMode = resolveBraveMode(braveConfig);
return {
@@ -423,7 +398,7 @@ function createBraveToolDefinition(
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
parameters: createBraveSchema(),
execute: async (args) => {
const apiKey = resolveBraveApiKey(config, searchConfig);
const apiKey = resolveBraveApiKey(searchConfig);
if (!apiKey) {
return missingBraveKeyPayload();
}
@@ -624,16 +599,30 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
setCredentialValue: (searchConfigTarget, value) => {
searchConfigTarget.apiKey = value;
},
setCredentialValue: setTopLevelCredentialValue,
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "brave", "apiKey", value);
},
createTool: (ctx) =>
createBraveToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
createBraveToolDefinition(
(() => {
const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined;
const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "brave");
if (!pluginConfig) {
return searchConfig;
}
return {
...(searchConfig ?? {}),
...(pluginConfig.apiKey === undefined ? {} : { apiKey: pluginConfig.apiKey }),
brave: {
...resolveBraveConfig(searchConfig),
...pluginConfig,
},
} as SearchConfigRecord;
})(),
),
};
}

View File

@@ -6,7 +6,7 @@ import {
} from "openclaw/plugin-sdk/provider-models";
import {
applyAgentDefaultModelPrimary,
applyProviderConfigWithModelCatalog,
applyProviderConfigWithModelCatalogPreset,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
@@ -17,24 +17,20 @@ export { CHUTES_DEFAULT_MODEL_REF };
* Registers all catalog models and sets provider aliases (chutes-fast, etc.).
*/
export function applyChutesProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
for (const m of CHUTES_MODEL_CATALOG) {
models[`chutes/${m.id}`] = {
...models[`chutes/${m.id}`],
};
}
models["chutes-fast"] = { alias: "chutes/zai-org/GLM-4.7-FP8" };
models["chutes-vision"] = { alias: "chutes/chutesai/Mistral-Small-3.2-24B-Instruct-2506" };
models["chutes-pro"] = { alias: "chutes/deepseek-ai/DeepSeek-V3.2-TEE" };
const chutesModels = CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition);
return applyProviderConfigWithModelCatalog(cfg, {
agentModels: models,
return applyProviderConfigWithModelCatalogPreset(cfg, {
providerId: "chutes",
api: "openai-completions",
baseUrl: CHUTES_BASE_URL,
catalogModels: chutesModels,
catalogModels: CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition),
aliases: [
...CHUTES_MODEL_CATALOG.map((model) => `chutes/${model.id}`),
{ modelRef: "chutes-fast", alias: "chutes/zai-org/GLM-4.7-FP8" },
{
modelRef: "chutes-vision",
alias: "chutes/chutesai/Mistral-Small-3.2-24B-Instruct-2506",
},
{ modelRef: "chutes-pro", alias: "chutes/deepseek-ai/DeepSeek-V3.2-TEE" },
],
});
}

View File

@@ -15,6 +15,8 @@ The tool can return:
- `details.viewerUrl`: a gateway URL that can be opened in the canvas
- `details.filePath`: a local rendered artifact path when file rendering is requested
- `details.fileFormat`: the rendered file format (`png` or `pdf`)
- `details.artifactId` and `details.expiresAt`: artifact identity and TTL metadata
- `details.context`: available routing metadata such as `agentId`, `sessionId`, `messageChannel`, and `agentAccountId`
When the plugin is enabled, it also ships a companion skill from `skills/` and prepends stable tool-usage guidance into system-prompt space via `before_prompt_build`. The hook uses `prependSystemContext`, so the guidance stays out of user-prompt space while still being available every turn.
@@ -49,6 +51,7 @@ Patch:
Useful options:
- `mode`: `view`, `file`, or `both`
Deprecated alias: `image` behaves like `file` and is still accepted for backward compatibility.
- `layout`: `unified` or `split`
- `theme`: `light` or `dark` (default: `dark`)
- `fileFormat`: `png` or `pdf` (default: `png`)

View File

@@ -2,7 +2,7 @@ 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 } from "./api.js";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "./api.js";
import plugin from "./index.js";
describe("diffs plugin registration", () => {
@@ -48,7 +48,9 @@ describe("diffs plugin registration", () => {
};
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
let registeredTool: RegisteredTool | undefined;
let registeredToolFactory:
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
| undefined;
let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined;
const api = createTestPluginApi({
@@ -75,7 +77,7 @@ describe("diffs plugin registration", () => {
},
runtime: {} as never,
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
registeredTool = typeof tool === "function" ? undefined : tool;
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
},
registerHttpRoute(params: RegisteredHttpRouteParams) {
registeredHttpRouteHandler = params.handler;
@@ -84,6 +86,12 @@ describe("diffs plugin registration", () => {
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",
@@ -108,6 +116,14 @@ describe("diffs plugin registration", () => {
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",
},
);
});
});

View File

@@ -1,6 +1,9 @@
import path from "node:path";
import type { OpenClawPluginApi } from "./api.js";
import { resolvePreferredOpenClawTmpDir } from "./api.js";
import {
definePluginEntry,
resolvePreferredOpenClawTmpDir,
type OpenClawPluginApi,
} from "./api.js";
import {
diffsPluginConfigSchema,
resolveDiffsPluginDefaults,
@@ -11,7 +14,7 @@ import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js";
import { DiffArtifactStore } from "./src/store.js";
import { createDiffsTool } from "./src/tool.js";
const plugin = {
export default definePluginEntry({
id: "diffs",
name: "Diffs",
description: "Read-only diff viewer and PNG/PDF renderer for agents.",
@@ -24,7 +27,9 @@ const plugin = {
logger: api.logger,
});
api.registerTool(createDiffsTool({ api, store, defaults }));
api.registerTool((ctx) => createDiffsTool({ api, store, defaults, context: ctx }), {
name: "diffs",
});
api.registerHttpRoute({
path: "/plugins/diffs",
auth: "plugin",
@@ -39,6 +44,4 @@ const plugin = {
prependSystemContext: DIFFS_AGENT_GUIDANCE,
}));
},
};
export default plugin;
});

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