Compare commits

..

113 Commits

Author SHA1 Message Date
Peter Steinberger
55300ea850 fix: preserve loopback ws cdp tab ops (#31085) (thanks @shrey150) 2026-03-08 18:47:48 +00:00
Shrey Pandya
8e5f702adf style(browser): fix oxfmt formatting in config.ts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
56d2662f9d chore: remove vendor-specific references from code comments 2026-03-08 18:41:49 +00:00
Shrey Pandya
e2ecd0a321 fix(browser): preserve wss:// cdpUrl in legacy default profile resolution 2026-03-08 18:41:49 +00:00
shrey150
7fce53976e fix(browser): update existing tests for ws/wss protocol support
Two pre-existing tests still expected ws:// URLs to be rejected by
parseHttpUrl, which now accepts them. Switch the invalid-protocol
fixture to ftp:// and tighten the assertion to match the full
"must be http(s) or ws(s)" error message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
shrey150
1cc021251e test+docs: comprehensive coverage and generic framing
- Add 12 new tests covering: isWebSocketUrl detection, parseHttpUrl WSS
  acceptance/rejection, direct WS target creation with query params,
  SSRF enforcement on WS URLs, WS reachability probing bypasses HTTP
- Reframe docs section as generic "Direct WebSocket CDP providers" with
  Browserbase as one example — any WSS-based provider works
- Update security tips to mention WSS alongside HTTPS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
shrey150
a8ad7e42af feat(browser): support direct WebSocket CDP URLs for Browserbase
Browserbase uses direct WebSocket connections (wss://) rather than the
standard HTTP-based /json/version CDP discovery flow used by Browserless.
This change teaches the browser tool to accept ws:// and wss:// URLs as
cdpUrl values: when a WebSocket URL is detected, OpenClaw connects
directly instead of attempting HTTP discovery.

Changes:
- config.ts: accept ws:// and wss:// in cdpUrl validation
- cdp.helpers.ts: add isWebSocketUrl() helper
- cdp.ts: skip /json/version when cdpUrl is already a WebSocket URL
- chrome.ts: probe WSS endpoints via WebSocket handshake instead of HTTP
- cdp.test.ts: add test for direct WebSocket target creation
- docs/tools/browser.md: update Browserbase section with correct URL
  format and notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
42320281c6 docs: simplify Browserbase section, drop pricing details
Restore platform-level feature description (CAPTCHA solving, stealth
mode, proxies) without plan-specific pricing gating. Keep free tier
note brief.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
f60168b735 docs: fact-check Browserbase section against official docs
- Fix CAPTCHA/stealth/proxy claims: these are Developer plan+ only,
  not available on free tier
- Fix free tier limits: 1 browser hour, 15-min session duration
  (not "60 minutes of monthly usage")
- Add link to pricing page for paid plan details
- Simplify structure to match Browserless section format
- Remove sub-headings to match Browserless section style

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
0a5701f468 docs: restore direct wss://connect.browserbase.com URL
Browserbase exposes a direct WebSocket connect endpoint that
auto-creates a session, similar to how Browserless works. Simplified
the section to use this static URL pattern instead of requiring
manual session creation via the API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
07f65838ed docs: fix Browserbase section to match official docs
Browserbase requires creating a session via their API to get a CDP
connect URL, unlike Browserless which uses a static endpoint. Updated
to show the correct curl-based session creation flow, removed
unverified static WebSocket URL, and added the 5-minute connect
timeout note from official docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
4d326271f0 docs: fix duplicate heading lint error
Rename "Configuration" sub-heading to "Profile setup" to avoid
MD024/no-duplicate-heading conflict with the existing top-level
"Configuration" heading.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
eb4ff4464e docs: add Browserbase as hosted remote CDP option
Add Browserbase documentation section alongside the existing Browserless
section in the browser docs. Includes signup instructions, CDP connection
configuration, and environment variable setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
cbcf9d0811 Revert "docs: add Browserbase as hosted remote CDP option"
This reverts commit c469657c97848c7a3e1e5135bf4ce735d07d6614.
2026-03-08 18:41:49 +00:00
Shrey Pandya
83a854bfa0 docs: add Browserbase as hosted remote CDP option
Add Browserbase documentation section alongside the existing Browserless
section in the browser docs. Includes signup instructions, CDP connection
configuration, and environment variable setup for both English and Chinese
(zh-CN) translations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:48 +00:00
Peter Steinberger
3ada30e670 fix: restore gate after rebase 2026-03-08 18:40:15 +00:00
Peter Steinberger
c5095153b0 refactor: extract qmd process runner 2026-03-08 18:40:15 +00:00
Peter Steinberger
68775745d2 fix: restore acp session meta narrowing 2026-03-08 18:40:15 +00:00
Peter Steinberger
f399a818ef refactor: extract ios watch reply coordinator 2026-03-08 18:40:15 +00:00
Peter Steinberger
6bd5735519 refactor: split doctor config analysis helpers 2026-03-08 18:40:15 +00:00
Peter Steinberger
11be305609 refactor: neutralize context engine runtime bridge 2026-03-08 18:40:15 +00:00
Peter Steinberger
f6cb77134c refactor: centralize acp session resolution guards 2026-03-08 18:40:14 +00:00
Peter Steinberger
25d0aa7296 refactor: simplify plugin sdk compatibility aliases 2026-03-08 18:40:14 +00:00
Peter Steinberger
dd7470730d test: isolate git commit resolution fallbacks 2026-03-08 18:40:14 +00:00
Peter Steinberger
c70151e873 test: isolate legacy plugin-sdk root import check 2026-03-08 18:40:14 +00:00
Peter Steinberger
a007bed375 test: isolate plugin loader from mocked module cache 2026-03-08 18:40:14 +00:00
Peter Steinberger
fa580e33c1 refactor: split android talk voice resolution 2026-03-08 18:40:14 +00:00
Peter Steinberger
371c53b282 test: expand talk config contract fixtures 2026-03-08 18:40:14 +00:00
Peter Steinberger
cee2f3e8b4 refactor: dedupe android talk config parsing 2026-03-08 18:40:14 +00:00
Peter Steinberger
2ed644f5d3 fix: require talk resolved payload 2026-03-08 18:40:14 +00:00
Mariano
404b1527e6 fix(acp): persist spawned child session history (#40137)
Merged via squash.

Prepared head SHA: 62de5d5669
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-08 19:37:00 +01:00
Peter Steinberger
72ebaf97c3 test: add implicit provider matrix coverage 2026-03-08 18:26:36 +00:00
Peter Steinberger
8ab762c005 test: standardize hermetic provider env snapshots 2026-03-08 18:26:36 +00:00
Peter Steinberger
d307a7ca1a refactor: extract bundled extension manifest parser 2026-03-08 18:26:36 +00:00
Peter Steinberger
52bc809143 refactor: extract provider stream wrappers 2026-03-08 18:26:36 +00:00
Peter Steinberger
6094035054 refactor: extract static provider builders 2026-03-08 18:26:36 +00:00
Peter Steinberger
f493b03202 refactor: validate bundled extension release metadata 2026-03-08 18:26:36 +00:00
Peter Steinberger
e53d840fed refactor: extract openai stream wrappers 2026-03-08 18:26:36 +00:00
Peter Steinberger
f66bd105a4 refactor: decompose implicit provider resolution 2026-03-08 18:26:36 +00:00
Peter Steinberger
ef2541ceb3 refactor: centralize transcript provider quirks 2026-03-08 18:26:35 +00:00
Peter Steinberger
8a18e2598f refactor: split models registry loading from persistence 2026-03-08 18:26:35 +00:00
Peter Steinberger
749eb4efea refactor: thread config runtime env through models config 2026-03-08 18:26:35 +00:00
Peter Steinberger
64d4d9aabb refactor: move bundled extension gap allowlists into manifests 2026-03-08 18:26:35 +00:00
Peter Steinberger
e5c06dd64a refactor: use model compat for anthropic tool payload normalization 2026-03-08 18:26:35 +00:00
Vincent Koc
efcca3d2ea Tests: format daemon lifecycle CLI coverage 2026-03-08 11:22:41 -07:00
Vincent Koc
0b452a5665 CLI: set local gateway mode in setup 2026-03-08 11:17:29 -07:00
Vincent Koc
4c71176c9f Chore: refresh detect-secrets baseline for Feishu docs 2026-03-08 11:16:03 -07:00
Vincent Koc
c5bba6628e Chore: refresh detect-secrets baseline after final scan 2026-03-08 11:16:03 -07:00
Vincent Koc
3b68d3fded Chore: refresh detect-secrets baseline after docs line changes 2026-03-08 11:16:03 -07:00
Vincent Koc
7856f5730c Web search: allowlist Perplexity auth source type name 2026-03-08 11:16:03 -07:00
Vincent Koc
aebfce7a36 Chore: refresh detect-secrets baseline 2026-03-08 11:16:03 -07:00
Vincent Koc
e19b3679d1 Chore: widen xxxxx detect-secrets allowlist 2026-03-08 11:16:03 -07:00
Vincent Koc
d23d36a2f9 Tests: lower entropy git commit fixtures 2026-03-08 11:16:03 -07:00
Vincent Koc
2ae58542a0 Fixtures: normalize talk config API key placeholder 2026-03-08 11:16:03 -07:00
Vincent Koc
55465d86d9 Docs: use placeholder OpenRouter key in web tool docs 2026-03-08 11:16:03 -07:00
Vincent Koc
615466bdf4 Docs: use placeholder OpenRouter key in Perplexity guide 2026-03-08 11:16:03 -07:00
Vincent Koc
6f4de3cc23 Web search: rename Perplexity auth source helper 2026-03-08 11:16:03 -07:00
Vincent Koc
f19761cefa Tests: reduce web search secret-scan noise 2026-03-08 11:16:03 -07:00
Vincent Koc
5387faa718 CI: satisfy provider merge fixture typing 2026-03-08 11:15:48 -07:00
Tak Hoffman
bdf9739e59 Add too-many-prs override label handling 2026-03-08 13:13:53 -05:00
Rémi
2970d72554 docs: update Brave Search API docs for Feb 2026 plan restructuring (#40111)
Merged via squash.

Prepared head SHA: c651f07855
Co-authored-by: remusao <1299873+remusao@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-08 14:06:21 -04:00
Tak Hoffman
74624e619d fix: prefer bundled channel plugins over npm duplicates (#40094)
* fix: prefer bundled channel plugins over npm duplicates

* fix: tighten bundled plugin review follow-ups

* fix: address check gate follow-ups

* docs: add changelog for bundled plugin install fix

* fix: align lifecycle test formatting with CI oxfmt
2026-03-08 13:00:24 -05:00
yuweuii
6c9b49a10b fix(sessions): clear stale contextTokens on model switch (#38044)
Merged via squash.

Prepared head SHA: bac2df4b7f
Co-authored-by: yuweuii <82372187+yuweuii@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-08 10:59:16 -07:00
GitBuck
caf1b84822 feat: allow compaction model override via config (#38753)
Merged via squash.

Prepared head SHA: a3d6d6c845
Co-authored-by: starbuck100 <25417736+starbuck100@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-08 10:47:34 -07:00
Vincent Koc
b6520d7172 CI: scope CodeQL JavaScript analysis 2026-03-08 10:29:56 -07:00
Ayaan Zaidi
d4ab731746 fix(telegram): use message previews in DMs 2026-03-08 21:59:43 +05:30
Peter Steinberger
95dff166cb refactor: fold implicit provider injection into resolver 2026-03-08 16:22:52 +00:00
Peter Steinberger
1ec1f0f1f2 refactor: scope prep push results to env artifacts 2026-03-08 16:22:52 +00:00
Peter Steinberger
bce9d93fb5 fix: publish models.json atomically 2026-03-08 16:22:52 +00:00
Peter Steinberger
bec3c0b71d refactor: reuse one models.json read per write 2026-03-08 16:22:52 +00:00
Peter Steinberger
b41bcb08a2 refactor: expand provider capability registry 2026-03-08 16:22:52 +00:00
Peter Steinberger
75e1521660 refactor: extract pure models config merge helpers 2026-03-08 16:22:52 +00:00
Peter Steinberger
79c5c660bb fix: treat model api drift as baseUrl refresh 2026-03-08 16:22:52 +00:00
Peter Steinberger
fa00b1d0ca refactor: dedupe prep branch push flow 2026-03-08 16:22:52 +00:00
Peter Steinberger
032778fb2e refactor: avoid checkout during prep head verification 2026-03-08 16:22:52 +00:00
Peter Steinberger
16a5f0b006 refactor: split talk gateway config loaders 2026-03-08 16:22:48 +00:00
Peter Steinberger
dc5645d459 test: add talk config contract fixtures 2026-03-08 16:22:48 +00:00
Peter Steinberger
8d3d742c6a refactor: require canonical talk resolved payload 2026-03-08 16:22:48 +00:00
Peter Steinberger
87640f9a61 fix: align talk config secret schemas 2026-03-08 16:22:48 +00:00
Peter Steinberger
b7ad8fd661 fix: fail closed talk provider selection 2026-03-08 16:22:48 +00:00
Altay
ca5e352c53 CLI: include commit hash in --version output (#39712)
* CLI: include commit hash in --version output

* fix(version): harden commit SHA resolution and keep output consistent

* CLI: keep install checks compatible with commit-tagged version output

* fix(cli): include commit hash in root version fast path

* test(cli): allow null commit-hash mocks

* Installer: share version parser across install scripts

* Installer: avoid sourcing helpers from stdin cwd

* CLI: note commit-tagged version output

* CLI: anchor commit hash resolution to module root

* CLI: harden commit hash resolution

* CLI: fix commit hash lookup edge cases

* CLI: prefer live git metadata in dev builds

* CLI: keep git lookup inside package root

* Infra: tolerate invalid moduleUrl hints

* CLI: cache baked commit metadata fallbacks

* CLI: align changelog attribution with prep gate

* CLI: restore changelog contributor credit

---------

Co-authored-by: echoVic <echovic@163.com>
Co-authored-by: echoVic <echoVic@users.noreply.github.com>
2026-03-08 19:10:48 +03:00
Hermione
c942655451 fix(hooks): use resolveAgentIdFromSessionKey in runBeforeReset (#39875)
Merged via squash.

Prepared head SHA: 00a2b241df
Co-authored-by: rbutera <6047293+rbutera@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 19:07:28 +03:00
Tak Hoffman
fa83010b17 fix(plugins): ship Feishu bundled runtime dependency (#39990)
* fix: ship feishu bundled runtime dependency

* test: align feishu bundled dependency specs
2026-03-08 10:36:41 -05:00
darkamenosa
67b2e81360 Zalo: fix provider lifecycle restarts (#39892)
* Zalo: fix provider lifecycle restarts

* Zalo: add typing indicators, smart webhook cleanup, and API type fixes

* fix review

* add allow list test secrect

* Zalo: bound webhook cleanup during shutdown

* Zalo: bound typing chat action timeout

* Zalo: use plugin-safe abort helper import
2026-03-08 22:33:18 +07:00
Ayaan Zaidi
28e46d04e5 fix(web-search): restore OpenRouter compatibility for Perplexity (#39937) (#39937) 2026-03-08 20:37:54 +05:30
Tak Hoffman
d9e8e8ac15 fix: resolve live config paths in status and gateway metadata (#39952)
* fix: resolve live config paths in status and gateway metadata

* fix: resolve remaining runtime config path references

* test: cover gateway config.set config path response
2026-03-08 09:59:32 -05:00
Peter Steinberger
da3cccb212 test: decouple ios talk parsing coverage 2026-03-08 14:58:29 +00:00
Peter Steinberger
e8ad80afc7 test: cover invalid talk config inputs 2026-03-08 14:58:29 +00:00
Peter Steinberger
b4c8950417 refactor: centralize talk silence timeout defaults 2026-03-08 14:58:29 +00:00
Peter Steinberger
4e2290b841 refactor: add canonical talk config payload 2026-03-08 14:58:29 +00:00
Peter Steinberger
4f482d2a2b refactor: share Apple talk config parsing 2026-03-08 14:58:29 +00:00
Peter Steinberger
eba9dcc67a Refactor release hardening follow-ups (#39959)
* build: fail fast on stale host-env swift policy

* build: sync generated host env swift policy

* build: guard bundled extension root dependency gaps

* refactor: centralize provider capability quirks

* test: table-drive provider regression coverage

* fix: block merge when prep branch has unpushed commits

* refactor: simplify models config merge preservation
2026-03-08 14:49:58 +00:00
Tak Hoffman
27558806b5 docs: clarify bot review conversation ownership (#39942)
* docs: clarify bot review conversations
2026-03-08 09:39:39 -05:00
Peter Steinberger
0af3118d08 fix: harden talk silence timeout parsing (#39607) (thanks @danodoesdesign)
Co-authored-by: dano does design <dano.does.design@gmail.com>
2026-03-08 14:30:25 +00:00
dano does design
6ff7e8f42e talk: add configurable silence timeout 2026-03-08 14:30:25 +00:00
Varun Chopra
097c588a6b transcript-policy: use named Set for anthropic signature-excluded providers 2026-03-08 14:16:21 +00:00
Varun Chopra
2bf53c2cb6 transcript-policy: don't preserve thinking signatures for kimi-coding (#39798) 2026-03-08 14:16:21 +00:00
Peter Steinberger
e2c07f8a47 fix: land mac universal release defaults (#33891) (thanks @cgdusek) 2026-03-08 14:14:36 +00:00
Charles Dusek
1a364cd066 Docs: clarify notarization handoff in mac release flow 2026-03-08 14:14:36 +00:00
Charles Dusek
9ce79bba34 Docs: mark basic mac dist example as non-notarized 2026-03-08 14:14:36 +00:00
Charles Dusek
047f4acacf Docs: clarify release build arch defaults for mac packaging 2026-03-08 14:14:36 +00:00
Charles Dusek
64760614aa macOS: default release app builds to universal binaries 2026-03-08 14:14:36 +00:00
GeekCheyun
76e4b8277f fix(issue-39839): address tool-call extra params parsing for kimi anthropic-messages 2026-03-08 14:14:06 +00:00
Peter Steinberger
6dadfaa18c docs: use alphabetical provider ordering 2026-03-08 14:10:36 +00:00
Peter Steinberger
d5b305b250 fix: follow up #39321 and #38445 landings 2026-03-08 13:58:13 +00:00
Peter Steinberger
ba2d580c4e docs: note /landpr merge process 2026-03-08 13:57:50 +00:00
Peter Steinberger
acac7e3132 fix: land Brave llm-context gaps (#33383) (thanks @thirumaleshp) 2026-03-08 13:57:12 +00:00
Thirumalesh
8a1015f1aa feat: add Brave Search LLM Context API mode for web_search
Add support for Brave's LLM Context API endpoint (/res/v1/llm/context)
as an optional mode for the web_search tool. When configured with
tools.web.search.brave.mode set to llm-context, the tool returns
pre-extracted page content optimized for LLM grounding instead of
standard URL/snippet results.

The llm-context cache key excludes count and ui_lang parameters that
the LLM Context API does not accept, preventing unnecessary cache
misses.

Closes #14992

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:57:12 +00:00
Ayane
38f4ac5e3c fix(feishu): restore @larksuiteoapi/node-sdk in root dependencies
The bundled Feishu extension fails to load after npm global install because
`@larksuiteoapi/node-sdk` was removed from the root package.json in
e1503349c ("scope extension runtime deps to plugin manifests").

Bundled extensions shipped inside the npm package resolve modules through
the root node_modules tree.  Since `.gitignore` excludes nested
`node_modules/` directories, the extension-level `node_modules/` is
never published, so the module is unreachable at runtime.

Other bundled channel dependencies (e.g. `@discordjs/voice`,
`@slack/bolt`) remain in the root manifest for the same reason.

Re-add the entry — matching the version already declared in
`extensions/feishu/package.json` — so that both global npm installs and
the bundled extension path can locate the SDK.

Closes #39733
2026-03-08 13:56:46 +00:00
Peter Steinberger
d91d24e41d refactor: tighten codex inline api fallback follow-up 2026-03-08 13:54:21 +00:00
Dmitri
d2347ed825 macOS: set speech recognition taskHint for Talk Mode mic capture
Add taskHint = .dictation to Talk Mode's SFSpeechAudioBufferRecognitionRequest,
matching what Voice Wake already sets. Without this hint the recognizer may not
properly initialize audio capture, causing Talk Mode to appear unresponsive.

Co-Authored-By: dmiv <dmiv@users.noreply.github.com>
2026-03-08 13:52:08 +00:00
justinhuangcode
6e086a5b3b chore: update secrets baseline line numbers 2026-03-08 13:51:37 +00:00
justinhuangcode
c9f2d6b761 fix(agents): let forward-compat resolve api when inline model omits it
When a user configures `models.providers.openai-codex` with a models
array but omits the `api` field, `buildInlineProviderModels` produces
an entry with `api: undefined`.  The inline-match early return then
hands this incomplete model straight to the caller, skipping the
forward-compat resolver that would supply the correct
`openai-codex-responses` api — causing a crash loop.

Let the inline match fall through to forward-compat when `api` is
absent so the resolver chain can fill it in.

Fixes #39682
2026-03-08 13:51:37 +00:00
225 changed files with 10704 additions and 3746 deletions

View File

@@ -0,0 +1,18 @@
name: openclaw-codeql-javascript-typescript
paths:
- src
- extensions
- ui/src
- skills
paths-ignore:
- apps
- dist
- docs
- "**/node_modules"
- "**/coverage"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"

View File

@@ -87,6 +87,13 @@ What you personally verified (not just CI), and how:
- Edge cases checked:
- What you did **not** verify:
## Review Conversations
- [ ] I replied to or resolved every bot review conversation I addressed in this PR.
- [ ] I left unresolved only the conversations that still need reviewer or maintainer judgment.
If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.
## Compatibility / Migration
- Backward compatible? (`Yes/No`)

View File

@@ -261,6 +261,8 @@ jobs:
};
const triggerLabel = "trigger-response";
const activePrLimitLabel = "r: too-many-prs";
const activePrLimitOverrideLabel = "r: too-many-prs-override";
const target = context.payload.issue ?? context.payload.pull_request;
if (!target) {
return;
@@ -448,6 +450,10 @@ jobs:
return;
}
if (pullRequest && labelSet.has(activePrLimitOverrideLabel)) {
labelSet.delete(activePrLimitLabel);
}
const rule = rules.find((item) => labelSet.has(item.label));
if (!rule) {
return;

View File

@@ -28,6 +28,7 @@ jobs:
needs_swift_tools: false
needs_manual_build: false
needs_autobuild: false
config_file: ./.github/codeql/codeql-javascript-typescript.yml
- language: actions
runs_on: blacksmith-16vcpu-ubuntu-2404
needs_node: false
@@ -36,6 +37,7 @@ jobs:
needs_swift_tools: false
needs_manual_build: false
needs_autobuild: false
config_file: ""
- language: python
runs_on: blacksmith-16vcpu-ubuntu-2404
needs_node: false
@@ -44,6 +46,7 @@ jobs:
needs_swift_tools: false
needs_manual_build: false
needs_autobuild: false
config_file: ""
- language: java-kotlin
runs_on: blacksmith-16vcpu-ubuntu-2404
needs_node: false
@@ -52,6 +55,7 @@ jobs:
needs_swift_tools: false
needs_manual_build: true
needs_autobuild: false
config_file: ""
- language: swift
runs_on: macos-latest
needs_node: false
@@ -60,6 +64,7 @@ jobs:
needs_swift_tools: true
needs_manual_build: true
needs_autobuild: false
config_file: ""
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -95,6 +100,7 @@ jobs:
with:
languages: ${{ matrix.language }}
queries: security-and-quality
config-file: ${{ matrix.config_file || '' }}
- name: Autobuild
if: matrix.needs_autobuild

View File

@@ -213,6 +213,7 @@ jobs:
}
const activePrLimitLabel = "r: too-many-prs";
const activePrLimitOverrideLabel = "r: too-many-prs-override";
const activePrLimit = 10;
const labelColor = "B60205";
const labelDescription = `Author has more than ${activePrLimit} active PRs in this repo`;
@@ -221,12 +222,37 @@ jobs:
return;
}
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
per_page: 100,
});
const labelNames = new Set(
(pullRequest.labels ?? [])
currentLabels
.map((label) => (typeof label === "string" ? label : label?.name))
.filter((name) => typeof name === "string"),
);
if (labelNames.has(activePrLimitOverrideLabel)) {
if (labelNames.has(activePrLimitLabel)) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
name: activePrLimitLabel,
});
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
}
return;
}
const ensureLabelExists = async () => {
try {
await github.rest.issues.getLabel({

View File

@@ -66,7 +66,7 @@ repos:
- --exclude-lines
- 'env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \},'
- --exclude-lines
- '"ap[i]Key": "xxxxx",'
- '"ap[i]Key": "xxxxx"(,)?'
- --exclude-lines
- 'ap[i]Key: "A[I]za\.\.\.",'
# Shell script linting

View File

@@ -151,7 +151,7 @@
"export CUSTOM_API_K[E]Y=\"your-key\"",
"grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \\|\\| cat >> ~/.bashrc <<'EOF'",
"env: \\{ MISTRAL_API_K[E]Y: \"sk-\\.\\.\\.\" \\},",
"\"ap[i]Key\": \"xxxxx\",",
"\"ap[i]Key\": \"xxxxx\"(,)?",
"ap[i]Key: \"A[I]za\\.\\.\\.\","
]
},
@@ -9619,14 +9619,14 @@
"filename": "docs/channels/feishu.md",
"hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3",
"is_verified": false,
"line_number": 189
"line_number": 187
},
{
"type": "Secret Keyword",
"filename": "docs/channels/feishu.md",
"hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c",
"is_verified": false,
"line_number": 501
"line_number": 499
}
],
"docs/channels/irc.md": [
@@ -9809,49 +9809,49 @@
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "7f8aaf142ce0552c260f2e546dda43ddd7c9aef3",
"is_verified": false,
"line_number": 1813
"line_number": 1815
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
"is_verified": false,
"line_number": 1986
"line_number": 1988
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
"is_verified": false,
"line_number": 2042
"line_number": 2044
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 2274
"line_number": 2276
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
"is_verified": false,
"line_number": 2402
"line_number": 2404
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
"is_verified": false,
"line_number": 2655
"line_number": 2657
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25",
"is_verified": false,
"line_number": 2657
"line_number": 2659
}
],
"docs/gateway/configuration.md": [
@@ -9972,7 +9972,7 @@
"filename": "docs/perplexity.md",
"hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8",
"is_verified": false,
"line_number": 29
"line_number": 43
}
],
"docs/plugins/voice-call.md": [
@@ -10198,21 +10198,21 @@
"filename": "docs/tools/web.md",
"hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8",
"is_verified": false,
"line_number": 90
"line_number": 135
},
{
"type": "Secret Keyword",
"filename": "docs/tools/web.md",
"hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac",
"is_verified": false,
"line_number": 179
"line_number": 228
},
{
"type": "Secret Keyword",
"filename": "docs/tools/web.md",
"hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217",
"is_verified": false,
"line_number": 277
"line_number": 332
}
],
"docs/tts.md": [
@@ -10255,14 +10255,14 @@
"filename": "docs/zh-CN/channels/feishu.md",
"hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3",
"is_verified": false,
"line_number": 195
"line_number": 191
},
{
"type": "Secret Keyword",
"filename": "docs/zh-CN/channels/feishu.md",
"hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c",
"is_verified": false,
"line_number": 509
"line_number": 505
}
],
"docs/zh-CN/channels/line.md": [
@@ -11481,7 +11481,7 @@
"filename": "src/agents/models-config.e2e-harness.ts",
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
"is_verified": false,
"line_number": 130
"line_number": 131
}
],
"src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts": [
@@ -11583,7 +11583,7 @@
"filename": "src/agents/pi-embedded-runner/model.ts",
"hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c",
"is_verified": false,
"line_number": 272
"line_number": 267
}
],
"src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [
@@ -11680,7 +11680,7 @@
"filename": "src/agents/tools/web-search.ts",
"hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b",
"is_verified": false,
"line_number": 254
"line_number": 292
}
],
"src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [
@@ -12335,14 +12335,14 @@
"filename": "src/config/schema.help.ts",
"hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208",
"is_verified": false,
"line_number": 649
"line_number": 651
},
{
"type": "Secret Keyword",
"filename": "src/config/schema.help.ts",
"hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae",
"is_verified": false,
"line_number": 680
"line_number": 684
}
],
"src/config/schema.irc.ts": [
@@ -12388,7 +12388,7 @@
"filename": "src/config/schema.labels.ts",
"hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff",
"is_verified": false,
"line_number": 324
"line_number": 325
}
],
"src/config/slack-http-config.test.ts": [
@@ -13034,5 +13034,5 @@
}
]
},
"generated_at": "2026-03-08T05:05:36Z"
"generated_at": "2026-03-08T18:30:57Z"
}

View File

@@ -6,6 +6,7 @@
- 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.
@@ -28,6 +29,7 @@
- Docs are hosted on Mintlify (docs.openclaw.ai).
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
- When working with documentation, read the mintlify skill.
- For docs, UI copy, and picker lists, order services/providers alphabetically unless the section is explicitly describing runtime behavior (for example auto-detection or execution order).
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
- When Peter asks for links, reply with full `https://docs.openclaw.ai/...` URLs (not root-relative).
@@ -113,6 +115,7 @@
**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.
- `/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.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.

View File

@@ -7,14 +7,27 @@ Docs: https://docs.openclaw.ai
### Changes
- TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7.
- Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.
- Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147.
- CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman.
- Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao.
### Fixes
- Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092)
- macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.
- Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.
- Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan.
- Android/Play distribution: remove self-update, background location, `screen.record`, and background mic capture from the Android app, narrow the foreground service to `dataSync` only, and clean up the legacy `location.enabledMode=always` preference migration. (#39660) Thanks @obviyus.
- Agents/openai-codex model resolution: fall through from inline `openai-codex` model entries without an `api` so GPT-5.4 keeps the codex transport and still preserves configured `baseUrl` and headers. (#39753) Thanks @justinhuangcode.
- Telegram/DM partial streaming: keep DM preview lanes on real message edits instead of native draft materialization so final replies no longer flash a second duplicate copy before collapsing back to one.
- macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared `inout` visibility mutation from `OverlayPanelFactory.present`, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH.
- macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv.
- macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek.
- Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus.
- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.
- Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.
## 2026.3.7
@@ -47,6 +60,7 @@ Docs: https://docs.openclaw.ai
- Mattermost/model picker: add Telegram-style interactive provider/model browsing for `/oc_model` and `/oc_models`, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm.
- Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add `OPENCLAW_VARIANT=slim` build arg for a bookworm-slim variant. (#38479) Thanks @sallyom.
- Google/Gemini 3.1 Flash-Lite: add first-class `google/gemini-3.1-flash-lite-preview` support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.
- Agents/compaction model override: allow `agents.defaults.compaction.model` to route compaction summarization through a different model than the main session, and document the override across config help/reference surfaces. (#38753) thanks @starbuck100.
### Breaking

View File

@@ -74,8 +74,19 @@ Welcome to the lobster tank! 🦞
- Ensure CI checks pass
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
- Describe what & why
- Reply to or resolve bot review conversations you addressed before asking for review again
- **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes)
## Review Conversations Are Author-Owned
If a review bot leaves review conversations on your PR, you are expected to handle the follow-through:
- Resolve the conversation yourself once the code or explanation fully addresses the bot's concern
- Reply and leave it open only when you need maintainer or reviewer judgment
- Do not leave "fixed" bot review conversations for maintainers to clean up for you
This applies to both human-authored and AI-assisted PRs.
## Control UI Decorators
The Control UI uses Lit with **legacy** decorators (current Rollup parsing does not support
@@ -101,8 +112,9 @@ Please include in your PR:
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
- [ ] Include prompts or session logs if possible (super helpful!)
- [ ] Confirm you understand what the code does
- [ ] Resolve or reply to bot review conversations after you address them
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. If you are using an LLM coding agent, instruct it to resolve bot review conversations it has addressed instead of leaving them for maintainers.
## Current Focus & Roadmap 🗺

View File

@@ -0,0 +1,5 @@
package ai.openclaw.app.voice
internal object TalkDefaults {
const val defaultSilenceTimeoutMs = 700L
}

View File

@@ -0,0 +1,161 @@
package ai.openclaw.app.voice
import ai.openclaw.app.normalizeMainKey
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
internal data class TalkProviderConfigSelection(
val provider: String,
val config: JsonObject,
val normalizedPayload: Boolean,
)
internal data class TalkModeGatewayConfigState(
val activeProvider: String,
val normalizedPayload: Boolean,
val missingResolvedPayload: Boolean,
val mainSessionKey: String,
val defaultVoiceId: String?,
val voiceAliases: Map<String, String>,
val defaultModelId: String,
val defaultOutputFormat: String,
val apiKey: String?,
val interruptOnSpeech: Boolean?,
val silenceTimeoutMs: Long,
)
internal object TalkModeGatewayConfigParser {
private const val defaultTalkProvider = "elevenlabs"
fun parse(
config: JsonObject?,
defaultProvider: String,
defaultModelIdFallback: String,
defaultOutputFormatFallback: String,
envVoice: String?,
sagVoice: String?,
envKey: String?,
): TalkModeGatewayConfigState {
val talk = config?.get("talk").asObjectOrNull()
val selection = selectTalkProviderConfig(talk)
val activeProvider = selection?.provider ?: defaultProvider
val activeConfig = selection?.config
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val aliases =
activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
normalizeTalkAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id }
}?.toMap().orEmpty()
val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val outputFormat =
activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
val silenceTimeoutMs = resolvedSilenceTimeoutMs(talk)
return TalkModeGatewayConfigState(
activeProvider = activeProvider,
normalizedPayload = selection?.normalizedPayload == true,
missingResolvedPayload = talk != null && selection == null,
mainSessionKey = mainKey,
defaultVoiceId =
if (activeProvider == defaultProvider) {
voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
} else {
voice
},
voiceAliases = aliases,
defaultModelId = model ?: defaultModelIdFallback,
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback,
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() },
interruptOnSpeech = interrupt,
silenceTimeoutMs = silenceTimeoutMs,
)
}
fun fallback(
defaultProvider: String,
defaultModelIdFallback: String,
defaultOutputFormatFallback: String,
envVoice: String?,
sagVoice: String?,
envKey: String?,
): TalkModeGatewayConfigState =
TalkModeGatewayConfigState(
activeProvider = defaultProvider,
normalizedPayload = false,
missingResolvedPayload = false,
mainSessionKey = "main",
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() },
voiceAliases = emptyMap(),
defaultModelId = defaultModelIdFallback,
defaultOutputFormat = defaultOutputFormatFallback,
apiKey = envKey?.takeIf { it.isNotEmpty() },
interruptOnSpeech = null,
silenceTimeoutMs = TalkDefaults.defaultSilenceTimeoutMs,
)
fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? {
if (talk == null) return null
selectResolvedTalkProviderConfig(talk)?.let { return it }
val rawProvider = talk["provider"].asStringOrNull()
val rawProviders = talk["providers"].asObjectOrNull()
val hasNormalizedPayload = rawProvider != null || rawProviders != null
if (hasNormalizedPayload) {
return null
}
return TalkProviderConfigSelection(
provider = defaultTalkProvider,
config = talk,
normalizedPayload = false,
)
}
fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long {
val fallback = TalkDefaults.defaultSilenceTimeoutMs
val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback
if (primitive.isString) return fallback
val timeout = primitive.content.toDoubleOrNull() ?: return fallback
if (timeout <= 0 || timeout % 1.0 != 0.0 || timeout > Long.MAX_VALUE.toDouble()) {
return fallback
}
return timeout.toLong()
}
private fun selectResolvedTalkProviderConfig(talk: JsonObject): TalkProviderConfigSelection? {
val resolved = talk["resolved"].asObjectOrNull() ?: return null
val providerId = normalizeTalkProviderId(resolved["provider"].asStringOrNull()) ?: return null
return TalkProviderConfigSelection(
provider = providerId,
config = resolved["config"].asObjectOrNull() ?: buildJsonObject {},
normalizedPayload = true,
)
}
private fun normalizeTalkProviderId(raw: String?): String? {
val trimmed = raw?.trim()?.lowercase().orEmpty()
return trimmed.takeIf { it.isNotEmpty() }
}
}
private fun normalizeTalkAliasKey(value: String): String =
value.trim().lowercase()
private fun JsonElement?.asStringOrNull(): String? =
this?.let { element ->
element as? JsonPrimitive
}?.contentOrNull
private fun JsonElement?.asBooleanOrNull(): Boolean? {
val primitive = this as? JsonPrimitive ?: return null
return primitive.booleanOrNull
}
private fun JsonElement?.asObjectOrNull(): JsonObject? =
this as? JsonObject

View File

@@ -59,52 +59,11 @@ class TalkModeManager(
private const val tag = "TalkMode"
private const val defaultModelIdFallback = "eleven_v3"
private const val defaultOutputFormatFallback = "pcm_24000"
private const val defaultTalkProvider = "elevenlabs"
private const val silenceWindowMs = 500L
private const val defaultTalkProvider = "elevenlabs"
private const val listenWatchdogMs = 12_000L
private const val chatFinalWaitWithSubscribeMs = 45_000L
private const val chatFinalWaitWithoutSubscribeMs = 6_000L
private const val maxCachedRunCompletions = 128
internal data class TalkProviderConfigSelection(
val provider: String,
val config: JsonObject,
val normalizedPayload: Boolean,
)
private fun normalizeTalkProviderId(raw: String?): String? {
val trimmed = raw?.trim()?.lowercase().orEmpty()
return trimmed.takeIf { it.isNotEmpty() }
}
internal fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? {
if (talk == null) return null
val rawProvider = talk["provider"].asStringOrNull()
val rawProviders = talk["providers"].asObjectOrNull()
val hasNormalizedPayload = rawProvider != null || rawProviders != null
if (hasNormalizedPayload) {
val providers =
rawProviders?.entries?.mapNotNull { (key, value) ->
val providerId = normalizeTalkProviderId(key) ?: return@mapNotNull null
val providerConfig = value.asObjectOrNull() ?: return@mapNotNull null
providerId to providerConfig
}?.toMap().orEmpty()
val providerId =
normalizeTalkProviderId(rawProvider)
?: providers.keys.sorted().firstOrNull()
?: defaultTalkProvider
return TalkProviderConfigSelection(
provider = providerId,
config = providers[providerId] ?: buildJsonObject {},
normalizedPayload = true,
)
}
return TalkProviderConfigSelection(
provider = defaultTalkProvider,
config = talk,
normalizedPayload = false,
)
}
}
private val mainHandler = Handler(Looper.getMainLooper())
@@ -134,7 +93,7 @@ private const val defaultTalkProvider = "elevenlabs"
private var listeningMode = false
private var silenceJob: Job? = null
private val silenceWindowMs = 700L
private var silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs
private var lastTranscript: String = ""
private var lastHeardAtMs: Long? = null
private var lastSpokenText: String? = null
@@ -854,7 +813,7 @@ private const val defaultTalkProvider = "elevenlabs"
_lastAssistantText.value = cleaned
val requestedVoice = directive?.voiceId?.trim()?.takeIf { it.isNotEmpty() }
val resolvedVoice = resolveVoiceAlias(requestedVoice)
val resolvedVoice = TalkModeVoiceResolver.resolveVoiceAlias(requestedVoice, voiceAliases)
if (requestedVoice != null && resolvedVoice == null) {
Log.w(tag, "unknown voice alias: $requestedVoice")
}
@@ -877,12 +836,35 @@ private const val defaultTalkProvider = "elevenlabs"
apiKey?.trim()?.takeIf { it.isNotEmpty() }
?: System.getenv("ELEVENLABS_API_KEY")?.trim()
val preferredVoice = resolvedVoice ?: currentVoiceId ?: defaultVoiceId
val voiceId =
val resolvedPlaybackVoice =
if (!apiKey.isNullOrEmpty()) {
resolveVoiceId(preferredVoice, apiKey)
try {
TalkModeVoiceResolver.resolveVoiceId(
preferred = preferredVoice,
fallbackVoiceId = fallbackVoiceId,
defaultVoiceId = defaultVoiceId,
currentVoiceId = currentVoiceId,
voiceOverrideActive = voiceOverrideActive,
listVoices = { TalkModeVoiceResolver.listVoices(apiKey, json) },
)
} catch (err: Throwable) {
Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}")
null
}
} else {
null
}
resolvedPlaybackVoice?.let { resolved ->
fallbackVoiceId = resolved.fallbackVoiceId
defaultVoiceId = resolved.defaultVoiceId
currentVoiceId = resolved.currentVoiceId
resolved.selectedVoiceName?.let { name ->
resolved.voiceId?.let { voiceId ->
Log.d(tag, "default voice selected $name ($voiceId)")
}
}
}
val voiceId = resolvedPlaybackVoice?.voiceId
_statusText.value = "Speaking…"
_isSpeaking.value = true
@@ -1393,60 +1375,64 @@ private const val defaultTalkProvider = "elevenlabs"
try {
val res = session.request("talk.config", """{"includeSecrets":true}""")
val root = json.parseToJsonElement(res).asObjectOrNull()
val config = root?.get("config").asObjectOrNull()
val talk = config?.get("talk").asObjectOrNull()
val selection = selectTalkProviderConfig(talk)
val activeProvider = selection?.provider ?: defaultTalkProvider
val activeConfig = selection?.config
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val aliases =
activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
normalizeAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id }
}?.toMap().orEmpty()
val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val outputFormat =
activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
val parsed =
TalkModeGatewayConfigParser.parse(
config = root?.get("config").asObjectOrNull(),
defaultProvider = defaultTalkProvider,
defaultModelIdFallback = defaultModelIdFallback,
defaultOutputFormatFallback = defaultOutputFormatFallback,
envVoice = envVoice,
sagVoice = sagVoice,
envKey = envKey,
)
if (parsed.missingResolvedPayload) {
Log.w(tag, "talk config ignored: normalized payload missing talk.resolved")
}
if (!isCanonicalMainSessionKey(mainSessionKey)) {
mainSessionKey = mainKey
mainSessionKey = parsed.mainSessionKey
}
defaultVoiceId =
if (activeProvider == defaultTalkProvider) {
voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
} else {
voice
}
voiceAliases = aliases
defaultVoiceId = parsed.defaultVoiceId
voiceAliases = parsed.voiceAliases
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
defaultModelId = model ?: defaultModelIdFallback
defaultModelId = parsed.defaultModelId
if (!modelOverrideActive) currentModelId = defaultModelId
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() }
Log.d(tag, "reloadConfig apiKey=${if (apiKey != null) "set" else "null"} voiceId=$defaultVoiceId")
if (interrupt != null) interruptOnSpeech = interrupt
activeProviderIsElevenLabs = activeProvider == defaultTalkProvider
defaultOutputFormat = parsed.defaultOutputFormat
apiKey = parsed.apiKey
silenceWindowMs = parsed.silenceTimeoutMs
Log.d(
tag,
"reloadConfig apiKey=${if (apiKey != null) "set" else "null"} voiceId=$defaultVoiceId silenceTimeoutMs=${parsed.silenceTimeoutMs}",
)
if (parsed.interruptOnSpeech != null) interruptOnSpeech = parsed.interruptOnSpeech
activeProviderIsElevenLabs = parsed.activeProvider == defaultTalkProvider
if (!activeProviderIsElevenLabs) {
// Clear ElevenLabs credentials so playAssistant won't attempt ElevenLabs calls
apiKey = null
defaultVoiceId = null
if (!voiceOverrideActive) currentVoiceId = null
Log.w(tag, "talk provider $activeProvider unsupported; using system voice fallback")
} else if (selection?.normalizedPayload == true) {
Log.w(tag, "talk provider ${parsed.activeProvider} unsupported; using system voice fallback")
} else if (parsed.normalizedPayload) {
Log.d(tag, "talk config provider=elevenlabs")
}
configLoaded = true
} catch (_: Throwable) {
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
defaultModelId = defaultModelIdFallback
val fallback =
TalkModeGatewayConfigParser.fallback(
defaultProvider = defaultTalkProvider,
defaultModelIdFallback = defaultModelIdFallback,
defaultOutputFormatFallback = defaultOutputFormatFallback,
envVoice = envVoice,
sagVoice = sagVoice,
envKey = envKey,
)
silenceWindowMs = fallback.silenceTimeoutMs
defaultVoiceId = fallback.defaultVoiceId
defaultModelId = fallback.defaultModelId
if (!modelOverrideActive) currentModelId = defaultModelId
apiKey = envKey?.takeIf { it.isNotEmpty() }
voiceAliases = emptyMap()
defaultOutputFormat = defaultOutputFormatFallback
apiKey = fallback.apiKey
voiceAliases = fallback.voiceAliases
defaultOutputFormat = fallback.defaultOutputFormat
// Keep config load retryable after transient fetch failures.
configLoaded = false
}
@@ -1740,82 +1726,6 @@ private const val defaultTalkProvider = "elevenlabs"
}
}
private fun resolveVoiceAlias(value: String?): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val normalized = normalizeAliasKey(trimmed)
voiceAliases[normalized]?.let { return it }
if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed
return if (isLikelyVoiceId(trimmed)) trimmed else null
}
private suspend fun resolveVoiceId(preferred: String?, apiKey: String): String? {
val trimmed = preferred?.trim().orEmpty()
if (trimmed.isNotEmpty()) {
val resolved = resolveVoiceAlias(trimmed)
// If it resolves as an alias, use the alias target.
// Otherwise treat it as a direct voice ID (e.g. "21m00Tcm4TlvDq8ikWAM").
return resolved ?: trimmed
}
fallbackVoiceId?.let { return it }
return try {
val voices = listVoices(apiKey)
val first = voices.firstOrNull() ?: return null
fallbackVoiceId = first.voiceId
if (defaultVoiceId.isNullOrBlank()) {
defaultVoiceId = first.voiceId
}
if (!voiceOverrideActive) {
currentVoiceId = first.voiceId
}
val name = first.name ?: "unknown"
Log.d(tag, "default voice selected $name (${first.voiceId})")
first.voiceId
} catch (err: Throwable) {
Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}")
null
}
}
private suspend fun listVoices(apiKey: String): List<ElevenLabsVoice> {
return withContext(Dispatchers.IO) {
val url = URL("https://api.elevenlabs.io/v1/voices")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "GET"
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
conn.setRequestProperty("xi-api-key", apiKey)
val code = conn.responseCode
val stream = if (code >= 400) conn.errorStream else conn.inputStream
val data = stream.readBytes()
if (code >= 400) {
val message = data.toString(Charsets.UTF_8)
throw IllegalStateException("ElevenLabs voices failed: $code $message")
}
val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull()
val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList())
voices.mapNotNull { entry ->
val obj = entry.asObjectOrNull() ?: return@mapNotNull null
val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null
val name = obj["name"].asStringOrNull()
ElevenLabsVoice(voiceId, name)
}
}
}
private fun isLikelyVoiceId(value: String): Boolean {
if (value.length < 10) return false
return value.all { it.isLetterOrDigit() || it == '-' || it == '_' }
}
private fun normalizeAliasKey(value: String): String =
value.trim().lowercase()
private data class ElevenLabsVoice(val voiceId: String, val name: String?)
private val listener =
object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {

View File

@@ -0,0 +1,118 @@
package ai.openclaw.app.voice
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
internal data class ElevenLabsVoice(val voiceId: String, val name: String?)
internal data class TalkModeResolvedVoice(
val voiceId: String?,
val fallbackVoiceId: String?,
val defaultVoiceId: String?,
val currentVoiceId: String?,
val selectedVoiceName: String? = null,
)
internal object TalkModeVoiceResolver {
fun resolveVoiceAlias(value: String?, voiceAliases: Map<String, String>): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val normalized = normalizeAliasKey(trimmed)
voiceAliases[normalized]?.let { return it }
if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed
return if (isLikelyVoiceId(trimmed)) trimmed else null
}
suspend fun resolveVoiceId(
preferred: String?,
fallbackVoiceId: String?,
defaultVoiceId: String?,
currentVoiceId: String?,
voiceOverrideActive: Boolean,
listVoices: suspend () -> List<ElevenLabsVoice>,
): TalkModeResolvedVoice {
val trimmed = preferred?.trim().orEmpty()
if (trimmed.isNotEmpty()) {
return TalkModeResolvedVoice(
voiceId = trimmed,
fallbackVoiceId = fallbackVoiceId,
defaultVoiceId = defaultVoiceId,
currentVoiceId = currentVoiceId,
)
}
if (!fallbackVoiceId.isNullOrBlank()) {
return TalkModeResolvedVoice(
voiceId = fallbackVoiceId,
fallbackVoiceId = fallbackVoiceId,
defaultVoiceId = defaultVoiceId,
currentVoiceId = currentVoiceId,
)
}
val first = listVoices().firstOrNull()
if (first == null) {
return TalkModeResolvedVoice(
voiceId = null,
fallbackVoiceId = fallbackVoiceId,
defaultVoiceId = defaultVoiceId,
currentVoiceId = currentVoiceId,
)
}
return TalkModeResolvedVoice(
voiceId = first.voiceId,
fallbackVoiceId = first.voiceId,
defaultVoiceId = if (defaultVoiceId.isNullOrBlank()) first.voiceId else defaultVoiceId,
currentVoiceId = if (voiceOverrideActive) currentVoiceId else first.voiceId,
selectedVoiceName = first.name,
)
}
suspend fun listVoices(apiKey: String, json: Json): List<ElevenLabsVoice> {
return withContext(Dispatchers.IO) {
val url = URL("https://api.elevenlabs.io/v1/voices")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "GET"
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
conn.setRequestProperty("xi-api-key", apiKey)
val code = conn.responseCode
val stream = if (code >= 400) conn.errorStream else conn.inputStream
val data = stream.readBytes()
if (code >= 400) {
val message = data.toString(Charsets.UTF_8)
throw IllegalStateException("ElevenLabs voices failed: $code $message")
}
val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull()
val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList())
voices.mapNotNull { entry ->
val obj = entry.asObjectOrNull() ?: return@mapNotNull null
val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null
val name = obj["name"].asStringOrNull()
ElevenLabsVoice(voiceId, name)
}
}
}
private fun isLikelyVoiceId(value: String): Boolean {
if (value.length < 10) return false
return value.all { it.isLetterOrDigit() || it == '-' || it == '_' }
}
private fun normalizeAliasKey(value: String): String =
value.trim().lowercase()
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
(this as? JsonPrimitive)?.takeIf { it.isString }?.content

View File

@@ -0,0 +1,100 @@
package ai.openclaw.app.voice
import java.io.File
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
@Serializable
private data class TalkConfigContractFixture(
@SerialName("selectionCases") val selectionCases: List<SelectionCase>,
@SerialName("timeoutCases") val timeoutCases: List<TimeoutCase>,
) {
@Serializable
data class SelectionCase(
val id: String,
val defaultProvider: String,
val payloadValid: Boolean,
val expectedSelection: ExpectedSelection? = null,
val talk: JsonObject,
)
@Serializable
data class ExpectedSelection(
val provider: String,
val normalizedPayload: Boolean,
val voiceId: String? = null,
val apiKey: String? = null,
)
@Serializable
data class TimeoutCase(
val id: String,
val fallback: Long,
val expectedTimeoutMs: Long,
val talk: JsonObject,
)
}
class TalkModeConfigContractTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun selectionFixtures() {
for (fixture in loadFixtures().selectionCases) {
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(fixture.talk)
val expected = fixture.expectedSelection
if (expected == null) {
assertNull(fixture.id, selection)
continue
}
assertNotNull(fixture.id, selection)
assertEquals(fixture.id, expected.provider, selection?.provider)
assertEquals(fixture.id, expected.normalizedPayload, selection?.normalizedPayload)
assertEquals(
fixture.id,
expected.voiceId,
(selection?.config?.get("voiceId") as? JsonPrimitive)?.content,
)
assertEquals(
fixture.id,
expected.apiKey,
(selection?.config?.get("apiKey") as? JsonPrimitive)?.content,
)
assertEquals(fixture.id, true, fixture.payloadValid)
}
}
@Test
fun timeoutFixtures() {
for (fixture in loadFixtures().timeoutCases) {
val timeout = TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(fixture.talk)
assertEquals(fixture.id, fixture.expectedTimeoutMs, timeout)
assertEquals(fixture.id, TalkDefaults.defaultSilenceTimeoutMs, fixture.fallback)
}
}
private fun loadFixtures(): TalkConfigContractFixture {
val fixturePath = findFixtureFile()
return json.decodeFromString(File(fixturePath).readText())
}
private fun findFixtureFile(): String {
val startDir = System.getProperty("user.dir") ?: error("user.dir unavailable")
var current = File(startDir).absoluteFile
while (true) {
val candidate = File(current, "test-fixtures/talk-config-contract.json")
if (candidate.exists()) {
return candidate.absolutePath
}
current = current.parentFile ?: break
}
error("talk-config-contract.json not found from $startDir")
}
}

View File

@@ -13,6 +13,36 @@ import org.junit.Test
class TalkModeConfigParsingTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun prefersCanonicalResolvedTalkProviderPayload() {
val talk =
json.parseToJsonElement(
"""
{
"resolved": {
"provider": "elevenlabs",
"config": {
"voiceId": "voice-resolved"
}
},
"provider": "elevenlabs",
"providers": {
"elevenlabs": {
"voiceId": "voice-normalized"
}
}
}
""".trimIndent(),
)
.jsonObject
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertNotNull(selection)
assertEquals("elevenlabs", selection?.provider)
assertTrue(selection?.normalizedPayload == true)
assertEquals("voice-resolved", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
}
@Test
fun prefersNormalizedTalkProviderPayload() {
val talk =
@@ -31,11 +61,52 @@ class TalkModeConfigParsingTest {
)
.jsonObject
val selection = TalkModeManager.selectTalkProviderConfig(talk)
assertNotNull(selection)
assertEquals("elevenlabs", selection?.provider)
assertTrue(selection?.normalizedPayload == true)
assertEquals("voice-normalized", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertEquals(null, selection)
}
@Test
fun rejectsNormalizedTalkProviderPayloadWhenProviderMissingFromProviders() {
val talk =
json.parseToJsonElement(
"""
{
"provider": "acme",
"providers": {
"elevenlabs": {
"voiceId": "voice-normalized"
}
}
}
""".trimIndent(),
)
.jsonObject
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertEquals(null, selection)
}
@Test
fun rejectsNormalizedTalkProviderPayloadWhenProviderIsAmbiguous() {
val talk =
json.parseToJsonElement(
"""
{
"providers": {
"acme": {
"voiceId": "voice-acme"
},
"elevenlabs": {
"voiceId": "voice-normalized"
}
}
}
""".trimIndent(),
)
.jsonObject
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertEquals(null, selection)
}
@Test
@@ -47,11 +118,46 @@ class TalkModeConfigParsingTest {
put("apiKey", legacyApiKey) // pragma: allowlist secret
}
val selection = TalkModeManager.selectTalkProviderConfig(talk)
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertNotNull(selection)
assertEquals("elevenlabs", selection?.provider)
assertTrue(selection?.normalizedPayload == false)
assertEquals("voice-legacy", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
assertEquals("legacy-key", selection?.config?.get("apiKey")?.jsonPrimitive?.content)
}
@Test
fun readsConfiguredSilenceTimeoutMs() {
val talk = buildJsonObject { put("silenceTimeoutMs", 1500) }
assertEquals(1500L, TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk))
}
@Test
fun defaultsSilenceTimeoutMsWhenMissing() {
assertEquals(
TalkDefaults.defaultSilenceTimeoutMs,
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(null),
)
}
@Test
fun defaultsSilenceTimeoutMsWhenInvalid() {
val talk = buildJsonObject { put("silenceTimeoutMs", 0) }
assertEquals(
TalkDefaults.defaultSilenceTimeoutMs,
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk),
)
}
@Test
fun defaultsSilenceTimeoutMsWhenString() {
val talk = buildJsonObject { put("silenceTimeoutMs", "1500") }
assertEquals(
TalkDefaults.defaultSilenceTimeoutMs,
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk),
)
}
}

View File

@@ -0,0 +1,92 @@
package ai.openclaw.app.voice
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class TalkModeVoiceResolverTest {
@Test
fun resolvesVoiceAliasCaseInsensitively() {
val resolved =
TalkModeVoiceResolver.resolveVoiceAlias(
" Clawd ",
mapOf("clawd" to "voice-123"),
)
assertEquals("voice-123", resolved)
}
@Test
fun acceptsDirectVoiceIds() {
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("21m00Tcm4TlvDq8ikWAM", emptyMap())
assertEquals("21m00Tcm4TlvDq8ikWAM", resolved)
}
@Test
fun rejectsUnknownAliases() {
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("nickname", emptyMap())
assertNull(resolved)
}
@Test
fun reusesCachedFallbackVoiceBeforeFetchingCatalog() =
runBlocking {
var fetchCount = 0
val resolved =
TalkModeVoiceResolver.resolveVoiceId(
preferred = null,
fallbackVoiceId = "cached-voice",
defaultVoiceId = null,
currentVoiceId = null,
voiceOverrideActive = false,
listVoices = {
fetchCount += 1
emptyList()
},
)
assertEquals("cached-voice", resolved.voiceId)
assertEquals(0, fetchCount)
}
@Test
fun seedsDefaultVoiceFromCatalogWhenNeeded() =
runBlocking {
val resolved =
TalkModeVoiceResolver.resolveVoiceId(
preferred = null,
fallbackVoiceId = null,
defaultVoiceId = null,
currentVoiceId = null,
voiceOverrideActive = false,
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
)
assertEquals("voice-1", resolved.voiceId)
assertEquals("voice-1", resolved.fallbackVoiceId)
assertEquals("voice-1", resolved.defaultVoiceId)
assertEquals("voice-1", resolved.currentVoiceId)
assertEquals("First", resolved.selectedVoiceName)
}
@Test
fun preservesCurrentVoiceWhenOverrideIsActive() =
runBlocking {
val resolved =
TalkModeVoiceResolver.resolveVoiceId(
preferred = null,
fallbackVoiceId = null,
defaultVoiceId = null,
currentVoiceId = null,
voiceOverrideActive = true,
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
)
assertEquals("voice-1", resolved.voiceId)
assertNull(resolved.currentVoiceId)
}
}

View File

@@ -129,8 +129,7 @@ final class NodeAppModel {
private var backgroundReconnectSuppressed = false
private var backgroundReconnectLeaseUntil: Date?
private var lastSignificantLocationWakeAt: Date?
private var queuedWatchReplies: [WatchQuickReplyEvent] = []
private var seenWatchReplyIds = Set<String>()
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
private var gatewayConnected = false
private var operatorConnected = false
@@ -2199,37 +2198,22 @@ extension NodeAppModel {
}
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
if replyId.isEmpty || actionId.isEmpty {
switch self.watchReplyCoordinator.ingest(event, isGatewayConnected: await self.isGatewayConnected()) {
case .dropMissingFields:
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
return
}
if self.seenWatchReplyIds.contains(replyId) {
case .deduped(let replyId):
self.watchReplyLogger.debug(
"watch reply deduped replyId=\(replyId, privacy: .public)")
return
}
self.seenWatchReplyIds.insert(replyId)
if await !self.isGatewayConnected() {
self.queuedWatchReplies.append(event)
case .queue(let replyId, let actionId):
self.watchReplyLogger.info(
"watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)")
return
case .forward:
await self.forwardWatchReplyToAgent(event)
}
await self.forwardWatchReplyToAgent(event)
}
private func flushQueuedWatchRepliesIfConnected() async {
guard await self.isGatewayConnected() else { return }
guard !self.queuedWatchReplies.isEmpty else { return }
let pending = self.queuedWatchReplies
self.queuedWatchReplies.removeAll()
for event in pending {
for event in self.watchReplyCoordinator.drainIfConnected(await self.isGatewayConnected()) {
await self.forwardWatchReplyToAgent(event)
}
}
@@ -2259,7 +2243,7 @@ extension NodeAppModel {
"watch reply forwarding failed replyId=\(event.replyId) "
+ "error=\(error.localizedDescription)"
self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
self.queuedWatchReplies.insert(event, at: 0)
self.watchReplyCoordinator.requeueFront(event)
}
}
@@ -2852,7 +2836,7 @@ extension NodeAppModel {
}
func _test_queuedWatchReplyCount() -> Int {
self.queuedWatchReplies.count
self.watchReplyCoordinator.queuedCount
}
func _test_setGatewayConnected(_ connected: Bool) {

View File

@@ -0,0 +1,46 @@
import Foundation
@MainActor
final class WatchReplyCoordinator {
enum Decision {
case dropMissingFields
case deduped(replyId: String)
case queue(replyId: String, actionId: String)
case forward
}
private var queuedReplies: [WatchQuickReplyEvent] = []
private var seenReplyIds = Set<String>()
func ingest(_ event: WatchQuickReplyEvent, isGatewayConnected: Bool) -> Decision {
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
if replyId.isEmpty || actionId.isEmpty {
return .dropMissingFields
}
if self.seenReplyIds.contains(replyId) {
return .deduped(replyId: replyId)
}
self.seenReplyIds.insert(replyId)
if !isGatewayConnected {
self.queuedReplies.append(event)
return .queue(replyId: replyId, actionId: actionId)
}
return .forward
}
func drainIfConnected(_ isGatewayConnected: Bool) -> [WatchQuickReplyEvent] {
guard isGatewayConnected, !self.queuedReplies.isEmpty else { return [] }
let pending = self.queuedReplies
self.queuedReplies.removeAll()
return pending
}
func requeueFront(_ event: WatchQuickReplyEvent) {
self.queuedReplies.insert(event, at: 0)
}
var queuedCount: Int {
self.queuedReplies.count
}
}

View File

@@ -0,0 +1,3 @@
enum TalkDefaults {
static let silenceTimeoutMs = 900
}

View File

@@ -0,0 +1,69 @@
import Foundation
import OpenClawKit
struct TalkModeGatewayConfigState {
let activeProvider: String
let normalizedPayload: Bool
let missingResolvedPayload: Bool
let defaultVoiceId: String?
let voiceAliases: [String: String]
let defaultModelId: String
let defaultOutputFormat: String?
let rawConfigApiKey: String?
let interruptOnSpeech: Bool?
let silenceTimeoutMs: Int
}
enum TalkModeGatewayConfigParser {
static func parse(
config: [String: Any],
defaultProvider: String,
defaultModelIdFallback: String,
defaultSilenceTimeoutMs: Int
) -> TalkModeGatewayConfigState {
let talk = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any])
let selection = TalkConfigParsing.selectProviderConfig(
talk,
defaultProvider: defaultProvider,
allowLegacyFallback: false)
let activeProvider = selection?.provider ?? defaultProvider
let activeConfig = selection?.config
let defaultVoiceId = activeConfig?["voiceId"]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines)
let voiceAliases: [String: String]
if let aliases = activeConfig?["voiceAliases"]?.dictionaryValue {
var resolved: [String: String] = [:]
for (key, value) in aliases {
guard let id = value.stringValue else { continue }
let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue }
resolved[normalizedKey] = trimmedId
}
voiceAliases = resolved
} else {
voiceAliases = [:]
}
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
let defaultModelId = (model?.isEmpty == false) ? model! : defaultModelIdFallback
let defaultOutputFormat = activeConfig?["outputFormat"]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines)
let rawConfigApiKey = activeConfig?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
let interruptOnSpeech = talk?["interruptOnSpeech"]?.boolValue
let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs(
talk,
fallback: defaultSilenceTimeoutMs)
return TalkModeGatewayConfigState(
activeProvider: activeProvider,
normalizedPayload: selection?.normalizedPayload == true,
missingResolvedPayload: talk != nil && selection == nil,
defaultVoiceId: defaultVoiceId,
voiceAliases: voiceAliases,
defaultModelId: defaultModelId,
defaultOutputFormat: defaultOutputFormat,
rawConfigApiKey: rawConfigApiKey,
interruptOnSpeech: interruptOnSpeech,
silenceTimeoutMs: silenceTimeoutMs)
}
}

View File

@@ -34,6 +34,7 @@ final class TalkModeManager: NSObject {
private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest
private static let defaultModelIdFallback = "eleven_v3"
private static let defaultTalkProvider = "elevenlabs"
private static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs
private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__"
var isEnabled: Bool = false
var isListening: Bool = false
@@ -97,7 +98,7 @@ final class TalkModeManager: NSObject {
private var gateway: GatewayNodeSession?
private var gatewayConnected = false
private let silenceWindow: TimeInterval = 0.9
private var silenceWindow: TimeInterval = TimeInterval(TalkModeManager.defaultSilenceTimeoutMs) / 1000
private var lastAudioActivity: Date?
private var noiseFloorSamples: [Double] = []
private var noiseFloor: Double?
@@ -1969,38 +1970,6 @@ extension TalkModeManager {
return trimmed
}
struct TalkProviderConfigSelection {
let provider: String
let config: [String: Any]
}
private static func normalizedTalkProviderID(_ raw: String?) -> String? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
static func selectTalkProviderConfig(_ talk: [String: Any]?) -> TalkProviderConfigSelection? {
guard let talk else { return nil }
let rawProvider = talk["provider"] as? String
let rawProviders = talk["providers"] as? [String: Any]
guard rawProvider != nil || rawProviders != nil else { return nil }
let providers = rawProviders ?? [:]
let normalizedProviders = providers.reduce(into: [String: [String: Any]]()) { acc, entry in
guard
let providerID = Self.normalizedTalkProviderID(entry.key),
let config = entry.value as? [String: Any]
else { return }
acc[providerID] = config
}
let providerID =
Self.normalizedTalkProviderID(rawProvider) ??
normalizedProviders.keys.min() ??
Self.defaultTalkProvider
return TalkProviderConfigSelection(
provider: providerID,
config: normalizedProviders[providerID] ?? [:])
}
func reloadConfig() async {
guard let gateway else { return }
self.pcmFormatUnavailable = false
@@ -2012,40 +1981,27 @@ extension TalkModeManager {
)
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return }
let talk = config["talk"] as? [String: Any]
let selection = Self.selectTalkProviderConfig(talk)
if talk != nil, selection == nil {
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: Self.defaultTalkProvider,
defaultModelIdFallback: Self.defaultModelIdFallback,
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs)
if parsed.missingResolvedPayload {
GatewayDiagnostics.log(
"talk config ignored: legacy payload unsupported on iOS beta; expected talk.provider/providers")
}
let activeProvider = selection?.provider ?? Self.defaultTalkProvider
let activeConfig = selection?.config
self.defaultVoiceId = (activeConfig?["voiceId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let aliases = activeConfig?["voiceAliases"] as? [String: Any] {
var resolved: [String: String] = [:]
for (key, value) in aliases {
guard let id = value as? String else { continue }
let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue }
resolved[normalizedKey] = trimmedId
}
self.voiceAliases = resolved
} else {
self.voiceAliases = [:]
"talk config ignored: normalized payload missing talk.resolved")
}
let activeProvider = parsed.activeProvider
self.defaultVoiceId = parsed.defaultVoiceId
self.voiceAliases = parsed.voiceAliases
if !self.voiceOverrideActive {
self.currentVoiceId = self.defaultVoiceId
}
let model = (activeConfig?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
self.defaultModelId = (model?.isEmpty == false) ? model : Self.defaultModelIdFallback
self.defaultModelId = parsed.defaultModelId
if !self.modelOverrideActive {
self.currentModelId = self.defaultModelId
}
self.defaultOutputFormat = (activeConfig?["outputFormat"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let rawConfigApiKey = (activeConfig?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
self.defaultOutputFormat = parsed.defaultOutputFormat
let rawConfigApiKey = parsed.rawConfigApiKey
let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey)
let localApiKey = Self.normalizedTalkApiKey(
GatewaySettingsStore.loadTalkProviderApiKey(provider: activeProvider))
@@ -2064,11 +2020,13 @@ extension TalkModeManager {
self.gatewayTalkDefaultModelId = self.defaultModelId
self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false)
self.gatewayTalkConfigLoaded = true
if let interrupt = talk?["interruptOnSpeech"] as? Bool {
if let interrupt = parsed.interruptOnSpeech {
self.interruptOnSpeech = interrupt
}
if selection != nil {
GatewayDiagnostics.log("talk config provider=\(activeProvider)")
self.silenceWindow = TimeInterval(parsed.silenceTimeoutMs) / 1000
if parsed.normalizedPayload || parsed.defaultVoiceId != nil || parsed.rawConfigApiKey != nil {
GatewayDiagnostics.log(
"talk config provider=\(activeProvider) silenceTimeoutMs=\(parsed.silenceTimeoutMs)")
}
} catch {
self.defaultModelId = Self.defaultModelIdFallback
@@ -2079,6 +2037,7 @@ extension TalkModeManager {
self.gatewayTalkDefaultModelId = nil
self.gatewayTalkApiKeyConfigured = false
self.gatewayTalkConfigLoaded = false
self.silenceWindow = TimeInterval(Self.defaultSilenceTimeoutMs) / 1000
}
}

View File

@@ -13,6 +13,7 @@ Sources/OpenClawApp.swift
Sources/Location/LocationService.swift
Sources/Model/NodeAppModel.swift
Sources/Model/NodeAppModel+Canvas.swift
Sources/Model/WatchReplyCoordinator.swift
Sources/RootCanvas.swift
Sources/RootTabs.swift
Sources/Screen/ScreenController.swift

View File

@@ -0,0 +1,75 @@
import Foundation
import OpenClawKit
import Testing
private let iOSSilenceTimeoutMs = 900
@Suite struct TalkConfigParsingTests {
@Test func rejectsNormalizedTalkProviderPayloadWithoutResolved() {
let talk: [String: Any] = [
"provider": "elevenlabs",
"providers": [
"elevenlabs": [
"voiceId": "voice-normalized",
],
],
"voiceId": "voice-legacy",
]
let selection = TalkConfigParsing.selectProviderConfig(
TalkConfigParsing.bridgeFoundationDictionary(talk),
defaultProvider: "elevenlabs",
allowLegacyFallback: false)
#expect(selection == nil)
}
@Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() {
let talk: [String: Any] = [
"voiceId": "voice-legacy",
"apiKey": "legacy-key", // pragma: allowlist secret
]
let selection = TalkConfigParsing.selectProviderConfig(
TalkConfigParsing.bridgeFoundationDictionary(talk),
defaultProvider: "elevenlabs",
allowLegacyFallback: false)
#expect(selection == nil)
}
@Test func readsConfiguredSilenceTimeoutMs() {
let talk: [String: Any] = [
"silenceTimeoutMs": 1500,
]
#expect(
TalkConfigParsing.resolvedSilenceTimeoutMs(
TalkConfigParsing.bridgeFoundationDictionary(talk),
fallback: iOSSilenceTimeoutMs) == 1500)
}
@Test func defaultsSilenceTimeoutMsWhenMissing() {
#expect(TalkConfigParsing.resolvedSilenceTimeoutMs(nil, fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
}
@Test func defaultsSilenceTimeoutMsWhenInvalid() {
let talk: [String: Any] = [
"silenceTimeoutMs": 0,
]
#expect(
TalkConfigParsing.resolvedSilenceTimeoutMs(
TalkConfigParsing.bridgeFoundationDictionary(talk),
fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
}
@Test func defaultsSilenceTimeoutMsWhenBool() {
let talk: [String: Any] = [
"silenceTimeoutMs": true,
]
#expect(
TalkConfigParsing.resolvedSilenceTimeoutMs(
TalkConfigParsing.bridgeFoundationDictionary(talk),
fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
}
}

View File

@@ -3,33 +3,7 @@ import Testing
@testable import OpenClaw
@MainActor
@Suite struct TalkModeConfigParsingTests {
@Test func prefersNormalizedTalkProviderPayload() {
let talk: [String: Any] = [
"provider": "elevenlabs",
"providers": [
"elevenlabs": [
"voiceId": "voice-normalized",
],
],
"voiceId": "voice-legacy",
]
let selection = TalkModeManager.selectTalkProviderConfig(talk)
#expect(selection?.provider == "elevenlabs")
#expect(selection?.config["voiceId"] as? String == "voice-normalized")
}
@Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() {
let talk: [String: Any] = [
"voiceId": "voice-legacy",
"apiKey": "legacy-key", // pragma: allowlist secret
]
let selection = TalkModeManager.selectTalkProviderConfig(talk)
#expect(selection == nil)
}
@Suite struct TalkModeManagerTests {
@Test func detectsPCMFormatRejectionFromElevenLabsError() {
let error = NSError(
domain: "ElevenLabsTTS",

View File

@@ -25,6 +25,15 @@ schemes:
test:
targets:
- OpenClawTests
- OpenClawLogicTests
OpenClawLogicTests:
shared: true
build:
targets:
OpenClawLogicTests: all
test:
targets:
- OpenClawLogicTests
targets:
OpenClaw:
@@ -117,8 +126,11 @@ targets:
NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing.
NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always.
NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake.
NSMotionUsageDescription: OpenClaw may use motion data to support device-aware interactions and automations.
NSPhotoLibraryUsageDescription: OpenClaw needs photo library access when you choose existing photos to share with your assistant.
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
NSSupportsLiveActivities: true
ITSAppUsesNonExemptEncryption: false
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
@@ -259,6 +271,8 @@ targets:
Release: Signing.xcconfig
sources:
- path: Tests
excludes:
- Logic
dependencies:
- target: OpenClaw
- package: Swabble
@@ -281,3 +295,29 @@ targets:
CFBundleDisplayName: OpenClawTests
CFBundleShortVersionString: "2026.3.8"
CFBundleVersion: "20260308"
OpenClawLogicTests:
type: bundle.unit-test
platform: iOS
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
sources:
- path: Tests/Logic
dependencies:
- package: OpenClawKit
settings:
base:
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.logic-tests
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
info:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawLogicTests
CFBundleShortVersionString: "2026.3.8"
CFBundleVersion: "20260308"

View File

@@ -4,40 +4,3 @@ import OpenClawKit
// Prefer the OpenClawKit wrapper to keep gateway request payloads consistent.
typealias AnyCodable = OpenClawKit.AnyCodable
typealias InstanceIdentity = OpenClawKit.InstanceIdentity
extension AnyCodable {
var stringValue: String? {
self.value as? String
}
var boolValue: Bool? {
self.value as? Bool
}
var intValue: Int? {
self.value as? Int
}
var doubleValue: Double? {
self.value as? Double
}
var dictionaryValue: [String: AnyCodable]? {
self.value as? [String: AnyCodable]
}
var arrayValue: [AnyCodable]? {
self.value as? [AnyCodable]
}
var foundationValue: Any {
switch self.value {
case let dict as [String: AnyCodable]:
dict.mapValues { $0.foundationValue }
case let array as [AnyCodable]:
array.map(\.foundationValue)
default:
self.value
}
}
}

View File

@@ -0,0 +1,3 @@
enum TalkDefaults {
static let silenceTimeoutMs = 700
}

View File

@@ -0,0 +1,104 @@
import Foundation
import OpenClawKit
struct TalkModeGatewayConfigState {
let activeProvider: String
let normalizedPayload: Bool
let missingResolvedPayload: Bool
let voiceId: String?
let voiceAliases: [String: String]
let modelId: String?
let outputFormat: String?
let interruptOnSpeech: Bool
let silenceTimeoutMs: Int
let apiKey: String?
let seamColorHex: String?
}
enum TalkModeGatewayConfigParser {
static func parse(
snapshot: ConfigSnapshot,
defaultProvider: String,
defaultModelIdFallback: String,
defaultSilenceTimeoutMs: Int,
envVoice: String?,
sagVoice: String?,
envApiKey: String?
) -> TalkModeGatewayConfigState {
let talk = snapshot.config?["talk"]?.dictionaryValue
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: defaultProvider)
let activeProvider = selection?.provider ?? defaultProvider
let activeConfig = selection?.config
let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs(
talk,
fallback: defaultSilenceTimeoutMs)
let ui = snapshot.config?["ui"]?.dictionaryValue
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let voice = activeConfig?["voiceId"]?.stringValue
let rawAliases = activeConfig?["voiceAliases"]?.dictionaryValue
let resolvedAliases: [String: String] =
rawAliases?.reduce(into: [:]) { acc, entry in
let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let value = entry.value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !key.isEmpty, !value.isEmpty else { return }
acc[key] = value
} ?? [:]
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedModel = (model?.isEmpty == false) ? model! : defaultModelIdFallback
let outputFormat = activeConfig?["outputFormat"]?.stringValue
let interrupt = talk?["interruptOnSpeech"]?.boolValue
let apiKey = activeConfig?["apiKey"]?.stringValue
let resolvedVoice: String? = if activeProvider == defaultProvider {
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ??
(envVoice?.isEmpty == false ? envVoice : nil) ??
(sagVoice?.isEmpty == false ? sagVoice : nil)
} else {
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil)
}
let resolvedApiKey: String? = if activeProvider == defaultProvider {
(envApiKey?.isEmpty == false ? envApiKey : nil) ??
(apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil)
} else {
nil
}
return TalkModeGatewayConfigState(
activeProvider: activeProvider,
normalizedPayload: selection?.normalizedPayload == true,
missingResolvedPayload: talk != nil && selection == nil,
voiceId: resolvedVoice,
voiceAliases: resolvedAliases,
modelId: resolvedModel,
outputFormat: outputFormat,
interruptOnSpeech: interrupt ?? true,
silenceTimeoutMs: silenceTimeoutMs,
apiKey: resolvedApiKey,
seamColorHex: rawSeam.isEmpty ? nil : rawSeam)
}
static func fallback(
defaultModelIdFallback: String,
defaultSilenceTimeoutMs: Int,
envVoice: String?,
sagVoice: String?,
envApiKey: String?
) -> TalkModeGatewayConfigState {
let resolvedVoice =
(envVoice?.isEmpty == false ? envVoice : nil) ??
(sagVoice?.isEmpty == false ? sagVoice : nil)
let resolvedApiKey = envApiKey?.isEmpty == false ? envApiKey : nil
return TalkModeGatewayConfigState(
activeProvider: "elevenlabs",
normalizedPayload: false,
missingResolvedPayload: false,
voiceId: resolvedVoice,
voiceAliases: [:],
modelId: defaultModelIdFallback,
outputFormat: nil,
interruptOnSpeech: true,
silenceTimeoutMs: defaultSilenceTimeoutMs,
apiKey: resolvedApiKey,
seamColorHex: nil)
}
}

View File

@@ -12,6 +12,7 @@ actor TalkModeRuntime {
private let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts")
private static let defaultModelIdFallback = "eleven_v3"
private static let defaultTalkProvider = "elevenlabs"
private static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs
private final class RMSMeter: @unchecked Sendable {
private let lock = NSLock()
@@ -66,10 +67,15 @@ actor TalkModeRuntime {
private var fallbackVoiceId: String?
private var lastPlaybackWasPCM: Bool = false
private let silenceWindow: TimeInterval = 0.7
private var silenceWindow: TimeInterval = .init(TalkModeRuntime.defaultSilenceTimeoutMs) / 1000
private let minSpeechRMS: Double = 1e-3
private let speechBoostFactor: Double = 6.0
static func configureRecognitionRequest(_ request: SFSpeechAudioBufferRecognitionRequest) {
request.shouldReportPartialResults = true
request.taskHint = .dictation
}
// MARK: - Lifecycle
func setEnabled(_ enabled: Bool) async {
@@ -176,9 +182,9 @@ actor TalkModeRuntime {
return
}
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
self.recognitionRequest?.shouldReportPartialResults = true
guard let request = self.recognitionRequest else { return }
let request = SFSpeechAudioBufferRecognitionRequest()
Self.configureRecognitionRequest(request)
self.recognitionRequest = request
if self.audioEngine == nil {
self.audioEngine = AVAudioEngine()
@@ -778,6 +784,7 @@ extension TalkModeRuntime {
}
self.defaultOutputFormat = cfg.outputFormat
self.interruptOnSpeech = cfg.interruptOnSpeech
self.silenceWindow = TimeInterval(cfg.silenceTimeoutMs) / 1000
self.apiKey = cfg.apiKey
let hasApiKey = (cfg.apiKey?.isEmpty == false)
let voiceLabel = (cfg.voiceId?.isEmpty == false) ? cfg.voiceId! : "none"
@@ -787,95 +794,21 @@ extension TalkModeRuntime {
"talk config voiceId=\(voiceLabel, privacy: .public) " +
"modelId=\(modelLabel, privacy: .public) " +
"apiKey=\(hasApiKey, privacy: .public) " +
"interrupt=\(cfg.interruptOnSpeech, privacy: .public)")
}
private struct TalkRuntimeConfig {
let voiceId: String?
let voiceAliases: [String: String]
let modelId: String?
let outputFormat: String?
let interruptOnSpeech: Bool
let apiKey: String?
}
struct TalkProviderConfigSelection {
let provider: String
let config: [String: AnyCodable]
let normalizedPayload: Bool
}
private static func normalizedTalkProviderID(_ raw: String?) -> String? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func normalizedTalkProviderConfig(_ value: AnyCodable) -> [String: AnyCodable]? {
if let typed = value.value as? [String: AnyCodable] {
return typed
}
if let foundation = value.value as? [String: Any] {
return foundation.mapValues(AnyCodable.init)
}
if let nsDict = value.value as? NSDictionary {
var converted: [String: AnyCodable] = [:]
for case let (key as String, raw) in nsDict {
converted[key] = AnyCodable(raw)
}
return converted
}
return nil
}
private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] {
guard let raw else { return [:] }
var providerMap: [String: AnyCodable] = [:]
if let typed = raw.value as? [String: AnyCodable] {
providerMap = typed
} else if let foundation = raw.value as? [String: Any] {
providerMap = foundation.mapValues(AnyCodable.init)
} else if let nsDict = raw.value as? NSDictionary {
for case let (key as String, value) in nsDict {
providerMap[key] = AnyCodable(value)
}
} else {
return [:]
}
return providerMap.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in
guard
let providerID = Self.normalizedTalkProviderID(entry.key),
let providerConfig = Self.normalizedTalkProviderConfig(entry.value)
else { return }
acc[providerID] = providerConfig
}
"interrupt=\(cfg.interruptOnSpeech, privacy: .public) " +
"silenceTimeoutMs=\(cfg.silenceTimeoutMs, privacy: .public)")
}
static func selectTalkProviderConfig(
_ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection?
{
guard let talk else { return nil }
let rawProvider = talk["provider"]?.stringValue
let rawProviders = talk["providers"]
let hasNormalizedPayload = rawProvider != nil || rawProviders != nil
if hasNormalizedPayload {
let normalizedProviders = Self.normalizedTalkProviders(rawProviders)
let providerID =
Self.normalizedTalkProviderID(rawProvider) ??
normalizedProviders.keys.min() ??
Self.defaultTalkProvider
return TalkProviderConfigSelection(
provider: providerID,
config: normalizedProviders[providerID] ?? [:],
normalizedPayload: true)
}
return TalkProviderConfigSelection(
provider: Self.defaultTalkProvider,
config: talk,
normalizedPayload: false)
TalkConfigParsing.selectProviderConfig(talk, defaultProvider: self.defaultTalkProvider)
}
private func fetchTalkConfig() async -> TalkRuntimeConfig {
static func resolvedSilenceTimeoutMs(_ talk: [String: AnyCodable]?) -> Int {
TalkConfigParsing.resolvedSilenceTimeoutMs(talk, fallback: self.defaultSilenceTimeoutMs)
}
private func fetchTalkConfig() async -> TalkModeGatewayConfigState {
let env = ProcessInfo.processInfo.environment
let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines)
let sagVoice = env["SAG_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -886,67 +819,34 @@ extension TalkModeRuntime {
method: .talkConfig,
params: ["includeSecrets": AnyCodable(true)],
timeoutMs: 8000)
let talk = snap.config?["talk"]?.dictionaryValue
let selection = Self.selectTalkProviderConfig(talk)
let activeProvider = selection?.provider ?? Self.defaultTalkProvider
let activeConfig = selection?.config
let ui = snap.config?["ui"]?.dictionaryValue
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsed = TalkModeGatewayConfigParser.parse(
snapshot: snap,
defaultProvider: Self.defaultTalkProvider,
defaultModelIdFallback: Self.defaultModelIdFallback,
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs,
envVoice: envVoice,
sagVoice: sagVoice,
envApiKey: envApiKey)
if parsed.missingResolvedPayload {
self.ttsLogger.info("talk config ignored: normalized payload missing talk.resolved")
}
await MainActor.run {
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
AppStateStore.shared.seamColorHex = parsed.seamColorHex
}
let voice = activeConfig?["voiceId"]?.stringValue
let rawAliases = activeConfig?["voiceAliases"]?.dictionaryValue
let resolvedAliases: [String: String] =
rawAliases?.reduce(into: [:]) { acc, entry in
let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let value = entry.value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !key.isEmpty, !value.isEmpty else { return }
acc[key] = value
} ?? [:]
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedModel = (model?.isEmpty == false) ? model! : Self.defaultModelIdFallback
let outputFormat = activeConfig?["outputFormat"]?.stringValue
let interrupt = talk?["interruptOnSpeech"]?.boolValue
let apiKey = activeConfig?["apiKey"]?.stringValue
let resolvedVoice: String? = if activeProvider == Self.defaultTalkProvider {
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ??
(envVoice?.isEmpty == false ? envVoice : nil) ??
(sagVoice?.isEmpty == false ? sagVoice : nil)
} else {
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil)
}
let resolvedApiKey: String? = if activeProvider == Self.defaultTalkProvider {
(envApiKey?.isEmpty == false ? envApiKey : nil) ??
(apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil)
} else {
nil
}
if activeProvider != Self.defaultTalkProvider {
if parsed.activeProvider != Self.defaultTalkProvider {
self.ttsLogger
.info("talk provider \(activeProvider, privacy: .public) unsupported; using system voice")
} else if selection?.normalizedPayload == true {
self.ttsLogger.info("talk config provider elevenlabs")
.info("talk provider \(parsed.activeProvider, privacy: .public) unsupported; using system voice")
} else if parsed.normalizedPayload {
self.ttsLogger.info("talk config provider from talk.resolved")
}
return TalkRuntimeConfig(
voiceId: resolvedVoice,
voiceAliases: resolvedAliases,
modelId: resolvedModel,
outputFormat: outputFormat,
interruptOnSpeech: interrupt ?? true,
apiKey: resolvedApiKey)
return parsed
} catch {
let resolvedVoice =
(envVoice?.isEmpty == false ? envVoice : nil) ??
(sagVoice?.isEmpty == false ? sagVoice : nil)
let resolvedApiKey = envApiKey?.isEmpty == false ? envApiKey : nil
return TalkRuntimeConfig(
voiceId: resolvedVoice,
voiceAliases: [:],
modelId: Self.defaultModelIdFallback,
outputFormat: nil,
interruptOnSpeech: true,
apiKey: resolvedApiKey)
return TalkModeGatewayConfigParser.fallback(
defaultModelIdFallback: Self.defaultModelIdFallback,
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs,
envVoice: envVoice,
sagVoice: sagVoice,
envApiKey: envApiKey)
}
}

View File

@@ -66,6 +66,15 @@ struct LowCoverageViewSmokeTests {
try? await Task.sleep(nanoseconds: 250_000_000)
}
@Test func `talk overlay presents twice and dismisses`() async {
let controller = TalkOverlayController()
controller.present()
controller.updateLevel(0.4)
controller.present()
controller.dismiss()
try? await Task.sleep(nanoseconds: 250_000_000)
}
@Test func `visual effect view hosts in NS hosting view`() {
let hosting = NSHostingView(rootView: VisualEffectView(material: .sidebar))
_ = hosting.fittingSize

View File

@@ -3,7 +3,7 @@ import Testing
@testable import OpenClaw
struct TalkModeConfigParsingTests {
@Test func `prefers normalized talk provider payload`() {
@Test func `rejects normalized talk provider payload without resolved`() {
let talk: [String: AnyCodable] = [
"provider": AnyCodable("elevenlabs"),
"providers": AnyCodable([
@@ -15,9 +15,7 @@ struct TalkModeConfigParsingTests {
]
let selection = TalkModeRuntime.selectTalkProviderConfig(talk)
#expect(selection?.provider == "elevenlabs")
#expect(selection?.normalizedPayload == true)
#expect(selection?.config["voiceId"]?.stringValue == "voice-normalized")
#expect(selection == nil)
}
@Test func `falls back to legacy talk fields when normalized payload missing`() {
@@ -32,4 +30,24 @@ struct TalkModeConfigParsingTests {
#expect(selection?.config["voiceId"]?.stringValue == "voice-legacy")
#expect(selection?.config["apiKey"]?.stringValue == "legacy-key")
}
@Test func `reads configured silence timeout ms`() {
let talk: [String: AnyCodable] = [
"silenceTimeoutMs": AnyCodable(1500),
]
#expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == 1500)
}
@Test func `defaults silence timeout ms when missing`() {
#expect(TalkModeRuntime.resolvedSilenceTimeoutMs(nil) == TalkDefaults.silenceTimeoutMs)
}
@Test func `defaults silence timeout ms when invalid`() {
let talk: [String: AnyCodable] = [
"silenceTimeoutMs": AnyCodable(0),
]
#expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == TalkDefaults.silenceTimeoutMs)
}
}

View File

@@ -0,0 +1,14 @@
import Speech
import Testing
@testable import OpenClaw
struct TalkModeRuntimeSpeechTests {
@Test func `speech request uses dictation defaults`() {
let request = SFSpeechAudioBufferRecognitionRequest()
TalkModeRuntime.configureRecognitionRequest(request)
#expect(request.shouldReportPartialResults)
#expect(request.taskHint == .dictation)
}
}

View File

@@ -0,0 +1,88 @@
import Foundation
public extension AnyCodable {
var stringValue: String? {
self.value as? String
}
var boolValue: Bool? {
if let value = self.value as? Bool {
return value
}
if let number = self.value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() {
return number.boolValue
}
return nil
}
var intValue: Int? {
if let value = self.value as? Int {
return value
}
if let number = self.value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() {
let value = number.doubleValue
if value > 0, value.rounded(.towardZero) == value, value <= Double(Int.max) {
return Int(value)
}
}
return nil
}
var doubleValue: Double? {
if let value = self.value as? Double {
return value
}
if let value = self.value as? Int {
return Double(value)
}
if let number = self.value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() {
return number.doubleValue
}
return nil
}
var dictionaryValue: [String: AnyCodable]? {
if let value = self.value as? [String: AnyCodable] {
return value
}
if let value = self.value as? [String: Any] {
return value.mapValues(AnyCodable.init)
}
if let value = self.value as? NSDictionary {
var converted: [String: AnyCodable] = [:]
for case let (key as String, raw) in value {
converted[key] = AnyCodable(raw)
}
return converted
}
return nil
}
var arrayValue: [AnyCodable]? {
if let value = self.value as? [AnyCodable] {
return value
}
if let value = self.value as? [Any] {
return value.map(AnyCodable.init)
}
if let value = self.value as? NSArray {
return value.map(AnyCodable.init)
}
return nil
}
var foundationValue: Any {
switch self.value {
case let dict as [String: AnyCodable]:
dict.mapValues(\.foundationValue)
case let array as [AnyCodable]:
array.map(\.foundationValue)
case let dict as [String: Any]:
dict.mapValues { AnyCodable($0).foundationValue }
case let array as [Any]:
array.map { AnyCodable($0).foundationValue }
default:
self.value
}
}
}

View File

@@ -0,0 +1,76 @@
import Foundation
public struct TalkProviderConfigSelection: Sendable {
public let provider: String
public let config: [String: AnyCodable]
public let normalizedPayload: Bool
public init(provider: String, config: [String: AnyCodable], normalizedPayload: Bool) {
self.provider = provider
self.config = config
self.normalizedPayload = normalizedPayload
}
}
public enum TalkConfigParsing {
public static func bridgeFoundationDictionary(_ raw: [String: Any]?) -> [String: AnyCodable]? {
raw?.mapValues(AnyCodable.init)
}
public static func selectProviderConfig(
_ talk: [String: AnyCodable]?,
defaultProvider: String,
allowLegacyFallback: Bool = true,
) -> TalkProviderConfigSelection? {
guard let talk else { return nil }
if let resolvedSelection = self.resolvedProviderConfig(talk) {
return resolvedSelection
}
let hasNormalizedPayload = talk["provider"] != nil || talk["providers"] != nil
if hasNormalizedPayload {
return nil
}
guard allowLegacyFallback else { return nil }
return TalkProviderConfigSelection(
provider: defaultProvider,
config: talk,
normalizedPayload: false)
}
public static func resolvedPositiveInt(_ value: AnyCodable?, fallback: Int) -> Int {
if let timeout = value?.intValue, timeout > 0 {
return timeout
}
if
let timeout = value?.doubleValue,
timeout > 0,
timeout.rounded(.towardZero) == timeout,
timeout <= Double(Int.max)
{
return Int(timeout)
}
return fallback
}
public static func resolvedSilenceTimeoutMs(_ talk: [String: AnyCodable]?, fallback: Int) -> Int {
self.resolvedPositiveInt(talk?["silenceTimeoutMs"], fallback: fallback)
}
private static func normalizedTalkProviderID(_ raw: String?) -> String? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
private static func resolvedProviderConfig(
_ talk: [String: AnyCodable]
) -> TalkProviderConfigSelection? {
guard
let resolved = talk["resolved"]?.dictionaryValue,
let providerID = self.normalizedTalkProviderID(resolved["provider"]?.stringValue)
else { return nil }
return TalkProviderConfigSelection(
provider: providerID,
config: resolved["config"]?.dictionaryValue ?? [:],
normalizedPayload: true)
}
}

View File

@@ -0,0 +1,80 @@
import Foundation
import OpenClawKit
import Testing
private struct TalkConfigContractFixture: Decodable {
let selectionCases: [SelectionCase]
let timeoutCases: [TimeoutCase]
struct SelectionCase: Decodable {
let id: String
let defaultProvider: String
let payloadValid: Bool
let expectedSelection: ExpectedSelection?
let talk: [String: AnyCodable]
}
struct ExpectedSelection: Decodable {
let provider: String
let normalizedPayload: Bool
let voiceId: String?
let apiKey: String?
}
struct TimeoutCase: Decodable {
let id: String
let fallback: Int
let expectedTimeoutMs: Int
let talk: [String: AnyCodable]
}
}
private enum TalkConfigContractFixtureLoader {
static func load() throws -> TalkConfigContractFixture {
let fixtureURL = try self.findFixtureURL(startingAt: URL(fileURLWithPath: #filePath))
let data = try Data(contentsOf: fixtureURL)
return try JSONDecoder().decode(TalkConfigContractFixture.self, from: data)
}
private static func findFixtureURL(startingAt fileURL: URL) throws -> URL {
var directory = fileURL.deletingLastPathComponent()
while directory.path != "/" {
let candidate = directory.appendingPathComponent("test-fixtures/talk-config-contract.json")
if FileManager.default.fileExists(atPath: candidate.path) {
return candidate
}
directory.deleteLastPathComponent()
}
throw NSError(domain: "TalkConfigContractFixtureLoader", code: 1)
}
}
struct TalkConfigContractTests {
@Test func selectionFixtures() throws {
for fixture in try TalkConfigContractFixtureLoader.load().selectionCases {
let selection = TalkConfigParsing.selectProviderConfig(
fixture.talk,
defaultProvider: fixture.defaultProvider)
if let expected = fixture.expectedSelection {
#expect(selection != nil)
#expect(selection?.provider == expected.provider)
#expect(selection?.normalizedPayload == expected.normalizedPayload)
#expect(selection?.config["voiceId"]?.stringValue == expected.voiceId)
#expect(selection?.config["apiKey"]?.stringValue == expected.apiKey)
} else {
#expect(selection == nil)
}
#expect(fixture.payloadValid == (selection != nil))
}
}
@Test func timeoutFixtures() throws {
for fixture in try TalkConfigContractFixtureLoader.load().timeoutCases {
#expect(
TalkConfigParsing.resolvedSilenceTimeoutMs(
fixture.talk,
fallback: fixture.fallback) == fixture.expectedTimeoutMs,
"\(fixture.id)")
}
}
}

View File

@@ -0,0 +1,119 @@
import OpenClawKit
import Testing
struct TalkConfigParsingTests {
@Test func prefersCanonicalResolvedTalkProviderPayload() {
let talk: [String: AnyCodable] = [
"resolved": AnyCodable([
"provider": "elevenlabs",
"config": [
"voiceId": "voice-resolved",
],
]),
"provider": AnyCodable("elevenlabs"),
"providers": AnyCodable([
"elevenlabs": [
"voiceId": "voice-normalized",
],
]),
]
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
#expect(selection?.provider == "elevenlabs")
#expect(selection?.normalizedPayload == true)
#expect(selection?.config["voiceId"]?.stringValue == "voice-resolved")
}
@Test func rejectsNormalizedTalkProviderPayloadWithoutResolved() {
let talk: [String: AnyCodable] = [
"provider": AnyCodable("elevenlabs"),
"providers": AnyCodable([
"elevenlabs": [
"voiceId": "voice-normalized",
],
]),
"voiceId": AnyCodable("voice-legacy"),
]
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
#expect(selection == nil)
}
@Test func fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() {
let talk: [String: AnyCodable] = [
"voiceId": AnyCodable("voice-legacy"),
"apiKey": AnyCodable("legacy-key"),
]
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
#expect(selection?.provider == "elevenlabs")
#expect(selection?.normalizedPayload == false)
#expect(selection?.config["voiceId"]?.stringValue == "voice-legacy")
#expect(selection?.config["apiKey"]?.stringValue == "legacy-key")
}
@Test func canDisableLegacyFallback() {
let talk: [String: AnyCodable] = [
"voiceId": AnyCodable("voice-legacy"),
]
let selection = TalkConfigParsing.selectProviderConfig(
talk,
defaultProvider: "elevenlabs",
allowLegacyFallback: false)
#expect(selection == nil)
}
@Test func rejectsNormalizedPayloadWhenProviderMissingFromProviders() {
let talk: [String: AnyCodable] = [
"provider": AnyCodable("acme"),
"providers": AnyCodable([
"elevenlabs": [
"voiceId": "voice-normalized",
],
]),
]
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
#expect(selection == nil)
}
@Test func rejectsNormalizedPayloadWhenMultipleProvidersAndNoProvider() {
let talk: [String: AnyCodable] = [
"providers": AnyCodable([
"acme": [
"voiceId": "voice-acme",
],
"elevenlabs": [
"voiceId": "voice-eleven",
],
]),
]
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
#expect(selection == nil)
}
@Test func bridgesFoundationDictionary() {
let raw: [String: Any] = [
"provider": "elevenlabs",
"providers": [
"elevenlabs": [
"voiceId": "voice-normalized",
],
],
]
let bridged = TalkConfigParsing.bridgeFoundationDictionary(raw)
#expect(bridged?["provider"]?.stringValue == "elevenlabs")
let nested = bridged?["providers"]?.dictionaryValue?["elevenlabs"]?.dictionaryValue
#expect(nested?["voiceId"]?.stringValue == "voice-normalized")
}
@Test func resolvesPositiveIntegerTimeout() {
#expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable(1500), fallback: 700) == 1500)
#expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable(0), fallback: 700) == 700)
#expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable(true), fallback: 700) == 700)
#expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable("1500"), fallback: 700) == 700)
}
}

View File

@@ -8,13 +8,13 @@ title: "Brave Search"
# Brave Search API
OpenClaw supports Brave Search as a web search provider for `web_search`.
OpenClaw supports Brave Search API as a `web_search` provider.
## Get an API key
1. Create a Brave Search API account at [https://brave.com/search/api/](https://brave.com/search/api/)
2. In the dashboard, choose the **Data for Search** plan and generate an API key.
3. Store the key in config (recommended) or set `BRAVE_API_KEY` in the Gateway environment.
2. In the dashboard, choose the **Search** plan and generate an API key.
3. Store the key in config or set `BRAVE_API_KEY` in the Gateway environment.
## Config example
@@ -72,9 +72,9 @@ await web_search({
## Notes
- The Data for AI plan is **not** compatible with `web_search`.
- Brave provides paid plans; check the Brave API portal for current limits.
- Brave Terms include restrictions on some AI-related uses of Search Results. Review the Brave Terms of Service and confirm your intended use is compliant. For legal questions, consult your counsel.
- OpenClaw uses the Brave **Search** plan. If you have a legacy subscription (e.g. the original Free plan with 2,000 queries/month), it remains valid but does not include newer features like LLM Context or higher rate limits.
- Each Brave plan includes **$5/month in free credit** (renewing). The Search plan costs $5 per 1,000 requests, so the credit covers 1,000 queries/month. Set your usage limit in the Brave dashboard to avoid unexpected charges. See the [Brave API portal](https://brave.com/search/api/) for current plans.
- The Search plan includes the LLM Context endpoint and AI inference rights. Storing results to train or tune models requires a plan with explicit storage rights. See the Brave [Terms of Service](https://api-dashboard.search.brave.com/terms-of-service).
- Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`).
See [Web tools](/tools/web) for the full web_search configuration.

View File

@@ -12,20 +12,18 @@ Feishu (Lark) is a team chat platform used by companies for messaging and collab
---
## Plugin required
## Bundled plugin
Install the Feishu plugin:
Feishu ships bundled with current OpenClaw releases, so no separate plugin install
is required.
If you are using an older build or a custom install that does not include bundled
Feishu, install it manually:
```bash
openclaw plugins install @openclaw/feishu
```
Local checkout (when running from a git repo):
```bash
openclaw plugins install ./extensions/feishu
```
---
## Quickstart

View File

@@ -232,10 +232,10 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
## Feature reference
<AccordionGroup>
<Accordion title="Live stream preview (native drafts + message edits)">
<Accordion title="Live stream preview (message edits)">
OpenClaw can stream partial replies in real time:
- direct chats: Telegram native draft streaming via `sendMessageDraft`
- direct chats: preview message + `editMessageText`
- groups/topics: preview message + `editMessageText`
Requirement:
@@ -244,11 +244,9 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `progress` maps to `partial` on Telegram (compat with cross-channel naming)
- legacy `channels.telegram.streamMode` and boolean `streaming` values are auto-mapped
Telegram enabled `sendMessageDraft` for all bots in Bot API 9.5 (March 1, 2026).
For text-only replies:
- DM: OpenClaw updates the draft in place (no extra preview message)
- DM: OpenClaw keeps the same preview message and performs a final edit in place (no second message)
- group/topic: OpenClaw keeps the same preview message and performs a final edit in place (no second message)
For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.
@@ -872,7 +870,7 @@ Primary reference:
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). In DMs, `partial` uses native `sendMessageDraft` when available.
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). Telegram preview streaming uses a single preview message that is edited in place.
- `channels.telegram.mediaMaxMb`: inbound/outbound Telegram media cap (MB, default: 100).
- `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter).
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled.

View File

@@ -24,6 +24,36 @@ Compaction **persists** in the sessions JSONL history.
Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.).
Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). You can override this with `identifierPolicy: "off"` or provide custom text with `identifierPolicy: "custom"` and `identifierInstructions`.
You can optionally specify a different model for compaction summarization via `agents.defaults.compaction.model`. This is useful when your primary model is a local or small model and you want compaction summaries produced by a more capable model. The override accepts any `provider/model-id` string:
```json
{
"agents": {
"defaults": {
"compaction": {
"model": "openrouter/anthropic/claude-sonnet-4-5"
}
}
}
}
```
This also works with local models, for example a second Ollama model dedicated to summarization or a fine-tuned compaction specialist:
```json
{
"agents": {
"defaults": {
"compaction": {
"model": "ollama/llama3.1:8b"
}
}
}
}
```
When unset, compaction uses the agent's primary model.
## Auto-compaction (default on)
When a session nears or exceeds the models context window, OpenClaw triggers auto-compaction and may retry the original request using the compacted context.

View File

@@ -138,7 +138,7 @@ Legacy key migration:
Telegram:
- Uses Bot API `sendMessageDraft` in DMs when available, and `sendMessage` + `editMessageText` for group/topic preview updates.
- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics.
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
- `/reasoning stream` can write reasoning to preview.

View File

@@ -1005,6 +1005,7 @@ Periodic heartbeat runs.
identifierPolicy: "strict", // strict | off | custom
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection
model: "openrouter/anthropic/claude-sonnet-4-5", // optional compaction-only model override
memoryFlush: {
enabled: true,
softThresholdTokens: 6000,
@@ -1021,6 +1022,7 @@ Periodic heartbeat runs.
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback.
- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Skipped when workspace is read-only.
### `agents.defaults.contextPruning`
@@ -1659,6 +1661,7 @@ Defaults for Talk mode (macOS/iOS/Android).
modelId: "eleven_v3",
outputFormat: "mp3_44100_128",
apiKey: "elevenlabs_api_key",
silenceTimeoutMs: 1500,
interruptOnSpeech: true,
},
}
@@ -1668,6 +1671,7 @@ Defaults for Talk mode (macOS/iOS/Android).
- `apiKey` and `providers.*.apiKey` accept plaintext strings or SecretRef objects.
- `ELEVENLABS_API_KEY` fallback applies only when no Talk API key is configured.
- `voiceAliases` lets Talk directives use friendly names.
- `silenceTimeoutMs` controls how long Talk mode waits after user silence before it sends the transcript. Unset keeps the platform default pause window (`700 ms on macOS and Android, 900 ms on iOS`).
---

View File

@@ -56,6 +56,7 @@ Supported keys:
modelId: "eleven_v3",
outputFormat: "mp3_44100_128",
apiKey: "elevenlabs_api_key",
silenceTimeoutMs: 1500,
interruptOnSpeech: true,
},
}
@@ -64,6 +65,7 @@ Supported keys:
Defaults:
- `interruptOnSpeech`: true
- `silenceTimeoutMs`: when unset, Talk keeps the platform default pause window before sending the transcript (`700 ms on macOS and Android, 900 ms on iOS`)
- `voiceId`: falls back to `ELEVENLABS_VOICE_ID` / `SAG_VOICE_ID` (or first ElevenLabs voice when API key is available)
- `modelId`: defaults to `eleven_v3` when unset
- `apiKey`: falls back to `ELEVENLABS_API_KEY` (or gateway shell profile if available)

View File

@@ -1,23 +1,37 @@
---
summary: "Perplexity Search API setup for web_search"
summary: "Perplexity Search API and Sonar/OpenRouter compatibility for web_search"
read_when:
- You want to use Perplexity Search for web search
- You need PERPLEXITY_API_KEY setup
- You need PERPLEXITY_API_KEY or OPENROUTER_API_KEY setup
title: "Perplexity Search"
---
# Perplexity Search API
OpenClaw uses Perplexity Search API for the `web_search` tool when `provider: "perplexity"` is set.
Perplexity Search returns structured results (title, URL, snippet) for fast research.
OpenClaw supports Perplexity Search API as a `web_search` provider.
It returns structured results with `title`, `url`, and `snippet` fields.
For compatibility, OpenClaw also supports legacy Perplexity Sonar/OpenRouter setups.
If you use `OPENROUTER_API_KEY`, an `sk-or-...` key in `tools.web.search.perplexity.apiKey`, or set `tools.web.search.perplexity.baseUrl` / `model`, the provider switches to the chat-completions path and returns AI-synthesized answers with citations instead of structured Search API results.
## Getting a Perplexity API key
1. Create a Perplexity account at <https://www.perplexity.ai/settings/api>
2. Generate an API key in the dashboard
3. Store the key in config (recommended) or set `PERPLEXITY_API_KEY` in the Gateway environment.
3. Store the key in config or set `PERPLEXITY_API_KEY` in the Gateway environment.
## Config example
## OpenRouter compatibility
If you were already using OpenRouter for Perplexity Sonar, keep `provider: "perplexity"` and set `OPENROUTER_API_KEY` in the Gateway environment, or store an `sk-or-...` key in `tools.web.search.perplexity.apiKey`.
Optional legacy controls:
- `tools.web.search.perplexity.baseUrl`
- `tools.web.search.perplexity.model`
## Config examples
### Native Perplexity Search API
```json5
{
@@ -34,7 +48,7 @@ Perplexity Search returns structured results (title, URL, snippet) for fast rese
}
```
## Switching from Brave
### OpenRouter / Sonar compatibility
```json5
{
@@ -43,7 +57,9 @@ Perplexity Search returns structured results (title, URL, snippet) for fast rese
search: {
provider: "perplexity",
perplexity: {
apiKey: "pplx-...",
apiKey: "<openrouter-api-key>",
baseUrl: "https://openrouter.ai/api/v1",
model: "perplexity/sonar-pro",
},
},
},
@@ -51,17 +67,19 @@ Perplexity Search returns structured results (title, URL, snippet) for fast rese
}
```
## Where to set the key (recommended)
## Where to set the key
**Recommended:** run `openclaw configure --section web`. It stores the key in
**Via config:** run `openclaw configure --section web`. It stores the key in
`~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`.
**Environment alternative:** set `PERPLEXITY_API_KEY` in the Gateway process
environment. For a gateway install, put it in `~/.openclaw/.env` (or your
service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
**Via environment:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
in the Gateway process environment. For a gateway install, put it in
`~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
## Tool parameters
These parameters apply to the native Perplexity Search API path.
| Parameter | Description |
| --------------------- | ---------------------------------------------------- |
| `query` | Search query (required) |
@@ -75,6 +93,9 @@ service environment). See [Env vars](/help/faq#how-does-openclaw-load-environmen
| `max_tokens` | Total content budget (default: 25000, max: 1000000) |
| `max_tokens_per_page` | Per-page token limit (default: 2048) |
For the legacy Sonar/OpenRouter compatibility path, only `query` and `freshness` are supported.
Search API-only filters such as `country`, `language`, `date_after`, `date_before`, `domain_filter`, `max_tokens`, and `max_tokens_per_page` return explicit errors.
**Examples:**
```javascript
@@ -126,7 +147,8 @@ await web_search({
## Notes
- Perplexity Search API returns structured web search results (title, URL, snippet)
- Perplexity Search API returns structured web search results (`title`, `url`, `snippet`)
- OpenRouter or explicit `baseUrl` / `model` switches Perplexity back to Sonar chat completions for compatibility
- Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`)
See [Web tools](/tools/web) for the full web_search configuration.

View File

@@ -29,23 +29,27 @@ Notes:
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
- If `APP_BUILD` is omitted, `scripts/package-mac-app.sh` derives a Sparkle-safe default from `APP_VERSION` (`YYYYMMDDNN`: stable defaults to `90`, prereleases use a suffix-derived lane) and uses the higher of that value and git commit count.
- You can still override `APP_BUILD` explicitly when release engineering needs a specific monotonic value.
- Defaults to the current architecture (`$(uname -m)`). For release/universal builds, set `BUILD_ARCHS="arm64 x86_64"` (or `BUILD_ARCHS=all`).
- For `BUILD_CONFIG=release`, `scripts/package-mac-app.sh` now defaults to universal (`arm64 x86_64`) automatically. You can still override with `BUILD_ARCHS=arm64` or `BUILD_ARCHS=x86_64`. For local/dev builds (`BUILD_CONFIG=debug`), it defaults to the current architecture (`$(uname -m)`).
- Use `scripts/package-mac-dist.sh` for release artifacts (zip + DMG + notarization). Use `scripts/package-mac-app.sh` for local/dev packaging.
```bash
# From repo root; set release IDs so Sparkle feed is enabled.
# This command builds release artifacts without notarization.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
# Default is auto-derived from APP_VERSION when omitted.
SKIP_NOTARIZE=1 \
BUNDLE_ID=ai.openclaw.mac \
APP_VERSION=2026.3.8 \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
scripts/package-mac-dist.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
# `package-mac-dist.sh` already creates the zip + DMG.
# If you used `package-mac-app.sh` directly instead, create them manually:
# If you want notarization/stapling in this step, use the NOTARIZE command below.
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.8.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
# Optional: build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.8.dmg
# Recommended: build + notarize/staple zip + DMG

View File

@@ -79,11 +79,16 @@ See [Memory](/concepts/memory).
`web_search` uses API keys and may incur usage charges depending on your provider:
- **Perplexity Search API**: `PERPLEXITY_API_KEY`
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Gemini (Google Search)**: `GEMINI_API_KEY`
- **Grok (xAI)**: `XAI_API_KEY`
- **Kimi (Moonshot)**: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
- **Perplexity Search API**: `PERPLEXITY_API_KEY`
**Brave Search free credit:** Each Brave plan includes $5/month in renewing
free credit. The Search plan costs $5 per 1,000 requests, so the credit covers
1,000 requests/month at no charge. Set your usage limit in the Brave dashboard
to avoid unexpected charges.
See [Web tools](/tools/web).

View File

@@ -196,6 +196,53 @@ Notes:
- Replace `<BROWSERLESS_API_KEY>` with your real Browserless token.
- Choose the region endpoint that matches your Browserless account (see their docs).
## Direct WebSocket CDP providers
Some hosted browser services expose a **direct WebSocket** endpoint rather than
the standard HTTP-based CDP discovery (`/json/version`). OpenClaw supports both:
- **HTTP(S) endpoints** (e.g. Browserless) — OpenClaw calls `/json/version` to
discover the WebSocket debugger URL, then connects.
- **WebSocket endpoints** (`ws://` / `wss://`) — OpenClaw connects directly,
skipping `/json/version`. Use this for services like
[Browserbase](https://www.browserbase.com) or any provider that hands you a
WebSocket URL.
### Browserbase
[Browserbase](https://www.browserbase.com) is a cloud platform for running
headless browsers with built-in CAPTCHA solving, stealth mode, and residential
proxies.
```json5
{
browser: {
enabled: true,
defaultProfile: "browserbase",
remoteCdpTimeoutMs: 3000,
remoteCdpHandshakeTimeoutMs: 5000,
profiles: {
browserbase: {
cdpUrl: "wss://connect.browserbase.com?apiKey=<BROWSERBASE_API_KEY>",
color: "#F97316",
},
},
},
}
```
Notes:
- [Sign up](https://www.browserbase.com/sign-up) and copy your **API Key**
from the [Overview dashboard](https://www.browserbase.com/overview).
- Replace `<BROWSERBASE_API_KEY>` with your real Browserbase API key.
- Browserbase auto-creates a browser session on WebSocket connect, so no
manual session creation step is needed.
- The free tier allows one concurrent session and one browser hour per month.
See [pricing](https://www.browserbase.com/pricing) for paid plan limits.
- See the [Browserbase docs](https://docs.browserbase.com) for full API
reference, SDK guides, and integration examples.
## Security
Key ideas:
@@ -207,7 +254,7 @@ Key ideas:
Remote CDP tips:
- Prefer HTTPS endpoints and short-lived tokens where possible.
- Prefer encrypted endpoints (HTTPS or WSS) and short-lived tokens where possible.
- Avoid embedding long-lived tokens directly in config files.
## Profiles (multi-browser)

View File

@@ -1,8 +1,8 @@
---
summary: "Web search + fetch tools (Perplexity Search API, Brave, Gemini, Grok, and Kimi providers)"
summary: "Web search + fetch tools (Brave, Gemini, Grok, Kimi, and Perplexity providers)"
read_when:
- You want to enable web_search or web_fetch
- You need Perplexity or Brave Search API key setup
- You need Brave or Perplexity Search API key setup
- You want to use Gemini with Google Search grounding
title: "Web Tools"
---
@@ -11,7 +11,7 @@ title: "Web Tools"
OpenClaw ships two lightweight web tools:
- `web_search` — Search the web using Perplexity Search API, Brave Search API, Gemini with Google Search grounding, Grok, or Kimi.
- `web_search` — Search the web using Brave Search API, Gemini with Google Search grounding, Grok, Kimi, or Perplexity Search API.
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
These are **not** browser automation. For JS-heavy sites or logins, use the
@@ -25,26 +25,26 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
(HTML → markdown/text). It does **not** execute JavaScript.
- `web_fetch` is enabled by default (unless explicitly disabled).
See [Perplexity Search setup](/perplexity) and [Brave Search setup](/brave-search) for provider-specific details.
See [Brave Search setup](/brave-search) and [Perplexity Search setup](/perplexity) for provider-specific details.
## Choosing a search provider
| Provider | Pros | Cons | API Key |
| ------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------- | ----------------------------------- |
| **Perplexity Search API** | Fast, structured results; domain, language, region, and freshness filters; content extraction | — | `PERPLEXITY_API_KEY` |
| **Brave Search API** | Fast, structured results | Fewer filtering options; AI-use terms apply | `BRAVE_API_KEY` |
| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` |
| **Grok** | xAI web-grounded responses | Requires xAI API key | `XAI_API_KEY` |
| **Kimi** | Moonshot web search capability | Requires Moonshot API key | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
| Provider | Result shape | Provider-specific filters | Notes | API key |
| ------------------------- | ---------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- |
| **Brave Search API** | Structured results with snippets | `country`, `language`, `ui_lang`, time | Supports Brave `llm-context` mode | `BRAVE_API_KEY` |
| **Gemini** | AI-synthesized answers + citations | — | Uses Google Search grounding | `GEMINI_API_KEY` |
| **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` |
| **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
| **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls; OpenRouter uses Sonar compatibility path | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` |
### Auto-detection
If no `provider` is explicitly set, OpenClaw auto-detects which provider to use based on available API keys, checking in this order:
The table above is alphabetical. If no `provider` is explicitly set, runtime auto-detection checks providers in this order:
1. **Brave**`BRAVE_API_KEY` env var or `tools.web.search.apiKey` config
2. **Gemini**`GEMINI_API_KEY` env var or `tools.web.search.gemini.apiKey` config
3. **Kimi**`KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config
4. **Perplexity**`PERPLEXITY_API_KEY` env var or `tools.web.search.perplexity.apiKey` config
4. **Perplexity**`PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` config
5. **Grok**`XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config
If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one).
@@ -53,30 +53,75 @@ If no keys are found, it falls back to Brave (you'll get a missing-key error pro
Use `openclaw configure --section web` to set up your API key and choose a provider.
### Brave Search
1. Create a Brave Search API account at [brave.com/search/api](https://brave.com/search/api/)
2. In the dashboard, choose the **Search** plan and generate an API key.
3. Run `openclaw configure --section web` to store the key in config, or set `BRAVE_API_KEY` in your environment.
Each Brave plan includes **$5/month in free credit** (renewing). The Search
plan costs $5 per 1,000 requests, so the credit covers 1,000 queries/month. Set
your usage limit in the Brave dashboard to avoid unexpected charges. See the
[Brave API portal](https://brave.com/search/api/) for current plans and
pricing.
### Perplexity Search
1. Create a Perplexity account at [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api)
2. Generate an API key in the dashboard
3. Run `openclaw configure --section web` to store the key in config, or set `PERPLEXITY_API_KEY` in your environment.
For legacy Sonar/OpenRouter compatibility, set `OPENROUTER_API_KEY` instead, or configure `tools.web.search.perplexity.apiKey` with an `sk-or-...` key. Setting `tools.web.search.perplexity.baseUrl` or `model` also opts Perplexity back into the chat-completions compatibility path.
See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quickstart) for more details.
### Brave Search
1. Create a Brave Search API account at [brave.com/search/api](https://brave.com/search/api/)
2. In the dashboard, choose the **Data for Search** plan (not "Data for AI") and generate an API key.
3. Run `openclaw configure --section web` to store the key in config (recommended), or set `BRAVE_API_KEY` in your environment.
Brave provides paid plans; check the Brave API portal for the current limits and pricing.
### Where to store the key
**Via config (recommended):** run `openclaw configure --section web`. It stores the key under `tools.web.search.perplexity.apiKey` or `tools.web.search.apiKey`.
**Via config:** run `openclaw configure --section web`. It stores the key under `tools.web.search.apiKey` or `tools.web.search.perplexity.apiKey`, depending on provider.
**Via environment:** set `PERPLEXITY_API_KEY` or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
**Via environment:** set `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
### Config examples
**Brave Search:**
```json5
{
tools: {
web: {
search: {
enabled: true,
provider: "brave",
apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret
},
},
},
}
```
**Brave LLM Context mode:**
```json5
{
tools: {
web: {
search: {
enabled: true,
provider: "brave",
apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret
brave: {
mode: "llm-context",
},
},
},
},
}
```
`llm-context` returns extracted page chunks for grounding instead of standard Brave snippets.
In this mode, `country` and `language` / `search_lang` still work, but `ui_lang`,
`freshness`, `date_after`, and `date_before` are rejected.
**Perplexity Search:**
```json5
@@ -95,7 +140,7 @@ Brave provides paid plans; check the Brave API portal for the current limits and
}
```
**Brave Search:**
**Perplexity via OpenRouter / Sonar compatibility:**
```json5
{
@@ -103,8 +148,12 @@ Brave provides paid plans; check the Brave API portal for the current limits and
web: {
search: {
enabled: true,
provider: "brave",
apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret
provider: "perplexity",
perplexity: {
apiKey: "<openrouter-api-key>", // optional if OPENROUTER_API_KEY is set
baseUrl: "https://openrouter.ai/api/v1",
model: "perplexity/sonar-pro",
},
},
},
},
@@ -163,7 +212,7 @@ Search the web using your configured provider.
- `tools.web.search.enabled` must not be `false` (default: enabled)
- API key for your chosen provider:
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Perplexity**: `PERPLEXITY_API_KEY` or `tools.web.search.perplexity.apiKey`
- **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
- **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey`
- **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
@@ -188,7 +237,10 @@ Search the web using your configured provider.
### Tool parameters
All parameters work for both Brave and Perplexity unless noted.
All parameters work for Brave and for native Perplexity Search API unless noted.
Perplexity's OpenRouter / Sonar compatibility path supports only `query` and `freshness`.
If you set `tools.web.search.perplexity.baseUrl` / `model`, use `OPENROUTER_API_KEY`, or configure an `sk-or-...` key, Search API-only filters return explicit errors.
| Parameter | Description |
| --------------------- | ----------------------------------------------------- |
@@ -247,6 +299,9 @@ await web_search({
});
```
When Brave `llm-context` mode is enabled, `ui_lang`, `freshness`, `date_after`, and
`date_before` are not supported. Use Brave `web` mode for those filters.
## web_fetch
Fetch a URL and extract readable content.

View File

@@ -12,20 +12,16 @@ title: 飞书
---
## 需要插件
## 内置插件
安装 Feishu 插件:
当前版本的 OpenClaw 已内置 Feishu 插件,因此通常不需要单独安装。
如果你使用的是较旧版本,或是没有内置 Feishu 的自定义安装,可手动安装:
```bash
openclaw plugins install @openclaw/feishu
```
本地 checkout在 git 仓库内运行):
```bash
openclaw plugins install ./extensions/feishu
```
---
## 快速开始

View File

@@ -37,6 +37,11 @@
"npmSpec": "@openclaw/googlechat",
"localPath": "extensions/googlechat",
"defaultChoice": "npm"
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"google-auth-library"
]
}
}
}

View File

@@ -29,6 +29,13 @@
"npmSpec": "@openclaw/matrix",
"localPath": "extensions/matrix",
"defaultChoice": "npm"
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@matrix-org/matrix-sdk-crypto-nodejs",
"@vector-im/matrix-bot-sdk",
"music-metadata"
]
}
}
}

View File

@@ -27,6 +27,11 @@
"npmSpec": "@openclaw/msteams",
"localPath": "extensions/msteams",
"defaultChoice": "npm"
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@microsoft/agents-hosting"
]
}
}
}

View File

@@ -25,6 +25,11 @@
"npmSpec": "@openclaw/nostr",
"localPath": "extensions/nostr",
"defaultChoice": "npm"
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"nostr-tools"
]
}
}
}

View File

@@ -27,6 +27,13 @@
"npmSpec": "@openclaw/tlon",
"localPath": "extensions/tlon",
"defaultChoice": "npm"
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@tloncorp/api",
"@tloncorp/tlon-skill",
"@urbit/aura"
]
}
}
}

View File

@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import { deleteWebhook, getWebhookInfo, sendChatAction, type ZaloFetch } from "./api.js";
describe("Zalo API request methods", () => {
it("uses POST for getWebhookInfo", async () => {
const fetcher = vi.fn<ZaloFetch>(
async () => new Response(JSON.stringify({ ok: true, result: {} })),
);
await getWebhookInfo("test-token", fetcher);
expect(fetcher).toHaveBeenCalledTimes(1);
const [, init] = fetcher.mock.calls[0] ?? [];
expect(init?.method).toBe("POST");
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
});
it("keeps POST for deleteWebhook", async () => {
const fetcher = vi.fn<ZaloFetch>(
async () => new Response(JSON.stringify({ ok: true, result: {} })),
);
await deleteWebhook("test-token", fetcher);
expect(fetcher).toHaveBeenCalledTimes(1);
const [, init] = fetcher.mock.calls[0] ?? [];
expect(init?.method).toBe("POST");
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
});
it("aborts sendChatAction when the typing timeout elapses", async () => {
vi.useFakeTimers();
try {
const fetcher = vi.fn<ZaloFetch>(
(_, init) =>
new Promise<Response>((_, reject) => {
init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
once: true,
});
}),
);
const promise = sendChatAction(
"test-token",
{
chat_id: "chat-123",
action: "typing",
},
fetcher,
25,
);
const rejected = expect(promise).rejects.toThrow("aborted");
await vi.advanceTimersByTimeAsync(25);
await rejected;
const [, init] = fetcher.mock.calls[0] ?? [];
expect(init?.signal?.aborted).toBe(true);
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -58,11 +58,22 @@ export type ZaloSendPhotoParams = {
caption?: string;
};
export type ZaloSendChatActionParams = {
chat_id: string;
action: "typing" | "upload_photo";
};
export type ZaloSetWebhookParams = {
url: string;
secret_token: string;
};
export type ZaloWebhookInfo = {
url?: string;
updated_at?: number;
has_custom_certificate?: boolean;
};
export type ZaloGetUpdatesParams = {
/** Timeout in seconds (passed as string to API) */
timeout?: number;
@@ -161,6 +172,21 @@ export async function sendPhoto(
return callZaloApi<ZaloMessage>("sendPhoto", token, params, { fetch: fetcher });
}
/**
* Send a temporary chat action such as typing.
*/
export async function sendChatAction(
token: string,
params: ZaloSendChatActionParams,
fetcher?: ZaloFetch,
timeoutMs?: number,
): Promise<ZaloApiResponse<boolean>> {
return callZaloApi<boolean>("sendChatAction", token, params, {
timeoutMs,
fetch: fetcher,
});
}
/**
* Get updates using long polling (dev/testing only)
* Note: Zalo returns a single update per call, not an array like Telegram
@@ -183,8 +209,8 @@ export async function setWebhook(
token: string,
params: ZaloSetWebhookParams,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<boolean>> {
return callZaloApi<boolean>("setWebhook", token, params, { fetch: fetcher });
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
return callZaloApi<ZaloWebhookInfo>("setWebhook", token, params, { fetch: fetcher });
}
/**
@@ -193,8 +219,12 @@ export async function setWebhook(
export async function deleteWebhook(
token: string,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<boolean>> {
return callZaloApi<boolean>("deleteWebhook", token, undefined, { fetch: fetcher });
timeoutMs?: number,
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
return callZaloApi<ZaloWebhookInfo>("deleteWebhook", token, undefined, {
timeoutMs,
fetch: fetcher,
});
}
/**
@@ -203,6 +233,6 @@ export async function deleteWebhook(
export async function getWebhookInfo(
token: string,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<{ url?: string; has_custom_certificate?: boolean }>> {
return callZaloApi("getWebhookInfo", token, undefined, { fetch: fetcher });
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
return callZaloApi<ZaloWebhookInfo>("getWebhookInfo", token, undefined, { fetch: fetcher });
}

View File

@@ -0,0 +1,100 @@
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/zalo";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createStartAccountContext } from "../../test-utils/start-account-context.js";
import type { ResolvedZaloAccount } from "./accounts.js";
const hoisted = vi.hoisted(() => ({
monitorZaloProvider: vi.fn(),
probeZalo: vi.fn(async () => ({
ok: false as const,
error: "probe failed",
elapsedMs: 1,
})),
}));
vi.mock("./monitor.js", async () => {
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
return {
...actual,
monitorZaloProvider: hoisted.monitorZaloProvider,
};
});
vi.mock("./probe.js", async () => {
const actual = await vi.importActual<typeof import("./probe.js")>("./probe.js");
return {
...actual,
probeZalo: hoisted.probeZalo,
};
});
import { zaloPlugin } from "./channel.js";
function buildAccount(): ResolvedZaloAccount {
return {
accountId: "default",
enabled: true,
token: "test-token",
tokenSource: "config",
config: {},
};
}
describe("zaloPlugin gateway.startAccount", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("keeps startAccount pending until abort", async () => {
hoisted.monitorZaloProvider.mockImplementationOnce(
async ({ abortSignal }: { abortSignal: AbortSignal }) =>
await new Promise<void>((resolve) => {
if (abortSignal.aborted) {
resolve();
return;
}
abortSignal.addEventListener("abort", () => resolve(), { once: true });
}),
);
const patches: ChannelAccountSnapshot[] = [];
const abort = new AbortController();
const task = zaloPlugin.gateway!.startAccount!(
createStartAccountContext({
account: buildAccount(),
abortSignal: abort.signal,
statusPatchSink: (next) => patches.push({ ...next }),
}),
);
let settled = false;
void task.then(() => {
settled = true;
});
await vi.waitFor(() => {
expect(hoisted.probeZalo).toHaveBeenCalledOnce();
expect(hoisted.monitorZaloProvider).toHaveBeenCalledOnce();
});
expect(settled).toBe(false);
expect(patches).toContainEqual(
expect.objectContaining({
accountId: "default",
}),
);
abort.abort();
await task;
expect(settled).toBe(true);
expect(hoisted.monitorZaloProvider).toHaveBeenCalledWith(
expect.objectContaining({
token: "test-token",
account: expect.objectContaining({ accountId: "default" }),
abortSignal: abort.signal,
useWebhook: false,
}),
);
});
});

View File

@@ -334,6 +334,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
startAccount: async (ctx) => {
const account = ctx.account;
const token = account.token.trim();
const mode = account.config.webhookUrl ? "webhook" : "polling";
let zaloBotLabel = "";
const fetcher = resolveZaloProxyFetch(account.config.proxy);
try {
@@ -342,14 +343,21 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
if (name) {
zaloBotLabel = ` (${name})`;
}
if (!probe.ok) {
ctx.log?.warn?.(
`[${account.accountId}] Zalo probe failed before provider start (${String(probe.elapsedMs)}ms): ${probe.error}`,
);
}
ctx.setStatus({
accountId: account.accountId,
bot: probe.bot,
});
} catch {
// ignore probe errors
} catch (err) {
ctx.log?.warn?.(
`[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
);
}
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel}`);
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
const { monitorZaloProvider } = await import("./monitor.js");
return monitorZaloProvider({
token,

View File

@@ -0,0 +1,213 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
import type { ResolvedZaloAccount } from "./accounts.js";
const getWebhookInfoMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
const deleteWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
const getUpdatesMock = vi.fn(() => new Promise(() => {}));
const setWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
vi.mock("./api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./api.js")>();
return {
...actual,
deleteWebhook: deleteWebhookMock,
getWebhookInfo: getWebhookInfoMock,
getUpdates: getUpdatesMock,
setWebhook: setWebhookMock,
};
});
vi.mock("./runtime.js", () => ({
getZaloRuntime: () => ({
logging: {
shouldLogVerbose: () => false,
},
}),
}));
async function waitForPollingLoopStart(): Promise<void> {
await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
}
describe("monitorZaloProvider lifecycle", () => {
afterEach(() => {
vi.clearAllMocks();
setActivePluginRegistry(createEmptyPluginRegistry());
});
it("stays alive in polling mode until abort", async () => {
const { monitorZaloProvider } = await import("./monitor.js");
const abort = new AbortController();
const runtime = {
log: vi.fn<(message: string) => void>(),
error: vi.fn<(message: string) => void>(),
};
const account = {
accountId: "default",
config: {},
} as unknown as ResolvedZaloAccount;
const config = {} as OpenClawConfig;
let settled = false;
const run = monitorZaloProvider({
token: "test-token",
account,
config,
runtime,
abortSignal: abort.signal,
}).then(() => {
settled = true;
});
await waitForPollingLoopStart();
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
expect(deleteWebhookMock).not.toHaveBeenCalled();
expect(getUpdatesMock).toHaveBeenCalledTimes(1);
expect(settled).toBe(false);
abort.abort();
await run;
expect(settled).toBe(true);
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("Zalo provider stopped mode=polling"),
);
});
it("deletes an existing webhook before polling", async () => {
getWebhookInfoMock.mockResolvedValueOnce({
ok: true,
result: { url: "https://example.com/hooks/zalo" },
});
const { monitorZaloProvider } = await import("./monitor.js");
const abort = new AbortController();
const runtime = {
log: vi.fn<(message: string) => void>(),
error: vi.fn<(message: string) => void>(),
};
const account = {
accountId: "default",
config: {},
} as unknown as ResolvedZaloAccount;
const config = {} as OpenClawConfig;
const run = monitorZaloProvider({
token: "test-token",
account,
config,
runtime,
abortSignal: abort.signal,
});
await waitForPollingLoopStart();
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("Zalo polling mode ready (webhook disabled)"),
);
abort.abort();
await run;
});
it("continues polling when webhook inspection returns 404", async () => {
const { ZaloApiError } = await import("./api.js");
getWebhookInfoMock.mockRejectedValueOnce(new ZaloApiError("Not Found", 404, "Not Found"));
const { monitorZaloProvider } = await import("./monitor.js");
const abort = new AbortController();
const runtime = {
log: vi.fn<(message: string) => void>(),
error: vi.fn<(message: string) => void>(),
};
const account = {
accountId: "default",
config: {},
} as unknown as ResolvedZaloAccount;
const config = {} as OpenClawConfig;
const run = monitorZaloProvider({
token: "test-token",
account,
config,
runtime,
abortSignal: abort.signal,
});
await waitForPollingLoopStart();
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
expect(deleteWebhookMock).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("webhook inspection unavailable; continuing without webhook cleanup"),
);
expect(runtime.error).not.toHaveBeenCalled();
abort.abort();
await run;
});
it("waits for webhook deletion before finishing webhook shutdown", async () => {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry);
let resolveDeleteWebhook: (() => void) | undefined;
deleteWebhookMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveDeleteWebhook = () => resolve({ ok: true, result: { url: "" } });
}),
);
const { monitorZaloProvider } = await import("./monitor.js");
const abort = new AbortController();
const runtime = {
log: vi.fn<(message: string) => void>(),
error: vi.fn<(message: string) => void>(),
};
const account = {
accountId: "default",
config: {},
} as unknown as ResolvedZaloAccount;
const config = {} as OpenClawConfig;
let settled = false;
const run = monitorZaloProvider({
token: "test-token",
account,
config,
runtime,
abortSignal: abort.signal,
useWebhook: true,
webhookUrl: "https://example.com/hooks/zalo",
webhookSecret: "supersecret", // pragma: allowlist secret
}).then(() => {
settled = true;
});
await vi.waitFor(() => expect(setWebhookMock).toHaveBeenCalledTimes(1));
expect(registry.httpRoutes).toHaveLength(1);
abort.abort();
await vi.waitFor(() => expect(deleteWebhookMock).toHaveBeenCalledTimes(1));
expect(deleteWebhookMock).toHaveBeenCalledWith("test-token", undefined, 5000);
expect(settled).toBe(false);
expect(registry.httpRoutes).toHaveLength(1);
resolveDeleteWebhook?.();
await run;
expect(settled).toBe(true);
expect(registry.httpRoutes).toHaveLength(0);
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("Zalo provider stopped mode=webhook"),
);
});
});

View File

@@ -5,9 +5,11 @@ import type {
OutboundReplyPayload,
} from "openclaw/plugin-sdk/zalo";
import {
createTypingCallbacks,
createScopedPairingAccess,
createReplyPrefixOptions,
issuePairingChallenge,
logTypingFailure,
resolveDirectDmAuthorizationOutcome,
resolveSenderCommandAuthorizationWithRuntime,
resolveOutboundMediaUrls,
@@ -15,13 +17,16 @@ import {
resolveInboundRouteEnvelopeBuilderWithRuntime,
sendMediaWithLeadingCaption,
resolveWebhookPath,
waitForAbortSignal,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/zalo";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
ZaloApiError,
deleteWebhook,
getWebhookInfo,
getUpdates,
sendChatAction,
sendMessage,
sendPhoto,
setWebhook,
@@ -64,15 +69,34 @@ export type ZaloMonitorOptions = {
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
export type ZaloMonitorResult = {
stop: () => void;
};
const ZALO_TEXT_LIMIT = 2000;
const DEFAULT_MEDIA_MAX_MB = 5;
const WEBHOOK_CLEANUP_TIMEOUT_MS = 5_000;
const ZALO_TYPING_TIMEOUT_MS = 5_000;
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
function formatZaloError(error: unknown): string {
if (error instanceof Error) {
return error.stack ?? `${error.name}: ${error.message}`;
}
return String(error);
}
function describeWebhookTarget(rawUrl: string): string {
try {
const parsed = new URL(rawUrl);
return `${parsed.origin}${parsed.pathname}`;
} catch {
return rawUrl;
}
}
function normalizeWebhookUrl(url: string | undefined): string | undefined {
const trimmed = url?.trim();
return trimmed ? trimmed : undefined;
}
function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
if (core.logging.shouldLogVerbose()) {
runtime.log?.(`[zalo] ${message}`);
@@ -151,6 +175,8 @@ function startPollingLoop(params: {
} = params;
const pollTimeout = 30;
runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`);
const poll = async () => {
if (isStopped() || abortSignal.aborted) {
return;
@@ -176,7 +202,7 @@ function startPollingLoop(params: {
if (err instanceof ZaloApiError && err.isPollingTimeout) {
// no updates
} else if (!isStopped() && !abortSignal.aborted) {
runtime.error?.(`[${account.accountId}] Zalo polling error: ${String(err)}`);
runtime.error?.(`[${account.accountId}] Zalo polling error: ${formatZaloError(err)}`);
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
@@ -522,12 +548,35 @@ async function processMessageWithPipeline(params: {
channel: "zalo",
accountId: account.accountId,
});
const typingCallbacks = createTypingCallbacks({
start: async () => {
await sendChatAction(
token,
{
chat_id: chatId,
action: "typing",
},
fetcher,
ZALO_TYPING_TIMEOUT_MS,
);
},
onStartError: (err) => {
logTypingFailure({
log: (message) => logVerbose(core, runtime, message),
channel: "zalo",
action: "start",
target: chatId,
error: err,
});
},
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
...prefixOptions,
typingCallbacks,
deliver: async (payload) => {
await deliverZaloReply({
payload,
@@ -567,7 +616,6 @@ async function deliverZaloReply(params: {
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const sentMedia = await sendMediaWithLeadingCaption({
mediaUrls: resolveOutboundMediaUrls(payload),
caption: text,
@@ -597,7 +645,7 @@ async function deliverZaloReply(params: {
}
}
export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<ZaloMonitorResult> {
export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<void> {
const {
token,
account,
@@ -615,78 +663,140 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
const core = getZaloRuntime();
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
const mode = useWebhook ? "webhook" : "polling";
let stopped = false;
const stopHandlers: Array<() => void> = [];
let cleanupWebhook: (() => Promise<void>) | undefined;
const stop = () => {
if (stopped) {
return;
}
stopped = true;
for (const handler of stopHandlers) {
handler();
}
};
if (useWebhook) {
if (!webhookUrl || !webhookSecret) {
throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
}
if (!webhookUrl.startsWith("https://")) {
throw new Error("Zalo webhook URL must use HTTPS");
}
if (webhookSecret.length < 8 || webhookSecret.length > 256) {
throw new Error("Zalo webhook secret must be 8-256 characters");
runtime.log?.(
`[${account.accountId}] Zalo provider init mode=${mode} mediaMaxMb=${String(effectiveMediaMaxMb)}`,
);
try {
if (useWebhook) {
if (!webhookUrl || !webhookSecret) {
throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
}
if (!webhookUrl.startsWith("https://")) {
throw new Error("Zalo webhook URL must use HTTPS");
}
if (webhookSecret.length < 8 || webhookSecret.length > 256) {
throw new Error("Zalo webhook secret must be 8-256 characters");
}
const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
if (!path) {
throw new Error("Zalo webhookPath could not be derived");
}
runtime.log?.(
`[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(webhookUrl)}`,
);
await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
let webhookCleanupPromise: Promise<void> | undefined;
cleanupWebhook = async () => {
if (!webhookCleanupPromise) {
webhookCleanupPromise = (async () => {
runtime.log?.(`[${account.accountId}] Zalo stopping; deleting webhook`);
try {
await deleteWebhook(token, fetcher, WEBHOOK_CLEANUP_TIMEOUT_MS);
runtime.log?.(`[${account.accountId}] Zalo webhook deleted`);
} catch (err) {
const detail =
err instanceof Error && err.name === "AbortError"
? `timed out after ${String(WEBHOOK_CLEANUP_TIMEOUT_MS)}ms`
: formatZaloError(err);
runtime.error?.(`[${account.accountId}] Zalo webhook delete failed: ${detail}`);
}
})();
}
await webhookCleanupPromise;
};
runtime.log?.(`[${account.accountId}] Zalo webhook registered path=${path}`);
const unregister = registerZaloWebhookTarget({
token,
account,
config,
runtime,
core,
path,
secret: webhookSecret,
statusSink: (patch) => statusSink?.(patch),
mediaMaxMb: effectiveMediaMaxMb,
fetcher,
});
stopHandlers.push(unregister);
await waitForAbortSignal(abortSignal);
return;
}
const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
if (!path) {
throw new Error("Zalo webhookPath could not be derived");
runtime.log?.(`[${account.accountId}] Zalo polling mode: clearing webhook before startup`);
try {
try {
const currentWebhookUrl = normalizeWebhookUrl(
(await getWebhookInfo(token, fetcher)).result?.url,
);
if (!currentWebhookUrl) {
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (no webhook configured)`);
} else {
runtime.log?.(
`[${account.accountId}] Zalo polling mode disabling existing webhook ${describeWebhookTarget(currentWebhookUrl)}`,
);
await deleteWebhook(token, fetcher);
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (webhook disabled)`);
}
} catch (err) {
if (err instanceof ZaloApiError && err.errorCode === 404) {
// Some Zalo environments do not expose webhook inspection for polling bots.
runtime.log?.(
`[${account.accountId}] Zalo polling mode webhook inspection unavailable; continuing without webhook cleanup`,
);
} else {
throw err;
}
}
} catch (err) {
runtime.error?.(
`[${account.accountId}] Zalo polling startup could not clear webhook: ${formatZaloError(err)}`,
);
}
await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
const unregister = registerZaloWebhookTarget({
startPollingLoop({
token,
account,
config,
runtime,
core,
path,
secret: webhookSecret,
statusSink: (patch) => statusSink?.(patch),
abortSignal,
isStopped: () => stopped,
mediaMaxMb: effectiveMediaMaxMb,
statusSink,
fetcher,
});
stopHandlers.push(unregister);
abortSignal.addEventListener(
"abort",
() => {
void deleteWebhook(token, fetcher).catch(() => {});
},
{ once: true },
await waitForAbortSignal(abortSignal);
} catch (err) {
runtime.error?.(
`[${account.accountId}] Zalo provider startup failed mode=${mode}: ${formatZaloError(err)}`,
);
return { stop };
throw err;
} finally {
await cleanupWebhook?.();
stop();
runtime.log?.(`[${account.accountId}] Zalo provider stopped mode=${mode}`);
}
try {
await deleteWebhook(token, fetcher);
} catch {
// ignore
}
startPollingLoop({
token,
account,
config,
runtime,
core,
abortSignal,
isStopped: () => stopped,
mediaMaxMb: effectiveMediaMaxMb,
statusSink,
fetcher,
});
return { stop };
}
export const __testing = {

View File

@@ -29,6 +29,11 @@
"npmSpec": "@openclaw/zalouser",
"localPath": "extensions/zalouser",
"defaultChoice": "npm"
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"zca-js"
]
}
}
}

View File

@@ -227,7 +227,7 @@
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
"check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
@@ -340,6 +340,7 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.5",
"@larksuiteoapi/node-sdk": "^1.59.0",
"@line/bot-sdk": "^10.6.0",
"@lydell/node-pty": "1.2.0-beta.3",
"@mariozechner/pi-agent-core": "0.55.3",

3
pnpm-lock.yaml generated
View File

@@ -48,6 +48,9 @@ importers:
'@homebridge/ciao':
specifier: ^1.3.5
version: 1.3.5
'@larksuiteoapi/node-sdk':
specifier: ^1.59.0
version: 1.59.0
'@line/bot-sdk':
specifier: ^10.6.0
version: 10.6.0

View File

@@ -1,5 +1,9 @@
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./version-parse.sh
source "$SCRIPT_DIR/version-parse.sh"
verify_installed_cli() {
local package_name="$1"
local expected_version="$2"
@@ -32,6 +36,8 @@ verify_installed_cli() {
installed_version="$(node "$entry_path" --version 2>/dev/null | head -n 1 | tr -d '\r')"
fi
installed_version="$(extract_openclaw_semver "$installed_version")"
echo "cli=$cli_name installed=$installed_version expected=$expected_version"
if [[ "$installed_version" != "$expected_version" ]]; then
echo "ERROR: expected ${cli_name}@${expected_version}, got ${cli_name}@${installed_version}" >&2

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
extract_openclaw_semver() {
local raw="${1:-}"
local parsed=""
parsed="$(
printf '%s\n' "$raw" \
| tr -d '\r' \
| grep -Eo 'v?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+[0-9A-Za-z.-]+)?' \
| head -n 1 \
|| true
)"
printf '%s' "${parsed#v}"
}

View File

@@ -8,6 +8,7 @@ RUN apt-get update \
git \
&& rm -rf /var/lib/apt/lists/*
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
COPY run.sh /usr/local/bin/openclaw-install-e2e
RUN chmod +x /usr/local/bin/openclaw-install-e2e

View File

@@ -1,6 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERIFY_HELPER_PATH="/usr/local/install-sh-common/version-parse.sh"
if [[ ! -f "$VERIFY_HELPER_PATH" ]]; then
VERIFY_HELPER_PATH="${SCRIPT_DIR}/../install-sh-common/version-parse.sh"
fi
# shellcheck source=../install-sh-common/version-parse.sh
source "$VERIFY_HELPER_PATH"
INSTALL_URL="${OPENCLAW_INSTALL_URL:-${CLAWDBOT_INSTALL_URL:-https://openclaw.bot/install.sh}}"
MODELS_MODE="${OPENCLAW_E2E_MODELS:-${CLAWDBOT_E2E_MODELS:-both}}" # both|openai|anthropic
INSTALL_TAG="${OPENCLAW_INSTALL_TAG:-${CLAWDBOT_INSTALL_TAG:-latest}}"
@@ -69,6 +77,7 @@ fi
echo "==> Verify installed version"
INSTALLED_VERSION="$(openclaw --version 2>/dev/null | head -n 1 | tr -d '\r')"
INSTALLED_VERSION="$(extract_openclaw_semver "$INSTALLED_VERSION")"
echo "installed=$INSTALLED_VERSION expected=$EXPECTED_VERSION"
if [[ "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then
echo "ERROR: expected openclaw@$EXPECTED_VERSION, got openclaw@$INSTALLED_VERSION" >&2

View File

@@ -27,6 +27,7 @@ ENV NPM_CONFIG_FUND=false
ENV NPM_CONFIG_AUDIT=false
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
COPY install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot
RUN sudo chmod +x /usr/local/bin/openclaw-install-nonroot

View File

@@ -19,6 +19,7 @@ RUN set -eux; \
&& rm -rf /var/lib/apt/lists/*
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
COPY install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke
RUN chmod +x /usr/local/bin/openclaw-install-smoke

View File

@@ -2085,14 +2085,52 @@ run_bootstrap_onboarding_if_needed() {
}
}
load_install_version_helpers() {
local source_path="${BASH_SOURCE[0]-}"
local script_dir=""
local helper_path=""
if [[ -z "$source_path" || ! -f "$source_path" ]]; then
return 0
fi
script_dir="$(cd "$(dirname "$source_path")" && pwd 2>/dev/null || true)"
helper_path="${script_dir}/docker/install-sh-common/version-parse.sh"
if [[ -n "$script_dir" && -r "$helper_path" ]]; then
# shellcheck source=docker/install-sh-common/version-parse.sh
source "$helper_path"
fi
}
load_install_version_helpers
if ! declare -F extract_openclaw_semver >/dev/null 2>&1; then
# Inline fallback when version-parse.sh could not be sourced (for example, stdin install).
extract_openclaw_semver() {
local raw="${1:-}"
local parsed=""
parsed="$(
printf '%s\n' "$raw" \
| tr -d '\r' \
| grep -Eo 'v?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+[0-9A-Za-z.-]+)?' \
| head -n 1 \
|| true
)"
printf '%s' "${parsed#v}"
}
fi
resolve_openclaw_version() {
local version=""
local raw_version_output=""
local claw="${OPENCLAW_BIN:-}"
if [[ -z "$claw" ]] && command -v openclaw &> /dev/null; then
claw="$(command -v openclaw)"
fi
if [[ -n "$claw" ]]; then
version=$("$claw" --version 2>/dev/null | head -n 1 | tr -d '\r')
raw_version_output=$("$claw" --version 2>/dev/null | head -n 1 | tr -d '\r')
version="$(extract_openclaw_semver "$raw_version_output")"
if [[ -z "$version" ]]; then
version="$raw_version_output"
fi
fi
if [[ -z "$version" ]]; then
local npm_root=""

View File

@@ -0,0 +1,71 @@
export type ExtensionPackageJson = {
name?: string;
version?: string;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
openclaw?: {
install?: {
npmSpec?: string;
};
releaseChecks?: {
rootDependencyMirrorAllowlist?: string[];
};
};
};
export type BundledExtension = { id: string; packageJson: ExtensionPackageJson };
export type BundledExtensionMetadata = BundledExtension & {
npmSpec?: string;
rootDependencyMirrorAllowlist: string[];
};
export function normalizeBundledExtensionMetadata(
extensions: BundledExtension[],
): BundledExtensionMetadata[] {
return extensions.map((extension) => ({
...extension,
npmSpec:
typeof extension.packageJson.openclaw?.install?.npmSpec === "string"
? extension.packageJson.openclaw.install.npmSpec.trim()
: undefined,
rootDependencyMirrorAllowlist:
extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist?.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
) ?? [],
}));
}
export function collectBundledExtensionManifestErrors(extensions: BundledExtension[]): string[] {
const errors: string[] = [];
for (const extension of extensions) {
const install = extension.packageJson.openclaw?.install;
if (
install &&
(!install.npmSpec || typeof install.npmSpec !== "string" || !install.npmSpec.trim())
) {
errors.push(
`bundled extension '${extension.id}' manifest invalid | openclaw.install.npmSpec must be a non-empty string`,
);
}
const allowlist = extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist;
if (allowlist === undefined) {
continue;
}
if (!Array.isArray(allowlist)) {
errors.push(
`bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array of non-empty strings`,
);
continue;
}
const invalidEntries = allowlist.filter((entry) => typeof entry !== "string" || !entry.trim());
if (invalidEntries.length > 0) {
errors.push(
`bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must contain only non-empty strings`,
);
}
}
return errors;
}

View File

@@ -16,7 +16,14 @@ GIT_BUILD_NUMBER=$(cd "$ROOT_DIR" && git rev-list --count HEAD 2>/dev/null || ec
APP_VERSION="${APP_VERSION:-$PKG_VERSION}"
APP_BUILD="${APP_BUILD:-}"
BUILD_CONFIG="${BUILD_CONFIG:-debug}"
BUILD_ARCHS_VALUE="${BUILD_ARCHS:-$(uname -m)}"
if [[ -n "${BUILD_ARCHS:-}" ]]; then
BUILD_ARCHS_VALUE="${BUILD_ARCHS}"
elif [[ "$BUILD_CONFIG" == "release" ]]; then
# Release packaging should be universal unless explicitly overridden.
BUILD_ARCHS_VALUE="all"
else
BUILD_ARCHS_VALUE="$(uname -m)"
fi
if [[ "${BUILD_ARCHS_VALUE}" == "all" ]]; then
BUILD_ARCHS_VALUE="arm64 x86_64"
fi

View File

@@ -220,13 +220,47 @@ checkout_prep_branch() {
# shellcheck disable=SC1091
source .local/prep-context.env
local prep_branch
prep_branch=$(resolve_prep_branch_name "$pr")
git checkout "$prep_branch"
}
resolve_prep_branch_name() {
local pr="$1"
require_artifact .local/prep-context.env
# shellcheck disable=SC1091
source .local/prep-context.env
local prep_branch="${PREP_BRANCH:-pr-$pr-prep}"
if ! git show-ref --verify --quiet "refs/heads/$prep_branch"; then
echo "Expected prep branch $prep_branch not found. Run prepare-init first."
exit 1
fi
git checkout "$prep_branch"
printf '%s\n' "$prep_branch"
}
verify_prep_branch_matches_prepared_head() {
local pr="$1"
local prepared_head_sha="$2"
local prep_branch
prep_branch=$(resolve_prep_branch_name "$pr")
local prep_branch_head_sha
prep_branch_head_sha=$(git rev-parse "refs/heads/$prep_branch")
if [ "$prep_branch_head_sha" = "$prepared_head_sha" ]; then
return 0
fi
echo "Local prep branch moved after prepare-push (branch=$prep_branch expected $prepared_head_sha, got $prep_branch_head_sha)."
if git merge-base --is-ancestor "$prepared_head_sha" "$prep_branch_head_sha" 2>/dev/null; then
echo "Unpushed local commits on prep branch:"
git log --oneline "${prepared_head_sha}..${prep_branch_head_sha}" | sed 's/^/ /' || true
echo "Run scripts/pr prepare-sync-head $pr to push them before merge."
else
echo "Prep branch no longer contains the prepared head. Re-run prepare-init."
fi
exit 1
}
resolve_head_push_url() {
@@ -389,6 +423,161 @@ resolve_head_push_url_https() {
return 1
}
verify_pr_head_branch_matches_expected() {
local pr="$1"
local expected_head="$2"
local current_head
current_head=$(gh pr view "$pr" --json headRefName --jq .headRefName)
if [ "$current_head" != "$expected_head" ]; then
echo "PR head branch changed from $expected_head to $current_head. Re-run prepare-init."
exit 1
fi
}
setup_prhead_remote() {
local push_url
push_url=$(resolve_head_push_url) || {
echo "Unable to resolve PR head repo push URL."
exit 1
}
# Always set prhead to the correct fork URL for this PR.
# The remote is repo-level (shared across worktrees), so a previous
# prepare-pr run for a different fork PR can leave a stale URL.
git remote remove prhead 2>/dev/null || true
git remote add prhead "$push_url"
}
resolve_prhead_remote_sha() {
local pr_head="$1"
local remote_sha
remote_sha=$(git ls-remote prhead "refs/heads/$pr_head" 2>/dev/null | awk '{print $1}' || true)
if [ -z "$remote_sha" ]; then
local https_url
https_url=$(resolve_head_push_url_https 2>/dev/null) || true
local current_push_url
current_push_url=$(git remote get-url prhead 2>/dev/null || true)
if [ -n "$https_url" ] && [ "$https_url" != "$current_push_url" ]; then
echo "SSH remote failed; falling back to HTTPS..."
git remote set-url prhead "$https_url"
git remote set-url --push prhead "$https_url"
remote_sha=$(git ls-remote prhead "refs/heads/$pr_head" 2>/dev/null | awk '{print $1}' || true)
fi
if [ -z "$remote_sha" ]; then
echo "Remote branch refs/heads/$pr_head not found on prhead"
exit 1
fi
fi
printf '%s\n' "$remote_sha"
}
run_prepare_push_retry_gates() {
local docs_only="${1:-false}"
bootstrap_deps_if_needed
run_quiet_logged "pnpm build (lease-retry)" ".local/lease-retry-build.log" pnpm build
run_quiet_logged "pnpm check (lease-retry)" ".local/lease-retry-check.log" pnpm check
if [ "$docs_only" != "true" ]; then
run_quiet_logged "pnpm test (lease-retry)" ".local/lease-retry-test.log" pnpm test
fi
}
push_prep_head_to_pr_branch() {
local pr="$1"
local pr_head="$2"
local prep_head_sha="$3"
local lease_sha="$4"
local rerun_gates_on_lease_retry="${5:-false}"
local docs_only="${6:-false}"
local result_env_path="${7:-.local/push-result.env}"
setup_prhead_remote
local remote_sha
remote_sha=$(resolve_prhead_remote_sha "$pr_head")
local pushed_from_sha="$remote_sha"
if [ "$remote_sha" = "$prep_head_sha" ]; then
echo "Remote branch already at local prep HEAD; skipping push."
else
if [ "$remote_sha" != "$lease_sha" ]; then
echo "Remote SHA $remote_sha differs from PR head SHA $lease_sha. Refreshing lease SHA from remote."
lease_sha="$remote_sha"
fi
pushed_from_sha="$lease_sha"
local push_output
if ! push_output=$(
git push --force-with-lease=refs/heads/$pr_head:$lease_sha prhead HEAD:$pr_head 2>&1
); then
echo "Push failed: $push_output"
if printf '%s' "$push_output" | grep -qiE '(permission|denied|403|forbidden)'; then
echo "Permission denied on git push; trying GraphQL createCommitOnBranch fallback..."
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
local graphql_oid
graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$pr_head" "$lease_sha")
prep_head_sha="$graphql_oid"
else
echo "Git push permission denied and no fork owner/repo info for GraphQL fallback."
exit 1
fi
else
echo "Lease push failed, retrying once with fresh PR head..."
lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
pushed_from_sha="$lease_sha"
if [ "$rerun_gates_on_lease_retry" = "true" ]; then
git fetch origin "pull/$pr/head:pr-$pr-latest" --force
git rebase "pr-$pr-latest"
prep_head_sha=$(git rev-parse HEAD)
run_prepare_push_retry_gates "$docs_only"
fi
if ! push_output=$(
git push --force-with-lease=refs/heads/$pr_head:$lease_sha prhead HEAD:$pr_head 2>&1
); then
echo "Retry push failed: $push_output"
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
echo "Retry failed; trying GraphQL createCommitOnBranch fallback..."
local graphql_oid
graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$pr_head" "$lease_sha")
prep_head_sha="$graphql_oid"
else
echo "Git push failed and no fork owner/repo info for GraphQL fallback."
exit 1
fi
fi
fi
fi
fi
if ! wait_for_pr_head_sha "$pr" "$prep_head_sha" 8 3; then
local observed_sha
observed_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
echo "Pushed head SHA propagation timed out. expected=$prep_head_sha observed=$observed_sha"
exit 1
fi
local pr_head_sha_after
pr_head_sha_after=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
git fetch origin main
git fetch origin "pull/$pr/head:pr-$pr-verify" --force
git merge-base --is-ancestor origin/main "pr-$pr-verify" || {
echo "PR branch is behind main after push."
exit 1
}
git branch -D "pr-$pr-verify" 2>/dev/null || true
cat > "$result_env_path" <<EOF_ENV
PUSH_PREP_HEAD_SHA=$prep_head_sha
PUSHED_FROM_SHA=$pushed_from_sha
PR_HEAD_SHA_AFTER_PUSH=$pr_head_sha_after
EOF_ENV
}
set_review_mode() {
local mode="$1"
cat > .local/review-mode.env <<EOF_ENV
@@ -1265,121 +1454,17 @@ prepare_push() {
local prep_head_sha
prep_head_sha=$(git rev-parse HEAD)
local current_head
current_head=$(gh pr view "$pr" --json headRefName --jq .headRefName)
local lease_sha
lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
local push_result_env=".local/prepare-push-result.env"
if [ "$current_head" != "$PR_HEAD" ]; then
echo "PR head branch changed from $PR_HEAD to $current_head. Re-run prepare-init."
exit 1
fi
local push_url
push_url=$(resolve_head_push_url) || {
echo "Unable to resolve PR head repo push URL."
exit 1
}
# Always set prhead to the correct fork URL for this PR.
# The remote is repo-level (shared across worktrees), so a previous
# prepare-pr run for a different fork PR can leave a stale URL.
git remote remove prhead 2>/dev/null || true
git remote add prhead "$push_url"
local remote_sha
remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true)
if [ -z "$remote_sha" ]; then
local https_url
https_url=$(resolve_head_push_url_https 2>/dev/null) || true
if [ -n "$https_url" ] && [ "$https_url" != "$push_url" ]; then
echo "SSH remote failed; falling back to HTTPS..."
git remote set-url prhead "$https_url"
git remote set-url --push prhead "$https_url"
push_url="$https_url"
remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true)
fi
if [ -z "$remote_sha" ]; then
echo "Remote branch refs/heads/$PR_HEAD not found on prhead"
exit 1
fi
fi
local pushed_from_sha="$remote_sha"
if [ "$remote_sha" = "$prep_head_sha" ]; then
echo "Remote branch already at local prep HEAD; skipping push."
else
if [ "$remote_sha" != "$lease_sha" ]; then
echo "Remote SHA $remote_sha differs from PR head SHA $lease_sha. Refreshing lease SHA from remote."
lease_sha="$remote_sha"
fi
pushed_from_sha="$lease_sha"
local push_output
if ! push_output=$(git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD 2>&1); then
echo "Push failed: $push_output"
# Check if this is a permission error (fork PR) vs a lease conflict.
# Permission errors go straight to GraphQL; lease conflicts retry with rebase.
if printf '%s' "$push_output" | grep -qiE '(permission|denied|403|forbidden)'; then
echo "Permission denied on git push; trying GraphQL createCommitOnBranch fallback..."
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
local graphql_oid
graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha")
prep_head_sha="$graphql_oid"
else
echo "Git push permission denied and no fork owner/repo info for GraphQL fallback."
exit 1
fi
else
echo "Lease push failed, retrying once with fresh PR head..."
lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
pushed_from_sha="$lease_sha"
git fetch origin "pull/$pr/head:pr-$pr-latest" --force
git rebase "pr-$pr-latest"
prep_head_sha=$(git rev-parse HEAD)
bootstrap_deps_if_needed
run_quiet_logged "pnpm build (lease-retry)" ".local/lease-retry-build.log" pnpm build
run_quiet_logged "pnpm check (lease-retry)" ".local/lease-retry-check.log" pnpm check
if [ "${DOCS_ONLY:-false}" != "true" ]; then
run_quiet_logged "pnpm test (lease-retry)" ".local/lease-retry-test.log" pnpm test
fi
if ! git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD; then
# Retry also failed — try GraphQL as last resort.
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
echo "Git push retry failed; trying GraphQL createCommitOnBranch fallback..."
local graphql_oid
graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha")
prep_head_sha="$graphql_oid"
else
echo "Git push failed and no fork owner/repo info for GraphQL fallback."
exit 1
fi
fi
fi
fi
fi
if ! wait_for_pr_head_sha "$pr" "$prep_head_sha" 8 3; then
local observed_sha
observed_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
echo "Pushed head SHA propagation timed out. expected=$prep_head_sha observed=$observed_sha"
exit 1
fi
local pr_head_sha_after
pr_head_sha_after=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
git fetch origin main
git fetch origin "pull/$pr/head:pr-$pr-verify" --force
git merge-base --is-ancestor origin/main "pr-$pr-verify" || {
echo "PR branch is behind main after push."
exit 1
}
git branch -D "pr-$pr-verify" 2>/dev/null || true
verify_pr_head_branch_matches_expected "$pr" "$PR_HEAD"
push_prep_head_to_pr_branch "$pr" "$PR_HEAD" "$prep_head_sha" "$lease_sha" true "${DOCS_ONLY:-false}" "$push_result_env"
# shellcheck disable=SC1090
source "$push_result_env"
prep_head_sha="$PUSH_PREP_HEAD_SHA"
local pushed_from_sha="$PUSHED_FROM_SHA"
local pr_head_sha_after="$PR_HEAD_SHA_AFTER_PUSH"
local contrib="${PR_AUTHOR:-}"
if [ -z "$contrib" ]; then
@@ -1430,107 +1515,17 @@ prepare_sync_head() {
local prep_head_sha
prep_head_sha=$(git rev-parse HEAD)
local current_head
current_head=$(gh pr view "$pr" --json headRefName --jq .headRefName)
local lease_sha
lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
local push_result_env=".local/prepare-sync-result.env"
if [ "$current_head" != "$PR_HEAD" ]; then
echo "PR head branch changed from $PR_HEAD to $current_head. Re-run prepare-init."
exit 1
fi
local push_url
push_url=$(resolve_head_push_url) || {
echo "Unable to resolve PR head repo push URL."
exit 1
}
# Always set prhead to the correct fork URL for this PR.
# The remote is repo-level (shared across worktrees), so a previous
# run for a different fork PR can leave a stale URL.
git remote remove prhead 2>/dev/null || true
git remote add prhead "$push_url"
local remote_sha
remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true)
if [ -z "$remote_sha" ]; then
local https_url
https_url=$(resolve_head_push_url_https 2>/dev/null) || true
if [ -n "$https_url" ] && [ "$https_url" != "$push_url" ]; then
echo "SSH remote failed; falling back to HTTPS..."
git remote set-url prhead "$https_url"
git remote set-url --push prhead "$https_url"
push_url="$https_url"
remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true)
fi
if [ -z "$remote_sha" ]; then
echo "Remote branch refs/heads/$PR_HEAD not found on prhead"
exit 1
fi
fi
local pushed_from_sha="$remote_sha"
if [ "$remote_sha" = "$prep_head_sha" ]; then
echo "Remote branch already at local prep HEAD; skipping push."
else
if [ "$remote_sha" != "$lease_sha" ]; then
echo "Remote SHA $remote_sha differs from PR head SHA $lease_sha. Refreshing lease SHA from remote."
lease_sha="$remote_sha"
fi
pushed_from_sha="$lease_sha"
local push_output
if ! push_output=$(git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD 2>&1); then
echo "Push failed: $push_output"
if printf '%s' "$push_output" | grep -qiE '(permission|denied|403|forbidden)'; then
echo "Permission denied on git push; trying GraphQL createCommitOnBranch fallback..."
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
local graphql_oid
graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha")
prep_head_sha="$graphql_oid"
else
echo "Git push permission denied and no fork owner/repo info for GraphQL fallback."
exit 1
fi
else
echo "Lease push failed, retrying once with fresh PR head lease..."
lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
pushed_from_sha="$lease_sha"
if ! push_output=$(git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD 2>&1); then
echo "Retry push failed: $push_output"
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
echo "Retry failed; trying GraphQL createCommitOnBranch fallback..."
local graphql_oid
graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha")
prep_head_sha="$graphql_oid"
else
echo "Git push failed and no fork owner/repo info for GraphQL fallback."
exit 1
fi
fi
fi
fi
fi
if ! wait_for_pr_head_sha "$pr" "$prep_head_sha" 8 3; then
local observed_sha
observed_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
echo "Pushed head SHA propagation timed out. expected=$prep_head_sha observed=$observed_sha"
exit 1
fi
local pr_head_sha_after
pr_head_sha_after=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
git fetch origin main
git fetch origin "pull/$pr/head:pr-$pr-verify" --force
git merge-base --is-ancestor origin/main "pr-$pr-verify" || {
echo "PR branch is behind main after push."
exit 1
}
git branch -D "pr-$pr-verify" 2>/dev/null || true
verify_pr_head_branch_matches_expected "$pr" "$PR_HEAD"
push_prep_head_to_pr_branch "$pr" "$PR_HEAD" "$prep_head_sha" "$lease_sha" false false "$push_result_env"
# shellcheck disable=SC1090
source "$push_result_env"
prep_head_sha="$PUSH_PREP_HEAD_SHA"
local pushed_from_sha="$PUSHED_FROM_SHA"
local pr_head_sha_after="$PR_HEAD_SHA_AFTER_PUSH"
local contrib="${PR_AUTHOR:-}"
if [ -z "$contrib" ]; then
@@ -1667,6 +1662,7 @@ merge_verify() {
require_artifact .local/prep.env
# shellcheck disable=SC1091
source .local/prep.env
verify_prep_branch_matches_prepared_head "$pr" "$PREP_HEAD_SHA"
local json
json=$(pr_meta_json "$pr")

View File

@@ -4,8 +4,16 @@ import { execSync } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import {
collectBundledExtensionManifestErrors,
normalizeBundledExtensionMetadata,
type BundledExtension,
type ExtensionPackageJson as PackageJson,
} from "./lib/bundled-extension-manifest.ts";
import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts";
export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts";
type PackFile = { path: string };
type PackResult = { files?: PackFile[] };
@@ -108,11 +116,6 @@ const appcastPath = resolve("appcast.xml");
const laneBuildMin = 1_000_000_000;
const laneFloorAdoptionDateKey = 20260227;
type PackageJson = {
name?: string;
version?: string;
};
function normalizePluginSyncVersion(version: string): string {
const normalized = version.trim().replace(/^v/, "");
const base = /^([0-9]+\.[0-9]+\.[0-9]+)/.exec(normalized)?.[1];
@@ -122,6 +125,90 @@ function normalizePluginSyncVersion(version: string): string {
return normalized.replace(/[-+].*$/, "");
}
export function collectBundledExtensionRootDependencyGapErrors(params: {
rootPackage: PackageJson;
extensions: BundledExtension[];
}): string[] {
const rootDeps = {
...params.rootPackage.dependencies,
...params.rootPackage.optionalDependencies,
};
const errors: string[] = [];
for (const extension of normalizeBundledExtensionMetadata(params.extensions)) {
if (!extension.npmSpec) {
continue;
}
const missing = Object.keys(extension.packageJson.dependencies ?? {})
.filter((dep) => dep !== "openclaw" && !rootDeps[dep])
.toSorted();
const allowlisted = extension.rootDependencyMirrorAllowlist.toSorted();
if (missing.join("\n") !== allowlisted.join("\n")) {
const unexpected = missing.filter((dep) => !allowlisted.includes(dep));
const resolved = allowlisted.filter((dep) => !missing.includes(dep));
const parts = [
`bundled extension '${extension.id}' root dependency mirror drift`,
`missing in root package: ${missing.length > 0 ? missing.join(", ") : "(none)"}`,
];
if (unexpected.length > 0) {
parts.push(`new gaps: ${unexpected.join(", ")}`);
}
if (resolved.length > 0) {
parts.push(`remove stale allowlist entries: ${resolved.join(", ")}`);
}
errors.push(parts.join(" | "));
}
}
return errors;
}
function collectBundledExtensions(): BundledExtension[] {
const extensionsDir = resolve("extensions");
const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) =>
entry.isDirectory(),
);
return entries.flatMap((entry) => {
const packagePath = join(extensionsDir, entry.name, "package.json");
try {
return [
{
id: entry.name,
packageJson: JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson,
},
];
} catch {
return [];
}
});
}
function checkBundledExtensionRootDependencyMirrors() {
const rootPackage = JSON.parse(readFileSync(resolve("package.json"), "utf8")) as PackageJson;
const extensions = collectBundledExtensions();
const manifestErrors = collectBundledExtensionManifestErrors(extensions);
if (manifestErrors.length > 0) {
console.error("release-check: bundled extension manifest validation failed:");
for (const error of manifestErrors) {
console.error(` - ${error}`);
}
process.exit(1);
}
const errors = collectBundledExtensionRootDependencyGapErrors({
rootPackage,
extensions,
});
if (errors.length > 0) {
console.error("release-check: bundled extension root dependency mirror validation failed:");
for (const error of errors) {
console.error(` - ${error}`);
}
process.exit(1);
}
}
function runPackDry(): PackResult[] {
const raw = execSync("npm pack --dry-run --json --ignore-scripts", {
encoding: "utf8",
@@ -321,6 +408,7 @@ function main() {
checkPluginVersions();
checkAppcastSparkleVersions();
checkPluginSdkExports();
checkBundledExtensionRootDependencyMirrors();
const results = runPackDry();
const files = results.flatMap((entry) => entry.files ?? []);

View File

@@ -31,6 +31,8 @@ const unitIsolatedFilesRaw = [
"src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts",
// Setup-heavy CLI update flow suite; move off unit-fast critical path.
"src/cli/update-cli.test.ts",
// Uses temp repos + module cache resets; keep it off vmForks to avoid ref-resolution flakes.
"src/infra/git-commit.test.ts",
// Expensive schema build/bootstrap checks; keep coverage but run in isolated lane.
"src/config/schema.test.ts",
"src/config/schema.tags.test.ts",
@@ -86,6 +88,8 @@ const unitIsolatedFilesRaw = [
"src/slack/monitor/slash.test.ts",
// Uses process-level unhandledRejection listeners; keep it off vmForks to avoid cross-file leakage.
"src/imessage/monitor.shutdown.unhandled-rejection.test.ts",
// Mutates process.cwd() and mocks core module loaders; isolate from the shared fast lane.
"src/infra/git-commit.test.ts",
];
const unitIsolatedFiles = unitIsolatedFilesRaw.filter((file) => fs.existsSync(file));
@@ -119,7 +123,9 @@ const testProfile =
rawTestProfile === "serial"
? rawTestProfile
: "normal";
const shouldSplitUnitRuns = testProfile !== "low" && testProfile !== "serial";
// Even on low-memory hosts, keep the isolated lane split so files like
// git-commit.test.ts still get the worker/process isolation they require.
const shouldSplitUnitRuns = testProfile !== "serial";
const runs = [
...(shouldSplitUnitRuns
? [

View File

@@ -49,7 +49,9 @@ import {
normalizeAcpErrorCode,
normalizeActorKey,
normalizeSessionKey,
requireReadySessionMeta,
resolveAcpAgentFromSessionKey,
resolveAcpSessionResolutionError,
resolveMissingMetaError,
resolveRuntimeIdleTtlMs,
} from "./manager.utils.js";
@@ -332,15 +334,7 @@ export class AcpSessionManager {
cfg: params.cfg,
sessionKey,
});
if (resolution.kind === "none") {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${sessionKey}`,
);
}
if (resolution.kind === "stale") {
throw resolution.error;
}
const resolvedMeta = requireReadySessionMeta(resolution);
const {
runtime,
handle: ensuredHandle,
@@ -348,7 +342,7 @@ export class AcpSessionManager {
} = await this.ensureRuntimeHandle({
cfg: params.cfg,
sessionKey,
meta: resolution.meta,
meta: resolvedMeta,
});
let handle = ensuredHandle;
let meta = ensuredMeta;
@@ -414,19 +408,11 @@ export class AcpSessionManager {
cfg: params.cfg,
sessionKey,
});
if (resolution.kind === "none") {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${sessionKey}`,
);
}
if (resolution.kind === "stale") {
throw resolution.error;
}
const resolvedMeta = requireReadySessionMeta(resolution);
const { runtime, handle, meta } = await this.ensureRuntimeHandle({
cfg: params.cfg,
sessionKey,
meta: resolution.meta,
meta: resolvedMeta,
});
const capabilities = await this.resolveRuntimeCapabilities({ runtime, handle });
if (!capabilities.controls.includes("session/set_mode") || !runtime.setMode) {
@@ -479,19 +465,11 @@ export class AcpSessionManager {
cfg: params.cfg,
sessionKey,
});
if (resolution.kind === "none") {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${sessionKey}`,
);
}
if (resolution.kind === "stale") {
throw resolution.error;
}
const resolvedMeta = requireReadySessionMeta(resolution);
const { runtime, handle, meta } = await this.ensureRuntimeHandle({
cfg: params.cfg,
sessionKey,
meta: resolution.meta,
meta: resolvedMeta,
});
const inferredPatch = inferRuntimeOptionPatchFromConfigOption(key, value);
const capabilities = await this.resolveRuntimeCapabilities({ runtime, handle });
@@ -558,17 +536,9 @@ export class AcpSessionManager {
cfg: params.cfg,
sessionKey,
});
if (resolution.kind === "none") {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${sessionKey}`,
);
}
if (resolution.kind === "stale") {
throw resolution.error;
}
const resolvedMeta = requireReadySessionMeta(resolution);
const nextOptions = mergeRuntimeOptions({
current: resolveRuntimeOptionsFromMeta(resolution.meta),
current: resolveRuntimeOptionsFromMeta(resolvedMeta),
patch: validatedPatch,
});
await this.persistRuntimeOptions({
@@ -594,19 +564,11 @@ export class AcpSessionManager {
cfg: params.cfg,
sessionKey,
});
if (resolution.kind === "none") {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${sessionKey}`,
);
}
if (resolution.kind === "stale") {
throw resolution.error;
}
const resolvedMeta = requireReadySessionMeta(resolution);
const { runtime, handle } = await this.ensureRuntimeHandle({
cfg: params.cfg,
sessionKey,
meta: resolution.meta,
meta: resolvedMeta,
});
await withAcpRuntimeErrorBoundary({
run: async () =>
@@ -638,15 +600,7 @@ export class AcpSessionManager {
cfg: input.cfg,
sessionKey,
});
if (resolution.kind === "none") {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${sessionKey}`,
);
}
if (resolution.kind === "stale") {
throw resolution.error;
}
const resolvedMeta = requireReadySessionMeta(resolution);
const {
runtime,
@@ -655,7 +609,7 @@ export class AcpSessionManager {
} = await this.ensureRuntimeHandle({
cfg: input.cfg,
sessionKey,
meta: resolution.meta,
meta: resolvedMeta,
});
let handle = ensuredHandle;
const meta = ensuredMeta;
@@ -810,19 +764,11 @@ export class AcpSessionManager {
cfg: params.cfg,
sessionKey,
});
if (resolution.kind === "none") {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${sessionKey}`,
);
}
if (resolution.kind === "stale") {
throw resolution.error;
}
const resolvedMeta = requireReadySessionMeta(resolution);
const { runtime, handle } = await this.ensureRuntimeHandle({
cfg: params.cfg,
sessionKey,
meta: resolution.meta,
meta: resolvedMeta,
});
try {
await withAcpRuntimeErrorBoundary({
@@ -868,27 +814,17 @@ export class AcpSessionManager {
cfg: input.cfg,
sessionKey,
});
if (resolution.kind === "none") {
const resolutionError = resolveAcpSessionResolutionError(resolution);
if (resolutionError) {
if (input.requireAcpSession ?? true) {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${sessionKey}`,
);
}
return {
runtimeClosed: false,
metaCleared: false,
};
}
if (resolution.kind === "stale") {
if (input.requireAcpSession ?? true) {
throw resolution.error;
throw resolutionError;
}
return {
runtimeClosed: false,
metaCleared: false,
};
}
const meta = requireReadySessionMeta(resolution);
let runtimeClosed = false;
let runtimeNotice: string | undefined;
@@ -896,7 +832,7 @@ export class AcpSessionManager {
const { runtime, handle } = await this.ensureRuntimeHandle({
cfg: input.cfg,
sessionKey,
meta: resolution.meta,
meta,
});
await withAcpRuntimeErrorBoundary({
run: async () =>

View File

@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../../config/config.js";
import type { SessionAcpMeta } from "../../config/sessions/types.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { ACP_ERROR_CODES, AcpRuntimeError } from "../runtime/errors.js";
import type { AcpSessionResolution } from "./manager.types.js";
export function resolveAcpAgentFromSessionKey(sessionKey: string, fallback = "main"): string {
const parsed = parseAgentSessionKey(sessionKey);
@@ -15,6 +16,28 @@ export function resolveMissingMetaError(sessionKey: string): AcpRuntimeError {
);
}
export function resolveAcpSessionResolutionError(
resolution: AcpSessionResolution,
): AcpRuntimeError | null {
if (resolution.kind === "ready") {
return null;
}
if (resolution.kind === "stale") {
return resolution.error;
}
return new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${resolution.sessionKey}`,
);
}
export function requireReadySessionMeta(resolution: AcpSessionResolution): SessionAcpMeta {
if (resolution.kind === "ready") {
return resolution.meta;
}
throw resolveAcpSessionResolutionError(resolution);
}
export function normalizeSessionKey(sessionKey: string): string {
return sessionKey.trim();
}

View File

@@ -35,6 +35,9 @@ const hoisted = vi.hoisted(() => {
const initializeSessionMock = vi.fn();
const startAcpSpawnParentStreamRelayMock = vi.fn();
const resolveAcpSpawnStreamLogPathMock = vi.fn();
const loadSessionStoreMock = vi.fn();
const resolveStorePathMock = vi.fn();
const resolveSessionTranscriptFileMock = vi.fn();
const state = {
cfg: createDefaultSpawnConfig(),
};
@@ -49,6 +52,9 @@ const hoisted = vi.hoisted(() => {
initializeSessionMock,
startAcpSpawnParentStreamRelayMock,
resolveAcpSpawnStreamLogPathMock,
loadSessionStoreMock,
resolveStorePathMock,
resolveSessionTranscriptFileMock,
state,
};
});
@@ -86,6 +92,24 @@ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
}));
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions.js")>();
return {
...actual,
loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath),
resolveStorePath: (store: unknown, opts: unknown) => hoisted.resolveStorePathMock(store, opts),
};
});
vi.mock("../config/sessions/transcript.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions/transcript.js")>();
return {
...actual,
resolveSessionTranscriptFile: (params: unknown) =>
hoisted.resolveSessionTranscriptFileMock(params),
};
});
vi.mock("../acp/control-plane/manager.js", () => {
return {
getAcpSessionManager: () => ({
@@ -263,6 +287,34 @@ describe("spawnAcpDirect", () => {
hoisted.resolveAcpSpawnStreamLogPathMock
.mockReset()
.mockReturnValue("/tmp/sess-main.acp-stream.jsonl");
hoisted.resolveStorePathMock.mockReset().mockReturnValue("/tmp/codex-sessions.json");
hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => {
const store: Record<string, { sessionId: string; updatedAt: number }> = {};
return new Proxy(store, {
get(_target, prop) {
if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) {
return { sessionId: "sess-123", updatedAt: Date.now() };
}
return undefined;
},
});
});
hoisted.resolveSessionTranscriptFileMock
.mockReset()
.mockImplementation(async (params: unknown) => {
const typed = params as { threadId?: string };
const sessionFile = typed.threadId
? `/tmp/agents/codex/sessions/sess-123-topic-${typed.threadId}.jsonl`
: "/tmp/agents/codex/sessions/sess-123.jsonl";
return {
sessionFile,
sessionEntry: {
sessionId: "sess-123",
updatedAt: Date.now(),
sessionFile,
},
};
});
});
it("spawns ACP session, binds a new thread, and dispatches initial task", async () => {
@@ -286,6 +338,13 @@ describe("spawnAcpDirect", () => {
expect(result.childSessionKey).toMatch(/^agent:codex:acp:/);
expect(result.runId).toBe("run-1");
expect(result.mode).toBe("session");
const patchCalls = hoisted.callGatewayMock.mock.calls
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
.filter((request) => request.method === "sessions.patch");
expect(patchCalls[0]?.params).toMatchObject({
key: result.childSessionKey,
spawnedBy: "agent:main:main",
});
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
targetKind: "session",
@@ -308,6 +367,12 @@ describe("spawnAcpDirect", () => {
mode: "persistent",
}),
);
const transcriptCalls = hoisted.resolveSessionTranscriptFileMock.mock.calls.map(
(call: unknown[]) => call[0] as { threadId?: string },
);
expect(transcriptCalls).toHaveLength(2);
expect(transcriptCalls[0]?.threadId).toBeUndefined();
expect(transcriptCalls[1]?.threadId).toBe("child-thread");
});
it("does not inline delivery for fresh oneshot ACP runs", async () => {
@@ -328,6 +393,13 @@ describe("spawnAcpDirect", () => {
expect(result.status).toBe("accepted");
expect(result.mode).toBe("run");
expect(hoisted.resolveSessionTranscriptFileMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "sess-123",
storePath: "/tmp/codex-sessions.json",
agentId: "codex",
}),
);
const agentCall = hoisted.callGatewayMock.mock.calls
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
.find((request) => request.method === "agent");
@@ -337,6 +409,32 @@ describe("spawnAcpDirect", () => {
expect(agentCall?.params?.threadId).toBeUndefined();
});
it("keeps ACP spawn running when session-file persistence fails", async () => {
hoisted.resolveSessionTranscriptFileMock.mockRejectedValueOnce(new Error("disk full"));
const result = await spawnAcpDirect(
{
task: "Investigate flaky tests",
agentId: "codex",
mode: "run",
},
{
agentSessionKey: "agent:main:main",
agentChannel: "telegram",
agentAccountId: "default",
agentTo: "telegram:6098642967",
agentThreadId: "1",
},
);
expect(result.status).toBe("accepted");
expect(result.childSessionKey).toMatch(/^agent:codex:acp:/);
const agentCall = hoisted.callGatewayMock.mock.calls
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
.find((request) => request.method === "agent");
expect(agentCall?.params?.sessionKey).toBe(result.childSessionKey);
});
it("includes cwd in ACP thread intro banner when provided at spawn time", async () => {
const result = await spawnAcpDirect(
{

View File

@@ -23,6 +23,8 @@ import {
} from "../channels/thread-bindings-policy.js";
import { loadConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js";
import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js";
import { callGateway } from "../gateway/call.js";
import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js";
import {
@@ -30,6 +32,7 @@ import {
isSessionBindingError,
type SessionBindingRecord,
} from "../infra/outbound/session-binding-service.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import {
@@ -38,6 +41,9 @@ import {
startAcpSpawnParentStreamRelay,
} from "./acp-spawn-parent-stream.js";
import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js";
const log = createSubsystemLogger("agents/acp-spawn");
export const ACP_SPAWN_MODES = ["run", "session"] as const;
export type SpawnAcpMode = (typeof ACP_SPAWN_MODES)[number];
@@ -162,6 +168,50 @@ function summarizeError(err: unknown): string {
return "error";
}
function resolveRequesterInternalSessionKey(params: {
cfg: OpenClawConfig;
requesterSessionKey?: string;
}): string {
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
const requesterSessionKey = params.requesterSessionKey?.trim();
return requesterSessionKey
? resolveInternalSessionKey({
key: requesterSessionKey,
alias,
mainKey,
})
: alias;
}
async function persistAcpSpawnSessionFileBestEffort(params: {
sessionId: string;
sessionKey: string;
sessionEntry: SessionEntry | undefined;
sessionStore: Record<string, SessionEntry>;
storePath: string;
agentId: string;
threadId?: string | number;
stage: "spawn" | "thread-bind";
}): Promise<SessionEntry | undefined> {
try {
const resolvedSessionFile = await resolveSessionTranscriptFile({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
storePath: params.storePath,
agentId: params.agentId,
threadId: params.threadId,
});
return resolvedSessionFile.sessionEntry;
} catch (error) {
log.warn(
`ACP session-file persistence failed during ${params.stage} for ${params.sessionKey}: ${summarizeError(error)}`,
);
return params.sessionEntry;
}
}
function resolveConversationIdForThreadBinding(params: {
to?: string;
threadId?: string | number;
@@ -257,6 +307,10 @@ export async function spawnAcpDirect(
ctx: SpawnAcpContext,
): Promise<SpawnAcpResult> {
const cfg = loadConfig();
const requesterInternalKey = resolveRequesterInternalSessionKey({
cfg,
requesterSessionKey: ctx.agentSessionKey,
});
if (!isAcpEnabledByPolicy(cfg)) {
return {
status: "forbidden",
@@ -346,11 +400,27 @@ export async function spawnAcpDirect(
method: "sessions.patch",
params: {
key: sessionKey,
spawnedBy: requesterInternalKey,
...(params.label ? { label: params.label } : {}),
},
timeoutMs: 10_000,
});
sessionCreated = true;
const storePath = resolveStorePath(cfg.session?.store, { agentId: targetAgentId });
const sessionStore = loadSessionStore(storePath);
let sessionEntry: SessionEntry | undefined = sessionStore[sessionKey];
const sessionId = sessionEntry?.sessionId;
if (sessionId) {
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
sessionId,
sessionKey,
sessionStore,
storePath,
sessionEntry,
agentId: targetAgentId,
stage: "spawn",
});
}
const initialized = await acpManager.initializeSession({
cfg,
sessionKey,
@@ -408,6 +478,21 @@ export async function spawnAcpDirect(
`Failed to create and bind a ${preparedBinding.channel} thread for this ACP session.`,
);
}
if (sessionId) {
const boundThreadId = String(binding.conversation.conversationId).trim() || undefined;
if (boundThreadId) {
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
sessionId,
sessionKey,
sessionStore,
storePath,
sessionEntry,
agentId: targetAgentId,
threadId: boundThreadId,
stage: "thread-bind",
});
}
}
}
} catch (err) {
await cleanupFailedAcpSpawn({

View File

@@ -271,11 +271,14 @@ export async function resolveApiKeyForProvider(params: {
export type EnvApiKeyResult = { apiKey: string; source: string };
export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "aws-sdk" | "unknown";
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
export function resolveEnvApiKey(
provider: string,
env: NodeJS.ProcessEnv = process.env,
): EnvApiKeyResult | null {
const normalized = normalizeProviderId(provider);
const applied = new Set(getShellEnvAppliedKeys());
const pick = (envVar: string): EnvApiKeyResult | null => {
const value = normalizeOptionalSecretInput(process.env[envVar]);
const value = normalizeOptionalSecretInput(env[envVar]);
if (!value) {
return null;
}

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
CUSTOM_PROXY_MODELS_CONFIG,
installModelsConfigTestHooks,
unsetEnv,
withModelsTempHome as withTempHome,
@@ -14,33 +14,55 @@ installModelsConfigTestHooks();
const TEST_ENV_VAR = "OPENCLAW_MODELS_CONFIG_TEST_ENV";
describe("models-config", () => {
it("applies config env.vars entries while ensuring models.json", async () => {
it("uses config env.vars entries for implicit provider discovery without mutating process.env", async () => {
await withTempHome(async () => {
await withTempEnv([TEST_ENV_VAR], async () => {
unsetEnv([TEST_ENV_VAR]);
await withTempEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR], async () => {
unsetEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR]);
const cfg: OpenClawConfig = {
...CUSTOM_PROXY_MODELS_CONFIG,
env: { vars: { [TEST_ENV_VAR]: "from-config" } },
models: { providers: {} },
env: {
vars: {
OPENROUTER_API_KEY: "from-config",
[TEST_ENV_VAR]: "from-config",
},
},
};
await ensureOpenClawModelsJson(cfg);
const { agentDir } = await ensureOpenClawModelsJson(cfg);
expect(process.env[TEST_ENV_VAR]).toBe("from-config");
expect(process.env.OPENROUTER_API_KEY).toBeUndefined();
expect(process.env[TEST_ENV_VAR]).toBeUndefined();
const modelsJson = JSON.parse(await fs.readFile(`${agentDir}/models.json`, "utf8")) as {
providers?: { openrouter?: { apiKey?: string } };
};
expect(modelsJson.providers?.openrouter?.apiKey).toBe("OPENROUTER_API_KEY");
});
});
});
it("does not overwrite already-set host env vars", async () => {
it("does not overwrite already-set host env vars while ensuring models.json", async () => {
await withTempHome(async () => {
await withTempEnv([TEST_ENV_VAR], async () => {
await withTempEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR], async () => {
process.env.OPENROUTER_API_KEY = "from-host";
process.env[TEST_ENV_VAR] = "from-host";
const cfg: OpenClawConfig = {
...CUSTOM_PROXY_MODELS_CONFIG,
env: { vars: { [TEST_ENV_VAR]: "from-config" } },
models: { providers: {} },
env: {
vars: {
OPENROUTER_API_KEY: "from-config",
[TEST_ENV_VAR]: "from-config",
},
},
};
await ensureOpenClawModelsJson(cfg);
const { agentDir } = await ensureOpenClawModelsJson(cfg);
const modelsJson = JSON.parse(await fs.readFile(`${agentDir}/models.json`, "utf8")) as {
providers?: { openrouter?: { apiKey?: string } };
};
expect(modelsJson.providers?.openrouter?.apiKey).toBe("OPENROUTER_API_KEY");
expect(process.env.OPENROUTER_API_KEY).toBe("from-host");
expect(process.env[TEST_ENV_VAR]).toBe("from-host");
});
});

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { OpenClawConfig } from "../config/config.js";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import { resolveImplicitProviders } from "./models-config.providers.js";
export async function withModelsTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "openclaw-models-" });
@@ -106,6 +107,8 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
"TOGETHER_API_KEY",
"VOLCANO_ENGINE_API_KEY",
"BYTEPLUS_API_KEY",
"KILOCODE_API_KEY",
"KIMI_API_KEY",
"KIMICODE_API_KEY",
"GEMINI_API_KEY",
"VENICE_API_KEY",
@@ -123,6 +126,29 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
"AWS_SHARED_CREDENTIALS_FILE",
];
export function snapshotImplicitProviderEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const source = env ?? process.env;
const snapshot: NodeJS.ProcessEnv = {};
for (const envVar of MODELS_CONFIG_IMPLICIT_ENV_VARS) {
const value = source[envVar];
if (value !== undefined) {
snapshot[envVar] = value;
}
}
return snapshot;
}
export async function resolveImplicitProvidersForTest(
params: Parameters<typeof resolveImplicitProviders>[0],
) {
return await resolveImplicitProviders({
...params,
env: snapshotImplicitProviderEnv(params.env),
});
}
export const CUSTOM_PROXY_MODELS_CONFIG: OpenClawConfig = {
models: {
providers: {

View File

@@ -65,7 +65,7 @@ async function runCustomProviderMergeTest(params: {
baseUrl: string;
apiKey: string;
api: string;
models: Array<{ id: string; name: string; input: string[] }>;
models: Array<{ id: string; name: string; input: string[]; api?: string }>;
};
existingProviderKey?: string;
configProviderKey?: string;
@@ -246,6 +246,43 @@ describe("models-config", () => {
});
});
it("replaces stale merged baseUrl when the provider api changes", async () => {
await withTempHome(async () => {
const parsed = await runCustomProviderMergeTest({
seedProvider: {
baseUrl: "https://agent.example/v1",
apiKey: "AGENT_KEY", // pragma: allowlist secret
api: "openai-completions",
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
},
});
expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY");
expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1");
});
});
it("replaces stale merged baseUrl when only model-level apis change", async () => {
await withTempHome(async () => {
const parsed = await runCustomProviderMergeTest({
seedProvider: {
baseUrl: "https://agent.example/v1",
apiKey: "AGENT_KEY", // pragma: allowlist secret
api: "",
models: [
{
id: "agent-model",
name: "Agent model",
input: ["text"],
api: "openai-completions",
},
],
},
});
expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY");
expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1");
});
});
it("replaces stale merged apiKey when provider is SecretRef-managed in current config", async () => {
await withTempHome(async () => {
await writeAgentModelsJson({

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import {
mergeProviderModels,
mergeProviders,
mergeWithExistingProviderSecrets,
type ExistingProviderConfig,
} from "./models-config.merge.js";
import type { ProviderConfig } from "./models-config.providers.js";
describe("models-config merge helpers", () => {
const preservedApiKey = "AGENT_KEY"; // pragma: allowlist secret
it("refreshes implicit model metadata while preserving explicit reasoning overrides", () => {
const merged = mergeProviderModels(
{
api: "openai-responses",
models: [
{
id: "gpt-5.4",
name: "GPT-5.4",
input: ["text"],
reasoning: true,
contextWindow: 1_000_000,
maxTokens: 100_000,
},
],
} as ProviderConfig,
{
api: "openai-responses",
models: [
{
id: "gpt-5.4",
name: "GPT-5.4",
input: ["image"],
reasoning: false,
contextWindow: 2_000_000,
maxTokens: 200_000,
},
],
} as ProviderConfig,
);
expect(merged.models).toEqual([
expect.objectContaining({
id: "gpt-5.4",
input: ["text"],
reasoning: false,
contextWindow: 2_000_000,
maxTokens: 200_000,
}),
]);
});
it("merges explicit providers onto trimmed keys", () => {
const merged = mergeProviders({
explicit: {
" custom ": {
api: "openai-responses",
models: [] as ProviderConfig["models"],
} as ProviderConfig,
},
});
expect(merged).toEqual({
custom: expect.objectContaining({ api: "openai-responses" }),
});
});
it("replaces stale baseUrl when model api surface changes", () => {
const merged = mergeWithExistingProviderSecrets({
nextProviders: {
custom: {
baseUrl: "https://config.example/v1",
models: [{ id: "model", api: "openai-responses" }],
} as ProviderConfig,
},
existingProviders: {
custom: {
baseUrl: "https://agent.example/v1",
apiKey: preservedApiKey,
models: [{ id: "model", api: "openai-completions" }],
} as ExistingProviderConfig,
},
secretRefManagedProviders: new Set<string>(),
explicitBaseUrlProviders: new Set<string>(),
});
expect(merged.custom).toEqual(
expect.objectContaining({
apiKey: preservedApiKey,
baseUrl: "https://config.example/v1",
}),
);
});
});

View File

@@ -0,0 +1,217 @@
import { isNonSecretApiKeyMarker } from "./model-auth-markers.js";
import type { ProviderConfig } from "./models-config.providers.js";
export type ExistingProviderConfig = ProviderConfig & {
apiKey?: string;
baseUrl?: string;
api?: string;
};
function isPositiveFiniteTokenLimit(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}
function resolvePreferredTokenLimit(params: {
explicitPresent: boolean;
explicitValue: unknown;
implicitValue: unknown;
}): number | undefined {
if (params.explicitPresent && isPositiveFiniteTokenLimit(params.explicitValue)) {
return params.explicitValue;
}
if (isPositiveFiniteTokenLimit(params.implicitValue)) {
return params.implicitValue;
}
return isPositiveFiniteTokenLimit(params.explicitValue) ? params.explicitValue : undefined;
}
function getProviderModelId(model: unknown): string {
if (!model || typeof model !== "object") {
return "";
}
const id = (model as { id?: unknown }).id;
return typeof id === "string" ? id.trim() : "";
}
export function mergeProviderModels(
implicit: ProviderConfig,
explicit: ProviderConfig,
): ProviderConfig {
const implicitModels = Array.isArray(implicit.models) ? implicit.models : [];
const explicitModels = Array.isArray(explicit.models) ? explicit.models : [];
if (implicitModels.length === 0) {
return { ...implicit, ...explicit };
}
const implicitById = new Map(
implicitModels
.map((model) => [getProviderModelId(model), model] as const)
.filter(([id]) => Boolean(id)),
);
const seen = new Set<string>();
const mergedModels = explicitModels.map((explicitModel) => {
const id = getProviderModelId(explicitModel);
if (!id) {
return explicitModel;
}
seen.add(id);
const implicitModel = implicitById.get(id);
if (!implicitModel) {
return explicitModel;
}
const contextWindow = resolvePreferredTokenLimit({
explicitPresent: "contextWindow" in explicitModel,
explicitValue: explicitModel.contextWindow,
implicitValue: implicitModel.contextWindow,
});
const maxTokens = resolvePreferredTokenLimit({
explicitPresent: "maxTokens" in explicitModel,
explicitValue: explicitModel.maxTokens,
implicitValue: implicitModel.maxTokens,
});
return {
...explicitModel,
input: implicitModel.input,
reasoning: "reasoning" in explicitModel ? explicitModel.reasoning : implicitModel.reasoning,
...(contextWindow === undefined ? {} : { contextWindow }),
...(maxTokens === undefined ? {} : { maxTokens }),
};
});
for (const implicitModel of implicitModels) {
const id = getProviderModelId(implicitModel);
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
mergedModels.push(implicitModel);
}
return {
...implicit,
...explicit,
models: mergedModels,
};
}
export function mergeProviders(params: {
implicit?: Record<string, ProviderConfig> | null;
explicit?: Record<string, ProviderConfig> | null;
}): Record<string, ProviderConfig> {
const out: Record<string, ProviderConfig> = params.implicit ? { ...params.implicit } : {};
for (const [key, explicit] of Object.entries(params.explicit ?? {})) {
const providerKey = key.trim();
if (!providerKey) {
continue;
}
const implicit = out[providerKey];
out[providerKey] = implicit ? mergeProviderModels(implicit, explicit) : explicit;
}
return out;
}
function resolveProviderApi(entry: { api?: unknown } | undefined): string | undefined {
if (typeof entry?.api !== "string") {
return undefined;
}
const api = entry.api.trim();
return api || undefined;
}
function resolveModelApiSurface(entry: { models?: unknown } | undefined): string | undefined {
if (!Array.isArray(entry?.models)) {
return undefined;
}
const apis = entry.models
.flatMap((model) => {
if (!model || typeof model !== "object") {
return [];
}
const api = (model as { api?: unknown }).api;
return typeof api === "string" && api.trim() ? [api.trim()] : [];
})
.toSorted();
return apis.length > 0 ? JSON.stringify(apis) : undefined;
}
function resolveProviderApiSurface(
entry: ExistingProviderConfig | ProviderConfig | undefined,
): string | undefined {
return resolveProviderApi(entry) ?? resolveModelApiSurface(entry);
}
function shouldPreserveExistingApiKey(params: {
providerKey: string;
existing: ExistingProviderConfig;
secretRefManagedProviders: ReadonlySet<string>;
}): boolean {
const { providerKey, existing, secretRefManagedProviders } = params;
return (
!secretRefManagedProviders.has(providerKey) &&
typeof existing.apiKey === "string" &&
existing.apiKey.length > 0 &&
!isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false })
);
}
function shouldPreserveExistingBaseUrl(params: {
providerKey: string;
existing: ExistingProviderConfig;
nextEntry: ProviderConfig;
explicitBaseUrlProviders: ReadonlySet<string>;
}): boolean {
const { providerKey, existing, nextEntry, explicitBaseUrlProviders } = params;
if (
explicitBaseUrlProviders.has(providerKey) ||
typeof existing.baseUrl !== "string" ||
existing.baseUrl.length === 0
) {
return false;
}
const existingApi = resolveProviderApiSurface(existing);
const nextApi = resolveProviderApiSurface(nextEntry);
return !existingApi || !nextApi || existingApi === nextApi;
}
export function mergeWithExistingProviderSecrets(params: {
nextProviders: Record<string, ProviderConfig>;
existingProviders: Record<string, ExistingProviderConfig>;
secretRefManagedProviders: ReadonlySet<string>;
explicitBaseUrlProviders: ReadonlySet<string>;
}): Record<string, ProviderConfig> {
const { nextProviders, existingProviders, secretRefManagedProviders, explicitBaseUrlProviders } =
params;
const mergedProviders: Record<string, ProviderConfig> = {};
for (const [key, entry] of Object.entries(existingProviders)) {
mergedProviders[key] = entry;
}
for (const [key, newEntry] of Object.entries(nextProviders)) {
const existing = existingProviders[key];
if (!existing) {
mergedProviders[key] = newEntry;
continue;
}
const preserved: Record<string, unknown> = {};
if (shouldPreserveExistingApiKey({ providerKey: key, existing, secretRefManagedProviders })) {
preserved.apiKey = existing.apiKey;
}
if (
shouldPreserveExistingBaseUrl({
providerKey: key,
existing,
nextEntry: newEntry,
explicitBaseUrlProviders,
})
) {
preserved.baseUrl = existing.baseUrl;
}
mergedProviders[key] = { ...newEntry, ...preserved };
}
return mergedProviders;
}

View File

@@ -9,7 +9,7 @@ import {
NON_ENV_SECRETREF_MARKER,
QWEN_OAUTH_MARKER,
} from "./model-auth-markers.js";
import { resolveImplicitProviders } from "./models-config.providers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
describe("models-config provider auth provenance", () => {
it("persists env keyRef and tokenRef auth profiles as env var markers", async () => {
@@ -41,7 +41,7 @@ describe("models-config provider auth provenance", () => {
"utf8",
);
try {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
expect(providers?.together?.apiKey).toBe("TOGETHER_API_KEY");
@@ -78,7 +78,7 @@ describe("models-config provider auth provenance", () => {
"utf8",
);
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
expect(providers?.byteplus?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
expect(providers?.["byteplus-plan"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
expect(providers?.together?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
@@ -114,7 +114,7 @@ describe("models-config provider auth provenance", () => {
"utf8",
);
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER);
expect(providers?.["qwen-portal"]?.apiKey).toBe(QWEN_OAUTH_MARKER);
});

View File

@@ -5,7 +5,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
import { resolveImplicitProviders } from "./models-config.providers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
describe("cloudflare-ai-gateway profile provenance", () => {
it("prefers env keyRef marker over runtime plaintext for persistence", async () => {
@@ -37,7 +37,7 @@ describe("cloudflare-ai-gateway profile provenance", () => {
"utf8",
);
try {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe("CLOUDFLARE_AI_GATEWAY_API_KEY");
} finally {
envSnapshot.restore();
@@ -70,7 +70,7 @@ describe("cloudflare-ai-gateway profile provenance", () => {
"utf8",
);
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
});
});

View File

@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
import { resolveImplicitProviders } from "./models-config.providers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
describe("provider discovery auth marker guardrails", () => {
let originalVitest: string | undefined;
@@ -63,7 +63,7 @@ describe("provider discovery auth marker guardrails", () => {
"utf8",
);
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
expect(providers?.vllm?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
const request = fetchMock.mock.calls[0]?.[1] as
| { headers?: Record<string, string> }
@@ -96,7 +96,7 @@ describe("provider discovery auth marker guardrails", () => {
"utf8",
);
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
expect(providers?.huggingface?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
const huggingfaceCalls = fetchMock.mock.calls.filter(([url]) =>
String(url).includes("router.huggingface.co"),
@@ -132,7 +132,7 @@ describe("provider discovery auth marker guardrails", () => {
"utf8",
);
await resolveImplicitProviders({ agentDir });
await resolveImplicitProvidersForTest({ agentDir, env: {} });
const vllmCall = fetchMock.mock.calls.find(([url]) => String(url).includes(":8000"));
const request = vllmCall?.[1] as { headers?: Record<string, string> } | undefined;
expect(request?.headers?.Authorization).toBe("Bearer ALLCAPS_SAMPLE");

View File

@@ -3,7 +3,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { buildKilocodeProvider, resolveImplicitProviders } from "./models-config.providers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
import { buildKilocodeProvider } from "./models-config.providers.js";
const KILOCODE_MODEL_IDS = ["kilo/auto"];
@@ -14,7 +15,7 @@ describe("Kilo Gateway implicit provider", () => {
process.env.KILOCODE_API_KEY = "test-key"; // pragma: allowlist secret
try {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.kilocode).toBeDefined();
expect(providers?.kilocode?.models?.length).toBeGreaterThan(0);
} finally {
@@ -28,7 +29,7 @@ describe("Kilo Gateway implicit provider", () => {
delete process.env.KILOCODE_API_KEY;
try {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.kilocode).toBeUndefined();
} finally {
envSnapshot.restore();

View File

@@ -3,7 +3,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { buildKimiCodingProvider, resolveImplicitProviders } from "./models-config.providers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
import { buildKimiCodingProvider } from "./models-config.providers.js";
describe("kimi-coding implicit provider (#22409)", () => {
it("should include kimi-coding when KIMI_API_KEY is configured", async () => {
@@ -12,7 +13,7 @@ describe("kimi-coding implicit provider (#22409)", () => {
process.env.KIMI_API_KEY = "test-key"; // pragma: allowlist secret
try {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["kimi-coding"]).toBeDefined();
expect(providers?.["kimi-coding"]?.api).toBe("anthropic-messages");
expect(providers?.["kimi-coding"]?.baseUrl).toBe("https://api.kimi.com/coding/");
@@ -36,7 +37,7 @@ describe("kimi-coding implicit provider (#22409)", () => {
delete process.env.KIMI_API_KEY;
try {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["kimi-coding"]).toBeUndefined();
} finally {
envSnapshot.restore();

View File

@@ -0,0 +1,175 @@
import { mkdtempSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
MINIMAX_OAUTH_MARKER,
NON_ENV_SECRETREF_MARKER,
OLLAMA_LOCAL_AUTH_MARKER,
} from "./model-auth-markers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
type ProvidersMap = Awaited<ReturnType<typeof resolveImplicitProvidersForTest>>;
type ExplicitProviders = NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]>;
type MatrixCase = {
name: string;
env?: NodeJS.ProcessEnv;
authProfiles?: Record<string, unknown>;
explicitProviders?: ExplicitProviders;
assertProviders: (providers: ProvidersMap) => void;
};
async function writeAuthProfiles(
agentDir: string,
profiles: Record<string, unknown> | undefined,
): Promise<void> {
if (!profiles) {
return;
}
await writeFile(
join(agentDir, "auth-profiles.json"),
JSON.stringify({ version: 1, profiles }, null, 2),
"utf8",
);
}
const MATRIX_CASES: MatrixCase[] = [
{
name: "env api key injects a simple provider",
env: { NVIDIA_API_KEY: "test-nvidia-key" },
assertProviders(providers) {
expect(providers?.nvidia?.apiKey).toBe("NVIDIA_API_KEY");
expect(providers?.nvidia?.baseUrl).toBe("https://integrate.api.nvidia.com/v1");
expect(providers?.nvidia?.models?.length).toBeGreaterThan(0);
},
},
{
name: "env api key injects paired plan providers",
env: { VOLCANO_ENGINE_API_KEY: "test-volcengine-key" },
assertProviders(providers) {
expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
expect(providers?.["volcengine-plan"]?.api).toBe("openai-completions");
},
},
{
name: "env-backed auth profiles persist env markers",
env: {},
authProfiles: {
"together:default": {
type: "token",
provider: "together",
tokenRef: { source: "env", provider: "default", id: "TOGETHER_API_KEY" },
},
},
assertProviders(providers) {
expect(providers?.together?.apiKey).toBe("TOGETHER_API_KEY");
},
},
{
name: "non-env secret refs preserve compatibility markers",
env: {},
authProfiles: {
"byteplus:default": {
type: "api_key",
provider: "byteplus",
key: "runtime-byteplus-key",
keyRef: { source: "file", provider: "vault", id: "/byteplus/apiKey" },
},
},
assertProviders(providers) {
expect(providers?.byteplus?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
expect(providers?.["byteplus-plan"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
},
},
{
name: "oauth profiles still inject compatibility providers",
env: {},
authProfiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "codex-access-token",
refresh: "codex-refresh-token",
expires: Date.now() + 60_000,
},
"minimax-portal:default": {
type: "oauth",
provider: "minimax-portal",
access: "minimax-access-token",
refresh: "minimax-refresh-token",
expires: Date.now() + 60_000,
},
},
assertProviders(providers) {
expect(providers?.["openai-codex"]).toMatchObject({
baseUrl: "https://chatgpt.com/backend-api",
api: "openai-codex-responses",
models: [],
});
expect(providers?.["openai-codex"]).not.toHaveProperty("apiKey");
expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER);
},
},
{
name: "explicit vllm config suppresses implicit vllm injection",
env: { VLLM_API_KEY: "test-vllm-key" },
explicitProviders: {
vllm: {
baseUrl: "http://127.0.0.1:8000/v1",
api: "openai-completions",
models: [],
},
},
assertProviders(providers) {
expect(providers?.vllm).toBeUndefined();
},
},
{
name: "explicit ollama models still normalize the returned provider",
env: {},
explicitProviders: {
ollama: {
baseUrl: "http://remote-ollama:11434/v1",
models: [
{
id: "gpt-oss:20b",
name: "GPT-OSS 20B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 81920,
},
],
},
},
assertProviders(providers) {
expect(providers?.ollama?.baseUrl).toBe("http://remote-ollama:11434");
expect(providers?.ollama?.api).toBe("ollama");
expect(providers?.ollama?.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
expect(providers?.ollama?.models).toHaveLength(1);
},
},
];
describe("implicit provider resolution matrix", () => {
it.each(MATRIX_CASES)(
"$name",
async ({ env, authProfiles, explicitProviders, assertProviders }) => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await writeAuthProfiles(agentDir, authProfiles);
const providers = await resolveImplicitProvidersForTest({
agentDir,
env,
explicitProviders,
});
assertProviders(providers);
},
);
});

View File

@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveImplicitProviders } from "./models-config.providers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
describe("minimax provider catalog", () => {
it("does not advertise the removed lightning model for api-key or oauth providers", async () => {
@@ -34,7 +34,7 @@ describe("minimax provider catalog", () => {
"utf8",
);
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.minimax?.models?.map((model) => model.id)).toEqual([
"MiniMax-VL-01",
"MiniMax-M2.5",

View File

@@ -5,13 +5,14 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { resolveApiKeyForProvider } from "./model-auth.js";
import { buildNvidiaProvider, resolveImplicitProviders } from "./models-config.providers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
import { buildNvidiaProvider } from "./models-config.providers.js";
describe("NVIDIA provider", () => {
it("should include nvidia when NVIDIA_API_KEY is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await withEnvAsync({ NVIDIA_API_KEY: "test-key" }, async () => {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.nvidia).toBeDefined();
expect(providers?.nvidia?.models?.length).toBeGreaterThan(0);
});
@@ -52,7 +53,7 @@ describe("MiniMax implicit provider (#15275)", () => {
it("should use anthropic-messages API for API-key provider", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await withEnvAsync({ MINIMAX_API_KEY: "test-key" }, async () => {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.minimax).toBeDefined();
expect(providers?.minimax?.api).toBe("anthropic-messages");
expect(providers?.minimax?.authHeader).toBe(true);
@@ -83,14 +84,14 @@ describe("MiniMax implicit provider (#15275)", () => {
"utf8",
);
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["minimax-portal"]?.authHeader).toBe(true);
});
it("should include minimax portal provider when MINIMAX_OAUTH_TOKEN is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await withEnvAsync({ MINIMAX_OAUTH_TOKEN: "portal-token" }, async () => {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["minimax-portal"]).toBeDefined();
expect(providers?.["minimax-portal"]?.authHeader).toBe(true);
expect(providers?.["minimax-portal"]?.models?.some((m) => m.id === "MiniMax-VL-01")).toBe(
@@ -104,7 +105,7 @@ describe("vLLM provider", () => {
it("should not include vllm when no API key is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await withEnvAsync({ VLLM_API_KEY: undefined }, async () => {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.vllm).toBeUndefined();
});
});
@@ -112,7 +113,7 @@ describe("vLLM provider", () => {
it("should include vllm when VLLM_API_KEY is set", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await withEnvAsync({ VLLM_API_KEY: "test-key" }, async () => {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.vllm).toBeDefined();
expect(providers?.vllm?.apiKey).toBe("VLLM_API_KEY");

View File

@@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveImplicitProviders } from "./models-config.providers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
describe("Ollama auto-discovery", () => {
let originalVitest: string | undefined;
@@ -55,7 +55,7 @@ describe("Ollama auto-discovery", () => {
}) as unknown as typeof fetch;
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.ollama).toBeDefined();
expect(providers?.ollama?.apiKey).toBe("ollama-local");
@@ -73,7 +73,7 @@ describe("Ollama auto-discovery", () => {
mockOllamaUnreachable();
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.ollama).toBeUndefined();
const ollamaWarnings = warnSpy.mock.calls.filter(
@@ -89,7 +89,7 @@ describe("Ollama auto-discovery", () => {
mockOllamaUnreachable();
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await resolveImplicitProviders({
await resolveImplicitProvidersForTest({
agentDir,
explicitProviders: {
ollama: {

View File

@@ -3,7 +3,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ModelDefinitionConfig } from "../config/types.models.js";
import { resolveImplicitProviders, resolveOllamaApiBase } from "./models-config.providers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
import { resolveOllamaApiBase } from "./models-config.providers.js";
afterEach(() => {
vi.unstubAllEnvs();
@@ -60,7 +61,7 @@ describe("Ollama provider", () => {
}
async function resolveProvidersWithOllamaKey(agentDir: string) {
return await withOllamaApiKey(async () => await resolveImplicitProviders({ agentDir }));
return await withOllamaApiKey(async () => await resolveImplicitProvidersForTest({ agentDir }));
}
const createTagModel = (name: string) => ({ name, modified_at: "", size: 1, digest: "" });
@@ -78,7 +79,7 @@ describe("Ollama provider", () => {
it("should not include ollama when no API key is configured", async () => {
const agentDir = createAgentDir();
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.ollama).toBeUndefined();
});
@@ -86,7 +87,7 @@ describe("Ollama provider", () => {
it("should use native ollama api type", async () => {
const agentDir = createAgentDir();
await withOllamaApiKey(async () => {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.ollama).toBeDefined();
expect(providers?.ollama?.apiKey).toBe("OLLAMA_API_KEY");
@@ -98,7 +99,7 @@ describe("Ollama provider", () => {
it("should preserve explicit ollama baseUrl on implicit provider injection", async () => {
const agentDir = createAgentDir();
await withOllamaApiKey(async () => {
const providers = await resolveImplicitProviders({
const providers = await resolveImplicitProvidersForTest({
agentDir,
explicitProviders: {
ollama: {
@@ -239,7 +240,7 @@ describe("Ollama provider", () => {
},
];
const providers = await resolveImplicitProviders({
const providers = await resolveImplicitProvidersForTest({
agentDir,
explicitProviders: {
ollama: {
@@ -264,7 +265,7 @@ describe("Ollama provider", () => {
it("should preserve explicit apiKey when discovery path has no models and no env key", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const providers = await resolveImplicitProviders({
const providers = await resolveImplicitProvidersForTest({
agentDir,
explicitProviders: {
ollama: {

View File

@@ -5,12 +5,12 @@ import { resolveOpenClawAgentDir } from "./agent-paths.js";
import {
installModelsConfigTestHooks,
MODELS_CONFIG_IMPLICIT_ENV_VARS,
resolveImplicitProvidersForTest,
unsetEnv,
withModelsTempHome,
withTempEnv,
} from "./models-config.e2e-harness.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { resolveImplicitProviders } from "./models-config.providers.js";
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
installModelsConfigTestHooks();
@@ -50,7 +50,7 @@ describe("openai-codex implicit provider", () => {
const agentDir = resolveOpenClawAgentDir();
await writeCodexOauthProfile(agentDir);
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["openai-codex"]).toMatchObject({
baseUrl: "https://chatgpt.com/backend-api",
api: "openai-codex-responses",

View File

@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { resolveImplicitProviders } from "./models-config.providers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
const qianfanApiKeyEnv = ["QIANFAN_API", "KEY"].join("_");
@@ -13,7 +13,7 @@ describe("Qianfan provider", () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const qianfanApiKey = "test-key"; // pragma: allowlist secret
await withEnvAsync({ [qianfanApiKeyEnv]: qianfanApiKey }, async () => {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.qianfan).toBeDefined();
expect(providers?.qianfan?.apiKey).toBe("QIANFAN_API_KEY");
});

View File

@@ -0,0 +1,440 @@
import type { OpenClawConfig } from "../config/config.js";
import {
KILOCODE_BASE_URL,
KILOCODE_DEFAULT_CONTEXT_WINDOW,
KILOCODE_DEFAULT_COST,
KILOCODE_DEFAULT_MAX_TOKENS,
KILOCODE_MODEL_CATALOG,
} from "../providers/kilocode-shared.js";
import {
buildBytePlusModelDefinition,
BYTEPLUS_BASE_URL,
BYTEPLUS_MODEL_CATALOG,
BYTEPLUS_CODING_BASE_URL,
BYTEPLUS_CODING_MODEL_CATALOG,
} from "./byteplus-models.js";
import {
buildDoubaoModelDefinition,
DOUBAO_BASE_URL,
DOUBAO_MODEL_CATALOG,
DOUBAO_CODING_BASE_URL,
DOUBAO_CODING_MODEL_CATALOG,
} from "./doubao-models.js";
import {
buildSyntheticModelDefinition,
SYNTHETIC_BASE_URL,
SYNTHETIC_MODEL_CATALOG,
} from "./synthetic-models.js";
import {
TOGETHER_BASE_URL,
TOGETHER_MODEL_CATALOG,
buildTogetherModelDefinition,
} from "./together-models.js";
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
type ProviderModelConfig = NonNullable<ProviderConfig["models"]>[number];
const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic";
const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5";
const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01";
const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000;
const MINIMAX_DEFAULT_MAX_TOKENS = 8192;
const MINIMAX_API_COST = {
input: 0.3,
output: 1.2,
cacheRead: 0.03,
cacheWrite: 0.12,
};
function buildMinimaxModel(params: {
id: string;
name: string;
reasoning: boolean;
input: ProviderModelConfig["input"];
}): ProviderModelConfig {
return {
id: params.id,
name: params.name,
reasoning: params.reasoning,
input: params.input,
cost: MINIMAX_API_COST,
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
};
}
function buildMinimaxTextModel(params: {
id: string;
name: string;
reasoning: boolean;
}): ProviderModelConfig {
return buildMinimaxModel({ ...params, input: ["text"] });
}
const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic";
export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash";
const XIAOMI_DEFAULT_CONTEXT_WINDOW = 262144;
const XIAOMI_DEFAULT_MAX_TOKENS = 8192;
const XIAOMI_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5";
const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
const MOONSHOT_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/";
const KIMI_CODING_DEFAULT_MODEL_ID = "k2p5";
const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144;
const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768;
const KIMI_CODING_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1";
const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000;
const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192;
const QWEN_PORTAL_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
const OPENROUTER_DEFAULT_MODEL_ID = "auto";
const OPENROUTER_DEFAULT_CONTEXT_WINDOW = 200000;
const OPENROUTER_DEFAULT_MAX_TOKENS = 8192;
const OPENROUTER_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
export const QIANFAN_BASE_URL = "https://qianfan.baidubce.com/v2";
export const QIANFAN_DEFAULT_MODEL_ID = "deepseek-v3.2";
const QIANFAN_DEFAULT_CONTEXT_WINDOW = 98304;
const QIANFAN_DEFAULT_MAX_TOKENS = 32768;
const QIANFAN_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
const NVIDIA_DEFAULT_MODEL_ID = "nvidia/llama-3.1-nemotron-70b-instruct";
const NVIDIA_DEFAULT_CONTEXT_WINDOW = 131072;
const NVIDIA_DEFAULT_MAX_TOKENS = 4096;
const NVIDIA_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
export function buildMinimaxProvider(): ProviderConfig {
return {
baseUrl: MINIMAX_PORTAL_BASE_URL,
api: "anthropic-messages",
authHeader: true,
models: [
buildMinimaxModel({
id: MINIMAX_DEFAULT_VISION_MODEL_ID,
name: "MiniMax VL 01",
reasoning: false,
input: ["text", "image"],
}),
buildMinimaxTextModel({
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
reasoning: true,
}),
buildMinimaxTextModel({
id: "MiniMax-M2.5-highspeed",
name: "MiniMax M2.5 Highspeed",
reasoning: true,
}),
],
};
}
export function buildMinimaxPortalProvider(): ProviderConfig {
return {
baseUrl: MINIMAX_PORTAL_BASE_URL,
api: "anthropic-messages",
authHeader: true,
models: [
buildMinimaxModel({
id: MINIMAX_DEFAULT_VISION_MODEL_ID,
name: "MiniMax VL 01",
reasoning: false,
input: ["text", "image"],
}),
buildMinimaxTextModel({
id: MINIMAX_DEFAULT_MODEL_ID,
name: "MiniMax M2.5",
reasoning: true,
}),
buildMinimaxTextModel({
id: "MiniMax-M2.5-highspeed",
name: "MiniMax M2.5 Highspeed",
reasoning: true,
}),
],
};
}
export function buildMoonshotProvider(): ProviderConfig {
return {
baseUrl: MOONSHOT_BASE_URL,
api: "openai-completions",
models: [
{
id: MOONSHOT_DEFAULT_MODEL_ID,
name: "Kimi K2.5",
reasoning: false,
input: ["text", "image"],
cost: MOONSHOT_DEFAULT_COST,
contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW,
maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS,
},
],
};
}
export function buildKimiCodingProvider(): ProviderConfig {
return {
baseUrl: KIMI_CODING_BASE_URL,
api: "anthropic-messages",
models: [
{
id: KIMI_CODING_DEFAULT_MODEL_ID,
name: "Kimi for Coding",
reasoning: true,
input: ["text", "image"],
cost: KIMI_CODING_DEFAULT_COST,
contextWindow: KIMI_CODING_DEFAULT_CONTEXT_WINDOW,
maxTokens: KIMI_CODING_DEFAULT_MAX_TOKENS,
compat: {
requiresOpenAiAnthropicToolPayload: true,
},
},
],
};
}
export function buildQwenPortalProvider(): ProviderConfig {
return {
baseUrl: QWEN_PORTAL_BASE_URL,
api: "openai-completions",
models: [
{
id: "coder-model",
name: "Qwen Coder",
reasoning: false,
input: ["text"],
cost: QWEN_PORTAL_DEFAULT_COST,
contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW,
maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS,
},
{
id: "vision-model",
name: "Qwen Vision",
reasoning: false,
input: ["text", "image"],
cost: QWEN_PORTAL_DEFAULT_COST,
contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW,
maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS,
},
],
};
}
export function buildSyntheticProvider(): ProviderConfig {
return {
baseUrl: SYNTHETIC_BASE_URL,
api: "anthropic-messages",
models: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition),
};
}
export function buildDoubaoProvider(): ProviderConfig {
return {
baseUrl: DOUBAO_BASE_URL,
api: "openai-completions",
models: DOUBAO_MODEL_CATALOG.map(buildDoubaoModelDefinition),
};
}
export function buildDoubaoCodingProvider(): ProviderConfig {
return {
baseUrl: DOUBAO_CODING_BASE_URL,
api: "openai-completions",
models: DOUBAO_CODING_MODEL_CATALOG.map(buildDoubaoModelDefinition),
};
}
export function buildBytePlusProvider(): ProviderConfig {
return {
baseUrl: BYTEPLUS_BASE_URL,
api: "openai-completions",
models: BYTEPLUS_MODEL_CATALOG.map(buildBytePlusModelDefinition),
};
}
export function buildBytePlusCodingProvider(): ProviderConfig {
return {
baseUrl: BYTEPLUS_CODING_BASE_URL,
api: "openai-completions",
models: BYTEPLUS_CODING_MODEL_CATALOG.map(buildBytePlusModelDefinition),
};
}
export function buildXiaomiProvider(): ProviderConfig {
return {
baseUrl: XIAOMI_BASE_URL,
api: "anthropic-messages",
models: [
{
id: XIAOMI_DEFAULT_MODEL_ID,
name: "Xiaomi MiMo V2 Flash",
reasoning: false,
input: ["text"],
cost: XIAOMI_DEFAULT_COST,
contextWindow: XIAOMI_DEFAULT_CONTEXT_WINDOW,
maxTokens: XIAOMI_DEFAULT_MAX_TOKENS,
},
],
};
}
export function buildTogetherProvider(): ProviderConfig {
return {
baseUrl: TOGETHER_BASE_URL,
api: "openai-completions",
models: TOGETHER_MODEL_CATALOG.map(buildTogetherModelDefinition),
};
}
export function buildOpenrouterProvider(): ProviderConfig {
return {
baseUrl: OPENROUTER_BASE_URL,
api: "openai-completions",
models: [
{
id: OPENROUTER_DEFAULT_MODEL_ID,
name: "OpenRouter Auto",
reasoning: false,
input: ["text", "image"],
cost: OPENROUTER_DEFAULT_COST,
contextWindow: OPENROUTER_DEFAULT_CONTEXT_WINDOW,
maxTokens: OPENROUTER_DEFAULT_MAX_TOKENS,
},
],
};
}
export function buildOpenAICodexProvider(): ProviderConfig {
return {
baseUrl: OPENAI_CODEX_BASE_URL,
api: "openai-codex-responses",
models: [],
};
}
export function buildQianfanProvider(): ProviderConfig {
return {
baseUrl: QIANFAN_BASE_URL,
api: "openai-completions",
models: [
{
id: QIANFAN_DEFAULT_MODEL_ID,
name: "DEEPSEEK V3.2",
reasoning: true,
input: ["text"],
cost: QIANFAN_DEFAULT_COST,
contextWindow: QIANFAN_DEFAULT_CONTEXT_WINDOW,
maxTokens: QIANFAN_DEFAULT_MAX_TOKENS,
},
{
id: "ernie-5.0-thinking-preview",
name: "ERNIE-5.0-Thinking-Preview",
reasoning: true,
input: ["text", "image"],
cost: QIANFAN_DEFAULT_COST,
contextWindow: 119000,
maxTokens: 64000,
},
],
};
}
export function buildNvidiaProvider(): ProviderConfig {
return {
baseUrl: NVIDIA_BASE_URL,
api: "openai-completions",
models: [
{
id: NVIDIA_DEFAULT_MODEL_ID,
name: "NVIDIA Llama 3.1 Nemotron 70B Instruct",
reasoning: false,
input: ["text"],
cost: NVIDIA_DEFAULT_COST,
contextWindow: NVIDIA_DEFAULT_CONTEXT_WINDOW,
maxTokens: NVIDIA_DEFAULT_MAX_TOKENS,
},
{
id: "meta/llama-3.3-70b-instruct",
name: "Meta Llama 3.3 70B Instruct",
reasoning: false,
input: ["text"],
cost: NVIDIA_DEFAULT_COST,
contextWindow: 131072,
maxTokens: 4096,
},
{
id: "nvidia/mistral-nemo-minitron-8b-8k-instruct",
name: "NVIDIA Mistral NeMo Minitron 8B Instruct",
reasoning: false,
input: ["text"],
cost: NVIDIA_DEFAULT_COST,
contextWindow: 8192,
maxTokens: 2048,
},
],
};
}
export function buildKilocodeProvider(): ProviderConfig {
return {
baseUrl: KILOCODE_BASE_URL,
api: "openai-completions",
models: KILOCODE_MODEL_CATALOG.map((model) => ({
id: model.id,
name: model.name,
reasoning: model.reasoning,
input: model.input,
cost: KILOCODE_DEFAULT_COST,
contextWindow: model.contextWindow ?? KILOCODE_DEFAULT_CONTEXT_WINDOW,
maxTokens: model.maxTokens ?? KILOCODE_DEFAULT_MAX_TOKENS,
})),
};
}

File diff suppressed because it is too large Load Diff

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