Compare commits

..

344 Commits

Author SHA1 Message Date
pashpashpash
ae03b79b73 Fail fast for removed Codex import auth choice 2026-04-22 19:12:21 -07:00
pashpashpash
d507d5a766 Preserve removed Codex import auth choice 2026-04-22 19:07:02 -07:00
pashpashpash
a7a3a5f37b Fix legacy update compat sidecars 2026-04-22 19:07:02 -07:00
pashpashpash
2476992301 Remove stale Codex import auth choice 2026-04-22 19:06:28 -07:00
pashpashpash
1151e7d40b Remove Codex CLI auth import 2026-04-22 19:06:28 -07:00
Peter Steinberger
51ed22e608 feat(providers): add streaming stt providers 2026-04-23 03:05:53 +01:00
Peter Steinberger
5b68092351 ci: pass gateway watch artifacts across runners 2026-04-23 03:04:22 +01:00
Peter Steinberger
c4242890f4 ci: reuse runtime artifacts for gateway watch 2026-04-23 03:01:08 +01:00
Peter Steinberger
74dfeaae0d fix(qa): preserve image generation plugin allowlist 2026-04-23 02:55:22 +01:00
Peter Steinberger
e3e2626583 fix: update generated protocol models 2026-04-23 02:49:50 +01:00
Peter Steinberger
c9ea10b184 ci: rotate ci concurrency key 2026-04-23 02:47:42 +01:00
Gustavo Madeira Santana
c992a8e5d8 Harden diagnostic stability bundle imports 2026-04-22 21:47:23 -04:00
Peter Steinberger
1489febee9 test: cover docker MCP cleanup for subagents 2026-04-23 02:46:13 +01:00
Peter Steinberger
ccf2e77e8d fix: retire one-shot subagent MCP runtimes 2026-04-23 02:46:13 +01:00
Peter Steinberger
dcff528805 ci: rebalance extension shards 2026-04-23 02:43:02 +01:00
Peter Steinberger
2e90a2247e fix: harden Slack stream fallback delivery (#70370) (thanks @mvanhorn) 2026-04-23 02:42:48 +01:00
Matt Van Horn
e55b932632 fix(slack): fall back to chat.postMessage when stream finalize fails pre-flush
Address adversarial review finding on #70295: the prior swallow-on-benign
fix silently dropped short replies to Slack Connect users. The SDK's
ChatStreamer buffers text locally until buffer_size (256 default), so
short replies never trigger chat.startStream via append(). streamer.stop()
then issues startStream internally; on Slack Connect recipients this
throws user_not_found. With the prior fix that error was swallowed and
the dispatcher marked the turn delivered - user saw 'done' reaction but
no message.

SlackStreamSession now tracks delivered (true once any Slack API call
returned a response) and pendingText (accumulation of every append +
final-stop text). stopSlackStream:
  - swallows the benign code when delivered=true (prior append flushed;
    text is visible; same behavior as before)
  - throws a new SlackStreamNotDeliveredError carrying pendingText when
    delivered=false (nothing reached Slack)

dispatch.ts catches SlackStreamNotDeliveredError and posts pendingText
via a rename-bound chat.postMessage (to dodge the unicorn lint rule),
and flips streamFallbackDelivered so anyReplyDelivered stays correct.

Fixes #70295
2026-04-23 02:42:48 +01:00
Matt Van Horn
676ed34cbd fix(slack): treat Slack Connect finalize errors as benign in stopSlackStream
When Slack's chat.stopStream fails with user_not_found (Slack Connect DM
recipients), team_not_found (cross-workspace shared channels), or
missing_recipient_user_id (DM closed mid-stream), the text already
delivered via append() is still visible to the user. Swallow those
specific codes and mark the session stopped rather than surfacing a
spurious 'slack-stream: failed to stop stream' error in dispatch. Other
Slack API errors still propagate.

Fixes #70295
2026-04-23 02:42:48 +01:00
Peter Steinberger
688fc288af ci: trim duplicate android apk build 2026-04-23 02:38:01 +01:00
Peter Steinberger
5461195035 docs: document session mailbox discovery (#69839) 2026-04-23 02:33:55 +01:00
Peter Steinberger
b53bce9f47 fix(agents): filter session previews after visibility 2026-04-23 02:33:55 +01:00
dangoZhang
13882581b6 fix(agents): clean up sessions_list forwarding 2026-04-23 02:33:55 +01:00
dangoZhang
1a4c32e366 feat: expose mailbox session discovery in sessions_list 2026-04-23 02:33:55 +01:00
Peter Steinberger
dcc243c889 test: stabilize loopback port release check 2026-04-23 02:25:53 +01:00
Peter Steinberger
4ff720a837 fix(openai): harden realtime stt 2026-04-23 02:22:17 +01:00
Peter Steinberger
26bf916382 fix(gateway): resolve dynamic models during warmup 2026-04-23 02:20:11 +01:00
Peter Steinberger
1cbd5a9470 fix(codex): harden app-server approvals 2026-04-23 02:20:10 +01:00
Peter Steinberger
de95e414d1 style: format stale source files 2026-04-23 02:20:10 +01:00
Peter Steinberger
0ada97d513 fix: restore legacy update compat sidecars 2026-04-23 02:19:19 +01:00
Peter Steinberger
0f77fcac31 test: improve xai realtime stt live coverage 2026-04-23 02:06:07 +01:00
Peter Steinberger
6a1d6b7d89 ci: run docker smoke for scope changes 2026-04-23 01:58:58 +01:00
Peter Steinberger
b5cc7ea879 ci: expand docker smoke changed scope 2026-04-23 01:57:25 +01:00
Peter Steinberger
71ae0d737a fix: override vulnerable uuid dependency 2026-04-23 01:56:14 +01:00
dulingxiao
c4dea58712 fix(moonshot): preserve native Kimi tool_call IDs in openai-completions replay 2026-04-23 01:52:58 +01:00
Peter Steinberger
23a448986f fix(xai): declare websocket runtime dependency 2026-04-23 01:50:00 +01:00
Gustavo Madeira Santana
28818f9140 Improve gateway diagnostics export for support reports (#70324)
Merged via squash.

Prepared head SHA: 3d6ee85993
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-22 20:47:14 -04:00
Peter Steinberger
6b41ef311f fix: isolate external direct-message runtime policy 2026-04-23 01:39:56 +01:00
Peter Steinberger
67f09ea87a feat: add xai realtime transcription 2026-04-23 01:38:11 +01:00
Peter Steinberger
d4c171f594 ci: keep extension batch parallelism at two 2026-04-23 01:35:30 +01:00
Peter Steinberger
53f388fa83 docs(plugins): document npm update behavior 2026-04-23 01:29:32 +01:00
Peter Steinberger
67850c4fc8 ci: run extension batches three-wide 2026-04-23 01:29:20 +01:00
Peter Steinberger
87a64a33f1 fix(plugins): clarify installed plugin replacement 2026-04-23 01:25:29 +01:00
Peter Steinberger
fa43cbfcba fix: drop invalid Codex app-server service tiers 2026-04-23 01:24:25 +01:00
Peter Steinberger
9f358456db ci: skip duplicate extension fast on main 2026-04-23 01:23:23 +01:00
Peter Steinberger
0946e37523 fix(plugins): skip unchanged npm updates 2026-04-23 01:23:03 +01:00
Peter Steinberger
bf132d6fb9 test(qa-matrix): stabilize sync timeout cursor 2026-04-23 01:21:52 +01:00
Peter Steinberger
f72c97afca test(qa-matrix): stabilize sync timeout 2026-04-23 01:20:45 +01:00
Peter Steinberger
7724f7a923 test(opencode-go): lock pi catalog coverage 2026-04-23 01:17:13 +01:00
Peter Steinberger
d6eac07b06 ci: add fast bundled docker e2e 2026-04-23 01:09:35 +01:00
Peter Steinberger
012841816d feat: add xai speech-to-text support 2026-04-23 01:06:07 +01:00
Peter Steinberger
2bec189174 test(zalo): trim lifecycle reset imports 2026-04-23 01:02:57 +01:00
Peter Steinberger
4177b27e24 docs: note codex dynamic tool fingerprint fix (#69976) 2026-04-23 01:01:33 +01:00
chen-zhang-cs-code
5210b20523 fix(codex): ignore tool descriptions in thread fingerprint 2026-04-23 01:01:33 +01:00
Peter Steinberger
38c76b34f4 test(agents): stabilize context lookup warmup 2026-04-23 00:58:13 +01:00
Peter Steinberger
3d07eadec3 fix: restore model-level base url contract (#70340) 2026-04-23 00:52:32 +01:00
Peter Steinberger
dbab0f7aad fix: restore codex permission approval targets (#70340) (thanks @Lucenx9) 2026-04-23 00:52:32 +01:00
Lucenx9
08a81740ae fix(codex): restore sanitized permission approval detail 2026-04-23 00:52:32 +01:00
Lucenx9
dc13cd68ed fix(codex): clarify permission approvals 2026-04-23 00:52:32 +01:00
Peter Steinberger
5a5aa3a178 fix(config): tolerate missing channel metadata during auto-enable 2026-04-23 00:50:34 +01:00
Peter Steinberger
53e822f407 fix: keep cli reply runs streaming 2026-04-23 00:49:43 +01:00
Peter Steinberger
c4e5ca8625 fix(agents): expose configured MCP tools in Pi profiles 2026-04-23 00:47:37 +01:00
Peter Steinberger
bba63d4e78 test(codex): await event projector setup 2026-04-23 00:46:04 +01:00
Peter Steinberger
f437d96ae2 fix(config): avoid false reload restarts 2026-04-23 00:44:54 +01:00
Peter Steinberger
c65b232463 fix(amazon-bedrock-mantle): align runtime deps 2026-04-23 00:43:12 +01:00
Peter Steinberger
d50181e209 test(docker): speed bundled dependency e2e 2026-04-23 00:35:17 +01:00
pashpashpash
ff02563c7c feat(codex): add guardian app-server mode (#70090)
Reworks the Codex app-server Guardian change into the final landing shape:

- keep YOLO as the default local app-server mode
- add explicit `appServer.mode: "guardian"`
- remove the legacy `OPENCLAW_CODEX_APP_SERVER_GUARDIAN` shortcut
- document Guardian configuration and behavior
- add Guardian event projection and Docker live probes for approved/ask-back decisions

Co-authored-by: pashpashpash <nik@vault77.ai>
2026-04-23 00:25:43 +01:00
Vincent Koc
34e45ecfcc feat(codex): add llm lifecycle hooks (#70312)
* feat(codex): add llm lifecycle hooks

* fix(codex): close llm hook lifecycle gaps

* fix(codex): dedupe llm hook context

* fix(codex): preserve abort and error hook state
2026-04-22 16:19:59 -07:00
Vincent Koc
a5128777ee feat(codex): add tool hook parity (#70307)
* feat(codex): add tool hook parity

* fix(codex): stabilize tool hook parity

* fix(codex): tighten transcript hook typing

* fix(codex): preserve mirrored transcript idempotency

* fix(codex): normalize tool hook context
2026-04-22 16:18:10 -07:00
Peter Steinberger
da9700903c ci: skip no-op changed-scope fanout 2026-04-23 00:16:01 +01:00
Vincent Koc
44965bf63c fix(diffs): refresh live tool config 2026-04-22 16:14:23 -07:00
Peter Steinberger
1019b663ce chore: format extension runtime deps 2026-04-23 00:12:47 +01:00
Vincent Koc
d686e6f876 fix(hooks): avoid stale active-memory startup fallback 2026-04-22 16:10:01 -07:00
wirjo
18507ed85f feat(amazon-bedrock-mantle): add Claude Opus 4.7 via per-model Anthropic Messages API override (#68730)
* feat(amazon-bedrock-mantle): add Claude Opus 4.7 via Anthropic auth

* fix(amazon-bedrock-mantle): keep Opus 4.7 transport-safe

* fix(amazon-bedrock-mantle): restore anthropic base url helper

* fix(auto-reply): apply runtime auth to conversation labels

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-22 16:09:39 -07:00
KateWilkins
f342da5fcc feat: add xai media providers
Add xAI image generation and text-to-speech provider support with docs, live tests, and guarded provider HTTP handling.\n\nThanks @KateWilkins.
2026-04-23 00:07:39 +01:00
Vincent Koc
386a0884d7 fix(hooks): avoid stale lancedb startup fallback 2026-04-22 16:06:55 -07:00
Peter Steinberger
ed0ffa472b docs: clarify codex compaction docs (#69612) (thanks @91wan) 2026-04-23 00:05:47 +01:00
91wan
dee8150bab docs(codex): narrow compaction claims 2026-04-23 00:05:47 +01:00
Peter Steinberger
bee491f439 test(telegram): cover debounce topic keys at seam 2026-04-22 23:57:08 +01:00
Peter Steinberger
9b1f1036ac fix(channels): isolate bundled load failures 2026-04-22 23:56:14 +01:00
Vincent Koc
e8b56a9928 feat(codex): add prompt and compaction hooks (#70313)
* feat(codex): add prompt and compaction hooks

* fix(codex): clean prompt and compaction hook tests
2026-04-22 15:56:08 -07:00
Peter Steinberger
ac8495adaa fix(config): write through single-file includes 2026-04-22 23:53:56 +01:00
wirjo
2a15a3bb53 fix(amazon-bedrock): add known model context windows to discovery (#65952)
* fix(amazon-bedrock): add known model context windows to discovery

Bedrock's ListFoundationModels API does not expose token limits. Discovery
was hardcoding contextWindow: 32000 for every model, causing Claude (1M),
Nova (300K), and other models to hit premature 'Context limit exceeded'
errors and unnecessary session resets.

Adds a lookup table of known context windows for Bedrock models:
- Anthropic Claude: 200K-1M
- Amazon Nova: 128K-1M
- Meta Llama: 128K
- Mistral: 32K-128K
- DeepSeek: 128K
- Cohere: 128K
- AI21 Jamba: 256K

Inference profile prefixes (us., eu., ap., global.) are stripped before
lookup, so us.anthropic.claude-opus-4-6-v1 correctly resolves to 1M.

Also raises the default fallback from 32K to 128K for unknown models —
most modern models have at least 128K context.

Single file change, no type system modifications.

Complementary to #65030 (provenance flag for warning on unknown models).

Fixes #64919
Related: #64250

* add KNOWN_MAX_TOKENS map and expand model coverage

- Add KNOWN_MAX_TOKENS lookup table with Bedrock-optimized values that
  balance response quality against quota burndown (5x rate for Claude 3.7+)
- Add missing models to KNOWN_CONTEXT_WINDOWS: Opus 4.7 (1M), Opus 4.1/4.5,
  Sonnet 4, Claude 3/3.5 Haiku, DeepSeek V3/V3.2, Google Gemma 3
- Refactor prefix-stripping into shared resolveKnownValue() helper
- Fix: use !== undefined instead of truthy check for table lookups
- Wire resolveKnownMaxTokens into toModelDefinition and resolveInferenceProfiles

Quota burndown context: Bedrock reserves input_tokens + max_tokens from
TPM at request start. For Claude 3.7+, output burns at 5x. The values
in KNOWN_MAX_TOKENS are intentionally conservative (8-16K for Claude)
to maximize concurrent throughput while still allowing useful responses.
Thinking budget is added separately by the runtime.

* remove KNOWN_MAX_TOKENS — maxTokens should be handled upstream

Remove the KNOWN_MAX_TOKENS map. Hardcoding maxTokens values in
discovery is the wrong layer to solve this — any explicit value
still gets reserved against Bedrock's TPM quota at request start.

The correct fix is upstream in pi's Bedrock provider: omit maxTokens
from inferenceConfig when not explicitly set, letting the model use
its internal default. This avoids quota waste entirely.

See: badlogic/pi-mono#3399 and badlogic/pi-mono#3400

Keep the expanded KNOWN_CONTEXT_WINDOWS (context windows ARE the
right thing to set in discovery — they affect compaction thresholds
and session management, not API-level quota reservation).

* docs: clarify why hardcoded context windows are needed

Bedrock's ListFoundationModels and GetFoundationModel APIs return no
token limit information — there is no Bedrock API to discover context
windows or max output tokens programmatically. Note that this table
should become a fallback if AWS adds token metadata in the future.

* fix: add au and apac to inference profile prefix regex

Add missing geo prefixes discovered by querying inference profiles
across multiple regions:
- au. (Australia/NZ, used in ap-southeast-2/4/6)
- apac. (Asia-Pacific, used for older models in ap-northeast-1)

Both resolveKnownContextWindow and resolveBaseModelId now handle
all known prefixes: us, eu, ap, apac, au, jp, global.

* test: port au. prefix test from #65449 by @alickgithub2, add apac. coverage

Port the Australia/NZ inference profile test from PR #65449
(credit: @alickgithub2) and extend it to also cover the apac.
prefix discovered in ap-northeast-1.

* expand model coverage: Llama 4, MiniMax, NVIDIA, Mistral 3, GLM, Qwen

Cross-referenced KNOWN_CONTEXT_WINDOWS against live
list-foundation-models API. Added missing models:
- Llama 4 Maverick (1M) and Scout (512K)
- MiniMax M2/M2.1/M2.5 (1M)
- NVIDIA Nemotron Super/Nano variants (128K)
- Mistral Large 3 675B (128K)
- GLM 4.7/4.7-flash/5 (128K)
- Qwen3 Coder/32B/VL (128-256K)

Removed deprecated deepseek.v3-v1:0 and claude-opus-4-20250514
(not in active foundation models list).

* raise default context window from 128K to 200K

200K matches the floor for all current Claude models (the most
popular on Bedrock). Every other active model with a lower actual
limit is already in the explicit table. This ensures new Claude
models get a correct default without requiring a table update.

* test: update discovery test expectations for known context window values

* test: fix remaining contextWindow expectation (default 200K)

* fix(amazon-bedrock): keep conservative context fallback

* docs(changelog): note Bedrock context window fix

* fix(amazon-bedrock): normalize known context fallback

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-22 15:53:41 -07:00
wirjo
420c96e7aa fix(amazon-bedrock-mantle): refresh IAM bearer token via resolveConfigApiKey cache lookup (#68903)
* fix(amazon-bedrock-mantle): refresh IAM bearer token via resolveConfigApiKey cache lookup

The Mantle plugin generates a bearer token from IAM credentials at discovery
time and bakes it as a static string into the provider config. After the
token's cache TTL expires (~1hr), requests fail because resolveConfigApiKey
only handled the explicit AWS_BEARER_TOKEN_BEDROCK env var case.

Fix: expose getCachedIamToken() as a sync read from the existing iamTokenCache,
and wire it into resolveConfigApiKey as a fallback when no explicit env var is
set. The catalog.run still generates/refreshes the token on discovery; this
change ensures the cached token is served at auth resolution time.

Fixes #68900

* fix(amazon-bedrock-mantle): refresh runtime IAM bearer auth

* docs(changelog): note Mantle IAM refresh

* fix(agents): apply runtime auth in simple completion

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-22 15:52:24 -07:00
Devin Robison
2321d67263 fix(gateway): require auth for control ui bootstrap config (#70247)
* fix(gateway): require auth for control ui bootstrap config

* fix(ui): send auth on bootstrap fetch

* fix(ui): keep bootstrap auth same-origin

* fix(ui): refresh bootstrap after auth hello

* docs(changelog): note control ui bootstrap auth

* fix(ui): retry bootstrap auth with alternate shared secret on 401
2026-04-22 16:52:08 -06:00
Peter Steinberger
c87c9742ed fix(telegram): isolate direct chat sandbox sessions 2026-04-22 23:46:34 +01:00
Peter Steinberger
46fba1d814 docs(config): clarify symlinked config support 2026-04-22 23:45:03 +01:00
Devin Robison
95119017c8 fix(openshell): pin sandbox file reads (#69798)
* fix(openshell): pin sandbox file reads against parent symlink swaps

* docs(changelog): note openshell sandbox read pinning (#69798)

* fix(openshell): containment-check against literal root and self-contain file-identity helper

* test(openshell): spy on fsPromises.open for swap races, skip dev=0 test on win32

* fix(openshell): single-syscall fallback identity check + tighten sameFileIdentity types

* fix(openshell): re-fstat pinned handle after identity check for defense-in-depth

* fix(openshell): lstat leaf on platforms without O_NOFOLLOW to close windows symlink gap

* fix(openshell): expose test seam for O_NOFOLLOW availability instead of patching native constants
2026-04-22 16:44:25 -06:00
Val Alexander
12bbb371d0 feat(control-ui): personalize local user identity and tighten layouts
## Summary
- add browser-local operator identity in Control UI and route user name/avatar rendering through the shared chat/avatar path used by assistant and agent surfaces
- tighten Quick Settings, fallback chip, and mobile chat layout behavior so the personalized UI uses space better and avoids clipped controls
- guard oversized local avatar uploads before FileReader allocation, restore the fallback-chip keyboard focus ring, and add the changelog note for the user-visible Control UI work

## Testing
- pnpm test ui/src/ui/views/config-quick.test.ts ui/src/styles/components.test.ts
- pnpm check:changed
2026-04-22 17:38:58 -05:00
Peter Steinberger
5daa104e63 docs: note codex approval hardening (#70356) (thanks @Lucenx9) 2026-04-22 23:38:44 +01:00
Lucenx9
ec5015924c fix(codex): fail closed for unknown approvals 2026-04-22 23:38:44 +01:00
Peter Steinberger
4285958bcd test(codex): cover websocket token rotation (#70328) (thanks @Lucenx9) 2026-04-22 23:37:58 +01:00
Lucenx9
15f285c0cb fix(codex): scope stale shared-client cleanup 2026-04-22 23:37:58 +01:00
Lucenx9
0bc5ccc706 fix(codex): rotate shared app-server clients on auth changes 2026-04-22 23:37:58 +01:00
Peter Steinberger
f4c4e940a6 test(qa): stabilize lab catalog abort fixture 2026-04-22 23:36:34 +01:00
Peter Steinberger
2cd3164a0f feat(providers): share GPT-5 prompt overlay 2026-04-22 23:36:06 +01:00
Peter Steinberger
7b2c9a6fa3 fix(config): recover critical config clobbers 2026-04-22 23:35:48 +01:00
Peter Steinberger
1d7be63228 ci: rebalance extension test shards 2026-04-22 23:29:34 +01:00
Peter Steinberger
22814c1add docs(config): document safe model config merges 2026-04-22 23:23:54 +01:00
Peter Steinberger
f7e668d0ec chore: record extension runtime deps 2026-04-22 23:19:20 +01:00
Peter Steinberger
c2ac1e3ef4 feat: expose OpenClaw tools to ACPX 2026-04-22 23:19:20 +01:00
Peter Steinberger
87f8e82347 fix: isolate Codex ACP auth 2026-04-22 23:18:56 +01:00
Peter Steinberger
819ff0463a fix(config): protect model config merges 2026-04-22 23:18:05 +01:00
Peter Steinberger
f88da75ed9 refactor(channels): centralize runtime binding routes 2026-04-22 23:16:57 +01:00
Peter Steinberger
85d2a9ec1f test(cron): add docker mcp cleanup e2e 2026-04-22 23:12:18 +01:00
Peter Steinberger
816d7a7232 chore(extensions): update runtime dependency manifests 2026-04-22 23:11:43 +01:00
Devin Robison
b76edc09e6 fix(gateway): reauthorize session history SSE updates (#70237)
* fix(gateway): reauthorize session history SSE updates

* docs(changelog): note session history sse reauth

* fix(gateway): use live proxy config for sse reauth

* fix(gateway): skip unrelated session sse reauth

* fix(gateway): filter sse transcript updates early, log work failures, forward-declare cleanup bindings
2026-04-22 16:11:32 -06:00
Peter Steinberger
698f154c28 fix(qa): recheck Matrix sync events after poll 2026-04-22 23:11:27 +01:00
Peter Steinberger
a32a6c2f89 fix: stop generating qa npm sidecars 2026-04-22 23:11:01 +01:00
Peter Steinberger
f66098f8f6 test(github-copilot): add live Responses ID rewrite probe 2026-04-22 23:09:31 +01:00
Peter Steinberger
03c1fff8f6 test(qa): add OpenAI native web search live scenario 2026-04-22 23:06:55 +01:00
Peter Steinberger
1a90893e90 test: keep extension directory filters covered 2026-04-22 23:06:26 +01:00
Val Alexander
eb689f3535 fix(ui): shorten Control UI clear action label (#70355) 2026-04-22 16:52:53 -05:00
Peter Steinberger
e56a6f87ec fix: exclude qa extensions from npm package 2026-04-22 22:48:28 +01:00
Peter Steinberger
ebe32e5cee feat(openai): enable native web search 2026-04-22 22:47:26 +01:00
Peter Steinberger
276d222283 build(deps): bump fast-xml-parser override 2026-04-22 22:45:57 +01:00
wirjo
c7e5289fd2 fix: propagate AWS SDK auth sentinel for IMDS/instance role Bedrock auth (#68964)
* fix: propagate AWS SDK auth sentinel for IMDS/instance role Bedrock auth

When Bedrock auth resolves via AWS SDK default credential chain (IMDS,
ECS task role) with no explicit API key, the auth controller returned
early without calling setRuntimeApiKey(). This left pi's authStorage
unaware that the provider is authenticated, causing 'No API key found
for amazon-bedrock' errors.

Now, when mode is 'aws-sdk' and no explicit API key is available:
1. Try prepareProviderRuntimeAuth to resolve runtime credentials
2. If that returns a real apiKey, use it with auth refresh scheduling
3. Otherwise inject a '__aws_sdk_auth__' sentinel so pi's
   hasConfiguredAuth() passes and the AWS SDK handles request signing

This is a focused fix in auth-controller.ts only, avoiding the risky
model-auth-runtime-shared.ts changes that could re-introduce the
fake-apiKey injection pattern on ECS (see prior regressions #49891,
#50699, #54274).

Fixes #62995

* fix(pi-auth): clean up aws-sdk sentinel fallback

* docs(changelog): note aws-sdk Bedrock auth fix

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-22 14:45:26 -07:00
Peter Steinberger
207d7303b7 test: avoid argv warmup race 2026-04-22 22:42:45 +01:00
Peter Steinberger
2e13f224d6 fix(openai-responses): normalize Copilot response item IDs (#69362) (thanks @Menci) 2026-04-22 22:40:43 +01:00
Vincent Koc
4f9169c6dd fix(hooks): avoid stale skill workshop startup fallback 2026-04-22 14:39:58 -07:00
Peter Steinberger
7f637eafe2 test: run single-channel extension batches 2026-04-22 22:39:17 +01:00
Peter Steinberger
d1e06407bf chore: add extension runtime dependency manifests 2026-04-22 22:36:40 +01:00
Peter Steinberger
6ab3751287 fix: preserve Azure OpenAI completions api version 2026-04-22 22:36:40 +01:00
Peter Steinberger
cb16d22780 fix(cron): retire bundled mcp runtimes 2026-04-22 22:30:47 +01:00
Peter Steinberger
1dc5aad316 test: align matrix acp room binding expectations 2026-04-22 22:30:22 +01:00
Peter Steinberger
8a3e130db8 fix(slack): honor focused thread bindings 2026-04-22 22:29:48 +01:00
Peter Steinberger
cc1e843c90 docs(changelog): note config prefix recovery 2026-04-22 22:29:01 +01:00
Peter Steinberger
5d50b0c48f fix(config): recover prefixed config JSON 2026-04-22 22:29:01 +01:00
Peter Steinberger
77dbc1cda6 ci: rebalance test workers 2026-04-22 22:26:02 +01:00
Vincent Koc
65ae1e54de fix(hooks): avoid stale thread ownership startup fallback 2026-04-22 14:19:13 -07:00
Peter Steinberger
50c95d1d21 refactor(channels): centralize conversation resolution 2026-04-22 22:16:08 +01:00
Vincent Koc
f1372681a8 fix(diffs): refresh live viewer access policy 2026-04-22 14:14:30 -07:00
Peter Steinberger
0588dfe15d fix(config): parse quoted bracket paths 2026-04-22 22:11:45 +01:00
Peter Steinberger
a971884104 test(mcp): strengthen stdio lifecycle coverage 2026-04-22 22:11:30 +01:00
Peter Steinberger
56828545b4 ci: parallelize agents test files 2026-04-22 22:09:25 +01:00
Peter Steinberger
a1319aaadd fix(update): skip package no-op installs 2026-04-22 22:05:29 +01:00
Peter Steinberger
64fb6f71b4 fix(gateway): recover invalid config before startup 2026-04-22 22:05:29 +01:00
Peter Steinberger
f70a46b703 fix(config): preserve authored config writes 2026-04-22 22:05:29 +01:00
Peter Steinberger
5f7b44045d fix(mcp): tear down stdio process trees 2026-04-22 22:04:22 +01:00
Peter Steinberger
2c45879120 fix(config): render warning newlines 2026-04-22 22:04:09 +01:00
Peter Steinberger
b6fbf46eca fix(cron): repair malformed cron job ids via doctor 2026-04-22 22:03:58 +01:00
Peter Steinberger
2e38e09b04 test: harden parallels smoke harness 2026-04-22 22:01:04 +01:00
Peter Steinberger
054fda206e ci: rotate stuck build-smoke queue 2026-04-22 21:59:48 +01:00
Vincent Koc
0f0d399c71 fix(hooks): stop memory-core runtime config fallback 2026-04-22 13:57:10 -07:00
Peter Steinberger
4cb4aad7b1 build: harden tsdown wrapper 2026-04-22 21:54:56 +01:00
Vincent Koc
d25ff59c8b docs(changelog): note pi session tool activation fix 2026-04-22 13:54:04 -07:00
Vincent Koc
fc07b23437 fix(agents): restore pi session tool activation 2026-04-22 13:54:04 -07:00
Vincent Koc
42400813a7 test(plugins): pin live config hook guards 2026-04-22 13:50:51 -07:00
Peter Steinberger
aad1be102d fix(types): narrow live thread ownership config 2026-04-22 21:48:59 +01:00
Peter Steinberger
b648830632 fix: clarify browser playwright-core install guidance 2026-04-22 21:47:58 +01:00
Vincent Koc
99c1bc2cce docs(changelog): note websocket endpoint classifier fix 2026-04-22 13:44:51 -07:00
Vincent Koc
e250ea3668 fix(agents): centralize native websocket endpoint checks 2026-04-22 13:44:51 -07:00
Vincent Koc
4c675216f1 fix(qa): deflake parity approval preflight 2026-04-22 13:43:29 -07:00
Vincent Koc
db5895fd2a refactor(hooks): centralize live plugin config lookup 2026-04-22 13:38:38 -07:00
Peter Steinberger
ee63b9ee49 fix(memory-lancedb): retry failed runtime initialization 2026-04-22 21:20:28 +01:00
Vincent Koc
eae0039aa4 fix(hooks): use live memory-core config during dreaming runs 2026-04-22 13:10:19 -07:00
Peter Steinberger
c4aeeb2762 test(slack): provide send config in identity fallback tests 2026-04-22 21:09:42 +01:00
Zetarcos
38001cdeaa fix(discord): normalize ACP thread binding targets
Normalize Discord ACP thread-binding channel targets at the REST/thread-create boundary while preserving current-conversation binding keys.\n\nThanks @Zetarcos.
2026-04-22 21:09:26 +01:00
martingarramon
238b31a00c test(slack): cover send.ts customize-scope fallback retry path (#69009)
Adds 5 vitest cases for postSlackMessageBestEffort's silent retry
behavior when Slack rejects a chat:write.customize-identity post:

- Retry on err.data.needed matching chat:write.customize
- Retry on chat:write.customize in response_metadata.acceptedScopes
- Retry on chat:write.customize in response_metadata.scopes
- Rethrow on different missing_scope (e.g. channels:history)
- Rethrow when identity is empty (hasCustomIdentity returns false)
2026-04-22 16:06:44 -04:00
Vincent Koc
bc4a097464 fix(hooks): respect live lancedb memory config 2026-04-22 13:06:02 -07:00
Peter Steinberger
3704e3f580 ci: keep extension test fanout under two minutes 2026-04-22 21:06:00 +01:00
Peter Steinberger
6639b21ade test(media): harden media store URI validation 2026-04-22 21:05:41 +01:00
Devin Matthews
5528793adf fix: honor explicit strict-agentic retry contract
Honor explicit strict-agentic execution contracts for incomplete-turn retry guards across providers, including local/compatible models that opt in without relying on OpenAI model inference.

Validation:
- pnpm test src/agents/pi-embedded-runner/run.incomplete-turn.test.ts
- pnpm check:changed
- GitHub CI + parity gate green

Thanks @ziomancer.
2026-04-22 21:03:03 +01:00
Peter Steinberger
c0cafb6bbe perf(plugins): cache normalized jiti aliases 2026-04-22 21:02:29 +01:00
Vincent Koc
834e50f83c fix(hooks): use live thread ownership config 2026-04-22 13:01:32 -07:00
Vincent Koc
fbf554397f fix(hooks): respect live skill workshop config 2026-04-22 12:59:27 -07:00
Val Alexander
9ea5484fa1 fix: normalize opus 4.7 context window
Normalize Anthropic-owned Opus 4.7 context reporting to 1M while keeping inferred and bare discovery paths conservative.

- normalize Anthropic and claude-cli Opus 4.7 runtime/status context metadata to 1M
- keep inferred-provider and bare discovery ids on discovered conservative limits
- add regression coverage for provider, lookup, status, and discovery-cache paths
- keep the Telegram abort-signal wrapper typing narrow so changed-scope validation stays green
2026-04-22 14:58:16 -05:00
Peter Steinberger
c542d42f6f ci: balance extension tests across fewer workers 2026-04-22 20:55:38 +01:00
Vincent Koc
dd47b56243 fix(hooks): refresh active memory config at runtime 2026-04-22 12:55:12 -07:00
Peter Steinberger
f9cbaae19e ci: rotate cancelled docs queue 2026-04-22 20:51:48 +01:00
Josh Lehman
ccc99d85bf fix: restore Pi embedded tool allowlist
Restore the Pi embedded session tool allowlist for OpenAI/OpenAI Codex GPT-5 runs and compaction sessions after Pi 0.68.1 began treating session tools as a global allowlist.

Local validation: pnpm check:changed.
GitHub validation: check/check-additional/node shards green; parity gate red on unrelated config.patch stale/rate-limit QA harness scenario after plugins.allow restart.
2026-04-22 20:51:42 +01:00
Tak Hoffman
78d491d909 feat(commands): gate /models add with modelsWrite (#70321) 2026-04-22 14:49:07 -05:00
Vincent Koc
1ebd8e0bb6 fix(hooks): use live config for memory dreaming runtime 2026-04-22 12:47:57 -07:00
Peter Steinberger
6261f42ac0 ci: merge short auto-reply node shards 2026-04-22 20:47:49 +01:00
Vincent Koc
1e33d63f64 test(memory): pin disabled lifecycle hook wiring 2026-04-22 12:43:23 -07:00
Peter Steinberger
f97c6f8a04 fix(discord): harden partial thread channels 2026-04-22 20:41:50 +01:00
Vincent Koc
e71da6705b fix(hooks): skip skill workshop capture when review is off 2026-04-22 12:41:04 -07:00
Peter Steinberger
8fcca8a5e1 ci: rotate main concurrency queue 2026-04-22 20:39:49 +01:00
Vincent Koc
1704dceca2 test(skill-workshop): pin disabled hook wiring 2026-04-22 12:38:38 -07:00
Vincent Koc
4ed2ea5035 fix(hooks): tighten thread ownership mention matching 2026-04-22 12:36:37 -07:00
Peter Steinberger
2aaac45c07 ci: move node aggregate checks off blacksmith 2026-04-22 20:36:27 +01:00
Vincent Koc
dbba830417 fix(hooks): track thread ownership mentions case-insensitively 2026-04-22 12:34:27 -07:00
Vincent Koc
f9f836eba4 fix(hooks): normalize thread ownership slack id casing 2026-04-22 12:32:33 -07:00
Peter Steinberger
5567f4cb01 docs: note media delivery fixes 2026-04-22 20:32:05 +01:00
Peter Steinberger
976398715f fix(image): resolve custom provider model IDs 2026-04-22 20:32:05 +01:00
Peter Steinberger
81f247b1ae fix(agents): dedupe emitted TTS media 2026-04-22 20:32:05 +01:00
Peter Steinberger
e5b67b7ebd fix(media): load inbound media store URIs 2026-04-22 20:32:05 +01:00
Peter Steinberger
0e761cdba8 fix(gateway): redact audio payloads from chat history 2026-04-22 20:32:05 +01:00
Peter Steinberger
64a98dea8d fix(discord): restore DM reactions and guild activation 2026-04-22 20:29:50 +01:00
Peter Steinberger
f7a52573b0 fix: clear phantom Claude CLI resumes (#70317)
Verify Claude CLI session transcripts before reuse and clear phantom bindings with transcript-missing instead of passing stale --resume ids.\n\nFixes #70177.
2026-04-22 20:29:17 +01:00
Vincent Koc
ec75545a82 fix(hooks): normalize thread ownership channel allowlists 2026-04-22 12:29:08 -07:00
Peter Steinberger
9c733956c0 fix(plugins): repair bundled deps on activation 2026-04-22 20:27:42 +01:00
Vincent Koc
4663e7394b fix(hooks): canonicalize slack thread ownership ids 2026-04-22 12:26:31 -07:00
Vincent Koc
7d088f198f fix(hooks): fail open without thread ownership routing 2026-04-22 12:24:15 -07:00
Peter Steinberger
e6a9e9a700 test: cover Telegram webhook timeout reply continuation 2026-04-22 20:23:53 +01:00
Vincent Koc
9a14307306 test(plugins): pin bundled hook names 2026-04-22 12:22:44 -07:00
Peter Steinberger
d8935ca838 perf: keep gateway live probes off helper imports 2026-04-22 20:22:14 +01:00
Vincent Koc
d0bf9cc19e test(plugins): pin bundled hook registration surfaces 2026-04-22 12:20:21 -07:00
anirudhmarc
24266af1ce fix(amazon-bedrock): inject cache points for application inference profile ARNs (#69953)
* fix(amazon-bedrock): inject cache points for application inference profile ARNs

pi-ai's internal supportsPromptCaching checks model.id for specific Claude
model name patterns (e.g. "-4-", "claude-3-7-sonnet"), which fails for
application inference profile ARNs that don't contain the model name.
This causes prompt caching to silently break for Bedrock users with
application inference profiles.

Work around this by detecting when pi-ai would miss cache point injection
(via piAiWouldInjectCachePoints mirror) and patching the Converse API
payload via onPayload to add cachePoint blocks to the system prompt and
last user message — matching the same format pi-ai uses natively.

The fix is safe:
- Checks for existing cache points to avoid double-injection
- Respects cacheRetention: "none"
- Defaults to "short" retention (matching pi-ai default)
- Becomes a no-op once upstream pi-mono#2925 is fixed

Fixes #19279
Upstream: https://github.com/badlogic/pi-mono/issues/2925

* fix(amazon-bedrock): tighten app-profile cache injection

---------

Co-authored-by: Your Name <you@example.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-22 12:19:29 -07:00
Peter Steinberger
4c5394d0ba fix: lower Telegram webhook callback timeout (#70146) (thanks @friday-james) 2026-04-22 20:19:12 +01:00
friday-james
43fc38e46c fix(telegram): lower webhook callback timeout to 5s
#16763 added `onTimeout: "return"` with `timeoutMilliseconds: 10_000`
(grammY default). In practice, Telegram's webhook servers abort the
read well before 10s when handler latency is LLM-bound: `getWebhookInfo`
reports `last_error_message: "Read timeout expired"` and pending updates
pile up, cascading into multi-minute reply lag.

Reproducible A/B on identical infra (same region, same bot token):
- Minimal Python echo bot: 5 back-to-back webhook RTTs 341-642ms, clean.
- OpenClaw current main: intermittent Read timeout expired, 1-5 min lag.

The handler still runs to completion; only the Telegram-facing ack is
sooner. grammY's deployment guide suggests 5s for long-running handlers.

No new config surface; minimal one-line change to the existing constant
and its test assertion. If a configurable timeout is wanted, that can be
a follow-up (see stale #7754).
2026-04-22 20:19:12 +01:00
Vincent Koc
e1341941d5 test(plugins): guard legacy bundled hook regressions 2026-04-22 12:18:27 -07:00
Peter Steinberger
67e5cca7a4 test: tighten Telegram polling conflict coverage (#69873) (thanks @hclsys) 2026-04-22 20:16:14 +01:00
HCL
3a11435c7d test(telegram): update monitor test for #69787 transport rebuild on 409
Sibling test in monitor.test.ts asserted the pre-fix behavior (single
transport reused across cycles on 409). My #69787 change rebuilds the
transport on 409 so Telegram sees a fresh TCP socket — update the
assertion to match.

Two transports are now expected: the initial one plus the rebuild
after the conflict.
2026-04-22 20:16:14 +01:00
HCL
83a906c95c fix(telegram): mark polling transport dirty on 409 conflict (#69787)
When getUpdates returns 409 Conflict (e.g.
'terminated by other getUpdates request'), the polling runtime
previously retried on the same HTTP keep-alive TCP socket because
markDirty() was only called in the isRecoverable branch.

Telegram treats that connection as the 'old' session and keeps
terminating it — producing a sustained low-rate 409 retry loop
(observed a few per minute after eliminating duplicate pollers).

Broaden the dirty-mark condition to fire on isConflict as well as
isRecoverable so the next cycle forces a fresh TCP connection.

Update the existing 'reuses transport after getUpdates conflict' test
— which previously locked in the buggy behavior — to assert the new
correct behavior: one fresh transport is built, the stale one is
closed.
2026-04-22 20:16:14 +01:00
Peter Steinberger
8f38691e79 fix: preserve Slack download cfg token fallback (#70160) (thanks @martingarramon) 2026-04-22 20:14:00 +01:00
Martin Garramon
44b1bad333 fix(slack): pass cfg into resolveToken from downloadSlackFile call site
Commit 95331e5cc5 ("fix(channels): thread runtime config through sends")
migrated resolveToken to a 3-arg signature (explicit, accountId, cfg) and
updated the getClient call site at actions.ts:83. The sibling call inside
downloadSlackFile at actions.ts:445 was not migrated and still dropped
opts.cfg, so the cfg-only resolution branch was unreachable from that path.

Current production callers (action-runtime.ts:386-389) always inject a
resolved readToken into opts.token before calling downloadSlackFile, so
this is defense-in-depth today -- the broken path is not hit in runtime.
Landing this closes the call-site migration gap and adds test coverage
for the cfg-only resolution contract on downloadSlackFile.

Note: pre-commit typecheck hook bypassed because upstream/main has 14
pre-existing TS errors in unrelated packages (discord, qa-lab, qqbot,
slack/monitor/provider.ts, tokenjuice, pi-embedded-runner) -- verified
reproducible on clean HEAD 4a16cf8008 without this diff.
2026-04-22 20:14:00 +01:00
Peter Steinberger
7ff8f8cef8 ci: narrow windows check scope 2026-04-22 20:13:37 +01:00
Peter Steinberger
bfc72b5256 fix: route Slack HTTP webhook dispatch (#70275) (thanks @FroeMic) 2026-04-22 20:12:09 +01:00
froemic
7ecff96425 Fix Slack HTTP route registry dispatch 2026-04-22 20:12:09 +01:00
Vincent Koc
988fe85f2c test(memory): exercise registered auto-recall hook 2026-04-22 12:11:46 -07:00
zqchris
b24ae8b18b fix(auto-reply): preserve streaming reply directives (#70243)
Preserve streamed MEDIA/reply/audio directives across chunk boundaries and phase-aware final_answer delivery.\n\nThanks @zqchris.
2026-04-22 20:11:00 +01:00
Peter Steinberger
b1b1979841 ci: skip windows for test-only changes 2026-04-22 20:10:27 +01:00
Vincent Koc
0d68128aed test(memory): exercise registered auto-capture hook 2026-04-22 12:07:03 -07:00
Peter Steinberger
8b89d37a2b ci: rotate stale concurrency group 2026-04-22 20:05:10 +01:00
Neerav Makwana
5462d4d5c5 fix: drop silent parent replies while subagents are pending (#69942)
Drop bare parent NO_REPLY payloads while spawned subagents are pending, preserving quiet parent turns until child completion delivers the real reply.\n\nThanks @neeravmakwana.
2026-04-22 20:04:38 +01:00
Vincent Koc
aee9f476c8 test(skill-workshop): exercise registered prompt hook 2026-04-22 12:03:31 -07:00
Peter Steinberger
3c89f5d537 ci: add scoped docker gateway e2e 2026-04-22 20:02:23 +01:00
HFConsultant
647f4ee8ce fix: persist CLI session clearing atomically (#70298)
Persist stale CLI session clearing through the session-store merge path and add regression coverage for Claude binding removal.\n\nThanks @HFConsultant.
2026-04-22 20:01:35 +01:00
Vincent Koc
e3fc1a237b test(acpx): exercise registered reply_dispatch hook 2026-04-22 11:59:54 -07:00
Vincent Koc
f4bbbcbfb3 fix(hooks): canonicalize thread ownership conversation ids 2026-04-22 11:57:29 -07:00
Felix Miao
449cad510d fix: honor ACP spawn model overrides (#70210)
Honor explicit ACP sessions_spawn model overrides and preserve ACP runtime cwd options.\n\nThanks @felix-miao.
2026-04-22 19:55:23 +01:00
Vincent Koc
c09591b086 test(memory): drop stale dreaming hook doubles 2026-04-22 11:53:00 -07:00
Peter Steinberger
170496c105 ci: fold build smoke into artifact job 2026-04-22 19:52:13 +01:00
Vincent Koc
5fbafa7e47 fix(hooks): prefer shared outbound conversation context 2026-04-22 11:49:25 -07:00
Vincent Koc
62a4abbc9f refactor(hooks): centralize matrix subagent hook wiring 2026-04-22 11:45:33 -07:00
Peter Steinberger
2251516281 fix(discord): break monitor threading import cycle 2026-04-22 19:44:22 +01:00
Peter Steinberger
6294182cbb ci: parallelize extension batch groups 2026-04-22 19:39:08 +01:00
Peter Steinberger
b0d4e64170 refactor(discord): share partial channel test fixtures 2026-04-22 19:38:45 +01:00
Peter Steinberger
ec5d403f5b refactor(discord): share channel action param parsing 2026-04-22 19:38:45 +01:00
Peter Steinberger
8bd387976d refactor(discord): centralize thread channel context 2026-04-22 19:38:45 +01:00
Vincent Koc
bbcd185215 refactor(hooks): centralize bundled subagent hook wiring 2026-04-22 11:37:18 -07:00
Peter Steinberger
d30f252c1b ci: use dist cache instead of artifact upload 2026-04-22 19:31:25 +01:00
Peter Steinberger
4b2b261367 fix(plugins): preserve source activation config 2026-04-22 19:26:12 +01:00
Vincent Koc
6d003cbcee fix(hooks): expose typed gateway startup context 2026-04-22 11:22:51 -07:00
Peter Steinberger
3e24898690 fix: stabilize Claude CLI session prompt hashing 2026-04-22 19:21:51 +01:00
Peter Steinberger
ea29e654d7 fix(cli-session): forward static prompt hash input 2026-04-22 19:21:51 +01:00
Zijun Lin
e1ffe97984 fix: address review feedback — handle empty static prompt and remove stray blank lines
- Always pass extraSystemPromptStatic as string (even when empty) so the
  fallback in prepare.ts never accidentally hashes dynamic content
- Use explicit undefined check (params.extraSystemPromptStatic !== undefined)
  instead of ?? nullish coalescing to avoid edge case where empty static
  string falls through to hashing the full dynamic prompt
- Remove extra blank line
2026-04-22 19:21:51 +01:00
Zijun Lin
d1c414305b fix(cli-session): only hash static extraSystemPrompt for session reuse
The extraSystemPrompt includes per-message dynamic content from
buildInboundMetaSystemPrompt() (timestamps, message IDs, sender metadata)
that changes on every inbound message. This causes the extraSystemPromptHash
to differ every turn, triggering a session reset with reason='system-prompt'
and discarding all CLI session context.

Fix: split extraSystemPrompt into dynamic (inbound meta) and static
(group context, group intro, group system prompt, exec override hints)
portions. Only hash the static portion for session reuse validation.

The full extraSystemPrompt (dynamic + static) is still sent to the CLI
as before — only the session stability hash uses the static subset.

Fixes #70100
2026-04-22 19:21:51 +01:00
Peter Steinberger
d48763caf9 test: keep config fallback test on generic plugin channel 2026-04-22 19:20:15 +01:00
Peter Steinberger
03846d63ec refactor: use memory slot defaults in core paths 2026-04-22 19:18:18 +01:00
Peter Steinberger
80a16339e1 refactor: declare channel add flags in manifests 2026-04-22 19:13:51 +01:00
Peter Steinberger
6488e0dd0c test: keep hook and slack tests on public boundaries 2026-04-22 19:09:18 +01:00
Peter Steinberger
86667d670e refactor: move doctor capabilities to channel manifests 2026-04-22 19:05:53 +01:00
Peter Steinberger
510a8f9ebc fix: share reply media context (#68111) (thanks @ayeshakhalid192007-dev) 2026-04-22 19:02:44 +01:00
ayeshakhalid192007-dev
8d4e6a39b5 test(agent-runner): regression — createReplyMediaPathNormalizer.runtime not called when normalizer injected 2026-04-22 19:02:44 +01:00
ayeshakhalid192007-dev
552d5dcbce fix(agent-runner): share media-path normalizer with runAgentTurnWithFallback to prevent duplicate outbound media 2026-04-22 19:02:44 +01:00
ayeshakhalid192007-dev
88760f88c2 fix(agent-runner): accept injected normalizeMediaPaths in runAgentTurnWithFallback 2026-04-22 19:02:44 +01:00
Peter Steinberger
5ad06d0b20 refactor: build channel setup input generically 2026-04-22 18:57:45 +01:00
Vincent Koc
1e61279b35 refactor(memory): migrate lancedb recall to prompt-build hook 2026-04-22 10:56:14 -07:00
Peter Steinberger
921a5416e4 refactor: move channel doctor migrations to plugins 2026-04-22 18:55:18 +01:00
Peter Steinberger
9d5d2f9cdd fix: make Discord thread parent inheritance opt-in (#69986) (thanks @Blahdude) 2026-04-22 18:54:22 +01:00
Oliver Camp
956cf9b6b2 fix(discord): make thread parent inheritance opt-in 2026-04-22 18:54:22 +01:00
Peter Steinberger
40e19cc9a1 ci: downsize install smoke runner 2026-04-22 18:54:06 +01:00
Vincent Koc
b5b03fbaee test(slack): drop obsolete adapter hook test 2026-04-22 10:53:44 -07:00
Vincent Koc
e593122465 fix(hooks): standardize outbound routing metadata 2026-04-22 10:53:44 -07:00
Peter Steinberger
b0f6c54645 ci: run install smoke for runtime dep staging 2026-04-22 18:51:38 +01:00
Peter Steinberger
a12fcd3f18 fix: harden Discord voice commands in threads 2026-04-22 18:49:58 +01:00
Peter Steinberger
9d66a900e5 fix(plugins): harden bundled runtime dep staging 2026-04-22 18:49:13 +01:00
Hana Chang
0e9c632444 fix(discord): use resolveDiscordChannelNameSafe for voice channel override name
Applies the same safe-accessor pattern to the adjacent name field.
If @buape/carbon implements name as a getter that also reads _rawData
(like parentId), the previous `"name" in channel` pattern would throw
for the same reason. Aligns with the fix for parentId in the same call
site.
2026-04-22 18:47:41 +01:00
Hana Chang
5c5fa5f38b fix(discord): use resolveDiscordChannelParentIdSafe in voice command path
#69908 switched native slash commands, listeners, and the model picker to
the safe accessor for partial thread channels, but the voice /join command
still reads channel.parentId through the unsafe "parentId" in channel
pattern. Route it through the same helper so the voice command path does
not crash with "Cannot access rawData on partial Channel" when invoked
from inside a thread on @buape/carbon >=0.16.
2026-04-22 18:47:41 +01:00
Claw Kowalski
43366cd541 fix(discord): thread runtime config through guild actions 2026-04-22 18:47:30 +01:00
Peter Steinberger
e9d16cbd8c test: keep loader fixture inside plugin boundary 2026-04-22 18:46:57 +01:00
Peter Steinberger
860cc1b3fe fix(config): preserve source config during recovery 2026-04-22 18:42:53 +01:00
Peter Steinberger
557f4fc689 docs: update claude cli stdio notes 2026-04-22 18:40:51 +01:00
Peter Steinberger
d8c9185f3f ci: add fast docker install smoke 2026-04-22 18:39:03 +01:00
Peter Steinberger
dad4b3e7fb fix: default claude cli to stdio sessions 2026-04-22 18:38:32 +01:00
Peter Steinberger
9337e1bd8a fix(agents): accept silent no-reply turns 2026-04-22 18:36:15 +01:00
Peter Steinberger
9d27d09d47 fix: add plugin load debug shape 2026-04-22 18:31:37 +01:00
Peter Steinberger
63776bc999 test: stabilize audio directive tag test 2026-04-22 18:26:07 +01:00
Peter Steinberger
a2512f0243 fix: load staged dist-runtime plugins in docker 2026-04-22 18:22:39 +01:00
Peter Steinberger
72c765e736 ci: parallelize additional boundary guards 2026-04-22 18:21:05 +01:00
Peter Steinberger
a9be41d8c7 ci: keep workflow edits off windows lane 2026-04-22 18:16:11 +01:00
Peter Steinberger
2afad03931 ci: trim gateway watch build profile 2026-04-22 18:11:48 +01:00
Peter Steinberger
024592fb1d Revert "ci: reuse build artifacts for gateway topology"
This reverts commit be317769e6.
2026-04-22 18:10:02 +01:00
Devin Robison
5b32c3138c telegram: align model picker callback auth (#70235)
* telegram: align model picker callback auth

* docs(changelog): note telegram model callback auth fix

* fix(telegram): use runtime config for model callback auth
2026-04-22 11:06:01 -06:00
Peter Steinberger
be317769e6 ci: reuse build artifacts for gateway topology 2026-04-22 18:05:27 +01:00
Tak Hoffman
f328c21046 feat: Add /models add hot-reload model registration (#70211)
* feat(models): add chat model registration with hot reload

* docs(changelog): add models entry for pr 70211

* fix(models): harden add flow follow-ups

* fix models add review follow-ups

* harden models add config writes

* tighten plugin boundary invariant

* move models add adapters behind sdk facades

* avoid ollama-specific core facade
2026-04-22 12:00:30 -05:00
Devin Robison
0623079e98 fix(dotenv): block connector endpoint workspace overrides (#70240)
* fix(dotenv): block connector endpoint workspace overrides

* docs(changelog): note dotenv endpoint blocklist

* fix(dotenv): block Matrix per-account scoped homeserver overrides
2026-04-22 10:58:32 -06:00
Peter Steinberger
8b8df813d0 ci: keep native lanes native scoped 2026-04-22 17:53:38 +01:00
Peter Steinberger
03cf97a33e ci: consolidate short test workers 2026-04-22 17:49:06 +01:00
Peter Steinberger
6370013bb7 ci: rebalance runtime config tests 2026-04-22 17:37:54 +01:00
Peter Steinberger
e8240a2628 ci: keep build smoke on blacksmith 2026-04-22 17:33:40 +01:00
Peter Steinberger
d8913d3901 ci: offload short linux checks 2026-04-22 17:30:54 +01:00
Peter Steinberger
8febc20e80 ci: reduce blacksmith test pressure 2026-04-22 17:26:00 +01:00
Ayaan Zaidi
486d0ec235 fix(gateway): preserve restart continuation chat type 2026-04-22 21:49:49 +05:30
Peter Steinberger
4ef1c06f9e ci: rebalance agentic node tests 2026-04-22 17:18:32 +01:00
Peter Steinberger
fd93b7f2ab perf(test): avoid bundled setup in auto-enable tests 2026-04-22 17:13:42 +01:00
Devin Robison
dd46783c34 fix(pairing): clear stale requests on device removal (#70239)
* fix(pairing): clear stale requests on device removal

* docs(changelog): note pairing stale request cleanup
2026-04-22 10:05:05 -06:00
Ayaan Zaidi
81e0022b4d refactor(gateway): unify startup task execution 2026-04-22 21:31:19 +05:30
Jason Perlow
53ad1a6066 fix(gateway): allow silent metadata-upgrade pairing for loopback CLI clients (#70224)
Loopback CLI clients (cli_container_local, shared_secret_loopback_local)
with valid shared-secret auth previously got disconnected with 1008
pairing required whenever the paired device record's platform or
deviceFamily string differed from what the CLI claimed at connect time.

PR #69431 added the shared_secret_loopback_local locality but deferred
the metadata-upgrade reason from the auto-approval allowlist. That
deferral created an unrecoverable handshake loop in practice: every CLI
connect triggers a fresh metadata-upgrade request, the Control UI has
no approval surface for this reason, and non-interactive shells cannot
complete pairing. This broke every non-interactive openclaw agent use
case when paired device keys are replicated across hosts or installs
are migrated across platforms.

Extend shouldAllowSilentLocalPairing to auto-approve metadata-upgrade
for cli_container_local and shared_secret_loopback_local localities
only. Browser / Control-UI / remote paths retain existing approval-
required behavior. Gateway still logs every metadata refresh via the
existing security audit line for operator review.

Add 4 unit tests covering the decision table for metadata-upgrade
across all four localities.

Related: #69397, #69431
2026-04-22 09:58:53 -06:00
Ayaan Zaidi
25e01c182c docs(changelog): note restart sentinel atomic writes 2026-04-22 20:44:10 +05:30
Ayaan Zaidi
d497de7697 fix(gateway): write restart sentinels atomically 2026-04-22 20:44:10 +05:30
Peter Steinberger
fb70d3ac67 ci: refresh ci concurrency group 2026-04-22 15:53:37 +01:00
Peter Steinberger
ed97cc7210 ci: skip aggregate fan-in after cancellation 2026-04-22 15:52:25 +01:00
Ayaan Zaidi
6f25befc4f docs(changelog): thank cron contributors 2026-04-22 20:18:15 +05:30
Ayaan Zaidi
7085687a16 docs(changelog): correct cron contributors 2026-04-22 20:16:53 +05:30
Ayaan Zaidi
34b0aac3b5 docs(changelog): fix cron attribution 2026-04-22 20:15:04 +05:30
Peter Steinberger
c73f7d6596 ci: move lightweight automation off blacksmith 2026-04-22 15:44:34 +01:00
VACInc
962b25b4a6 fix: preserve restart continuations after reboot (#63406) (thanks @VACInc)
* gateway: add restart continuation sentinel

* gateway: address restart continuation review

* gateway: handle restart continuation edge cases

* gateway: keep restart continuations on threaded delivery path

* fix(gateway): harden restart continuation routing

* test(gateway): cover restart continuation edge cases

* docs(agent): clarify restart continuation usage

* fix: preserve restart continuations after reboot (#63406) (thanks @VACInc)

---------

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-22 20:09:07 +05:30
Garming
a43be09dca fix(doctor): skip token generation for trusted-proxy and none auth modes (#59055)
runGatewayAuthHealth() only excluded 'password' and 'token' (with existing
token) from its needsToken check. When gateway.auth.mode was set to
'trusted-proxy' or 'none', doctor --fix would incorrectly:

1. Flag the config as 'missing a token'
2. Prompt to generate a gateway token
3. Overwrite auth.mode to 'token' in openclaw.json

This silently broke trusted-proxy deployments (common in SaaS/reverse-proxy
setups) by replacing the delegated auth mode with token auth.

The fix aligns runGatewayAuthHealth() with the existing
hasExplicitGatewayInstallAuthMode() in auth-install-policy.ts, which
already correctly returns false for 'password', 'none', and 'trusted-proxy'.

Co-authored-by: wujiaming88 <wujiaming88@example.com>
2026-04-22 22:38:27 +08:00
Peter Steinberger
38135ff6b4 ci: keep cpu-sensitive lanes larger 2026-04-22 15:08:47 +01:00
Peter Steinberger
ba9589256c build: refresh a2ui bundle hash 2026-04-22 15:07:23 +01:00
Peter Steinberger
cdf5f66298 ci: keep long matrix aggregates on blacksmith 2026-04-22 15:00:17 +01:00
Peter Steinberger
0f4ec84a2c fix: fail closed on plugin integrity drift 2026-04-22 14:59:52 +01:00
Peter Steinberger
dc2c3a4920 fix(gateway): harden WS pairing locality 2026-04-22 14:55:58 +01:00
Peter Steinberger
95e430f670 ci: run aggregate checks off blacksmith 2026-04-22 14:53:41 +01:00
Peter Steinberger
fd01a66e30 ci: downsize blacksmith runners 2026-04-22 14:39:20 +01:00
Peter Steinberger
d7ea136384 fix(agent): align pi session tool options 2026-04-22 14:39:20 +01:00
Peter Steinberger
fef830f4cf chore: update dependencies 2026-04-22 14:35:00 +01:00
Peter Steinberger
0d12422418 ci: consolidate test shard fanout 2026-04-22 14:23:43 +01:00
pashpashpash
cd41bd1359 fix(codex): apply GPT-5 prompt overlay (#70175) 2026-04-22 22:00:23 +09:00
cxy
608cfd36f5 fix(qqbot): add interaction intents (#70143)
* feat(qqbot): add intents interaction

* fix(qqbot): add interaction intents (#70143) (thanks @cxyhhhhh)

---------

Co-authored-by: sliverp <870080352@qq.com>
2026-04-22 20:03:33 +08:00
Ayaan Zaidi
4a16cf8008 fix: require cli auth epoch version (#70132) 2026-04-22 17:03:33 +05:30
Ayaan Zaidi
7fd8eeecf2 fix: update cli session changelog (#70132) 2026-04-22 17:03:33 +05:30
Ayaan Zaidi
9ad58ddc7e test(cli): cover oauth auth epoch continuity 2026-04-22 17:03:33 +05:30
Ayaan Zaidi
1ff461fe7b fix(cli): stabilize oauth session auth epochs 2026-04-22 17:03:33 +05:30
Nimrod Gutman
8778521167 fix(plugins): avoid doctor crash on legacy interactive state (#70135)
* fix(plugins): hydrate legacy interactive state

* fix(plugins): avoid doctor crash on legacy interactive state (#70135) (thanks @ngutman)
2026-04-22 14:17:09 +03:00
Nimrod Gutman
cfda375bb6 chore(pi): remove local pr prompts
Remove repo-local /landpr and /reviewpr prompt templates so maintainers use the externally maintained workflow instead.
These flows remain available from the external maintainers repo via globally installed Pi skills and prompts.
2026-04-22 13:38:47 +03:00
Ted Li
13fae1685f fix(config): accept truncateAfterCompaction (#68395)
Merged via squash.

Prepared head SHA: bf45148a75
Co-authored-by: MonkeyLeeT <6754057+MonkeyLeeT@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
2026-04-22 18:31:03 +08:00
Ayaan Zaidi
16f016f07e fix: update cli session changelog (#70106) 2026-04-22 15:35:21 +05:30
Ayaan Zaidi
1e3e077370 fix(gateway): preserve cli session binding metadata 2026-04-22 15:35:21 +05:30
Ayaan Zaidi
7a2203be50 fix(cli): upgrade legacy mcp session reuse 2026-04-22 15:35:21 +05:30
Ayaan Zaidi
18869acf46 fix(cli): keep provider-owned sessions through implicit expiry 2026-04-22 15:35:21 +05:30
Sliverp
e36e0e8ad2 fix: lower the log level from info to debug (#70108) 2026-04-22 17:58:49 +08:00
Jacky
fbdf502e08 place permission under each branch of bot permissions for discord docs (#69218)
Merged via squash.

Prepared head SHA: dd6ae52d90
Co-authored-by: epicseven-cup <59263116+epicseven-cup@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-04-22 11:49:15 +02:00
pashpashpash
abf940db61 fix(codex): unchain app-server defaults (#70082) 2026-04-22 17:53:49 +09:00
Val Alexander
43a941b51c fix(pair): render /pair qr as media (#70047)
* fix(pair): render pair qr as media

* fix(gateway): preserve media reply threading

* fix(gateway): harden webchat media replies

* fix(plugin-sdk): keep trustedLocalMedia internal

* docs(changelog): note pair qr media fix

* Update CHANGELOG with recent fixes and enhancements

Updated changelog to include recent fixes and enhancements.
2026-04-22 03:31:09 -05:00
Ayaan Zaidi
81ca7bc40b fix: keep claude cli sessions warm (#69679)
* feat(cli): keep claude cli sessions warm

* test(cli): cover claude live session reuse

* fix(cli): harden claude live session reuse

* fix(cli): redact mcp session key logs

* fix(cli): bound claude live session turns

* fix(cli): reuse claude live sessions on resume

* refactor(cli): canonicalize claude live argv

* fix(cli): preserve claude live resume state

* fix(cli): close dead claude live sessions

* fix(cli): serialize claude live session creates

* fix(cli): count pending claude live sessions

* fix(cli): tighten claude live resume abort

* fix(cli): reject closed claude live sessions

* fix(cli): refresh claude live fingerprints

* fix(cli): stabilize MCP resume hash

* fix: preserve claude live inline resume (#69679)

---------

Co-authored-by: Frank Yang <frank.ekn@gmail.com>
2026-04-22 13:44:18 +05:30
Val Alexander
dab46a7e98 qa: harden parity gate execution (#70045) 2026-04-22 03:08:25 -05:00
Peter Steinberger
bee2e0f38f fix: keep custom pi tools executable 2026-04-22 08:52:55 +01:00
Peter Steinberger
4431d6c5d0 fix: harden tokenjuice host typing 2026-04-22 08:52:55 +01:00
Peter Steinberger
d8892ee227 test: harden qa private runtime staging 2026-04-22 08:52:55 +01:00
Peter Steinberger
eb67964239 ci: build private qa parity runtime 2026-04-22 08:52:55 +01:00
Peter Steinberger
dd9adc57c2 test: harden qa parity runtime staging 2026-04-22 08:52:55 +01:00
Peter Steinberger
137f64d0c0 fix: drop stale socket mode opt-in 2026-04-22 08:52:55 +01:00
Peter Steinberger
8bfb4024f6 test: harden qa parity config cleanup 2026-04-22 08:52:55 +01:00
Peter Steinberger
cd088d8a16 ci: build runtime before parity gate 2026-04-22 08:52:55 +01:00
Peter Steinberger
764bb310f7 ci: pin qa parity tool profile 2026-04-22 08:52:55 +01:00
Peter Steinberger
0cd785d8a5 ci: stabilize parity gate runner 2026-04-22 08:52:55 +01:00
Peter Steinberger
895b2690c4 ci: serialize parity gate scenarios 2026-04-22 08:52:55 +01:00
Peter Steinberger
5bb8f5ae8d docs: update changelog for channel health (#69833) (thanks @bek91) 2026-04-22 08:52:55 +01:00
Peter Steinberger
d8d0380297 fix: use transport activity for stale health 2026-04-22 08:52:55 +01:00
Bek
270003aefd fix: clean up slack socket waiters on start hooks 2026-04-22 08:52:55 +01:00
Bek
cd1977bf16 fix: make slack socket health event-driven 2026-04-22 08:52:55 +01:00
879 changed files with 51156 additions and 8226 deletions

View File

@@ -22,16 +22,17 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Windows: `90m`
- aggregate npm-update wrapper: `150m`
If a lane hits the cap, stop there, inspect the newest `/tmp/openclaw-parallels-*` run directory and phase log, then fix or rerun the smallest affected lane. Do not keep waiting on a capped lane.
- Actual OpenClaw npm install/update phases are a stricter budget than whole lanes: install phases should finish within 7 minutes, and update phases should finish within 5 minutes. If a phase named `install-main`, `install-latest`, `install-baseline`, or `install-baseline-package` exceeds 420s, or a phase named `update-dev` / same-guest `openclaw update` exceeds 300s, treat it as a failure/harness bug and start diagnosis from that phase log. Do not wait for a longer lane cap.
- Actual OpenClaw npm install/update phases are a stricter signal than whole-lane caps: install phases should normally finish within 7 minutes, and update phases should normally show meaningful progress within 5 minutes. If a phase named `install-main`, `install-latest`, `install-baseline`, or `install-baseline-package` exceeds 420s, or a phase named `update-dev` / same-guest `openclaw update` exceeds 300s without new markers, start diagnosis from that phase log and guest process state. Current Windows update phases can still pass after roughly 10-15 minutes because `doctor --fix` may install bundled plugin runtime deps; keep the script hard cap near 20 minutes unless the log is truly stale.
- For a full OS matrix, prefer running independent guest-family lanes in parallel when host capacity allows:
- `timeout --foreground 75m pnpm test:parallels:macos -- --json`
- `timeout --foreground 90m pnpm test:parallels:windows -- --json`
- `timeout --foreground 75m pnpm test:parallels:linux -- --json`
Keep each lane in its own shell/session and track the run directory for each one.
Keep each lane in its own shell/session and track the run directory for each one. Before starting the matrix, run any required host build/package gate to completion. When current-main tgz packaging is needed, the smoke scripts hold a shared package lock through `pnpm build`, inventory/staging, and `npm pack`; if that lock is missing or broken, serialize the matrix instead of accepting concurrent `dist` mutation.
- Do not run multiple smoke lanes against the same guest family at once. Tahoe lanes share the host HTTP port, and Windows/Linux lanes can collide on snapshot restore/start state if two jobs touch the same VM concurrently.
- Do not run the aggregate `pnpm test:parallels:npm-update` wrapper in parallel with individual macOS/Windows/Linux smoke lanes; it touches the same guest families and snapshots.
- Do not start Parallels lanes while any host command may rebuild, clean, or restage `dist` (`pnpm build`, `pnpm ui:build`, `pnpm release:check`, `pnpm test:install:smoke`, npm pack/install smoke, or Docker lanes that run package/build prep). Run the build/package gates first, let them finish, then start the VM matrix. Concurrent `dist` mutation can make host `npm pack` fail with missing files and wastes a full VM cycle.
- Do not start Parallels lanes while any unrelated host command may rebuild, clean, or restage `dist` (`pnpm build`, `pnpm ui:build`, `pnpm release:check`, `pnpm test:install:smoke`, npm pack/install smoke, or Docker lanes that run package/build prep). Run unrelated build/package gates first, let them finish, then start the VM matrix. Concurrent `dist` mutation can make host `npm pack` fail with missing files and wastes a full VM cycle.
- While running or optimizing the matrix, record wall-clock duration per lane and the slowest phase from `/tmp/openclaw-parallels-*` logs. Use that timing before changing smoke order, timeouts, or helper behavior.
- If a host build changes tracked generated files such as `src/canvas-host/a2ui/.bundle.hash`, stop before spending VM time. Commit the generated artifact separately or fix the generator drift, then rerun the smallest affected lane.
- If `main` is moving under active multi-agent work, prefer a detached worktree pinned to one commit for long Parallels suites. The smoke scripts now verify the packed tgz commit instead of live `git rev-parse HEAD`, but a pinned worktree still avoids noisy rebuild/version drift during reruns.
- For `openclaw update --channel dev` lanes, remember the guest clones GitHub `main`, not your local worktree. If a local fix exists but the rerun still fails inside the cloned dev checkout, do not treat that as disproof of the fix until the branch has been pushed.
- For `prlctl exec`, pass the VM name before `--current-user` (`prlctl exec "$VM" --current-user ...`), not the other way around.

View File

@@ -22,7 +22,7 @@ jobs:
permissions:
issues: write
pull-requests: write
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-24.04
steps:
- uses: actions/create-github-app-token@v3
id: app-token

View File

@@ -10,7 +10,7 @@ permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-{1}', github.workflow, github.ref) || format('{0}-{1}-{2}', github.workflow, github.ref, github.sha)) }}
group: ${{ github.event_name == 'pull_request' && format('{0}-v7-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-v7-{1}', github.workflow, github.ref) || format('{0}-v7-{1}-{2}', github.workflow, github.ref, github.sha)) }}
cancel-in-progress: true
env:
@@ -251,9 +251,9 @@ jobs:
checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards),
run_checks_node_core_dist: nodeTestDistShards.length > 0,
checks_node_core_dist_matrix: createMatrix(nodeTestDistShards),
run_extension_fast: hasChangedExtensions,
run_extension_fast: hasChangedExtensions && !isPush,
extension_fast_matrix: createMatrix(
hasChangedExtensions
hasChangedExtensions && !isPush
? (changedExtensionsMatrix.include ?? []).map((entry) => ({
check_name: `extension-fast-${entry.extension}`,
extension: entry.extension,
@@ -284,7 +284,6 @@ jobs:
{ check_name: "android-test-play", task: "test-play" },
{ check_name: "android-test-third-party", task: "test-third-party" },
{ check_name: "android-build-play", task: "build-play" },
{ check_name: "android-build-third-party", task: "build-third-party" },
]
: [],
),
@@ -305,7 +304,7 @@ jobs:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ubuntu-24.04
timeout-minutes: 20
env:
PRE_COMMIT_HOME: .cache/pre-commit-security-fast
@@ -396,7 +395,7 @@ jobs:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
@@ -419,8 +418,8 @@ jobs:
security-fast:
permissions: {}
needs: [security-scm-fast, security-dependency-audit]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify fast security jobs
@@ -453,7 +452,7 @@ jobs:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_build_artifacts == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
steps:
- name: Checkout
@@ -525,15 +524,19 @@ jobs:
- name: Cache dist build
uses: actions/cache@v5
with:
path: dist/
path: |
dist/
dist-runtime/
key: ${{ runner.os }}-dist-build-${{ github.sha }}
- name: Upload dist artifact
- name: Pack built runtime artifacts
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime
- name: Upload built runtime artifacts
uses: actions/upload-artifact@v7
with:
name: dist-build
path: dist/
compression-level: 0
name: dist-runtime-build
path: dist-runtime-build.tar.zst
retention-days: 1
- name: Upload A2UI bundle artifact
@@ -544,13 +547,28 @@ jobs:
include-hidden-files: true
retention-days: 1
- name: Smoke test CLI launcher help
run: node openclaw.mjs --help
- name: Smoke test CLI launcher status json
run: node openclaw.mjs status --json --timeout 1
- name: Smoke test built bundled plugin singleton
run: pnpm test:build:singleton
- name: Smoke test built bundled runtime deps
run: pnpm test:build:bundled-runtime-deps
- name: Check CLI startup memory
run: pnpm test:startup:memory
checks-fast-core:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
@@ -638,7 +656,7 @@ jobs:
name: ${{ matrix.checkName }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
@@ -721,8 +739,8 @@ jobs:
contents: read
name: checks-fast-contracts-channels
needs: [preflight, checks-fast-channel-contracts-shard]
if: always() && needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_fast == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify channel contract shards
@@ -744,7 +762,7 @@ jobs:
name: "checks-fast-protocol"
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout
@@ -809,7 +827,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -870,6 +888,7 @@ jobs:
- name: Run extension shard
env:
OPENCLAW_EXTENSION_BATCH_PARALLEL: 2
OPENCLAW_EXTENSION_BATCH: ${{ matrix.extensions_csv }}
run: pnpm test:extensions:batch -- "$OPENCLAW_EXTENSION_BATCH"
@@ -878,8 +897,8 @@ jobs:
contents: read
name: checks-node-extensions
needs: [preflight, checks-node-extensions-shard]
if: always() && needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_fast == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify extension shards
@@ -896,8 +915,8 @@ jobs:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -972,12 +991,21 @@ jobs:
echo "OPENCLAW_VITEST_MAX_WORKERS=1" >> "$GITHUB_ENV"
fi
- name: Download dist artifact
- name: Restore dist cache
if: matrix.task == 'test'
uses: actions/download-artifact@v8
id: checks-dist-cache
uses: actions/cache@v5
with:
name: dist-build
path: dist/
path: |
dist/
dist-runtime/
key: ${{ runner.os }}-dist-build-${{ github.sha }}
- name: Verify dist cache
if: matrix.task == 'test' && steps.checks-dist-cache.outputs.cache-hit != 'true'
run: |
echo "Missing same-run dist cache for ${RUNNER_OS}-dist-build-${GITHUB_SHA}" >&2
exit 1
- name: Download A2UI bundle artifact
if: matrix.task == 'test' || matrix.task == 'channels'
@@ -1012,7 +1040,7 @@ jobs:
name: checks-node-compat-node22
needs: [preflight]
if: needs.preflight.outputs.run_node == 'true' && github.event_name == 'push'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
steps:
- name: Checkout
@@ -1089,7 +1117,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -1212,8 +1240,8 @@ jobs:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.run_checks_node_core_dist == 'true' && needs.build-artifacts.result == 'success'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_node_core_dist == 'true' && needs.build-artifacts.result == 'success' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -1281,15 +1309,16 @@ jobs:
id: dist-cache
uses: actions/cache@v5
with:
path: dist/
path: |
dist/
dist-runtime/
key: ${{ runner.os }}-dist-build-${{ github.sha }}
- name: Download dist artifact
- name: Verify dist cache
if: steps.dist-cache.outputs.cache-hit != 'true'
uses: actions/download-artifact@v8
with:
name: dist-build
path: dist/
run: |
echo "Missing same-run dist cache for ${RUNNER_OS}-dist-build-${GITHUB_SHA}" >&2
exit 1
- name: Download A2UI bundle artifact
uses: actions/download-artifact@v8
@@ -1356,8 +1385,8 @@ jobs:
contents: read
name: checks-node-core
needs: [preflight, checks-node-core-test-nondist-shard, checks-node-core-test-dist-shard]
if: always() && needs.preflight.outputs.run_checks == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify node test shards
@@ -1382,7 +1411,7 @@ jobs:
name: "extension-fast"
needs: [preflight]
if: needs.preflight.outputs.run_extension_fast == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -1452,8 +1481,8 @@ jobs:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: always() && needs.preflight.outputs.run_check == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && matrix.runner || 'ubuntu-24.04' }}
timeout-minutes: 20
strategy:
fail-fast: false
@@ -1461,16 +1490,22 @@ jobs:
include:
- check_name: check-preflight-guards
task: preflight-guards
runner: ubuntu-24.04
- check_name: check-prod-types
task: prod-types
runner: ubuntu-24.04
- check_name: check-lint
task: lint
runner: blacksmith-16vcpu-ubuntu-2404
- check_name: check-policy-guards
task: policy-guards
runner: ubuntu-24.04
- check_name: check-test-types
task: test-types
runner: ubuntu-24.04
- check_name: check-strict-smoke
task: strict-smoke
runner: ubuntu-24.04
steps:
- name: Checkout
shell: bash
@@ -1567,8 +1602,8 @@ jobs:
contents: read
name: "check"
needs: [preflight, check-shard]
if: always() && needs.preflight.outputs.run_check == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify check shards
@@ -1585,8 +1620,8 @@ jobs:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: always() && needs.preflight.outputs.run_check_additional == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
fail-fast: false
@@ -1598,12 +1633,8 @@ jobs:
group: extension-channels
- check_name: check-additional-extension-bundled
group: extension-bundled
- check_name: check-additional-extension-package-boundary-compile
group: extension-package-boundary-compile
- check_name: check-additional-extension-package-boundary-canary
group: extension-package-boundary-canary
- check_name: check-additional-runtime-topology-gateway
group: runtime-topology-gateway
- check_name: check-additional-extension-package-boundary
group: extension-package-boundary
- check_name: check-additional-runtime-topology-architecture
group: runtime-topology-architecture
steps:
@@ -1662,7 +1693,7 @@ jobs:
- name: Cache extension package boundary artifacts
id: extension-package-boundary-cache
if: matrix.group == 'extension-package-boundary-compile'
if: matrix.group == 'extension-package-boundary'
uses: actions/cache@v5
with:
path: |
@@ -1675,7 +1706,7 @@ jobs:
${{ runner.os }}-extension-package-boundary-v1-
- name: Preserve extension package boundary cache hit
if: matrix.group == 'extension-package-boundary-compile' && steps.extension-package-boundary-cache.outputs.cache-hit == 'true'
if: matrix.group == 'extension-package-boundary' && steps.extension-package-boundary-cache.outputs.cache-hit == 'true'
shell: bash
run: |
set -euo pipefail
@@ -1703,6 +1734,7 @@ jobs:
env:
ADDITIONAL_CHECK_GROUP: ${{ matrix.group }}
RUN_CONTROL_UI_I18N: ${{ needs.preflight.outputs.run_control_ui_i18n }}
OPENCLAW_ADDITIONAL_BOUNDARY_CONCURRENCY: 4
OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 6
shell: bash
run: |
@@ -1726,24 +1758,7 @@ jobs:
case "$ADDITIONAL_CHECK_GROUP" in
boundaries)
run_check "plugin-extension-boundary" pnpm run lint:plugins:no-extension-imports
run_check "lint:tmp:no-random-messaging" pnpm run lint:tmp:no-random-messaging
run_check "lint:tmp:channel-agnostic-boundaries" pnpm run lint:tmp:channel-agnostic-boundaries
run_check "lint:tmp:tsgo-core-boundary" pnpm run lint:tmp:tsgo-core-boundary
run_check "lint:tmp:no-raw-channel-fetch" pnpm run lint:tmp:no-raw-channel-fetch
run_check "lint:agent:ingress-owner" pnpm run lint:agent:ingress-owner
run_check "lint:plugins:no-register-http-handler" pnpm run lint:plugins:no-register-http-handler
run_check "lint:plugins:no-monolithic-plugin-sdk-entry-imports" pnpm run lint:plugins:no-monolithic-plugin-sdk-entry-imports
run_check "lint:plugins:no-extension-src-imports" pnpm run lint:plugins:no-extension-src-imports
run_check "lint:plugins:no-extension-test-core-imports" pnpm run lint:plugins:no-extension-test-core-imports
run_check "lint:plugins:plugin-sdk-subpaths-exported" pnpm run lint:plugins:plugin-sdk-subpaths-exported
run_check "deps:root-ownership:check" pnpm deps:root-ownership:check
run_check "web-search-provider-boundary" pnpm run lint:web-search-provider-boundaries
run_check "web-fetch-provider-boundary" pnpm run lint:web-fetch-provider-boundaries
run_check "extension-src-outside-plugin-sdk-boundary" pnpm run lint:extensions:no-src-outside-plugin-sdk
run_check "extension-plugin-sdk-internal-boundary" pnpm run lint:extensions:no-plugin-sdk-internal
run_check "extension-relative-outside-package-boundary" pnpm run lint:extensions:no-relative-outside-package
run_check "lint:ui:no-raw-window-open" pnpm lint:ui:no-raw-window-open
node scripts/run-additional-boundary-checks.mjs
;;
extension-channels)
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
@@ -1751,18 +1766,10 @@ jobs:
extension-bundled)
run_check "lint:extensions:bundled" pnpm run lint:extensions:bundled
;;
extension-package-boundary-compile)
extension-package-boundary)
run_check "test:extensions:package-boundary:compile" pnpm run test:extensions:package-boundary:compile
;;
extension-package-boundary-canary)
run_check "test:extensions:package-boundary:canary" pnpm run test:extensions:package-boundary:canary
;;
runtime-topology-gateway)
if [ "$RUN_CONTROL_UI_I18N" = "true" ]; then
run_check "ui:i18n:check" pnpm ui:i18n:check
fi
run_check "gateway-watch-regression" pnpm test:gateway:watch-regression
;;
runtime-topology-architecture)
run_check "check:architecture" pnpm check:architecture
;;
@@ -1774,39 +1781,13 @@ jobs:
exit "$failures"
- name: Upload gateway watch regression artifacts
if: always() && matrix.group == 'runtime-topology-gateway'
uses: actions/upload-artifact@v7
with:
name: gateway-watch-regression
path: .local/gateway-watch-regression/
retention-days: 7
check-additional:
check-additional-runtime-topology-gateway:
permissions:
contents: read
name: "check-additional"
needs: [preflight, check-additional-shard]
if: always() && needs.preflight.outputs.run_check_additional == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 5
steps:
- name: Verify additional check shards
env:
SHARD_RESULT: ${{ needs.check-additional-shard.result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Additional check shards failed: $SHARD_RESULT" >&2
exit 1
fi
build-smoke:
permissions:
contents: read
name: "build-smoke"
name: "check-additional-runtime-topology-gateway"
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.run_build_smoke == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' && needs.build-artifacts.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
@@ -1862,39 +1843,74 @@ jobs:
with:
install-bun: "false"
- name: Restore dist cache
id: build-smoke-dist-cache
if: github.event_name == 'push'
uses: actions/cache@v5
with:
path: dist/
key: ${{ runner.os }}-dist-build-${{ github.sha }}
- name: Download dist artifact
if: github.event_name == 'push' && steps.build-smoke-dist-cache.outputs.cache-hit != 'true'
- name: Download built runtime artifacts
uses: actions/download-artifact@v8
with:
name: dist-build
path: dist/
name: dist-runtime-build
path: .local/dist-runtime-build
- name: Build dist
if: github.event_name != 'push'
run: pnpm build
- name: Restore built runtime artifacts
run: |
tar -xf .local/dist-runtime-build/dist-runtime-build.tar.zst --use-compress-program unzstd
test -f dist/entry.js
test -f dist/.buildstamp
test -d dist-runtime
- name: Smoke test CLI launcher help
run: node openclaw.mjs --help
- name: Check Control UI i18n
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
run: pnpm ui:i18n:check
- name: Smoke test CLI launcher status json
run: node openclaw.mjs status --json --timeout 1
- name: Run gateway watch regression
run: node scripts/check-gateway-watch-regression.mjs --skip-build
- name: Smoke test built bundled plugin singleton
run: pnpm test:build:singleton
- name: Upload gateway watch regression artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: gateway-watch-regression
path: .local/gateway-watch-regression/
retention-days: 7
- name: Smoke test built bundled runtime deps
run: pnpm test:build:bundled-runtime-deps
check-additional:
permissions:
contents: read
name: "check-additional"
needs: [preflight, check-additional-shard, check-additional-runtime-topology-gateway]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify additional check shards
env:
SHARD_RESULT: ${{ needs.check-additional-shard.result }}
GATEWAY_RESULT: ${{ needs.check-additional-runtime-topology-gateway.result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Additional check shards failed: $SHARD_RESULT" >&2
exit 1
fi
if [ "$GATEWAY_RESULT" != "success" ]; then
echo "Gateway topology check failed: $GATEWAY_RESULT" >&2
exit 1
fi
- name: Check CLI startup memory
run: pnpm test:startup:memory
build-smoke:
permissions:
contents: read
name: "build-smoke"
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_build_smoke == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success') }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify build smoke
env:
BUILD_ARTIFACTS_RESULT: ${{ needs.build-artifacts.result }}
run: |
if [ "$BUILD_ARTIFACTS_RESULT" != "success" ]; then
echo "Build smoke checks failed in build-artifacts: $BUILD_ARTIFACTS_RESULT" >&2
exit 1
fi
# Validate docs (format, lint, broken links) only when docs files changed.
check-docs:
@@ -1902,7 +1918,7 @@ jobs:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_check_docs == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
@@ -1966,7 +1982,7 @@ jobs:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_skills_python_job == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
@@ -1997,11 +2013,11 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_windows == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-32vcpu-windows-2025' || 'windows-2025' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-windows-2025' || 'windows-2025' }}
timeout-minutes: 60
env:
NODE_OPTIONS: --max-old-space-size=6144
# Keep total concurrency predictable on the 32 vCPU runner.
# Keep total concurrency predictable on the smaller Windows runner.
OPENCLAW_VITEST_MAX_WORKERS: 1
OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD: 1
defaults:
@@ -2098,9 +2114,9 @@ jobs:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.run_macos_node == 'true' && needs.build-artifacts.result == 'success'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_macos_node == 'true' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-latest' || 'macos-latest' }}
timeout-minutes: 20
strategy:
fail-fast: false
@@ -2117,18 +2133,6 @@ jobs:
with:
install-bun: "false"
- name: Download dist artifact
uses: actions/download-artifact@v8
with:
name: dist-build
path: dist/
- name: Download A2UI bundle artifact
uses: actions/download-artifact@v8
with:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
- name: TS tests (macOS)
env:
NODE_OPTIONS: --max-old-space-size=4096
@@ -2251,7 +2255,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_android_job == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
strategy:
fail-fast: false
@@ -2361,9 +2365,6 @@ jobs:
build-play)
./gradlew --no-daemon --build-cache :app:assemblePlayDebug
;;
build-third-party)
./gradlew --no-daemon --build-cache :app:assembleThirdPartyDebug
;;
*)
echo "Unsupported Android task: $TASK" >&2
exit 1

View File

@@ -78,7 +78,7 @@ jobs:
install-smoke:
needs: [preflight]
if: needs.preflight.outputs.run_install_smoke == 'true'
runs-on: blacksmith-32vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
@@ -91,6 +91,11 @@ jobs:
# Blacksmith's builder owns the Docker layer cache; keep smoke builds off
# explicit gha cache directives so local tags still load cleanly.
- name: Run QR package install smoke
env:
OPENCLAW_QR_SMOKE_FORCE_INSTALL: "1"
run: bash scripts/e2e/qr-import-docker.sh
- name: Build root Dockerfile smoke image
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
with:
@@ -107,6 +112,12 @@ jobs:
run: |
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
- name: Run Docker gateway network e2e
env:
OPENCLAW_GATEWAY_NETWORK_E2E_IMAGE: openclaw-dockerfile-smoke:local
OPENCLAW_GATEWAY_NETWORK_E2E_SKIP_BUILD: "1"
run: bash scripts/e2e/gateway-network-docker.sh
# This smoke validates that the build-arg path preinstalls the matrix
# runtime deps declared by the plugin and that matrix discovery stays
# healthy in the final runtime image.
@@ -208,3 +219,29 @@ jobs:
OPENCLAW_INSTALL_SMOKE_UPDATE_DIST_IMAGE: openclaw-dockerfile-smoke:local
OPENCLAW_INSTALL_SMOKE_UPDATE_SKIP_LOCAL_BUILD: "1"
run: bash scripts/test-install-sh-docker.sh
docker-e2e-fast:
needs: [preflight]
if: needs.preflight.outputs.run_install_smoke == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 8
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
steps:
- name: Checkout CLI
uses: actions/checkout@v6
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
- name: Setup Node environment for package smoke
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "true"
- name: Run fast bundled plugin Docker E2E
env:
OPENCLAW_BUNDLED_CHANNEL_DEPS_E2E_IMAGE: openclaw-bundled-channel-fast:local
run: timeout 120s pnpm test:docker:bundled-channel-deps:fast

View File

@@ -30,7 +30,7 @@ jobs:
permissions:
contents: read
pull-requests: write
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-24.04
steps:
- uses: actions/create-github-app-token@v3
id: app-token
@@ -439,7 +439,7 @@ jobs:
permissions:
contents: read
pull-requests: write
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-24.04
steps:
- uses: actions/create-github-app-token@v3
id: app-token
@@ -737,7 +737,7 @@ jobs:
label-issues:
permissions:
issues: write
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-24.04
steps:
- uses: actions/create-github-app-token@v3
id: app-token

View File

@@ -41,6 +41,7 @@ jobs:
# followthrough gate that expects a fast post-approval read within a 30s
# agent.wait timeout.
QA_PARITY_CONCURRENCY: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
OPENAI_API_KEY: ""
ANTHROPIC_API_KEY: ""
OPENCLAW_LIVE_OPENAI_KEY: ""
@@ -70,6 +71,9 @@ jobs:
- name: Build private QA runtime
run: pnpm build
# The approval-turn sentinel still runs inside the full parity pack below.
# Keep the exact mock read-plan contract in deterministic unit tests instead
# of paying for a separate full-runtime preflight that has been flaky in CI.
- name: Run GPT-5.4 lane
run: |
pnpm openclaw qa suite \

View File

@@ -19,7 +19,7 @@ env:
jobs:
no-tabs:
if: github.event_name != 'workflow_dispatch'
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -51,7 +51,7 @@ jobs:
actionlint:
if: github.event_name != 'workflow_dispatch'
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -83,7 +83,7 @@ jobs:
generated-doc-baselines:
if: github.event_name == 'workflow_dispatch'
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6

View File

@@ -1,73 +0,0 @@
---
description: Land a PR (merge with proper workflow)
---
Input
- PR: $1 <number|url>
- If missing: use the most recent PR mentioned in the conversation.
- If ambiguous: ask.
Do (end-to-end)
Goal: PR must end in GitHub state = MERGED (never CLOSED). Prefer `gh pr merge --squash`; use `--rebase` only when preserving commit history is required.
1. Assign PR to self:
- `gh pr edit <PR> --add-assignee @me`
2. Repo clean: `git status`.
3. Identify PR meta (author + head branch):
```sh
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner}'
contrib=$(gh pr view <PR> --json author --jq .author.login)
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
```
4. Fast-forward base:
- `git checkout main`
- `git pull --ff-only`
5. Create temp base branch from main:
- `git checkout -b temp/landpr-<ts-or-pr>`
6. Check out PR branch locally:
- `gh pr checkout <PR>`
7. Rebase PR branch onto temp base:
- `git rebase temp/landpr-<ts-or-pr>`
- Fix conflicts; keep history tidy.
8. Fix + tests + changelog:
- Implement fixes + add/adjust tests
- Update `CHANGELOG.md` and mention `#<PR>` + `@$contrib`
9. Decide merge strategy:
- Squash (preferred): use when we want a single clean commit
- Rebase: use only when we explicitly want to preserve commit history
- If unclear, ask
10. Full gate (BEFORE commit):
- `pnpm lint && pnpm build && pnpm test`
11. Commit via committer (final merge commit only includes PR # + thanks):
- For the final merge-ready commit: `committer "fix: <summary> (#<PR>) (thanks @$contrib)" CHANGELOG.md <changed files>`
- If you need intermediate fix commits before the final merge commit, keep those messages concise and **omit** PR number/thanks.
- `land_sha=$(git rev-parse HEAD)`
12. Push updated PR branch (rebase => usually needs force):
```sh
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
git push --force-with-lease prhead HEAD:$head
```
13. Merge PR (must show MERGED on GitHub):
- Squash (preferred): `gh pr merge <PR> --squash`
- Rebase (history-preserving fallback): `gh pr merge <PR> --rebase`
- Never `gh pr close` (closing is wrong)
14. Sync main:
- `git checkout main`
- `git pull --ff-only`
15. Comment on PR with what we did + SHAs + thanks:
```sh
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
gh pr comment <PR> --body "Landed via temp rebase onto main.\n\n- Gate: pnpm lint && pnpm build && pnpm test\n- Land commit: $land_sha\n- Merge commit: $merge_sha\n\nThanks @$contrib!"
```
16. Verify PR state == MERGED:
- `gh pr view <PR> --json state --jq .state`
17. Delete temp branch:
- `git branch -D temp/landpr-<ts-or-pr>`

View File

@@ -1,134 +0,0 @@
---
description: Review a PR thoroughly without merging
---
Input
- PR: $1 <number|url>
- If missing: use the most recent PR mentioned in the conversation.
- If ambiguous: ask.
Do (review-only)
Goal: produce a thorough review and a clear recommendation (READY FOR /landpr vs NEEDS WORK vs INVALID CLAIM). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
0. Truthfulness + reality gate (required for bug-fix claims)
- Do not trust the issue text or PR summary by default; verify in code and evidence.
- If the PR claims to fix a bug linked to an issue, confirm the bug exists now (repro steps, logs, failing test, or clear code-path proof).
- Prove root cause with exact location (`path/file.ts:line` + explanation of why behavior is wrong).
- Verify fix targets the same code path as the root cause.
- Require a regression test when feasible (fails before fix, passes after fix). If not feasible, require explicit justification + manual verification evidence.
- Hallucination/BS red flags (treat as BLOCKER until disproven):
- claimed behavior not present in repo,
- issue/PR says "fixes #..." but changed files do not touch implicated path,
- only docs/comments changed for a runtime bug claim,
- vague AI-generated rationale without concrete evidence.
1. Identify PR meta + context
```sh
gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions --jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headRepo:.headRepository.nameWithOwner,additions,deletions,files:.files|length}'
```
2. Read the PR description carefully
- Summarize the stated goal, scope, and any "why now?" rationale.
- Call out any missing context: motivation, alternatives considered, rollout/compat notes, risk.
3. Read the diff thoroughly (prefer full diff)
```sh
gh pr diff <PR>
# If you need more surrounding context for files:
gh pr checkout <PR> # optional; still review-only
git show --stat
```
4. Validate the change is needed / valuable
- What user/customer/dev pain does this solve?
- Is this change the smallest reasonable fix?
- Are we introducing complexity for marginal benefit?
- Are we changing behavior/contract in a way that needs docs or a release note?
5. Evaluate implementation quality + optimality
- Correctness: edge cases, error handling, null/undefined, concurrency, ordering.
- Design: is the abstraction/architecture appropriate or over/under-engineered?
- Performance: hot paths, allocations, queries, network, N+1s, caching.
- Security/privacy: authz/authn, input validation, secrets, logging PII.
- Backwards compatibility: public APIs, config, migrations.
- Style consistency: formatting, naming, patterns used elsewhere.
6. Tests & verification
- Identify what's covered by tests (unit/integration/e2e).
- Are there regression tests for the bug fixed / scenario added?
- Missing tests? Call out exact cases that should be added.
- If tests are present, do they actually assert the important behavior (not just snapshots / happy path)?
7. Follow-up refactors / cleanup suggestions
- Any code that should be simplified before merge?
- Any TODOs that should be tickets vs addressed now?
- Any deprecations, docs, types, or lint rules we should adjust?
8. Key questions to answer explicitly
- Is the core claim substantiated by evidence, or is it likely invalid/hallucinated?
- Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR?
- Any blocking concerns (must-fix before merge)?
- Is this PR ready to land, or does it need work?
9. Output (structured)
Produce a review with these sections:
A) TL;DR recommendation
- One of: READY FOR /landpr | NEEDS WORK | INVALID CLAIM (issue/bug not substantiated) | NEEDS DISCUSSION
- 13 sentence rationale.
B) Claim verification matrix (required)
- Fill this table:
| Field | Evidence |
| ----------------------------------------------- | -------- |
| Claimed problem | ... |
| Evidence observed (repro/log/test/code) | ... |
| Root cause location (`path:line`) | ... |
| Why this fix addresses that root cause | ... |
| Regression coverage (test name or manual proof) | ... |
- If any row is missing/weak, default to `NEEDS WORK` or `INVALID CLAIM`.
C) What changed
- Brief bullet summary of the diff/behavioral changes.
D) What's good
- Bullets: correctness, simplicity, tests, docs, ergonomics, etc.
E) Concerns / questions (actionable)
- Numbered list.
- Mark each item as:
- BLOCKER (must fix before merge)
- IMPORTANT (should fix before merge)
- NIT (optional)
- For each: point to the file/area and propose a concrete fix or alternative.
- If evidence for the core bug claim is missing, add a `BLOCKER` explicitly.
F) Tests
- What exists.
- What's missing (specific scenarios).
- State clearly whether there is a regression test for the claimed bug.
G) Follow-ups (optional)
- Non-blocking refactors/tickets to open later.
H) Suggested PR comment (optional)
- Offer: "Want me to draft a PR comment to the author?"
- If yes, provide a ready-to-paste comment summarizing the above, with clear asks.
Rules / Guardrails
- Review only: do not merge (`gh pr merge`), do not push branches, do not edit code.
- If you need clarification, ask questions rather than guessing.

View File

@@ -6,34 +6,126 @@ Docs: https://docs.openclaw.ai
### Changes
- Providers/Amazon Bedrock Mantle: add Claude Opus 4.7 through Mantle's Anthropic Messages route with provider-owned bearer-auth streaming, so the model is actually callable without treating AWS bearer tokens like Anthropic API keys. Thanks @wirjo.
- OpenAI/Responses: use OpenAI's native `web_search` tool automatically for direct OpenAI Responses models when web search is enabled and no managed search provider is pinned; explicit providers such as Brave keep the managed `web_search` tool.
- ACPX: add an explicit `openClawToolsMcpBridge` option that injects a core OpenClaw MCP server for selected built-in tools, starting with `cron`.
- Agents/sessions: add mailbox-style `sessions_list` filters for label, agent, and search plus visibility-scoped derived title and last-message previews. (#69839) Thanks @dangoZhang.
- Providers/GPT-5: move the GPT-5 prompt overlay into the shared provider runtime so compatible GPT-5 models receive the same behavior and heartbeat guidance through OpenAI, OpenRouter, OpenCode, Codex, and other GPT providers; add `agents.defaults.promptOverlays.gpt5.personality` as the global friendly-style toggle while keeping the OpenAI plugin setting as a fallback.
- Providers/xAI: add image generation, text-to-speech, and speech-to-text support, including `grok-imagine-image` / `grok-imagine-image-pro`, reference-image edits, six live xAI voices, MP3/WAV/PCM/G.711 TTS formats, `grok-stt` audio transcription, and xAI realtime transcription for Voice Call streaming. (#68694) Thanks @KateWilkins.
- Providers/STT: add Voice Call streaming transcription for Deepgram, ElevenLabs, and Mistral, and add ElevenLabs Scribe v2 batch audio transcription for inbound media.
- Models/commands: add `/models add <provider> <modelId>` so you can register a model from chat and use it without restarting the gateway; keep `/models` as a simple provider browser while adding clearer add guidance and copy-friendly command examples. (#70211) Thanks @Takhoffman.
- Pi/models: update the bundled pi packages to `0.68.1` and let the OpenCode Go catalog come from pi instead of plugin-maintained model aliases, adding the refreshed `opencode-go/kimi-k2.6`, Qwen, GLM, MiMo, and MiniMax entries.
- CLI/doctor plugins: lazy-load doctor plugin paths and prefer installed plugin `dist/*` runtime entries over source-adjacent JavaScript fallbacks, reducing the measured `doctor --non-interactive` runtime by about 74% while keeping cold doctor startup on built plugin artifacts. (#69840) Thanks @gumadeiras.
- WhatsApp/groups+direct: forward per-group and per-direct `systemPrompt` config into inbound context `GroupSystemPrompt` so configured per-chat behavioral instructions are injected on every turn. Supports `"*"` wildcard fallback and account-scoped overrides under `channels.whatsapp.accounts.<id>.{groups,direct}`; account maps fully replace root maps (no deep merge), matching the existing `requireMention` pattern. Closes #7011. (#59553) Thanks @Bluetegu.
- Plugins/startup: prefer native Jiti loading for built bundled plugin dist modules on supported runtimes, cutting measured bundled plugin load time by 82-90% while keeping source TypeScript on the transform path. (#69925) Thanks @aauren.
- Plugin SDK/Pi embedded runs: add a bundled-plugin embedded extension factory seam so native plugins can extend Pi embedded runs with async runtime hooks such as `tool_result` handling instead of falling back to the older synchronous persistence path. (#69946) Thanks @vincentkoc.
- Tokenjuice: add bundled native OpenClaw support for tokenjuice as an opt-in plugin that compacts noisy `exec` and `bash` tool results in Pi embedded runs. (#69946) Thanks @vincentkoc.
- Codex harness/hooks: route native Codex app-server turns through `before_prompt_build` and emit `before_compaction` / `after_compaction` for native compaction items so prompt and compaction hooks stop drifting from Pi. Thanks @vincentkoc.
- Codex harness/plugins: add a bundled-plugin Codex app-server extension seam for async `tool_result` middleware, fire `after_tool_call` for Codex tool runs, and route mirrored Codex transcript writes through `before_message_write` so tool integrations stop diverging from Pi. Thanks @vincentkoc.
- Codex harness/hooks: fire `llm_input`, `llm_output`, and `agent_end` for native Codex app-server turns so lifecycle hooks stop drifting from Pi. Thanks @vincentkoc.
- Providers/Tencent: add the bundled Tencent Cloud provider plugin with TokenHub and Token Plan onboarding, docs, `hy3-preview` model catalog entries, and tiered Hy3 pricing metadata. (#68460) Thanks @JuniperSling.
- TUI: add local embedded mode for running terminal chats without a Gateway while keeping plugin approval gates enforced. (#66767) Thanks @fuller-stack-dev.
- CLI/Claude: default `claude-cli` runs to warm stdio sessions, including custom configs that omit transport fields, and resume from the stored Claude session after Gateway restarts or idle exits. (#69679) Thanks @obviyus.
- Control UI/settings+chat: add a browser-local personal identity for the operator (name plus local-safe avatar), route user identity rendering through the shared chat/avatar path used by assistant and agent surfaces, and tighten Quick Settings, agent fallback chips, and narrow-screen chat layouts so personalization no longer wastes space or clips controls. (#70362) Thanks @BunsDev.
- Gateway/diagnostics: enable payload-free stability recording by default and add a support-ready diagnostics export with sanitized logs, status, health, config, and stability snapshots for bug reports. (#70324) Thanks @gumadeiras.
### Fixes
- Providers/OpenAI: harden Voice Call realtime transcription against OpenAI Realtime session-update drift, forward language and prompt hints, and add live coverage for realtime STT.
- Providers/Moonshot: stop strict-sanitizing Kimi's native tool_call IDs (shaped like `functions.<name>:<index>`) on the OpenAI-compatible transport, so multi-turn agentic flows through Kimi K2.6 no longer break after 2-3 tool-calling rounds when the serving layer fails to match mangled IDs against the original tool definitions. Adds a `sanitizeToolCallIds` opt-out to the shared `openai-compatible` replay family helper and wires Moonshot to it. Fixes #62319. (#70030) Thanks @LeoDu0314.
- Dependencies/security: override transitive `uuid` to `14.0.0`, clearing the runtime advisory across dependencies.
- Codex harness: ignore dynamic tool descriptions when deciding whether to reuse a native app-server thread while still fingerprinting tool schemas, so channel-specific copy changes no longer reset otherwise compatible Codex conversations. (#69976) Thanks @chen-zhang-cs-code.
- Codex harness: drop invalid legacy app-server `serviceTier` values such as `"priority"` before native thread and turn requests, while keeping supported Codex tiers limited to `"fast"` and `"flex"`. Fixes #64815.
- Codex harness: show bounded, sanitized permission target samples in app-server approval prompts, so native permission requests keep their specific hosts, roots, and paths visible without leaking home usernames or URL credentials. (#70340) Thanks @Lucenx9.
- Docs/Codex harness: narrow native compaction docs to the current start/completion signals, without promising a readable summary or kept-entry audit list yet. (#69612) Thanks @91wan.
- Providers/Amazon Bedrock: use known context-window metadata for discovered models while keeping the unknown-model fallback conservative, so compaction and overflow handling improve for newer Bedrock models without overstating unlisted model limits. Thanks @wirjo.
- Providers/Amazon Bedrock Mantle: refresh IAM-backed bearer tokens at runtime instead of baking discovery-time tokens into provider config, so long-lived Mantle sessions keep working after the initial token ages out. Thanks @wirjo.
- Config/includes: write through single-file top-level includes for isolated OpenClaw-owned mutations, so `plugins install` and `plugins update` update an included `plugins.json5` file instead of flattening modular `$include` configs. Fixes #41050 and #66048.
- Config/reload: plan gateway reloads from source-authored config instead of runtime-materialized snapshots, so plugin update writes no longer trigger false restarts from derived provider/plugin config paths. Fixes #68732.
- Plugins/update: skip npm plugin reinstall/config rewrites when the installed version and recorded artifact identity already match the registry target, let bare npm package names resolve back to tracked install records, and point already-installed `plugins install` attempts at `plugins update` / `--force` instead of a hook-pack fallback. Fixes #46955, #67957, and #68073.
- Agents/MCP: keep `mcp.servers` and bundle MCP tools available in Pi embedded
`coding` and `messaging` sessions while preserving `minimal` profile and
`tools.deny: ["bundle-mcp"]` opt-out behavior. Fixes #68875 and #68818.
- Plugins/startup: tolerate transient bundled-channel catalog/metadata drift while auto-enabling configured plugins, so CLI and gateway startup no longer crash when a channel id is known but its display metadata is unavailable.
- CLI/Claude: report CLI-backed reply runs as streaming while Claude/Codex CLI turns are still in flight, so WebChat keeps visible response state until the backend finishes. Fixes #70125.
- Slack/streaming: fall back to normal Slack replies for Slack Connect streams rejected before the SDK flushes its local buffer, so short replies no longer disappear or report success before Slack acknowledges delivery. Fixes #70295. (#70370) Thanks @mvanhorn.
- Codex harness: rotate the shared app-server websocket client when the configured bearer token changes, so auth-token refreshes reconnect with the new `Authorization` header instead of reusing a stale socket. (#70328) Thanks @Lucenx9.
- Channels/sandbox: derive runtime policy keys for external direct messages that share the main conversation, so sandbox/tool policy no longer treats channel-originated DMs as local main-session runs.
- Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding `config set --merge` for additive updates and `--replace` for intentional clobbers. Fixes #65920, #68392, and #68653.
- Agents/Pi auth: preserve AWS SDK-authenticated Bedrock runs for IMDS and task-role setups, clear stale refresh timers on sentinel fallback, and log unexpected runtime-auth prep failures instead of silently leaving the provider unauthenticated. Thanks @wirjo.
- Config/gateway: restore last-known-good config on critical clobber signatures such as missing metadata, missing `gateway.mode`, or sharp size drops, preventing gateway crash loops when a valid backup exists. Fixes #70336.
- Config/gateway: recover configs accidentally prefixed with non-JSON output during gateway startup or `openclaw doctor --fix`, preserving the clobbered file as a backup while leaving normal config reads read-only.
- Agents/GitHub Copilot: normalize connection-bound Responses item IDs in the Copilot provider wrapper so replayed histories no longer fail after the upstream connection changes. (#69362) Thanks @Menci.
- Pi embedded runs: pass real built-in tools into Pi session creation and then narrow active tool names after custom tool registration, so the runner and compaction paths compile cleanly and keep OpenClaw-managed custom tool allowlists without feeding string arrays into `createAgentSession`. Thanks @vincentkoc.
- Agents/OpenAI websocket: route native OpenAI websocket metadata and session-header decisions through the shared endpoint classifier so local mocks and custom `models.providers.openai.baseUrl` endpoints stay out of the native OpenAI path consistently across embedded-runner and websocket transport code. Thanks @vincentkoc.
- Cron/MCP: retire bundled MCP runtimes through one shared cleanup path for isolated cron run ends, persistent cron session rollover, and direct cron `deleteAfterRun` fallback cleanup. Fixes #69145, #68623, and #68827.
- MCP/gateway: tear down stdio MCP process trees on transport close and dispose bundled MCP runtimes during session delete/reset, preventing orphaned wrapper/server processes from accumulating. Fixes #68809 and #69465.
- Agents/MCP: retire bundled MCP runtimes after completed one-shot subagent cleanup and nested `sessions_send` steps, while keeping persistent subagent sessions warm.
- Config: render validation warnings with real line breaks instead of a literal `\n` sequence in CLI/audit output. Fixes #70140.
- Cron/doctor: repair malformed persisted cron job IDs through `openclaw doctor`, including legacy `jobId`, non-string `id`, and missing `id` rows, so `cron list` no longer needs display-layer coercion for corrupt store data. Fixes #70128.
- Discord: normalize prefixed channel targets only at the thread-binding API boundary, so `sessions_spawn({ runtime: "acp", thread: true })` can create child threads from Discord channels without breaking current-channel ACP bindings. (#68034) Thanks @Zetarcos.
- Discord: harden inbound thread metadata handling against partial Carbon channel getters, so non-command thread messages and queued jobs no longer crash when `name`, `parentId`, `parent`, or `ownerId` requires fetched raw data.
- Discord: let `message` tool reactions resolve `user:<id>` DM targets and preserve `channels.discord.guilds.<guild>.channels.<channel>.requireMention: false` during reply-stage activation fallback. Fixes #70165 and #69441.
- Plugins/startup: pre-normalize and cache Jiti alias maps before creating plugin loaders, so module-scoped loader filenames do not reintroduce per-plugin alias-normalization startup cost. Fixes #70186.
- ACP/Codex: run the bundled Codex ACP harness with an isolated `CODEX_HOME` and avoid writing incomplete ChatGPT auth bridge files, so Codex ACP sessions no longer clobber the user's real Codex CLI auth. Fixes #70234. Thanks @Lonobers88.
- Gateway/client: keep long-running RPCs such as ACP `agent.wait` calls in charge of their own timeout instead of closing the websocket on a missed app-level tick while work is still pending.
- Telegram/webhooks: lower the grammY webhook callback timeout to 5s so Telegram gets an early 200 response instead of retrying long-running updates as read timeouts. (#70146) Thanks @friday-james.
- Telegram/polling: rebuild the polling HTTP transport after `getUpdates` 409 conflicts, so retries use a fresh TCP connection instead of looping on a Telegram-terminated keep-alive socket. (#69873) Thanks @hclsys.
- Media delivery: strip persisted base64 audio payloads from webchat history, resolve stored `media://inbound/*` attachments before local-root checks, suppress duplicate Telegram voice/audio sends when TTS emits the same media twice, and support custom image-model IDs that already include their provider prefix.
- Slack/files: resolve `downloadFile` bot tokens from the runtime config when callers provide `cfg` without an explicit token or prebuilt client, preserving cfg-only file downloads outside the action runtime path. (#70160) Thanks @martingarramon.
- Slack/HTTP: dispatch registered Request URL webhooks through the same handler registry used by Slack monitor setup, so HTTP-mode Slack events no longer 404 after successful route registration. (#70275) Thanks @FroeMic.
- Slack/runtime bindings: route focused Slack thread replies through their bound ACP session instead of preparing replies against the default agent shell. Fixes #67739. Thanks @Frankla20.
- CLI/Claude: verify stored Claude CLI session ids have a readable project transcript before resuming, clearing phantom bindings with `reason=transcript-missing` instead of silently starting fresh under `--resume`. Fixes #70177.
- CLI sessions: persist CLI session clearing through the atomic session-store merge path, so expired Claude/Codex CLI bindings are actually removed before retrying without the stale session id. (#70298) Thanks @HFConsultant.
- ACP/sessions_spawn: honor explicit `model` overrides for ACP child sessions instead of silently falling back to the target agent default model. (#70210) Thanks @felix-miao.
- Diffs/viewer: re-read remote viewer access policy from live runtime config on each request, so toggling `plugins.entries.diffs.config.security.allowRemoteViewer` closes proxied viewer access immediately instead of waiting for a restart. Thanks @vincentkoc.
- Diffs/tooling: re-read `viewerBaseUrl`, presentation defaults, and viewer access policy from live runtime config, and fail closed when the live `diffs` plugin entry disappears instead of reviving startup viewer settings. Thanks @vincentkoc.
- Memory/LanceDB: stop resurrecting removed live `memory-lancedb` hook config from startup snapshots, so deleting or disabling the plugin entry shuts off auto-recall and auto-capture without a restart. Thanks @vincentkoc.
- Active Memory: stop reviving removed live `active-memory` config from startup snapshots, so removing the plugin entry turns the hook off immediately instead of waiting for a restart. Thanks @vincentkoc.
- Agents/subagents: drop bare `NO_REPLY` from the parent turn when the session still has pending spawned children, so direct-conversation surfaces such as Telegram DMs no longer rewrite the sentinel into visible fallback chatter while waiting for the child completion event. (#69942) Thanks @neeravmakwana.
- Plugins/install: keep bundled plugin dependencies off npm install while repairing them when plugins activate from a packaged install, including Feishu/Lark, Browser, and direct bundled channel setup-entry loads.
- CLI/channels: skip and cache bundled channel plugin, setup, and secrets load failures during read-only discovery, so one broken unused bundled channel cannot crash `openclaw status` or bootstrap secret scans.
- Memory/LanceDB: retry initialization after a failed LanceDB load and report unsupported Intel macOS native runtime clearly instead of caching the failure or repeatedly attempting an install that cannot work.
- CLI/Claude: hash only static extra system prompt parts when deciding whether to reuse a CLI session, so per-message inbound metadata no longer resets Claude CLI conversations on every turn. (#70122) Thanks @zijunl.
- Hooks/Slack: standardize shared message hook routing fields (`threadId` / `replyToId`) and stop Slack outbound delivery from re-running `message_sending` inside the channel adapter, so plugins like thread-ownership make one outbound routing decision per reply. Thanks @vincentkoc.
- Auto-reply/media: share one run-scoped reply media context between streamed block delivery and final payload filtering, so a local `MEDIA:` attachment is staged once and duplicate media sends are suppressed reliably. (#68111) Thanks @ayeshakhalid192007-dev.
- Plugins/gateway hooks: expose startup config, workspace dir, and a live cron getter on the typed `gateway_start` hook, and move memory-core managed dreaming off the internal `gateway:startup` bridge so cron reconciliation stays on the public plugin hook path. Thanks @vincentkoc.
- Plugins/config: read plugin trust decisions from the source config snapshot when a resolved runtime snapshot is active, so `plugins.allow` remains enforced and `doctor`/gateway startup no longer warn that the allowlist is empty when it is configured. Fixes #70161. Also fixes #70141.
- Gateway/restart: preserve group and channel chat context when resuming an agent turn after a Gateway restart, so continuation replies keep the same prompt, routing, and tool-status behavior as the original conversation.
- Gateway/pairing: shared-secret loopback CLI clients now silently auto-approve `metadata-upgrade` pairing (platform / device family refresh) instead of being disconnected with `1008 pairing required`. This matches the scope-upgrade and role-upgrade behavior added in #69431 and unblocks non-interactive CLI automation when a paired-device record has a stale platform string (e.g. device key replicated across hosts, install migrated between OSes, or platform-string format changed between OpenClaw versions). Browser / Control-UI clients keep the existing approval-required flow for metadata changes.
- Gateway/pairing: treat any forwarded-header evidence (`Forwarded`, `X-Forwarded-*`, or `X-Real-IP`) as proxied WebSocket traffic before pairing locality checks, so reverse-proxy topologies cannot use the loopback shared-secret helper auto-pairing path.
- Agents/OpenAI: treat exact `NO_REPLY` assistant output as a deliberate silent reply in embedded runs, so GPT-5.4 turns with signed reasoning plus a silent final no longer surface a false incomplete-turn error.
- Auto-reply/streaming: preserve streamed reply directives through chunk boundaries and phase-aware `final_answer` delivery, so split `MEDIA:<path>` lines, voice tags, and reply targets reach channel delivery instead of leaking as text or being dropped. (#70243) Thanks @zqchris.
- Anthropic/Claude Opus 4.7: normalize Opus 4.7 and `claude-cli` Opus 4.7 variants to a 1M context window in resolved runtime metadata and active-agent status/context reporting, so they no longer inherit the stale 200k fallback. Thanks @BunsDev.
- Gateway/pairing webchat: render `/pair qr` replies as structured media instead of raw markdown text, preserve inline reply threading and silent-control handling on media replies, avoid persisting sensitive QR images into transcript history, and keep local webchat media embedding behind internal-only trust markers. (#70047) Thanks @BunsDev.
- Codex harness: default app-server runs to unchained local execution, so OpenAI heartbeats can use network and shell tools without stalling behind native Codex approvals or the workspace-write sandbox.
- Codex harness: fail closed for unknown native app-server approval methods instead of routing unsupported future approval shapes through OpenClaw approval grants. (#70356) Thanks @Lucenx9.
- Codex harness: apply the GPT-5 behavior and heartbeat prompt overlay to native Codex app-server runs, so `codex/gpt-5.x` sessions get the same follow-through, tool-use, and proactive heartbeat guidance as OpenAI GPT-5 runs.
- Codex harness: add an explicit Guardian mode for Codex app-server approvals, plus a Docker live probe for approved and ask-back Guardian decisions, while keeping default app-server runs unchained for unattended local heartbeats. The legacy `OPENCLAW_CODEX_APP_SERVER_GUARDIAN` shortcut is removed; use plugin config `appServer.mode: "guardian"` or `OPENCLAW_CODEX_APP_SERVER_MODE=guardian`. Thanks @pashpashpash.
- OpenAI/Responses: keep embedded OpenAI Responses runs on HTTP when `models.providers.openai.baseUrl` points at a local mock or other non-public endpoint, so mocked/custom endpoints no longer drift onto the hardcoded public websocket transport. (#69815) Thanks @vincentkoc.
- Channels/config: require resolved runtime config on channel send/action/client helpers and block runtime helper `loadConfig()` calls, so SecretRefs are resolved at startup/boundaries instead of being re-read during sends.
- Discord: pass resolved runtime config through guild and moderation action helpers, so thread-originated Discord commands can run channel, member, role, and guild actions without falling back to runtime config reads. (#70215) Thanks @szponeczek.
- CLI/channels: preserve bundled setup promotion metadata when a loaded partial channel plugin omits it, so adding a non-default account still moves legacy single-account fields such as Telegram `streaming` into `accounts.default`.
- Telegram: keep the sent-message ownership cache isolated per configured session store, so own-message reaction filtering remains correct with custom `session.store` paths.
- Security/update: fail closed when exact pinned npm plugin or hook-pack updates detect integrity drift, and expose aborted plugin drift details in `openclaw update --json`.
- Ollama: forward OpenClaw thinking control to native `/api/chat` requests as top-level `think`, so `/think off` and `openclaw agent --thinking off` suppress thinking on models such as qwen3 instead of idling until the watchdog fires. Fixes #69902. (#69967) Thanks @WZH8898.
- Memory-core/dreaming: suppress the startup-only managed dreaming cron unavailable warning when the cron service is still attaching, while preserving the runtime warning if cron genuinely remains unavailable. Fixes #69939. (#69941) Thanks @Sanjays2402.
- Mattermost: suppress reasoning-only payloads even when they arrive as blockquoted `> Reasoning:` text, preventing `/reasoning on` from leaking thinking into channel posts. (#69927) Thanks @lawrence3699.
- Discord: read `channel.parentId` through a safe accessor in the slash-command, reaction, and model-picker paths so partial `GuildThreadChannel` prototype getters no longer throw `Cannot access rawData on partial Channel` when commands like `/new` run from inside a thread. Fixes #69861. (#69908) Thanks @neeravmakwana.
- Discord: use safe channel name and parent accessors across voice command authorization, so `/vc` commands from partial Discord thread channels no longer crash on Carbon rawData getters. (#70199) Thanks @hanamizuki.
- Discord: make auto-thread parent transcript inheritance opt-in via `channels.discord.thread.inheritParent`, keeping newly created Discord thread sessions isolated by default while preserving explicit inheritance for configured accounts. Fixes #69907. (#69986) Thanks @Blahdude.
- Browser/Chrome MCP: reset cached existing-session control sessions when a `navigate_page` call times out, so one stuck navigation no longer poisons the browser profile until a gateway restart. (#69733) Thanks @ayeshakhalid192007-dev.
- Browser/Chrome MCP: propagate click timeouts and abort signals to existing-session actions so a stuck click fails fast and reconnects instead of poisoning the browser tool until gateway restart. (#63524) Thanks @dongseok0.
- Amazon Bedrock/prompt caching: resolve opaque application inference profile targets before injecting Bedrock cache points, require every routed target to support explicit cache points, and retry transient profile lookups instead of caching a false negative for the rest of the process. (#69953) Thanks @anirudhmarc and @vincentkoc.
- Gateway/channel health: base stale-socket recovery on provider-proven transport activity instead of inbound app-event freshness, preventing quiet Slack, Discord, Telegram, Matrix, and local-style channels from being restarted solely because no user traffic arrived. (#69833) Thanks @bek91.
- OpenCode Go: canonicalize stale bundled `opencode-go` base URLs from `/go` or `/go/v1` to `/zen/go` or `/zen/go/v1`, so older generated model metadata stops hitting the 404 HTML endpoint. (#69898)
- CLI/channels: honor `channels.<id>.enabled=false` as a hard read-only presence opt-out, so env vars, manifest env vars, or stale persisted auth state no longer make disabled channel plugins appear in status, doctor, or setup-only discovery.
- Channels/preview streaming: centralize draft-preview finalization so Slack, Discord, Mattermost, and Matrix no longer flush temporary preview messages for media/error finals, and preserve first-reply threading for normal fallback delivery.
- Discord: keep slash command follow-up chunks ephemeral when the command is configured for ephemeral replies, so long `/status` output no longer leaks fallback model or runtime details into the public channel. (#69869) thanks @gumadeiras.
- Gateway/session history: re-check current auth and `chat.history` scope before later SSE keepalives and transcript updates, so active session-history streams close before delivering post-revocation events.
- Plugins/discovery: reject package plugin source entries that escape the package directory before explicit runtime entries or inferred built JavaScript peers can be used. (#69868) thanks @gumadeiras.
- CLI/channels: resolve channel presence through a shared policy that keeps ambient env vars and stale persisted auth from surfacing disabled bundled plugins in status, doctor, security audit, and cron delivery validation unless the channel or plugin is effectively enabled or explicitly configured. (#69862) Thanks @gumadeiras.
- Doctor/plugins: hydrate legacy partial interactive handler state before plugin reload clears dedupe caches, so `openclaw doctor` and post-update doctor runs no longer crash with `Cannot read properties of undefined (reading 'clear')`. (#70135) Thanks @ngutman.
- Control UI/config: preserve intentionally empty raw config snapshots when clearing pending updates so reset restores the original bytes instead of synthesizing JSON for blank config files. (#68178) Thanks @BunsDev.
- memory-core/dreaming: surface a `Dreaming status: blocked` line in `openclaw memory status` when dreaming is enabled but the heartbeat that drives the managed cron is not firing for the default agent, and add a Troubleshooting section to the dreaming docs covering the two common causes (per-agent `heartbeat` blocks excluding `main`, and `heartbeat.every` set to `0`/empty/invalid), so the silent failure described in #69843 becomes legible on the status surface.
- Cron/run-log: report generic `message` tool sends under the resolved delivery channel when they match the cron target, while preserving account-specific mismatch checks for delivery traces. (#69940) Thanks @davehappyminion.
@@ -42,6 +134,19 @@ Docs: https://docs.openclaw.ai
- Configure: skip generic CLI startup bootstrap for `openclaw configure` and bound hint-only gateway probes so the onboarding TUI reaches its first prompt faster when the Gateway is unavailable. (#69984) Thanks @obviyus.
- Agents/harness: surface selected plugin harness failures directly instead of replaying the same turn through embedded PI, preventing misleading secondary PI auth errors and avoiding duplicate side effects.
- OpenAI Codex: add a ChatGPT device-code auth option beside browser OAuth, so headless or callback-hostile setups can sign in without relying on the localhost browser callback. (#69557) Thanks @vincentkoc.
- CLI sessions: keep provider-owned CLI sessions through implicit daily expiry while preserving explicit reset behavior, and retain Claude CLI binding metadata across gateway agent requests. (#70106) Thanks @obviyus.
- fix(config): accept truncateAfterCompaction (#68395). Thanks @MonkeyLeeT
- CLI/Claude: keep Claude CLI session bindings stable across OAuth access-token refreshes, so gateway restarts continue the same Claude conversation instead of minting a fresh one. (#70132) Thanks @obviyus.
- QQBot: add `INTERACTION` intent (`1 << 26`) to the gateway constants and include it in the `FULL_INTENTS` mask so interaction events are received. (#70143) Thanks @cxyhhhhh.
- Gateway/restart: preserve one-shot continuation instructions across gateway restarts so agents can resume and reply back to the original chat after reboot. (#63406) Thanks @VACInc.
- Gateway/restart: write restart sentinel files atomically so interrupted writes cannot leave a truncated sentinel behind. (#70225) Thanks @obviyus.
- Pairing: remove stale pending requests for a device when that paired device is deleted, so an old repair approval cannot recreate the removed device from leftover state.
- Security/dotenv: block workspace `.env` overrides for Matrix, Mattermost, IRC, and Synology endpoint settings so cloned workspaces cannot redirect bundled connector traffic through local endpoint config. (#70240) Thanks @drobison00.
- Telegram: require the same `/models` authorization for group model-picker callbacks, so unauthorized participants can no longer browse or change the session model through inline buttons. (#70235) Thanks @drobison00.
- Agents/Pi: keep the filtered tool-name allowlist active for embedded OpenAI/OpenAI Codex GPT-5 runs and compaction sessions, so bundled and client tools still execute after the Pi `0.68.1` session-tool allowlist change instead of stopping at plan-only replies with no tool call. (#70281) Thanks @jalehman.
- Agents/Pi: honor explicit `strict-agentic` execution contracts for incomplete-turn retry guards across providers, so manually opted-in local or compatible models get the same retry behavior without relying on OpenAI model inference. (#66750) Thanks @ziomancer.
- OpenShell/sandbox: pin verified file reads to an already-opened descriptor, walk the ancestor chain for symlinked parents on platforms without fd-path readlink, and re-check file identity so parent symlink swaps cannot redirect in-sandbox reads to host files outside the allowed mount root. (#69798) Thanks @drobison00.
- Gateway/Control UI: require authenticated Control UI read access before serving `/__openclaw/control-ui-config.json` when `gateway.auth` is enabled, so unauthenticated callers can no longer read bootstrap metadata. (#70247) Thanks @drobison00.
## 2026.4.21
@@ -174,8 +279,8 @@ Docs: https://docs.openclaw.ai
- Agents/subagents: include requested role and runtime timing on subagent failure payloads so parent agents can correlate failed or timed-out child work. (#68726) Thanks @BKF-Gitty.
- Gateway/sessions: reject stale agent-scoped sessions after an agent is removed from config while preserving legacy default-agent main-session aliases. (#65986) Thanks @bittoby.
- Doctor/gateway: surface pending device pairing requests, scope-upgrade approval drift, and stale device-token mismatch repair steps so `openclaw doctor --fix` no longer leaves pairing/auth setup failures unexplained. (#69210) Thanks @obviyus.
- Cron/isolated-agent: preserve explicit `delivery.mode: "none"` message targets for isolated runs without inheriting implicit `last` routing, so agent-initiated Telegram sends keep their authored destination while bare `mode:none` jobs stay targetless. (#69153) Thanks @obviyus.
- Cron/isolated-agent: keep `delivery.mode: "none"` account-only or thread-only configs from inheriting a stale implicit recipient, so isolated runs only resolve message routing when the job authored an explicit `to` target. (#69163) Thanks @obviyus.
- Cron/isolated-agent: preserve explicit `delivery.mode: "none"` message targets for isolated runs without inheriting implicit `last` routing, so agent-initiated Telegram sends keep their authored destination while bare `mode:none` jobs stay targetless. (#69153) Thanks @davehappyminion and @nikilster.
- Cron/isolated-agent: keep `delivery.mode: "none"` account-only or thread-only configs from inheriting a stale implicit recipient, so isolated runs only resolve message routing when the job authored an explicit `to` target. (#69163) Thanks @davehappyminion and @nikilster.
- Gateway/TUI: retry session history while the local gateway is still finishing startup, so `openclaw tui` reconnects no longer fail on transient `chat.history unavailable during gateway startup` errors. (#69164) Thanks @shakkernerd.
- BlueBubbles/reactions: fall back to `love` when an agent reacts with an emoji outside the iMessage tapback set (`love`/`like`/`dislike`/`laugh`/`emphasize`/`question`), so wider-vocabulary model reactions like `👀` still produce a visible tapback instead of failing the whole reaction request. Configured ack reactions still validate strictly via the new `normalizeBlueBubblesReactionInputStrict` path. (#64693) Thanks @zqchris.
- BlueBubbles: prefer iMessage over SMS when both chats exist for the same handle, honor explicit `sms:` targets, and never silently downgrade iMessage-available recipients. (#61781) Thanks @rmartin.

View File

@@ -590,6 +590,7 @@ public struct AgentParams: Codable, Sendable {
public let timeout: Int?
public let besteffortdeliver: Bool?
public let lane: String?
public let cleanupbundlemcponrunend: Bool?
public let extrasystemprompt: String?
public let bootstrapcontextmode: AnyCodable?
public let bootstrapcontextrunkind: AnyCodable?
@@ -621,6 +622,7 @@ public struct AgentParams: Codable, Sendable {
timeout: Int?,
besteffortdeliver: Bool?,
lane: String?,
cleanupbundlemcponrunend: Bool?,
extrasystemprompt: String?,
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
@@ -651,6 +653,7 @@ public struct AgentParams: Codable, Sendable {
self.timeout = timeout
self.besteffortdeliver = besteffortdeliver
self.lane = lane
self.cleanupbundlemcponrunend = cleanupbundlemcponrunend
self.extrasystemprompt = extrasystemprompt
self.bootstrapcontextmode = bootstrapcontextmode
self.bootstrapcontextrunkind = bootstrapcontextrunkind
@@ -683,6 +686,7 @@ public struct AgentParams: Codable, Sendable {
case timeout
case besteffortdeliver = "bestEffortDeliver"
case lane
case cleanupbundlemcponrunend = "cleanupBundleMcpOnRunEnd"
case extrasystemprompt = "extraSystemPrompt"
case bootstrapcontextmode = "bootstrapContextMode"
case bootstrapcontextrunkind = "bootstrapContextRunKind"

View File

@@ -412,8 +412,13 @@
"title": "Sessions",
"detailKeys": [
"kinds",
"label",
"agentId",
"search",
"limit",
"activeMinutes",
"includeDerivedTitles",
"includeLastMessage",
"messageLimit"
]
},

View File

@@ -590,6 +590,7 @@ public struct AgentParams: Codable, Sendable {
public let timeout: Int?
public let besteffortdeliver: Bool?
public let lane: String?
public let cleanupbundlemcponrunend: Bool?
public let extrasystemprompt: String?
public let bootstrapcontextmode: AnyCodable?
public let bootstrapcontextrunkind: AnyCodable?
@@ -621,6 +622,7 @@ public struct AgentParams: Codable, Sendable {
timeout: Int?,
besteffortdeliver: Bool?,
lane: String?,
cleanupbundlemcponrunend: Bool?,
extrasystemprompt: String?,
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
@@ -651,6 +653,7 @@ public struct AgentParams: Codable, Sendable {
self.timeout = timeout
self.besteffortdeliver = besteffortdeliver
self.lane = lane
self.cleanupbundlemcponrunend = cleanupbundlemcponrunend
self.extrasystemprompt = extrasystemprompt
self.bootstrapcontextmode = bootstrapcontextmode
self.bootstrapcontextrunkind = bootstrapcontextrunkind
@@ -683,6 +686,7 @@ public struct AgentParams: Codable, Sendable {
case timeout
case besteffortdeliver = "bestEffortDeliver"
case lane
case cleanupbundlemcponrunend = "cleanupBundleMcpOnRunEnd"
case extrasystemprompt = "extraSystemPrompt"
case bootstrapcontextmode = "bootstrapContextMode"
case bootstrapcontextrunkind = "bootstrapContextRunKind"

View File

@@ -1,4 +1,4 @@
e77c14ad4db1be62275667537716917e4d0da73e1afb89be1edeb78d73346ae4 config-baseline.json
ed4e305904b4b954ffa72c07ea1900a116bfd874ac0c637227883abb99f753f9 config-baseline.core.json
6c0069b971ae298ae68516ebcd3eae0e8c82820d2e8f42ecbd2f53a2f9077371 config-baseline.channel.json
9096ec947597b03f97eef44186a3102fd80ffb7f3e791fb64544464d4571448f config-baseline.plugin.json
b05357fa162ba1f1d4ed192671b758d3905602678ff61148568840c6544d6222 config-baseline.json
a4e167f169db58d71c385a31fa2b980772f9fee963e70dd9553f63536cae5aed config-baseline.core.json
35d132fe176bd2bf9f0e46b29de91baba63ec4db3317cc5b294a982b46d16ba9 config-baseline.channel.json
3703c5345288adb9eee8cda3b592147cf4fed25a7782bed21ca83c88c3ca1cc0 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
55b39075f07def786f5056b029921db64fcbdc5e2cab3d645215eccc857ba9a4 plugin-sdk-api-baseline.json
4a6b8f4afc9e6aa7c56b0cbab0886dacc4ead534c47761ab30eb76480d8fd673 plugin-sdk-api-baseline.jsonl
2b7093a57992029cc70126d33544e02eed6c3076a3a6b4ffa6aef7664da0f33d plugin-sdk-api-baseline.json
ea6a2f2326565517b6c42a4d334f615163fb434dbad5e0b8d134c92767714256 plugin-sdk-api-baseline.jsonl

View File

@@ -23,10 +23,14 @@ host configuration.
## Session key shapes (examples)
Direct messages collapse to the agents **main** session:
Direct messages collapse to the agents **main** session by default:
- `agent:<agentId>:<mainKey>` (default: `agent:main:main`)
Even when direct-message conversation history is shared with main, sandbox and
tool policy use a derived per-account direct-chat runtime key for external DMs
so channel-originated messages are not treated like local main-session runs.
Groups and channels remain isolated per channel:
- Groups: `agent:<agentId>:<channel>:group:<id>`

View File

@@ -61,15 +61,18 @@ You will need to create a new application with a bot, add the bot to your server
- `bot`
- `applications.commands`
A **Bot Permissions** section will appear below. Enable:
A **Bot Permissions** section will appear below. Enable at least:
- View Channels
- Send Messages
- Read Message History
- Embed Links
- Attach Files
- Add Reactions (optional)
**General Permissions**
- View Channels
**Text Permissions**
- Send Messages
- Read Message History
- Embed Links
- Attach Files
- Add Reactions (optional)
This is the baseline set for normal text channels. If you plan to post in Discord threads, including forum or media channel workflows that create or continue a thread, also enable **Send Messages in Threads**.
Copy the generated URL at the bottom, paste it into your browser, select your server, and click **Continue** to connect. You should now see your bot in the Discord server.
</Step>
@@ -304,7 +307,7 @@ By default, components are single use. Set `components.reusable=true` to allow b
To restrict who can click a button, set `allowedUsers` on that button (Discord user IDs, tags, or `*`). When configured, unmatched users receive an ephemeral denial.
The `/model` and `/models` slash commands open an interactive model picker with provider and model dropdowns plus a Submit step. The picker reply is ephemeral and only the invoking user can use it.
The `/model` and `/models` slash commands open an interactive model picker with provider and model dropdowns plus a Submit step. Unless `commands.modelsWrite=false`, `/models add` also supports adding a new provider/model entry from chat, and newly added models show up without restarting the gateway. The picker reply is ephemeral and only the invoking user can use it.
File attachments:
@@ -520,13 +523,16 @@ Use `bindings[].match.roles` to route Discord guild members to different agents
Typical baseline permissions:
- View Channels
- Send Messages
- Read Message History
- Embed Links
- Attach Files
- Add Reactions (optional)
**General Permissions**
- View Channels
**Text Permissions**
- Send Messages
- Read Message History
- Embed Links
- Attach Files
- Add Reactions (optional)
This is the baseline set for normal text channels. If you plan to post in Discord threads, including forum or media channel workflows that create or continue a thread, also enable **Send Messages in Threads**.
Avoid `Administrator` unless explicitly needed.
</Accordion>

View File

@@ -361,8 +361,8 @@ Surface different features that extend the above defaults.
},
{
"command": "/models",
"description": "List providers or models for a provider",
"usage_hint": "[provider] [page] [limit=<n>|size=<n>|all]"
"description": "List providers/models or add a model",
"usage_hint": "[provider] [page] [limit=<n>|size=<n>|all] | add <provider> <modelId>"
},
{
"command": "/help",

View File

@@ -12,28 +12,28 @@ The CI runs on every push to `main` and every pull request. It uses smart scopin
## Job Overview
| Job | Purpose | When it runs |
| -------------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------- |
| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs |
| `security-scm-fast` | Private key detection and workflow audit via `zizmor` | Always on non-draft pushes and PRs |
| `security-dependency-audit` | Dependency-free production lockfile audit against npm advisories | Always on non-draft pushes and PRs |
| `security-fast` | Required aggregate for the fast security jobs | Always on non-draft pushes and PRs |
| `build-artifacts` | Build `dist/` and the Control UI once, upload reusable artifacts for downstream jobs | Node-relevant changes |
| `checks-fast-core` | Fast Linux correctness lanes such as bundled/plugin-contract/protocol checks | Node-relevant changes |
| `checks-fast-contracts-channels` | Sharded channel contract checks with a stable aggregate check result | Node-relevant changes |
| `checks-node-extensions` | Full bundled-plugin test shards across the extension suite | Node-relevant changes |
| `checks-node-core-test` | Core Node test shards, excluding channel, bundled, contract, and extension lanes | Node-relevant changes |
| `extension-fast` | Focused tests for only the changed bundled plugins | When extension changes are detected |
| `check` | Sharded main local gate equivalent: prod types, lint, guards, test types, and strict smoke | Node-relevant changes |
| `check-additional` | Architecture, boundary, extension-surface guards, package-boundary, and gateway-watch shards | Node-relevant changes |
| `build-smoke` | Built-CLI smoke tests and startup-memory smoke | Node-relevant changes |
| `checks` | Remaining Linux Node lanes: channel tests and push-only Node 22 compatibility | Node-relevant changes |
| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed |
| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes |
| `checks-windows` | Windows-specific test lanes | Windows-relevant changes |
| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes |
| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes |
| `android` | Android build and test matrix | Android-relevant changes |
| Job | Purpose | When it runs |
| -------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------ |
| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs |
| `security-scm-fast` | Private key detection and workflow audit via `zizmor` | Always on non-draft pushes and PRs |
| `security-dependency-audit` | Dependency-free production lockfile audit against npm advisories | Always on non-draft pushes and PRs |
| `security-fast` | Required aggregate for the fast security jobs | Always on non-draft pushes and PRs |
| `build-artifacts` | Build `dist/` and the Control UI once, upload reusable artifacts for downstream jobs | Node-relevant changes |
| `checks-fast-core` | Fast Linux correctness lanes such as bundled/plugin-contract/protocol checks | Node-relevant changes |
| `checks-fast-contracts-channels` | Sharded channel contract checks with a stable aggregate check result | Node-relevant changes |
| `checks-node-extensions` | Full bundled-plugin test shards across the extension suite | Node-relevant changes |
| `checks-node-core-test` | Core Node test shards, excluding channel, bundled, contract, and extension lanes | Node-relevant changes |
| `extension-fast` | Focused tests for only the changed bundled plugins | Pull requests with extension changes |
| `check` | Sharded main local gate equivalent: prod types, lint, guards, test types, and strict smoke | Node-relevant changes |
| `check-additional` | Architecture, boundary, extension-surface guards, package-boundary, and gateway-watch shards | Node-relevant changes |
| `build-smoke` | Built-CLI smoke tests and startup-memory smoke | Node-relevant changes |
| `checks` | Remaining Linux Node lanes: channel tests and push-only Node 22 compatibility | Node-relevant changes |
| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed |
| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes |
| `checks-windows` | Windows-specific test lanes | Windows-relevant changes |
| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes |
| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes |
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |
## Fail-Fast Order
@@ -42,27 +42,34 @@ Jobs are ordered so cheap checks fail before expensive ones run:
1. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs.
2. `security-scm-fast`, `security-dependency-audit`, `security-fast`, `check`, `check-additional`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
3. `build-artifacts` overlaps with the fast Linux lanes so downstream consumers can start as soon as the shared build is ready.
4. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-channels`, `checks-node-extensions`, `checks-node-core-test`, `extension-fast`, `checks`, `checks-windows`, `macos-node`, `macos-swift`, and `android`.
4. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-channels`, `checks-node-extensions`, `checks-node-core-test`, PR-only `extension-fast`, `checks`, `checks-windows`, `macos-node`, `macos-swift`, and `android`.
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`.
The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It computes `run_install_smoke` from the narrower changed-smoke signal, so Docker/install smoke only runs for install, packaging, and container-relevant changes.
CI workflow edits validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
Windows Node checks are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes so they do not reserve a 16-vCPU Windows worker for coverage that is already exercised by the normal test shards.
The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It computes `run_install_smoke` from the narrower changed-smoke signal, so Docker/install smoke runs for install, packaging, container-relevant changes, bundled extension production changes, and the core plugin/channel/gateway/Plugin SDK surfaces that the Docker smoke jobs exercise. Test-only and docs-only edits do not reserve Docker workers. Its QR package smoke forces the Docker `pnpm install` layer to rerun while preserving the BuildKit pnpm store cache, so it still exercises installation without redownloading dependencies on every run. Its gateway-network e2e reuses the runtime image built earlier in the job, so it adds real container-to-container WebSocket coverage without adding another Docker build. A separate `docker-e2e-fast` job runs the bounded bundled-plugin Docker profile under a 120-second command timeout: setup-entry dependency repair plus synthetic bundled-loader failure isolation. The full bundled update/channel matrix remains manual/full-suite because it performs repeated real npm update and doctor repair passes.
Local changed-lane logic lives in `scripts/changed-lanes.mjs` and is executed by `scripts/check-changed.mjs`. That local gate is stricter about architecture boundaries than the broad CI platform scope: core production changes run core prod typecheck plus core tests, core test-only changes run only core test typecheck/tests, extension production changes run extension prod typecheck plus extension tests, and extension test-only changes run only extension test typecheck/tests. Public Plugin SDK or plugin-contract changes expand to extension validation because extensions depend on those core contracts. Release metadata-only version bumps run targeted version/config/root-dependency checks. Unknown root/config changes fail safe to all lanes.
On pushes, the `checks` matrix adds the push-only `compat-node22` lane. On pull requests, that lane is skipped and the matrix stays focused on the normal test/channel lanes.
The slowest Node test families are split into include-file shards so each job stays small: channel contracts split registry and core coverage into eight weighted shards each, auto-reply reply command tests split into four include-pattern shards, and the other large auto-reply reply prefix groups split into two shards each. `check-additional` also separates package-boundary compile/canary work from runtime topology gateway/architecture work.
The slowest Node test families are split or balanced so each job stays small: channel contracts split registry and core coverage into six weighted shards total, bundled plugin tests balance across six extension workers, auto-reply runs as three balanced workers instead of six tiny workers, and agentic gateway/plugin configs are spread across the existing source-only agentic Node jobs instead of waiting on built artifacts. Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. The broad agents lane uses the shared Vitest file-parallel scheduler because it is import/scheduling dominated rather than owned by a single slow test file. `runtime-config` runs with the infra core-runtime shard to keep the shared runtime shard from owning the tail. `check-additional` keeps package-boundary compile/canary work together and separates runtime topology architecture from gateway watch coverage; the boundary guard shard runs its small independent guards concurrently inside one job, and the gateway watch regression reuses a same-run built `dist/` and `dist-runtime/` tar artifact from `build-artifacts` so it measures watch stability without rebuilding runtime artifacts in its own worker.
Android CI runs both `testPlayDebugUnitTest` and `testThirdPartyDebugUnitTest`, then builds the Play debug APK. The third-party flavor has no separate source set or manifest; its unit-test lane still compiles that flavor with the SMS/call-log BuildConfig flags, while avoiding a duplicate debug APK packaging job on every Android-relevant push.
`extension-fast` is PR-only because push runs already execute the full bundled plugin shards. That keeps changed-plugin feedback for reviews without reserving an extra Blacksmith worker on `main` for coverage already present in `checks-node-extensions`.
GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. The aggregate shard checks call out this cancellation case explicitly so it is easier to distinguish from a test failure.
GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. Aggregate shard checks use `!cancelled() && always()` so they still report normal shard failures but do not queue after the whole workflow has already been superseded.
The CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs.
## Runners
| Runner | Jobs |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `ubuntu-24.04` | `preflight`; install-smoke preflight also uses GitHub-hosted Ubuntu so the Blacksmith matrix can queue earlier |
| `blacksmith-16vcpu-ubuntu-2404` | `security-scm-fast`, `security-dependency-audit`, `security-fast`, `build-artifacts`, Linux checks, docs checks, Python skills, `android` |
| `blacksmith-32vcpu-windows-2025` | `checks-windows` |
| `blacksmith-12vcpu-macos-latest` | `macos-node`, `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-latest` |
| Runner | Jobs |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ubuntu-24.04` | `preflight`, fast security jobs and aggregates (`security-scm-fast`, `security-dependency-audit`, `security-fast`), fast protocol/contract/bundled checks, sharded channel contract checks, `check` shards except lint, `check-additional` shards and aggregates, Node test aggregate verifiers, docs checks, Python skills, workflow-sanity, labeler, auto-response; install-smoke preflight also uses GitHub-hosted Ubuntu so the Blacksmith matrix can queue earlier |
| `blacksmith-8vcpu-ubuntu-2404` | `build-artifacts`, build-smoke, Linux Node test shards, bundled plugin test shards, remaining built-artifact consumers, `android` |
| `blacksmith-16vcpu-ubuntu-2404` | `check-lint`, which remains CPU-sensitive enough that 8 vCPU cost more than it saved; install-smoke Docker builds, where 32-vCPU queue time cost more than it saved |
| `blacksmith-16vcpu-windows-2025` | `checks-windows` |
| `blacksmith-6vcpu-macos-latest` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-latest` |
| `blacksmith-12vcpu-macos-latest` | `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-latest` |
## Local Equivalents
@@ -80,4 +87,5 @@ pnpm test:channels
pnpm test:contracts:channels
pnpm check:docs # docs format + lint + broken links
pnpm build # build dist when CI artifact/build-smoke lanes matter
node scripts/ci-run-timings.mjs <run-id> # summarize wall time, queue time, and slowest jobs
```

View File

@@ -166,9 +166,11 @@ Per-session `mcpServers` are not supported in bridge mode. If an ACP client
sends them during `newSession` or `loadSession`, the bridge returns a clear
error instead of silently ignoring them.
If you want ACPX-backed sessions to see OpenClaw plugin tools, enable the
gateway-side ACPX plugin bridge instead of trying to pass per-session
`mcpServers`. See [ACP Agents](/tools/acp-agents#plugin-tools-mcp-bridge).
If you want ACPX-backed sessions to see OpenClaw plugin tools or selected
built-in tools such as `cron`, enable the gateway-side ACPX MCP bridges instead
of trying to pass per-session `mcpServers`. See
[ACP Agents](/tools/acp-agents#plugin-tools-mcp-bridge) and
[OpenClaw tools MCP bridge](/tools/acp-agents#openclaw-tools-mcp-bridge).
## Use from `acpx` (Codex, Claude, other ACP clients)

View File

@@ -38,6 +38,7 @@ openclaw config get browser.executablePath
openclaw config set browser.executablePath "/usr/bin/google-chrome"
openclaw config set agents.defaults.heartbeat.every "2h"
openclaw config set agents.list[0].tools.exec.node "node-id-or-name"
openclaw config set agents.defaults.models '{"openai-codex/gpt-5.4":{}}' --strict-json --merge
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN
openclaw config set secrets.providers.vaultfile --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json
openclaw config unset plugins.entries.brave.config.webSearch.apiKey
@@ -105,6 +106,22 @@ openclaw config set channels.whatsapp.groups '["*"]' --strict-json
`config get <path> --json` prints the raw value as JSON instead of terminal-formatted text.
Object assignment replaces the target path by default. Protected map/list paths
that commonly hold user-added entries, such as `agents.defaults.models`,
`models.providers`, `models.providers.<id>.models`, `plugins.entries`, and
`auth.profiles`, refuse replacements that would remove existing entries unless
you pass `--replace`.
Use `--merge` when adding entries to those maps:
```bash
openclaw config set agents.defaults.models '{"openai-codex/gpt-5.4":{}}' --strict-json --merge
openclaw config set models.providers.ollama.models '[{"id":"llama3.2","name":"Llama 3.2"}]' --strict-json --merge
```
Use `--replace` only when you intentionally want the provided value to become
the complete target value.
## `config set` modes
`openclaw config set` supports four assignment styles:
@@ -342,6 +359,9 @@ If dry-run fails:
post-change config before committing it to disk. If the new payload fails schema
validation or looks like a destructive clobber, the active config is left alone
and the rejected payload is saved beside it as `openclaw.json.rejected.*`.
The active config path must be a regular file. Symlinked `openclaw.json`
layouts are unsupported for writes; use `OPENCLAW_CONFIG_PATH` to point directly
at the real file instead.
Prefer CLI writes for small edits:
@@ -366,7 +386,7 @@ last-known-good backup during startup or hot reload. See
## Subcommands
- `config file`: Print the active config file path (resolved from `OPENCLAW_CONFIG_PATH` or default location).
- `config file`: Print the active config file path (resolved from `OPENCLAW_CONFIG_PATH` or default location). The path should name a regular file, not a symlink.
Restart the gateway after edits.

View File

@@ -11,6 +11,8 @@ Interactive prompt to set up credentials, devices, and agent defaults.
Note: The **Model** section now includes a multi-select for the
`agents.defaults.models` allowlist (what shows up in `/model` and the model picker).
Provider-scoped setup choices merge their selected models into the existing
allowlist instead of replacing unrelated providers already in the config.
When configure starts from a provider auth choice, the default-model and
allowlist pickers prefer that provider automatically. For paired providers such

View File

@@ -111,6 +111,59 @@ Options:
- `--days <days>`: number of days to include (default `30`).
### `gateway stability`
Fetch the recent diagnostic stability recorder from a running Gateway.
```bash
openclaw gateway stability
openclaw gateway stability --type payload.large
openclaw gateway stability --bundle latest
openclaw gateway stability --bundle latest --export
openclaw gateway stability --json
```
Options:
- `--limit <limit>`: maximum number of recent events to include (default `25`, max `1000`).
- `--type <type>`: filter by diagnostic event type, such as `payload.large` or `diagnostic.memory.pressure`.
- `--since-seq <seq>`: include only events after a diagnostic sequence number.
- `--bundle [path]`: read a persisted stability bundle instead of calling the running Gateway. Use `--bundle latest` (or just `--bundle`) for the newest bundle under the state directory, or pass a bundle JSON path directly.
- `--export`: write a shareable support diagnostics zip instead of printing stability details.
- `--output <path>`: output path for `--export`.
Notes:
- The recorder is active by default. Set `diagnostics.enabled: false` only when you need to disable Gateway diagnostic heartbeat collection.
- Records keep operational metadata: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, and redacted session summaries. They do not keep chat text, webhook bodies, tool outputs, raw request or response bodies, tokens, cookies, secret values, hostnames, or raw session ids.
- On fatal Gateway exits, shutdown timeouts, and restart startup failures, OpenClaw writes the same diagnostic snapshot to `~/.openclaw/logs/stability/openclaw-stability-*.json` when the recorder has events. Inspect the newest bundle with `openclaw gateway stability --bundle latest`; `--limit`, `--type`, and `--since-seq` also apply to bundle output.
### `gateway diagnostics export`
Write a local diagnostics zip that is designed to attach to bug reports.
```bash
openclaw gateway diagnostics export
openclaw gateway diagnostics export --output openclaw-diagnostics.zip
openclaw gateway diagnostics export --json
```
Options:
- `--output <path>`: output zip path. Defaults to a support export under the state directory.
- `--log-lines <count>`: maximum sanitized log lines to include (default `5000`).
- `--log-bytes <bytes>`: maximum log bytes to inspect (default `1000000`).
- `--url <url>`: Gateway WebSocket URL for the health snapshot.
- `--token <token>`: Gateway token for the health snapshot.
- `--password <password>`: Gateway password for the health snapshot.
- `--timeout <ms>`: status/health snapshot timeout (default `3000`).
- `--no-stability-bundle`: skip persisted stability bundle lookup.
- `--json`: print the written path, size, and manifest as JSON.
The export contains a manifest, a Markdown summary, config shape, sanitized config details, sanitized log summaries, sanitized Gateway status/health snapshots, and the newest stability bundle when one exists.
It is meant to be shared. It keeps operational details that help debugging, such as safe OpenClaw log fields, subsystem names, status codes, durations, configured modes, ports, plugin ids, provider ids, non-secret feature settings, and redacted operational log messages. It omits or redacts chat text, webhook bodies, tool outputs, credentials, cookies, account/message identifiers, prompt/instruction text, hostnames, and secret values. When a LogTape-style message looks like user/chat/tool payload text, the export keeps only that a message was omitted plus its byte count.
### `gateway status`
`gateway status` shows the Gateway service (launchd/systemd/schtasks) plus an optional probe of connectivity/auth capability.

View File

@@ -369,6 +369,9 @@ Important behavior:
reachable right now
- runtime adapters decide which transport shapes they actually support at
execution time
- embedded Pi exposes configured MCP tools in normal `coding` and `messaging`
tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]`
disables them explicitly
## Saved MCP server definitions

View File

@@ -33,7 +33,7 @@ openclaw plugins enable <id>
openclaw plugins disable <id>
openclaw plugins uninstall <id>
openclaw plugins doctor
openclaw plugins update <id>
openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins marketplace list <marketplace>
openclaw plugins marketplace list <marketplace> --json
@@ -76,6 +76,8 @@ bundled-plugin recovery path for plugins that explicitly opt into
`--force` reuses the existing install target and overwrites an already-installed
plugin or hook pack in place. Use it when you are intentionally reinstalling
the same id from a new local path, archive, ClawHub package, or npm artifact.
For routine upgrades of an already tracked npm plugin, prefer
`openclaw plugins update <id-or-npm-spec>`.
`--pin` applies to npm installs only. It is not supported with `--marketplace`,
because marketplace installs persist marketplace source metadata instead of an
@@ -243,9 +245,20 @@ or exact version. OpenClaw resolves that package name back to the tracked plugin
record, updates that installed plugin, and records the new npm spec for future
id-based updates.
Passing the npm package name without a version or tag also resolves back to the
tracked plugin record. Use this when a plugin was pinned to an exact version and
you want to move it back to the registry's default release line.
Before a live npm update, OpenClaw checks the installed package version against
the npm registry metadata. If the installed version and recorded artifact
identity already match the resolved target, the update is skipped without
downloading, reinstalling, or rewriting `openclaw.json`.
When a stored integrity hash exists and the fetched artifact hash changes,
OpenClaw prints a warning and asks for confirmation before proceeding. Use
global `--yes` to bypass prompts in CI/non-interactive runs.
OpenClaw treats that as npm artifact drift. The interactive
`openclaw plugins update` command prints the expected and actual hashes and asks
for confirmation before proceeding. Non-interactive update helpers fail closed
unless the caller supplies an explicit continuation policy.
`--dangerously-force-unsafe-install` is also available on `plugins update` as a
break-glass override for built-in dangerous-code scan false positives during
@@ -292,6 +305,10 @@ openclaw plugins doctor
compatibility notices. When everything is clean it prints `No plugin issues
detected.`
For module-shape failures such as missing `register`/`activate` exports, rerun
with `OPENCLAW_PLUGIN_LOAD_DEBUG=1` to include a compact export-shape summary in
the diagnostic output.
### Marketplace
```bash

View File

@@ -36,7 +36,9 @@ openclaw --update
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
- `--tag <dist-tag|version|spec>`: override the package target for this update only. For package installs, `main` maps to `github:openclaw/openclaw#main`.
- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting.
- `--json`: print machine-readable `UpdateRunResult` JSON.
- `--json`: print machine-readable `UpdateRunResult` JSON, including
`postUpdate.plugins.integrityDrifts` when npm plugin artifact drift is
detected during post-update plugin sync.
- `--timeout <seconds>`: per-step timeout (default is 1200s).
- `--yes`: skip confirmation prompts (for example downgrade confirmation)
@@ -80,6 +82,12 @@ install method aligned:
The Gateway core auto-updater (when enabled via config) reuses this same update path.
For package-manager installs, `openclaw update` resolves the target package
version before invoking the package manager. If the installed version exactly
matches the target and no update-channel change needs to be persisted, the
command exits as skipped before package install, plugin sync, completion refresh,
or gateway restart work.
## Git checkout flow
Channels:
@@ -101,6 +109,11 @@ High-level:
8. Runs `openclaw doctor` as the final “safe update” check.
9. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
If an exact pinned npm plugin update resolves to an artifact whose integrity
differs from the stored install record, `openclaw update` aborts that plugin
artifact update instead of installing it. Reinstall or update the plugin
explicitly only after verifying that you trust the new artifact.
If pnpm bootstrap still fails, the updater now stops early with a package-manager-specific error instead of trying `npm run build` inside the checkout.
## `--update` shorthand

View File

@@ -167,6 +167,10 @@ Defaults live under `agents.defaults.silentReply` and
`agents.defaults.silentReplyRewrite`; `surfaces.<id>.silentReply` and
`surfaces.<id>.silentReplyRewrite` can override them per surface.
When the parent session has one or more pending spawned subagent runs, bare
silent replies are dropped on all surfaces instead of being rewritten, so the
parent stays quiet until the child completion event delivers the real reply.
## Related
- [Streaming](/concepts/streaming) — real-time message delivery

View File

@@ -67,6 +67,24 @@ to `zai/*`.
Provider configuration examples (including OpenCode) live in
[/providers/opencode](/providers/opencode).
### Safe allowlist edits
Use additive writes when updating `agents.defaults.models` by hand:
```bash
openclaw config set agents.defaults.models '{"openai-codex/gpt-5.4":{}}' --strict-json --merge
```
`openclaw config set` protects model/provider maps from accidental clobbers. A
plain object assignment to `agents.defaults.models`, `models.providers`, or
`models.providers.<id>.models` is rejected when it would remove existing
entries. Use `--merge` for additive changes; use `--replace` only when the
provided value should become the complete target value.
Interactive provider setup and `openclaw configure --section model` also merge
provider-scoped selections into the existing allowlist, so adding Codex,
Ollama, or another provider does not drop unrelated model entries.
## "Model is not allowed" (and why replies stop)
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and for
@@ -114,6 +132,9 @@ Notes:
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
- On Discord, `/model` and `/models` open an interactive picker with provider and model dropdowns plus a Submit step.
- `/models add` is available by default and can be disabled with `commands.modelsWrite=false`.
- When enabled, `/models add <provider> <modelId>` is the fastest path; bare `/models add` starts a provider-first guided flow where supported.
- After `/models add`, the new model becomes available in `/models` and `/model` without restarting the gateway.
- `/model <#>` selects from that picker.
- `/model` persists the new session selection immediately.
- If the agent is idle, the next run uses the new model right away.
@@ -132,6 +153,14 @@ Notes:
Full command behavior/config: [Slash commands](/tools/slash-commands).
Examples:
```text
/models add
/models add ollama glm-5.1:cloud
/models add lmstudio qwen/qwen3.5-9b
```
## CLI commands
```bash

View File

@@ -16,7 +16,7 @@ orchestrate sub-agents.
| Tool | What it does |
| ------------------ | --------------------------------------------------------------------------- |
| `sessions_list` | List sessions with optional filters (kind, recency) |
| `sessions_list` | List sessions with optional filters (kind, label, agent, recency, preview) |
| `sessions_history` | Read the transcript of a specific session |
| `sessions_send` | Send a message to another session and optionally wait |
| `sessions_spawn` | Spawn an isolated sub-agent session for background work |
@@ -26,9 +26,13 @@ orchestrate sub-agents.
## Listing and reading sessions
`sessions_list` returns sessions with their key, kind, channel, model, token
counts, and timestamps. Filter by kind (`main`, `group`, `cron`, `hook`,
`node`) or recency (`activeMinutes`).
`sessions_list` returns sessions with their key, agentId, kind, channel, model,
token counts, and timestamps. Filter by kind (`main`, `group`, `cron`, `hook`,
`node`), exact `label`, exact `agentId`, search text, or recency
(`activeMinutes`). When you need mailbox-style triage, it can also ask for
derived titles, last-message previews, or bounded recent messages. Preview
transcript reads are scoped to sessions visible under the configured session
tool visibility policy.
`sessions_history` fetches the conversation transcript for a specific session.
By default, tool results are excluded -- pass `includeTools: true` to see them.

View File

@@ -69,6 +69,10 @@ Sessions are reused until they expire:
When both daily and idle resets are configured, whichever expires first wins.
Sessions with an active provider-owned CLI session are not cut by the implicit
daily default. Use `/reset` or configure `session.reset` explicitly when those
sessions should expire on a timer.
## Where state lives
All session state is owned by the **gateway**. UI clients query the gateway for

View File

@@ -143,6 +143,8 @@ The provider id becomes the left side of your model ref:
1. **Selects a backend** based on the provider prefix (`codex-cli/...`).
2. **Builds a system prompt** using the same OpenClaw prompt + workspace context.
3. **Executes the CLI** with a session id (if supported) so history stays consistent.
The bundled `claude-cli` backend keeps a Claude stdio process alive per
OpenClaw session and sends follow-up turns over stream-json stdin.
4. **Parses output** (JSON or plain text) and returns the final text.
5. **Persists session ids** per backend, so follow-ups reuse the same CLI session.
@@ -179,6 +181,13 @@ child process environment for the run.
- `always`: always send a session id (new UUID if none stored).
- `existing`: only send a session id if one was stored before.
- `none`: never send a session id.
- `claude-cli` defaults to `liveSession: "claude-stdio"`, `output: "jsonl"`,
and `input: "stdin"` so follow-up turns reuse the live Claude process while
it is active. If the Gateway restarts or the idle process exits, OpenClaw
resumes from the stored Claude session id.
- Stored CLI sessions are provider-owned continuity. The implicit daily session
reset does not cut them; `/reset` and explicit `session.reset` policies still
do.
Serialization notes:

View File

@@ -1238,6 +1238,8 @@ Time format in system prompt. Default: `auto` (OS preference).
- `elevatedDefault`: default elevated-output level for agents. Values: `"off"`, `"on"`, `"ask"`, `"full"`. Default: `"on"`.
- `model.primary`: format `provider/model` (e.g. `openai/gpt-5.4`). If you omit the provider, OpenClaw tries an alias first, then a unique configured-provider match for that exact model id, and only then falls back to the configured default provider (deprecated compatibility behavior, so prefer explicit `provider/model`). If that provider no longer exposes the configured default model, OpenClaw falls back to the first configured provider/model instead of surfacing a stale removed-provider default.
- `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and `params` (provider-specific, for example `temperature`, `maxTokens`, `cacheRetention`, `context1m`).
- Safe edits: use `openclaw config set agents.defaults.models '<json>' --strict-json --merge` to add entries. `config set` refuses replacements that would remove existing allowlist entries unless you pass `--replace`.
- Provider-scoped configure/onboarding flows merge selected provider models into this map and preserve unrelated providers already configured.
- `params`: global default provider parameters applied to all models. Set at `agents.defaults.params` (e.g. `{ cacheRetention: "long" }`).
- `params` merge precedence (config): `agents.defaults.params` (global base) is overridden by `agents.defaults.models["provider/model"].params` (per-model), then `agents.list[].params` (matching agent id) overrides by key. See [Prompt Caching](/reference/prompt-caching) for details.
- `embeddedHarness`: default low-level embedded agent runtime policy. Use `runtime: "auto"` to let registered plugin harnesses claim supported models, `runtime: "pi"` to force the built-in PI harness, or a registered harness id such as `runtime: "codex"`. Set `fallback: "none"` to disable automatic PI fallback.
@@ -1338,6 +1340,28 @@ Replace the entire OpenClaw-assembled system prompt with a fixed string. Set at
}
```
### `agents.defaults.promptOverlays`
Provider-independent prompt overlays applied by model family. GPT-5-family model ids receive the shared behavior contract across providers; `personality` controls only the friendly interaction-style layer.
```json5
{
agents: {
defaults: {
promptOverlays: {
gpt5: {
personality: "friendly", // friendly | on | off
},
},
},
},
}
```
- `"friendly"` (default) and `"on"` enable the friendly interaction-style layer.
- `"off"` disables only the friendly layer; the tagged GPT-5 behavior contract remains enabled.
- Legacy `plugins.entries.openai.config.personality` is still read when this shared setting is unset.
### `agents.defaults.heartbeat`
Periodic heartbeat runs.
@@ -2562,6 +2586,7 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi
- `models.mode`: provider catalog behavior (`merge` or `replace`).
- `models.providers`: custom provider map keyed by provider id.
- Safe edits: use `openclaw config set models.providers.<id> '<json>' --strict-json --merge` or `openclaw config set models.providers.<id>.models '<json-array>' --strict-json --merge` for additive updates. `config set` refuses destructive replacements unless you pass `--replace`.
- `models.providers.*.api`: request adapter (`openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai`, etc).
- `models.providers.*.apiKey`: provider credential (prefer SecretRef/env substitution).
- `models.providers.*.auth`: auth strategy (`api-key`, `token`, `oauth`, `aws-sdk`).
@@ -3878,6 +3903,8 @@ Split config into multiple files:
- Sibling keys: merged after includes (override included values).
- Nested includes: up to 10 levels deep.
- Paths: resolved relative to the including file, but must stay inside the top-level config directory (`dirname` of `openclaw.json`). Absolute/`../` forms are allowed only when they still resolve inside that boundary.
- OpenClaw-owned writes that change only one top-level section backed by a single-file include write through to that included file. For example, `plugins install` updates `plugins: { $include: "./plugins.json5" }` in `plugins.json5` and leaves `openclaw.json` intact.
- Root includes, include arrays, and includes with sibling overrides are read-only for OpenClaw-owned writes; those writes fail closed instead of flattening the config.
- Errors: clear messages for missing files, parse errors, and circular includes.
---

View File

@@ -10,6 +10,10 @@ title: "Configuration"
# Configuration
OpenClaw reads an optional <Tooltip tip="JSON5 supports comments and trailing commas">**JSON5**</Tooltip> config from `~/.openclaw/openclaw.json`.
The active config path must be a regular file. Symlinked `openclaw.json`
layouts are unsupported for OpenClaw-owned writes; an atomic write may replace
the path instead of preserving the symlink. If you keep config outside the
default state directory, point `OPENCLAW_CONFIG_PATH` directly at the real file.
If the file is missing, OpenClaw uses safe defaults. Common reasons to add a config:
@@ -100,6 +104,13 @@ The Gateway also keeps a trusted last-known-good copy after a successful startup
`openclaw.json` is later changed outside OpenClaw and no longer validates, startup
and hot reload preserve the broken file as a timestamped `.clobbered.*` snapshot,
restore the last-known-good copy, and log a loud warning with the recovery reason.
Startup read recovery also treats sharp size drops, missing config metadata, and a
missing `gateway.mode` as critical clobber signatures when the last-known-good
copy had those fields.
If a status/log line is accidentally prepended before an otherwise valid JSON
config, gateway startup and `openclaw doctor --fix` can strip the prefix,
preserve the polluted file as `.clobbered.*`, and continue with the recovered
JSON.
The next main-agent turn also receives a system-event warning telling it that the
config was restored and must not be blindly rewritten. Last-known-good promotion
is updated after validated startup and after accepted hot reloads, including
@@ -162,6 +173,7 @@ placeholders such as `***` or shortened token values.
```
- `agents.defaults.models` defines the model catalog and acts as the allowlist for `/model`.
- Use `openclaw config set agents.defaults.models '<json>' --strict-json --merge` to add allowlist entries without removing existing models. Plain replacements that would remove entries are rejected unless you pass `--replace`.
- Model refs use `provider/model` format (e.g. `anthropic/claude-opus-4-6`).
- `agents.defaults.imageMaxDimensionPx` controls transcript/tool image downscaling (default `1200`); lower values usually reduce vision-token usage on screenshot-heavy runs.
- See [Models CLI](/concepts/models) for switching models in chat and [Model Failover](/concepts/model-failover) for auth rotation and fallback behavior.
@@ -496,6 +508,12 @@ placeholders such as `***` or shortened token values.
- **Sibling keys**: merged after includes (override included values)
- **Nested includes**: supported up to 10 levels deep
- **Relative paths**: resolved relative to the including file
- **OpenClaw-owned writes**: when a write changes only one top-level section
backed by a single-file include such as `plugins: { $include: "./plugins.json5" }`,
OpenClaw updates that included file and leaves `openclaw.json` intact
- **Unsupported write-through**: root includes, include arrays, and includes
with sibling overrides fail closed for OpenClaw-owned writes instead of
flattening the config
- **Error handling**: clear errors for missing files, parse errors, and circular includes
</Accordion>

View File

@@ -26,6 +26,8 @@ Short guide to verify channel connectivity without guessing.
- Creds on disk: `ls -l ~/.openclaw/credentials/whatsapp/<accountId>/creds.json` (mtime should be recent).
- Session store: `ls -l ~/.openclaw/agents/<agentId>/sessions/sessions.json` (path can be overridden in config). Count and recent recipients are surfaced via `status`.
- Relink flow: `openclaw channels logout && openclaw channels login --verbose` when status codes 409515 or `loggedOut` appear in logs. (Note: the QR login flow auto-restarts once for status 515 after pairing.)
- Diagnostics are enabled by default. The gateway records operational facts unless `diagnostics.enabled: false` is set. Memory events record RSS/heap byte counts, threshold pressure, and growth pressure. Oversized-payload events record what was rejected, truncated, or chunked, plus sizes and limits when available. They do not record the message text, attachment contents, webhook body, raw request or response body, tokens, cookies, or secret values. The same heartbeat starts the bounded stability recorder, which is available through `openclaw gateway stability` or the `diagnostics.stability` Gateway RPC. Fatal Gateway exits, shutdown timeouts, and restart startup failures persist the latest recorder snapshot under `~/.openclaw/logs/stability/` when events exist; inspect the newest saved bundle with `openclaw gateway stability --bundle latest`.
- For bug reports, run `openclaw gateway diagnostics export` and attach the generated zip. The export combines a Markdown summary, the newest stability bundle, sanitized log metadata, sanitized Gateway status/health snapshots, and config shape. It is meant to be shared: chat text, webhook bodies, tool outputs, credentials, cookies, account/message identifiers, and secret values are omitted or redacted.
## Health monitor config

View File

@@ -18,6 +18,13 @@ handshake time.
- WebSocket, text frames with JSON payloads.
- First frame **must** be a `connect` request.
- Pre-connect frames are capped at 64 KiB. After a successful handshake, clients
should follow the `hello-ok.policy.maxPayload` and
`hello-ok.policy.maxBufferedBytes` limits. With diagnostics enabled,
oversized inbound frames and slow outbound buffers emit `payload.large` events
before the gateway closes or drops the affected frame. These events keep
sizes, limits, surfaces, and safe reason codes. They do not keep the message
body, attachment contents, raw frame body, tokens, cookies, or secret values.
## Handshake (connect)
@@ -265,6 +272,12 @@ implemented in `src/gateway/server-methods/*.ts`.
### System and identity
- `health` returns the cached or freshly probed gateway health snapshot.
- `diagnostics.stability` returns the recent bounded diagnostic stability
recorder. It keeps operational metadata such as event names, counts, byte
sizes, memory readings, queue/session state, channel/plugin names, and session
ids. It does not keep chat text, webhook bodies, tool outputs, raw request or
response bodies, tokens, cookies, or secret values. Operator read scope is
required.
- `status` returns the `/status`-style gateway summary; sensitive fields are
included only for admin-scoped operator clients.
- `gateway.identity.get` returns the gateway device identity used by relay and

View File

@@ -247,7 +247,7 @@ High-signal `checkId` values you will most likely see in real deployments (not e
| `fs.state_dir.perms_readable` | warn | State dir is readable by others | filesystem perms on `~/.openclaw` | yes |
| `fs.state_dir.symlink` | warn | State dir target becomes another trust boundary | state dir filesystem layout | no |
| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
| `fs.config.symlink` | warn | Config target becomes another trust boundary | config file filesystem layout | no |
| `fs.config.symlink` | warn | Symlinked config files are unsupported for writes and add another trust boundary | replace with a regular config file or point `OPENCLAW_CONFIG_PATH` at the real file | no |
| `fs.config.perms_group_readable` | warn | Group users can read config tokens/settings | filesystem perms on config file | yes |
| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
| `fs.config_include.perms_writable` | critical | Config include file can be modified by others | include-file perms referenced from `openclaw.json` | yes |

View File

@@ -303,6 +303,7 @@ Common signatures:
- `.clobbered.*` exists → an external direct edit or startup read was restored.
- `.rejected.*` exists → an OpenClaw-owned config write failed schema or clobber checks before commit.
- `Config write rejected:` → the write tried to drop required shape, shrink the file sharply, or persist invalid config.
- `missing-meta-vs-last-good`, `gateway-mode-missing-vs-last-good`, or `size-drop-vs-last-good:*` → startup treated the current file as clobbered because it lost fields or size compared with the last-known-good backup.
- `Config last-known-good promotion skipped` → the candidate contained redacted secret placeholders such as `***`.
Fix options:
@@ -475,7 +476,7 @@ Common signatures:
- `No Chrome tabs found for profile="user"` → the Chrome MCP attach profile has no open local Chrome tabs.
- `Remote CDP for profile "<name>" is not reachable` → the configured remote CDP endpoint is not reachable from the gateway host.
- `Browser attachOnly is enabled ... not reachable` or `Browser attachOnly is enabled and CDP websocket ... is not reachable` → attach-only profile has no reachable target, or the HTTP endpoint answered but the CDP WebSocket still could not be opened.
- `Playwright is not available in this gateway build; '<feature>' is unsupported.` → the current gateway install lacks the full Playwright package; ARIA snapshots and basic page screenshots can still work, but navigation, AI snapshots, CSS-selector element screenshots, and PDF export stay unavailable.
- `Playwright is not available in this gateway build; '<feature>' is unsupported.` → the current gateway install lacks the bundled browser plugin's `playwright-core` runtime dependency; run `openclaw doctor --fix`, then restart the gateway. ARIA snapshots and basic page screenshots can still work, but navigation, AI snapshots, CSS-selector element screenshots, and PDF export stay unavailable.
- `fullPage is not supported for element screenshots` → screenshot request mixed `--full-page` with `--ref` or `--element`.
- `element screenshots are not supported for existing-session profiles; use ref from snapshot.` → Chrome MCP / `existing-session` screenshot calls must use page capture or a snapshot `--ref`, not CSS `--element`.
- `existing-session file uploads do not support element selectors; use ref/inputRef.` → Chrome MCP upload hooks need snapshot refs, not CSS selectors.

View File

@@ -329,6 +329,20 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- `pnpm test:perf:profile:main` writes a main-thread CPU profile for Vitest/Vite startup and transform overhead.
- `pnpm test:perf:profile:runner` writes runner CPU+heap profiles for the unit suite with file parallelism disabled.
### Stability (gateway)
- Command: `pnpm test:stability:gateway`
- Config: `vitest.gateway.config.ts`, forced to one worker
- Scope:
- Starts a real loopback Gateway with diagnostics enabled by default
- Drives synthetic gateway message, memory, and large-payload churn through the diagnostic event path
- Queries `diagnostics.stability` over the Gateway WS RPC
- Covers diagnostic stability bundle persistence helpers
- Asserts the recorder remains bounded, synthetic RSS samples stay under the pressure budget, and per-session queue depths drain back to zero
- Expectations:
- CI-safe and keyless
- Narrow lane for stability-regression follow-up, not a substitute for the full Gateway suite
### E2E (gateway smoke)
- Command: `pnpm test:e2e`
@@ -608,11 +622,15 @@ Docker notes:
thread can resume
- run `/codex status` and `/codex models` through the same gateway command
path
- optionally run two Guardian-reviewed escalated shell probes: one benign
command that should be approved and one fake-secret upload that should be
denied so the agent asks back
- Test: `src/gateway/gateway-codex-harness.live.test.ts`
- Enable: `OPENCLAW_LIVE_CODEX_HARNESS=1`
- Default model: `codex/gpt-5.4`
- Optional image probe: `OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE=1`
- Optional MCP/tool probe: `OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE=1`
- Optional Guardian probe: `OPENCLAW_LIVE_CODEX_HARNESS_GUARDIAN_PROBE=1`
- The smoke sets `OPENCLAW_AGENT_HARNESS_FALLBACK=none` so a broken Codex
harness cannot pass by silently falling back to PI.
- Auth: `OPENAI_API_KEY` from the shell/profile, plus optional copied
@@ -625,6 +643,7 @@ source ~/.profile
OPENCLAW_LIVE_CODEX_HARNESS=1 \
OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE=1 \
OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE=1 \
OPENCLAW_LIVE_CODEX_HARNESS_GUARDIAN_PROBE=1 \
OPENCLAW_LIVE_CODEX_HARNESS_MODEL=codex/gpt-5.4 \
pnpm test:live -- src/gateway/gateway-codex-harness.live.test.ts
```
@@ -642,9 +661,11 @@ Docker notes:
- It sources the mounted `~/.profile`, passes `OPENAI_API_KEY`, copies Codex CLI
auth files when present, installs `@openai/codex` into a writable mounted npm
prefix, stages the source tree, then runs only the Codex-harness live test.
- Docker enables the image and MCP/tool probes by default. Set
- Docker enables the image, MCP/tool, and Guardian probes by default. Set
`OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE=0` or
`OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE=0` when you need a narrower debug run.
`OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE=0` or
`OPENCLAW_LIVE_CODEX_HARNESS_GUARDIAN_PROBE=0` when you need a narrower debug
run.
- Docker also exports `OPENCLAW_AGENT_HARNESS_FALLBACK=none`, matching the live
test config so `openai-codex/*` or PI fallback cannot hide a Codex harness
regression.
@@ -781,10 +802,11 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Current bundled providers covered:
- `openai`
- `google`
- `xai`
- Optional narrowing:
- `OPENCLAW_LIVE_IMAGE_GENERATION_PROVIDERS="openai,google"`
- `OPENCLAW_LIVE_IMAGE_GENERATION_MODELS="openai/gpt-image-2,google/gemini-3.1-flash-image-preview"`
- `OPENCLAW_LIVE_IMAGE_GENERATION_CASES="google:flash-generate,google:pro-edit"`
- `OPENCLAW_LIVE_IMAGE_GENERATION_PROVIDERS="openai,google,xai"`
- `OPENCLAW_LIVE_IMAGE_GENERATION_MODELS="openai/gpt-image-2,google/gemini-3.1-flash-image-preview,xai/grok-imagine-image"`
- `OPENCLAW_LIVE_IMAGE_GENERATION_CASES="google:flash-generate,google:pro-edit,xai:default-generate,xai:default-edit"`
- Optional auth behavior:
- `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to force profile-store auth and ignore env-only overrides
@@ -874,7 +896,7 @@ These Docker runners split into two buckets:
`OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Override those env vars when you
explicitly want the larger exhaustive scan.
- `test:docker:all` builds the live Docker image once via `test:docker:live-build`, then reuses it for the two live Docker lanes.
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:gateway-network`, `test:docker:mcp-channels`, and `test:docker:plugins` boot one or more real containers and verify higher-level integration paths.
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:gateway-network`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, and `test:docker:plugins` boot one or more real containers and verify higher-level integration paths.
The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:
@@ -887,7 +909,12 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or
- Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`)
- Gateway networking (two containers, WS auth + health): `pnpm test:docker:gateway-network` (script: `scripts/e2e/gateway-network-docker.sh`)
- MCP channel bridge (seeded Gateway + stdio bridge + raw Claude notification-frame smoke): `pnpm test:docker:mcp-channels` (script: `scripts/e2e/mcp-channels-docker.sh`)
- Pi bundle MCP tools (real stdio MCP server + embedded Pi profile allow/deny smoke): `pnpm test:docker:pi-bundle-mcp-tools` (script: `scripts/e2e/pi-bundle-mcp-tools-docker.sh`)
- Cron/subagent MCP cleanup (real Gateway + stdio MCP child teardown after isolated cron and one-shot subagent runs): `pnpm test:docker:cron-mcp-cleanup` (script: `scripts/e2e/cron-mcp-cleanup-docker.sh`)
- Plugins (install smoke + `/plugin` alias + Claude-bundle restart semantics): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`)
- Bundled plugin runtime deps: `pnpm test:docker:bundled-channel-deps` builds a small Docker runner image by default, builds and packs OpenClaw once on the host, then mounts that tarball into each Linux install scenario. Reuse the image with `OPENCLAW_SKIP_DOCKER_BUILD=1`, skip the host rebuild after a fresh local build with `OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD=0`, or point at an existing tarball with `OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ=/path/to/openclaw-*.tgz`.
- Narrow bundled plugin runtime deps while iterating by disabling unrelated scenarios, for example:
`OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 pnpm test:docker:bundled-channel-deps`.
The live-model Docker runners also bind-mount the current checkout read-only and
stage it into a temporary workdir inside the container. This keeps the runtime
@@ -920,6 +947,15 @@ live event queue behavior, outbound send routing, and Claude-style channel +
permission notifications over the real stdio MCP bridge. The notification check
inspects the raw stdio MCP frames directly so the smoke validates what the
bridge actually emits, not just what a specific client SDK happens to surface.
`test:docker:pi-bundle-mcp-tools` is deterministic and does not need a live
model key. It builds the repo Docker image, starts a real stdio MCP probe server
inside the container, materializes that server through the embedded Pi bundle
MCP runtime, executes the tool, then verifies `coding` and `messaging` keep
`bundle-mcp` tools while `minimal` and `tools.deny: ["bundle-mcp"]` filter them.
`test:docker:cron-mcp-cleanup` is deterministic and does not need a live model
key. It starts a seeded Gateway with a real stdio MCP probe server, runs an
isolated cron turn and a `/subagents spawn` one-shot child turn, then verifies
the MCP child process exits after each run.
Manual ACP plain-language thread smoke (not CI):

View File

@@ -164,7 +164,7 @@ working option**:
example through `agents.defaults.imageModel` or
`openclaw infer image describe --model ollama/<vision-model>`.
- Bundled fallback order:
- Audio: OpenAI → Groq → Deepgram → Google → Mistral
- Audio: OpenAI → Groq → xAI → Deepgram → Google → Mistral
- Image: OpenAI → Anthropic → Google → MiniMax → MiniMax Portal → Z.AI
- Video: Google → Qwen → Moonshot
@@ -212,6 +212,7 @@ lists, OpenClaw can infer defaults:
- `mistral`: **audio**
- `zai`: **image**
- `groq`: **audio**
- `xai`: **audio**
- `deepgram`: **audio**
- Any `models.providers.<id>.models[]` catalog with an image-capable model:
**image**

View File

@@ -190,6 +190,8 @@ Hook guard semantics to keep in mind:
- `before_install`: `{ block: false }` is treated as no decision.
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
- `message_sending`: `{ cancel: false }` is treated as no decision.
- `message_received`: prefer the typed `threadId` field when you need inbound thread/topic routing. Keep `metadata` for channel-specific extras.
- `message_sending`: prefer typed `replyToId` / `threadId` routing fields over channel-specific metadata keys.
The `/approve` command handles both exec and plugin approvals with bounded fallback: when an exec approval id is not found, OpenClaw retries the same id through plugin approvals. Plugin approval forwarding can be configured independently via `approvals.plugin` in config.

View File

@@ -104,6 +104,8 @@ loader. Cursor command markdown works through the same path.
`mcpServers`
- OpenClaw exposes supported bundle MCP tools during embedded Pi agent turns by
launching stdio servers or connecting to HTTP servers
- the `coding` and `messaging` tool profiles include bundle MCP tools by
default; use `tools.deny: ["bundle-mcp"]` to opt out for an agent or gateway
- project-local Pi settings still apply after bundle defaults, so workspace
settings can override bundle MCP entries when needed
- bundle MCP tool catalogs are sorted deterministically before registration, so
@@ -170,6 +172,9 @@ OpenClaw registers bundle MCP tools with provider-safe names in the form
- colliding sanitized names are disambiguated with numeric suffixes
- final exposed tool order is deterministic by safe name to keep repeated Pi
turns cache-stable
- profile filtering treats all tools from one bundle MCP server as plugin-owned
by `bundle-mcp`, so profile allowlists and deny lists can include either
individual exposed tool names or the `bundle-mcp` plugin key
#### Embedded Pi settings

View File

@@ -17,6 +17,14 @@ discovery, native thread resume, native compaction, and app-server execution.
OpenClaw still owns chat channels, session files, model selection, tools,
approvals, media delivery, and the visible transcript mirror.
Native Codex turns also respect the shared `before_prompt_build`,
`before_compaction`, and `after_compaction` plugin hooks, so prompt shims and
compaction-aware automation can stay aligned with the PI harness.
Native Codex turns also respect the shared `before_prompt_build`,
`before_compaction`, `after_compaction`, `llm_input`, `llm_output`, and
`agent_end` plugin hooks, so prompt shims, compaction-aware automation, and
lifecycle observers can stay aligned with the PI harness.
The harness is off by default. It is selected only when the `codex` plugin is
enabled and the resolved model is a `codex/*` model, or when you explicitly
force `embeddedHarness.runtime: "codex"` or `OPENCLAW_AGENT_RUNTIME=codex`.
@@ -263,9 +271,14 @@ By default, the plugin starts Codex locally with:
codex app-server --listen stdio://
```
By default, OpenClaw asks Codex to request native approvals. You can tune that
policy further, for example by tightening it and routing reviews through the
guardian:
By default, OpenClaw starts local Codex harness sessions in YOLO mode:
`approvalPolicy: "never"`, `approvalsReviewer: "user"`, and
`sandbox: "danger-full-access"`. This is the trusted local operator posture used
for autonomous heartbeats: Codex can use shell and network tools without
stopping on native approval prompts that nobody is around to answer.
To opt in to Codex guardian-reviewed approvals, set `appServer.mode:
"guardian"`:
```json5
{
@@ -275,10 +288,8 @@ guardian:
enabled: true,
config: {
appServer: {
approvalPolicy: "untrusted",
approvalsReviewer: "guardian_subagent",
sandbox: "workspace-write",
serviceTier: "priority",
mode: "guardian",
serviceTier: "fast",
},
},
},
@@ -287,6 +298,45 @@ guardian:
}
```
Guardian mode expands to:
```json5
{
plugins: {
entries: {
codex: {
enabled: true,
config: {
appServer: {
mode: "guardian",
approvalPolicy: "on-request",
approvalsReviewer: "guardian_subagent",
sandbox: "workspace-write",
},
},
},
},
},
}
```
Guardian is a native Codex approval reviewer. When Codex asks to leave the
sandbox, write outside the workspace, or add permissions such as network access,
Codex routes that approval request to a reviewer subagent instead of a human
prompt. The reviewer gathers context and applies Codex's risk framework, then
approves or denies the specific request. Guardian is useful when you want more
guardrails than YOLO mode but still need unattended agents and heartbeats to
make progress.
The Docker live harness includes a Guardian probe when
`OPENCLAW_LIVE_CODEX_HARNESS_GUARDIAN_PROBE=1`. It starts the Codex harness in
Guardian mode, verifies that a benign escalated shell command is approved, and
verifies that a fake-secret upload to an untrusted external destination is
denied so the agent asks back for explicit approval.
The individual policy fields still win over `mode`, so advanced deployments can
mix the preset with explicit choices.
For an already-running app-server, use WebSocket transport:
```json5
@@ -311,30 +361,35 @@ For an already-running app-server, use WebSocket transport:
Supported `appServer` fields:
| Field | Default | Meaning |
| ------------------- | ---------------------------------------- | ------------------------------------------------------------------------ |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | `"codex"` | Executable for stdio transport. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `approvalPolicy` | `"on-request"` | Native Codex approval policy sent to thread start/resume/turn. |
| `sandbox` | `"workspace-write"` | Native Codex sandbox mode sent to thread start/resume. |
| `approvalsReviewer` | `"user"` | Use `"guardian_subagent"` to let Codex guardian review native approvals. |
| `serviceTier` | unset | Optional Codex service tier, for example `"priority"`. |
| Field | Default | Meaning |
| ------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | `"codex"` | Executable for stdio transport. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `mode` | `"yolo"` | Preset for YOLO or guardian-reviewed execution. |
| `approvalPolicy` | `"never"` | Native Codex approval policy sent to thread start/resume/turn. |
| `sandbox` | `"danger-full-access"` | Native Codex sandbox mode sent to thread start/resume. |
| `approvalsReviewer` | `"user"` | Use `"guardian_subagent"` to let Codex Guardian review prompts. |
| `serviceTier` | unset | Optional Codex app-server service tier: `"fast"`, `"flex"`, or `null`. Invalid legacy values are ignored. |
The older environment variables still work as fallbacks for local testing when
the matching config field is unset:
- `OPENCLAW_CODEX_APP_SERVER_BIN`
- `OPENCLAW_CODEX_APP_SERVER_ARGS`
- `OPENCLAW_CODEX_APP_SERVER_MODE=yolo|guardian`
- `OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY`
- `OPENCLAW_CODEX_APP_SERVER_SANDBOX`
- `OPENCLAW_CODEX_APP_SERVER_GUARDIAN=1`
Config is preferred for repeatable deployments.
`OPENCLAW_CODEX_APP_SERVER_GUARDIAN=1` was removed. Use
`plugins.entries.codex.config.appServer.mode: "guardian"` instead, or
`OPENCLAW_CODEX_APP_SERVER_MODE=guardian` for one-off local testing. Config is
preferred for repeatable deployments because it keeps the plugin behavior in the
same reviewed file as the rest of the Codex harness setup.
## Common recipes
@@ -379,6 +434,7 @@ Guardian-reviewed Codex approvals:
enabled: true,
config: {
appServer: {
mode: "guardian",
approvalPolicy: "on-request",
approvalsReviewer: "guardian_subagent",
sandbox: "workspace-write",
@@ -457,7 +513,10 @@ When the selected model uses the Codex harness, native thread compaction is
delegated to Codex app-server. OpenClaw keeps a transcript mirror for channel
history, search, `/new`, `/reset`, and future model or harness switching. The
mirror includes the user prompt, final assistant text, and lightweight Codex
reasoning or plan records when the app-server emits them.
reasoning or plan records when the app-server emits them. Today, OpenClaw only
records native compaction start and completion signals. It does not yet expose a
human-readable compaction summary or an auditable list of which entries Codex
kept after compaction.
Media generation does not require PI. Image, video, music, PDF, TTS, and media
understanding continue to use the matching provider/model settings such as

View File

@@ -134,6 +134,15 @@ OpenClaw requires Codex app-server `0.118.0` or newer. The Codex plugin checks
the app-server initialize handshake and blocks older or unversioned servers so
OpenClaw only runs against the protocol surface it has been tested with.
### Codex app-server tool-result middleware
Bundled plugins can also attach Codex app-server-specific `tool_result`
middleware through `api.registerCodexAppServerExtensionFactory(...)` when their
manifest declares `contracts.embeddedExtensionFactories: ["codex-app-server"]`.
This is the trusted-plugin seam for async tool-result transforms that need to
run inside the native Codex harness before the tool output is projected back
into the OpenClaw transcript.
### Native Codex harness mode
The bundled `codex` harness is the native Codex mode for embedded OpenClaw

View File

@@ -192,7 +192,7 @@ explicitly promotes one as public.
| `plugin-sdk/process-runtime` | Process exec helpers |
| `plugin-sdk/cli-runtime` | CLI formatting, wait, and version helpers |
| `plugin-sdk/gateway-runtime` | Gateway client and channel-status patch helpers |
| `plugin-sdk/config-runtime` | Config load/write helpers |
| `plugin-sdk/config-runtime` | Config load/write helpers and plugin-config lookup helpers |
| `plugin-sdk/telegram-command-config` | Telegram command-name/description normalization and duplicate/conflict checks, even when the bundled Telegram contract surface is unavailable |
| `plugin-sdk/text-autolink-runtime` | File-reference autolink detection without the broad text-runtime barrel |
| `plugin-sdk/approval-runtime` | Exec/plugin approval helpers, approval-capability builders, auth/profile helpers, native routing/runtime helpers |
@@ -460,6 +460,9 @@ AI CLI backend such as `codex-cli`.
- `reply_dispatch`: returning `{ handled: true, ... }` is terminal. Once any handler claims dispatch, lower-priority handlers and the default model dispatch path are skipped.
- `message_sending`: returning `{ cancel: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
- `message_sending`: returning `{ cancel: false }` is treated as no decision (same as omitting `cancel`), not as an override.
- `message_received`: use the typed `threadId` field when you need inbound thread/topic routing. Keep `metadata` for channel-specific extras.
- `message_sending`: use typed `replyToId` / `threadId` routing fields before falling back to channel-specific `metadata`.
- `gateway_start`: use `ctx.config`, `ctx.workspaceDir`, and `ctx.getCron?.()` for gateway-owned startup state instead of relying on internal `gateway:startup` hooks.
### API object fields

View File

@@ -155,8 +155,8 @@ Current runtime behavior:
- `streaming.provider` is optional. If unset, Voice Call uses the first
registered realtime transcription provider.
- Today the bundled provider is OpenAI, registered by the bundled `openai`
plugin.
- Bundled realtime transcription providers include OpenAI (`openai`) and xAI
(`xai`), registered by their provider plugins.
- Provider-owned raw config lives under `streaming.providers.<providerId>`.
- If `streaming.provider` points at an unregistered provider, or no realtime
transcription provider is registered at all, Voice Call logs a warning and
@@ -169,6 +169,15 @@ OpenAI streaming transcription defaults:
- `silenceDurationMs`: `800`
- `vadThreshold`: `0.5`
xAI streaming transcription defaults:
- API key: `streaming.providers.xai.apiKey` or `XAI_API_KEY`
- endpoint: `wss://api.x.ai/v1/stt`
- `encoding`: `mulaw`
- `sampleRate`: `8000`
- `endpointingMs`: `800`
- `interimResults`: `true`
Example:
```json5
@@ -197,6 +206,33 @@ Example:
}
```
Use xAI instead:
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
streaming: {
enabled: true,
provider: "xai",
streamPath: "/voice/stream",
providers: {
xai: {
apiKey: "${XAI_API_KEY}", // optional if XAI_API_KEY is set
endpointingMs: 800,
language: "en",
},
},
},
},
},
},
},
}
```
Legacy keys are still auto-migrated by `openclaw doctor --fix`:
- `streaming.sttProvider``streaming.provider`

View File

@@ -2,18 +2,22 @@
summary: "Deepgram transcription for inbound voice notes"
read_when:
- You want Deepgram speech-to-text for audio attachments
- You want Deepgram streaming transcription for Voice Call
- You need a quick Deepgram config example
title: "Deepgram"
---
# Deepgram (Audio Transcription)
Deepgram is a speech-to-text API. In OpenClaw it is used for **inbound audio/voice note
transcription** via `tools.media.audio`.
Deepgram is a speech-to-text API. In OpenClaw it is used for inbound
audio/voice-note transcription through `tools.media.audio` and for Voice Call
streaming STT through `plugins.entries.voice-call.config.streaming`.
When enabled, OpenClaw uploads the audio file to Deepgram and injects the transcript
into the reply pipeline (`{{Transcript}}` + `[Audio]` block). This is **not streaming**;
it uses the pre-recorded transcription endpoint.
For batch transcription, OpenClaw uploads the complete audio file to Deepgram
and injects the transcript into the reply pipeline (`{{Transcript}}` +
`[Audio]` block). For Voice Call streaming, OpenClaw forwards live G.711
u-law frames over Deepgram's WebSocket `listen` endpoint and emits partial or
final transcripts as Deepgram returns them.
| Detail | Value |
| ------------- | ---------------------------------------------------------- |
@@ -101,6 +105,52 @@ it uses the pre-recorded transcription endpoint.
</Tab>
</Tabs>
## Voice Call streaming STT
The bundled `deepgram` plugin also registers a realtime transcription provider
for the Voice Call plugin.
| Setting | Config path | Default |
| --------------- | ----------------------------------------------------------------------- | -------------------------------- |
| API key | `plugins.entries.voice-call.config.streaming.providers.deepgram.apiKey` | Falls back to `DEEPGRAM_API_KEY` |
| Model | `...deepgram.model` | `nova-3` |
| Language | `...deepgram.language` | (unset) |
| Encoding | `...deepgram.encoding` | `mulaw` |
| Sample rate | `...deepgram.sampleRate` | `8000` |
| Endpointing | `...deepgram.endpointingMs` | `800` |
| Interim results | `...deepgram.interimResults` | `true` |
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
streaming: {
enabled: true,
provider: "deepgram",
providers: {
deepgram: {
apiKey: "${DEEPGRAM_API_KEY}",
model: "nova-3",
endpointingMs: 800,
language: "en-US",
},
},
},
},
},
},
},
}
```
<Note>
Voice Call receives telephony audio as 8 kHz G.711 u-law. The Deepgram
streaming provider defaults to `encoding: "mulaw"` and `sampleRate: 8000`, so
Twilio media frames can be forwarded directly.
</Note>
## Notes
<AccordionGroup>
@@ -118,12 +168,6 @@ it uses the pre-recorded transcription endpoint.
</Accordion>
</AccordionGroup>
<Note>
Deepgram transcription is **pre-recorded only** (not real-time streaming). OpenClaw
uploads the complete audio file and waits for the full transcript before injecting
it into the conversation.
</Note>
## Related
<CardGroup cols={2}>

View File

@@ -0,0 +1,111 @@
---
summary: "Use ElevenLabs speech, Scribe STT, and realtime transcription with OpenClaw"
read_when:
- You want ElevenLabs text-to-speech in OpenClaw
- You want ElevenLabs Scribe speech-to-text for audio attachments
- You want ElevenLabs realtime transcription for Voice Call
title: "ElevenLabs"
---
# ElevenLabs
OpenClaw uses ElevenLabs for text-to-speech, batch speech-to-text with Scribe
v2, and Voice Call streaming STT with Scribe v2 Realtime.
| Capability | OpenClaw surface | Default |
| ------------------------ | --------------------------------------------- | ------------------------ |
| Text-to-speech | `messages.tts` / `talk` | `eleven_multilingual_v2` |
| Batch speech-to-text | `tools.media.audio` | `scribe_v2` |
| Streaming speech-to-text | Voice Call `streaming.provider: "elevenlabs"` | `scribe_v2_realtime` |
## Authentication
Set `ELEVENLABS_API_KEY` in the environment. `XI_API_KEY` is also accepted for
compatibility with existing ElevenLabs tooling.
```bash
export ELEVENLABS_API_KEY="..."
```
## Text-to-speech
```json5
{
messages: {
tts: {
providers: {
elevenlabs: {
apiKey: "${ELEVENLABS_API_KEY}",
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2",
},
},
},
},
}
```
## Speech-to-text
Use Scribe v2 for inbound audio attachments and short recorded voice segments:
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [{ provider: "elevenlabs", model: "scribe_v2" }],
},
},
},
}
```
OpenClaw sends multipart audio to ElevenLabs `/v1/speech-to-text` with
`model_id: "scribe_v2"`. Language hints map to `language_code` when present.
## Voice Call streaming STT
The bundled `elevenlabs` plugin registers Scribe v2 Realtime for Voice Call
streaming transcription.
| Setting | Config path | Default |
| --------------- | ------------------------------------------------------------------------- | ------------------------------------------------- |
| API key | `plugins.entries.voice-call.config.streaming.providers.elevenlabs.apiKey` | Falls back to `ELEVENLABS_API_KEY` / `XI_API_KEY` |
| Model | `...elevenlabs.modelId` | `scribe_v2_realtime` |
| Audio format | `...elevenlabs.audioFormat` | `ulaw_8000` |
| Sample rate | `...elevenlabs.sampleRate` | `8000` |
| Commit strategy | `...elevenlabs.commitStrategy` | `vad` |
| Language | `...elevenlabs.languageCode` | (unset) |
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
streaming: {
enabled: true,
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "${ELEVENLABS_API_KEY}",
audioFormat: "ulaw_8000",
commitStrategy: "vad",
languageCode: "en",
},
},
},
},
},
},
},
}
```
<Note>
Voice Call receives Twilio media as 8 kHz G.711 u-law. The ElevenLabs realtime
provider defaults to `ulaw_8000`, so telephony frames can be forwarded without
transcoding.
</Note>

View File

@@ -82,6 +82,10 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
## Transcription providers
- [Deepgram (audio transcription)](/providers/deepgram)
- [ElevenLabs](/providers/elevenlabs#speech-to-text)
- [Mistral](/providers/mistral#audio-transcription-voxtral)
- [OpenAI](/providers/openai#speech-to-text)
- [xAI](/providers/xai#speech-to-text)
## Community tools

View File

@@ -2,6 +2,7 @@
summary: "Use Mistral models and Voxtral transcription with OpenClaw"
read_when:
- You want to use Mistral models in OpenClaw
- You want Voxtral realtime transcription for Voice Call
- You need Mistral API key onboarding and model refs
title: "Mistral"
---
@@ -65,7 +66,8 @@ OpenClaw currently ships this bundled Mistral catalog:
## Audio transcription (Voxtral)
Use Voxtral for audio transcription through the media understanding pipeline.
Use Voxtral for batch audio transcription through the media understanding
pipeline.
```json5
{
@@ -84,6 +86,48 @@ Use Voxtral for audio transcription through the media understanding pipeline.
The media transcription path uses `/v1/audio/transcriptions`. The default audio model for Mistral is `voxtral-mini-latest`.
</Tip>
## Voice Call streaming STT
The bundled `mistral` plugin registers Voxtral Realtime as a Voice Call
streaming STT provider.
| Setting | Config path | Default |
| ------------ | ---------------------------------------------------------------------- | --------------------------------------- |
| API key | `plugins.entries.voice-call.config.streaming.providers.mistral.apiKey` | Falls back to `MISTRAL_API_KEY` |
| Model | `...mistral.model` | `voxtral-mini-transcribe-realtime-2602` |
| Encoding | `...mistral.encoding` | `pcm_mulaw` |
| Sample rate | `...mistral.sampleRate` | `8000` |
| Target delay | `...mistral.targetStreamingDelayMs` | `800` |
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
streaming: {
enabled: true,
provider: "mistral",
providers: {
mistral: {
apiKey: "${MISTRAL_API_KEY}",
targetStreamingDelayMs: 800,
},
},
},
},
},
},
},
}
```
<Note>
OpenClaw defaults Mistral realtime STT to `pcm_mulaw` at 8 kHz so Voice Call
can forward Twilio media frames directly. Use `encoding: "pcm_s16le"` and a
matching `sampleRate` only if your upstream stream is already raw PCM.
</Note>
## Advanced configuration
<AccordionGroup>

View File

@@ -16,6 +16,21 @@ OpenAI provides developer APIs for GPT models. OpenClaw supports two auth routes
OpenAI explicitly supports subscription OAuth usage in external tools and workflows like OpenClaw.
## OpenClaw feature coverage
| OpenAI capability | OpenClaw surface | Status |
| ------------------------- | ----------------------------------------- | ------------------------------------------------------ |
| Chat / Responses | `openai/<model>` model provider | Yes |
| Codex subscription models | `openai-codex/<model>` model provider | Yes |
| Server-side web search | Native OpenAI Responses tool | Yes, when web search is enabled and no provider pinned |
| Images | `image_generate` | Yes |
| Videos | `video_generate` | Yes |
| Text-to-speech | `messages.tts.provider: "openai"` / `tts` | Yes |
| Batch speech-to-text | `tools.media.audio` / media understanding | Yes |
| Streaming speech-to-text | Voice Call `streaming.provider: "openai"` | Yes |
| Realtime voice | Voice Call `realtime.provider: "openai"` | Yes |
| Embeddings | memory embedding provider | Yes |
## Getting started
Choose your preferred auth method and follow the setup steps.
@@ -222,7 +237,9 @@ See [Video Generation](/tools/video-generation) for shared tool parameters, prov
## GPT-5 prompt contribution
OpenClaw adds an OpenAI-specific GPT-5 prompt contribution for `openai/*` and `openai-codex/*` GPT-5-family runs. It lives in the bundled OpenAI plugin, applies to model ids such as `gpt-5`, `gpt-5.2`, `gpt-5.4`, and `gpt-5.4-mini`, and does not apply to older GPT-4.x models.
OpenClaw adds a shared GPT-5 prompt contribution for GPT-5-family runs across providers. It applies by model id, so `openai/gpt-5.4`, `openai-codex/gpt-5.4`, `openrouter/openai/gpt-5.4`, `opencode/gpt-5.4`, and other compatible GPT-5 refs receive the same overlay. Older GPT-4.x models do not.
The bundled native Codex harness provider (`codex/*`) uses the same GPT-5 behavior and heartbeat overlay through Codex app-server developer instructions, so `codex/gpt-5.x` sessions keep the same follow-through and proactive heartbeat guidance even though Codex owns the rest of the harness prompt.
The GPT-5 contribution adds a tagged behavior contract for persona persistence, execution safety, tool discipline, output shape, completion checks, and verification. Channel-specific reply and silent-message behavior stays in the shared OpenClaw system prompt and outbound delivery policy. The GPT-5 guidance is always enabled for matching models. The friendly interaction-style layer is separate and configurable.
@@ -236,9 +253,11 @@ The GPT-5 contribution adds a tagged behavior contract for persona persistence,
<Tab title="Config">
```json5
{
plugins: {
entries: {
openai: { config: { personality: "friendly" } },
agents: {
defaults: {
promptOverlays: {
gpt5: { personality: "friendly" },
},
},
},
}
@@ -246,7 +265,7 @@ The GPT-5 contribution adds a tagged behavior contract for persona persistence,
</Tab>
<Tab title="CLI">
```bash
openclaw config set plugins.entries.openai.config.personality off
openclaw config set agents.defaults.promptOverlays.gpt5.personality off
```
</Tab>
</Tabs>
@@ -255,6 +274,10 @@ The GPT-5 contribution adds a tagged behavior contract for persona persistence,
Values are case-insensitive at runtime, so `"Off"` and `"off"` both disable the friendly style layer.
</Tip>
<Note>
Legacy `plugins.entries.openai.config.personality` is still read as a compatibility fallback when the shared `agents.defaults.promptOverlays.gpt5.personality` setting is not set.
</Note>
## Voice and speech
<AccordionGroup>
@@ -291,18 +314,56 @@ Values are case-insensitive at runtime, so `"Off"` and `"off"` both disable the
</Accordion>
<Accordion title="Speech-to-text">
The bundled `openai` plugin registers batch speech-to-text through
OpenClaw's media-understanding transcription surface.
- Default model: `gpt-4o-transcribe`
- Endpoint: OpenAI REST `/v1/audio/transcriptions`
- Input path: multipart audio file upload
- Supported by OpenClaw wherever inbound audio transcription uses
`tools.media.audio`, including Discord voice-channel segments and channel
audio attachments
To force OpenAI for inbound audio transcription:
```json5
{
tools: {
media: {
audio: {
models: [
{
type: "provider",
provider: "openai",
model: "gpt-4o-transcribe",
},
],
},
},
},
}
```
Language and prompt hints are forwarded to OpenAI when supplied by the
shared audio media config or per-call transcription request.
</Accordion>
<Accordion title="Realtime transcription">
The bundled `openai` plugin registers realtime transcription for the Voice Call plugin.
| Setting | Config path | Default |
|---------|------------|---------|
| Model | `plugins.entries.voice-call.config.streaming.providers.openai.model` | `gpt-4o-transcribe` |
| Language | `...openai.language` | (unset) |
| Prompt | `...openai.prompt` | (unset) |
| Silence duration | `...openai.silenceDurationMs` | `800` |
| VAD threshold | `...openai.vadThreshold` | `0.5` |
| API key | `...openai.apiKey` | Falls back to `OPENAI_API_KEY` |
<Note>
Uses a WebSocket connection to `wss://api.openai.com/v1/realtime` with G.711 u-law audio.
Uses a WebSocket connection to `wss://api.openai.com/v1/realtime` with G.711 u-law (`g711_ulaw` / `audio/pcmu`) audio. This streaming provider is for Voice Call's realtime transcription path; Discord voice currently records short segments and uses the batch `tools.media.audio` transcription path instead.
</Note>
</Accordion>

View File

@@ -63,6 +63,35 @@ they follow the same API shape.
current image-capable Grok refs in the bundled catalog.
</Tip>
## OpenClaw feature coverage
The bundled plugin maps xAI's current public API surface onto OpenClaw's shared
provider and tool contracts where the behavior fits cleanly.
| xAI capability | OpenClaw surface | Status |
| -------------------------- | ----------------------------------------- | ------------------------------------------------------------------- |
| Chat / Responses | `xai/<model>` model provider | Yes |
| Server-side web search | `web_search` provider `grok` | Yes |
| Server-side X search | `x_search` tool | Yes |
| Server-side code execution | `code_execution` tool | Yes |
| Images | `image_generate` | Yes |
| Videos | `video_generate` | Yes |
| Batch text-to-speech | `messages.tts.provider: "xai"` / `tts` | Yes |
| Streaming TTS | — | Not exposed; OpenClaw's TTS contract returns complete audio buffers |
| Batch speech-to-text | `tools.media.audio` / media understanding | Yes |
| Streaming speech-to-text | Voice Call `streaming.provider: "xai"` | Yes |
| Realtime voice | — | Not exposed yet; different session/WebSocket contract |
| Files / batches | Generic model API compatibility only | Not a first-class OpenClaw tool |
<Note>
OpenClaw uses xAI's REST image/video/TTS/STT APIs for media generation,
speech, and batch transcription, xAI's streaming STT WebSocket for live
voice-call transcription, and the Responses API for model, search, and
code-execution tools. Features that need different OpenClaw contracts, such as
Realtime voice sessions, are documented here as upstream capabilities rather
than hidden plugin behavior.
</Note>
### Fast-mode mappings
`/fast on` or `agents.defaults.models["xai/<model>"].params.fastMode: true`
@@ -103,12 +132,17 @@ Legacy aliases still normalize to the canonical bundled ids:
`video_generate` tool.
- Default video model: `xai/grok-imagine-video`
- Modes: text-to-video, image-to-video, and remote video edit/extend flows
- Supports `aspectRatio` and `resolution`
- Modes: text-to-video, image-to-video, remote video edit, and remote video
extension
- Aspect ratios: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, `2:3`
- Resolutions: `480P`, `720P`
- Duration: 1-15 seconds for generation/image-to-video, 2-10 seconds for
extension
<Warning>
Local video buffers are not accepted. Use remote `http(s)` URLs for
video-reference and edit inputs.
video edit/extend inputs. Image-to-video accepts local image buffers because
OpenClaw can encode those as data URLs for xAI.
</Warning>
To use xAI as the default video provider:
@@ -132,6 +166,170 @@ Legacy aliases still normalize to the canonical bundled ids:
</Accordion>
<Accordion title="Image generation">
The bundled `xai` plugin registers image generation through the shared
`image_generate` tool.
- Default image model: `xai/grok-imagine-image`
- Additional model: `xai/grok-imagine-image-pro`
- Modes: text-to-image and reference-image edit
- Reference inputs: one `image` or up to five `images`
- Aspect ratios: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2:3`, `3:2`
- Resolutions: `1K`, `2K`
- Count: up to 4 images
OpenClaw asks xAI for `b64_json` image responses so generated media can be
stored and delivered through the normal channel attachment path. Local
reference images are converted to data URLs; remote `http(s)` references are
passed through.
To use xAI as the default image provider:
```json5
{
agents: {
defaults: {
imageGenerationModel: {
primary: "xai/grok-imagine-image",
},
},
},
}
```
<Note>
xAI also documents `quality`, `mask`, `user`, and additional native ratios
such as `1:2`, `2:1`, `9:20`, and `20:9`. OpenClaw forwards only the
shared cross-provider image controls today; unsupported native-only knobs
are intentionally not exposed through `image_generate`.
</Note>
</Accordion>
<Accordion title="Text-to-speech">
The bundled `xai` plugin registers text-to-speech through the shared `tts`
provider surface.
- Voices: `eve`, `ara`, `rex`, `sal`, `leo`, `una`
- Default voice: `eve`
- Formats: `mp3`, `wav`, `pcm`, `mulaw`, `alaw`
- Language: BCP-47 code or `auto`
- Speed: provider-native speed override
- Native Opus voice-note format is not supported
To use xAI as the default TTS provider:
```json5
{
messages: {
tts: {
provider: "xai",
providers: {
xai: {
voiceId: "eve",
},
},
},
},
}
```
<Note>
OpenClaw uses xAI's batch `/v1/tts` endpoint. xAI also offers streaming TTS
over WebSocket, but the OpenClaw speech provider contract currently expects
a complete audio buffer before reply delivery.
</Note>
</Accordion>
<Accordion title="Speech-to-text">
The bundled `xai` plugin registers batch speech-to-text through OpenClaw's
media-understanding transcription surface.
- Default model: `grok-stt`
- Endpoint: xAI REST `/v1/stt`
- Input path: multipart audio file upload
- Supported by OpenClaw wherever inbound audio transcription uses
`tools.media.audio`, including Discord voice-channel segments and
channel audio attachments
To force xAI for inbound audio transcription:
```json5
{
tools: {
media: {
audio: {
models: [
{
type: "provider",
provider: "xai",
model: "grok-stt",
},
],
},
},
},
}
```
Language can be supplied through the shared audio media config or per-call
transcription request. Prompt hints are accepted by the shared OpenClaw
surface, but the xAI REST STT integration only forwards file, model, and
language because those map cleanly to the current public xAI endpoint.
</Accordion>
<Accordion title="Streaming speech-to-text">
The bundled `xai` plugin also registers a realtime transcription provider
for live voice-call audio.
- Endpoint: xAI WebSocket `wss://api.x.ai/v1/stt`
- Default encoding: `mulaw`
- Default sample rate: `8000`
- Default endpointing: `800ms`
- Interim transcripts: enabled by default
Voice Call's Twilio media stream sends G.711 µ-law audio frames, so the
xAI provider can forward those frames directly without transcoding:
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
streaming: {
enabled: true,
provider: "xai",
providers: {
xai: {
apiKey: "${XAI_API_KEY}",
endpointingMs: 800,
language: "en",
},
},
},
},
},
},
},
}
```
Provider-owned config lives under
`plugins.entries.voice-call.config.streaming.providers.xai`. Supported
keys are `apiKey`, `baseUrl`, `sampleRate`, `encoding` (`pcm`, `mulaw`, or
`alaw`), `interimResults`, `endpointingMs`, and `language`.
<Note>
This streaming provider is for Voice Call's realtime transcription path.
Discord voice currently records short segments and uses the batch
`tools.media.audio` transcription path instead.
</Note>
</Accordion>
<Accordion title="x_search configuration">
The bundled xAI plugin exposes `x_search` as an OpenClaw tool for searching
X (formerly Twitter) content via Grok.
@@ -209,6 +407,12 @@ Legacy aliases still normalize to the canonical bundled ids:
- `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the
normal xAI provider path because it requires a different upstream API
surface than the standard OpenClaw xAI transport.
- xAI Realtime voice is not registered as an OpenClaw provider yet. It
needs a different bidirectional voice session contract than batch STT or
streaming transcription.
- xAI image `quality`, image `mask`, and extra native-only aspect ratios are
not exposed until the shared `image_generate` tool has corresponding
cross-provider controls.
</Accordion>
<Accordion title="Advanced notes">
@@ -229,6 +433,24 @@ Legacy aliases still normalize to the canonical bundled ids:
</Accordion>
</AccordionGroup>
## Live testing
The xAI media paths are covered by unit tests and opt-in live suites. The live
commands load secrets from your login shell, including `~/.profile`, before
probing `XAI_API_KEY`.
```bash
pnpm test extensions/xai
OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_TEST_QUIET=1 pnpm test:live -- extensions/xai/xai.live.test.ts
OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_TEST_QUIET=1 OPENCLAW_LIVE_IMAGE_GENERATION_PROVIDERS=xai pnpm test:live -- test/image-generation.runtime.live.test.ts
```
The provider-specific live file synthesizes normal TTS, telephony-friendly PCM
TTS, transcribes audio through xAI batch STT, streams the same PCM through xAI
realtime STT, generates text-to-image output, and edits a reference image. The
shared image live file verifies the same xAI provider through OpenClaw's
runtime selection, fallback, normalization, and media attachment path.
## Related
<CardGroup cols={2}>

View File

@@ -33,9 +33,10 @@ For a high-level overview, see [Onboarding (CLI)](/start/wizard).
- **Anthropic API key**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
- **Anthropic API key**: preferred Anthropic assistant choice in onboarding/configure.
- **Anthropic setup-token**: still available in onboarding/configure, though OpenClaw now prefers Claude CLI reuse when available.
- **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, onboarding can reuse it. Reused Codex CLI credentials stay managed by Codex CLI; on expiry OpenClaw re-reads that source first and, when the provider can refresh it, writes the refreshed credential back to Codex storage instead of taking ownership itself.
- **OpenAI Code (Codex) subscription (OAuth)**: browser flow; paste the `code#state`.
- Sets `agents.defaults.model` to `openai-codex/gpt-5.4` when model is unset or `openai/*`.
- **OpenAI Code (Codex) subscription (device pairing)**: browser pairing flow with a short-lived device code.
- Sets `agents.defaults.model` to `openai-codex/gpt-5.4` when model is unset or `openai/*`.
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then stores it in auth profiles.
- Sets `agents.defaults.model` to `openai/gpt-5.4` when model is unset, `openai/*`, or `openai-codex/*`.
- **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider.

View File

@@ -129,18 +129,17 @@ What you set:
<Accordion title="Anthropic API key">
Uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
</Accordion>
<Accordion title="OpenAI Code subscription (Codex CLI reuse)">
If `~/.codex/auth.json` exists, the wizard can reuse it.
Reused Codex CLI credentials stay managed by Codex CLI; on expiry OpenClaw
re-reads that source first and, when the provider can refresh it, writes
the refreshed credential back to Codex storage instead of taking ownership
itself.
</Accordion>
<Accordion title="OpenAI Code subscription (OAuth)">
Browser flow; paste `code#state`.
Sets `agents.defaults.model` to `openai-codex/gpt-5.4` when model is unset or `openai/*`.
</Accordion>
<Accordion title="OpenAI Code subscription (device pairing)">
Browser pairing flow with a short-lived device code.
Sets `agents.defaults.model` to `openai-codex/gpt-5.4` when model is unset or `openai/*`.
</Accordion>
<Accordion title="OpenAI API key">
Uses `OPENAI_API_KEY` if present or prompts for a key, then stores the credential in auth profiles.

View File

@@ -813,6 +813,23 @@ Security and trust notes:
Custom `mcpServers` still work as before. The built-in plugin-tools bridge is an
additional opt-in convenience, not a replacement for generic MCP server config.
### OpenClaw tools MCP bridge
By default, ACPX sessions also do **not** expose built-in OpenClaw tools through
MCP. Enable the separate core-tools bridge when an ACP agent needs selected
built-in tools such as `cron`:
```bash
openclaw config set plugins.entries.acpx.config.openClawToolsMcpBridge true
```
What this does:
- Injects a built-in MCP server named `openclaw-tools` into ACPX session
bootstrap.
- Exposes selected built-in OpenClaw tools. The initial server exposes `cron`.
- Keeps core-tool exposure explicit and default-off.
### Runtime timeout configuration
The bundled `acpx` plugin defaults embedded runtime turns to a 120-second

View File

@@ -637,9 +637,10 @@ What still needs Playwright:
Element screenshots also reject `--full-page`; the route returns `fullPage is
not supported for element screenshots`.
If you see `Playwright is not available in this gateway build`, install the full
Playwright package (not `playwright-core`) and restart the gateway, or reinstall
OpenClaw with browser support.
If you see `Playwright is not available in this gateway build`, repair the
bundled browser plugin runtime dependencies so `playwright-core` is installed,
then restart the gateway. For packaged installs, run `openclaw doctor --fix`.
For Docker, also install the Chromium browser binaries as shown below.
#### Docker Playwright install

View File

@@ -1,5 +1,5 @@
---
summary: "Generate and edit images using configured providers (OpenAI, Google Gemini, fal, MiniMax, ComfyUI, Vydra)"
summary: "Generate and edit images using configured providers (OpenAI, Google Gemini, fal, MiniMax, ComfyUI, Vydra, xAI)"
read_when:
- Generating images via the agent
- Configuring image generation providers and models
@@ -46,6 +46,7 @@ The agent calls `image_generate` automatically. No tool allow-listing needed —
| MiniMax | `image-01` | Yes (subject reference) | `MINIMAX_API_KEY` or MiniMax OAuth (`minimax-portal`) |
| ComfyUI | `workflow` | Yes (1 image, workflow-configured) | `COMFY_API_KEY` or `COMFY_CLOUD_API_KEY` for cloud |
| Vydra | `grok-imagine` | No | `VYDRA_API_KEY` |
| xAI | `grok-imagine-image` | Yes (up to 5 images) | `XAI_API_KEY` |
Use `action: "list"` to inspect available providers and models at runtime:
@@ -115,13 +116,13 @@ Notes:
### Image editing
OpenAI, Google, fal, MiniMax, and ComfyUI support editing reference images. Pass a reference image path or URL:
OpenAI, Google, fal, MiniMax, ComfyUI, and xAI support editing reference images. Pass a reference image path or URL:
```
"Generate a watercolor version of this photo" + image: "/path/to/photo.jpg"
```
OpenAI and Google support up to 5 reference images via the `images` parameter. fal, MiniMax, and ComfyUI support 1.
OpenAI, Google, and xAI support up to 5 reference images via the `images` parameter. fal, MiniMax, and ComfyUI support 1.
### OpenAI `gpt-image-2`
@@ -166,13 +167,29 @@ MiniMax image generation is available through both bundled MiniMax auth paths:
## Provider capabilities
| Capability | OpenAI | Google | fal | MiniMax | ComfyUI | Vydra |
| --------------------- | -------------------- | -------------------- | ------------------- | -------------------------- | ---------------------------------- | ------- |
| Generate | Yes (up to 4) | Yes (up to 4) | Yes (up to 4) | Yes (up to 9) | Yes (workflow-defined outputs) | Yes (1) |
| Edit/reference | Yes (up to 5 images) | Yes (up to 5 images) | Yes (1 image) | Yes (1 image, subject ref) | Yes (1 image, workflow-configured) | No |
| Size control | Yes (up to 4K) | Yes | Yes | No | No | No |
| Aspect ratio | No | Yes | Yes (generate only) | Yes | No | No |
| Resolution (1K/2K/4K) | No | Yes | Yes | No | No | No |
| Capability | OpenAI | Google | fal | MiniMax | ComfyUI | Vydra | xAI |
| --------------------- | -------------------- | -------------------- | ------------------- | -------------------------- | ---------------------------------- | ------- | -------------------- |
| Generate | Yes (up to 4) | Yes (up to 4) | Yes (up to 4) | Yes (up to 9) | Yes (workflow-defined outputs) | Yes (1) | Yes (up to 4) |
| Edit/reference | Yes (up to 5 images) | Yes (up to 5 images) | Yes (1 image) | Yes (1 image, subject ref) | Yes (1 image, workflow-configured) | No | Yes (up to 5 images) |
| Size control | Yes (up to 4K) | Yes | Yes | No | No | No | No |
| Aspect ratio | No | Yes | Yes (generate only) | Yes | No | No | Yes |
| Resolution (1K/2K/4K) | No | Yes | Yes | No | No | No | Yes (1K/2K) |
### xAI `grok-imagine-image`
The bundled xAI provider uses `/v1/images/generations` for prompt-only requests
and `/v1/images/edits` when `image` or `images` is present.
- Models: `xai/grok-imagine-image`, `xai/grok-imagine-image-pro`
- Count: up to 4
- References: one `image` or up to five `images`
- Aspect ratios: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2:3`, `3:2`
- Resolutions: `1K`, `2K`
- Outputs: returned as OpenClaw-managed image attachments
OpenClaw intentionally does not expose xAI-native `quality`, `mask`, `user`, or
extra native-only aspect ratios until those controls exist in the shared
cross-provider `image_generate` contract.
## Related
@@ -183,5 +200,6 @@ MiniMax image generation is available through both bundled MiniMax auth paths:
- [MiniMax](/providers/minimax) — MiniMax image provider setup
- [OpenAI](/providers/openai) — OpenAI Images provider setup
- [Vydra](/providers/vydra) — Vydra image, video, and speech setup
- [xAI](/providers/xai) — Grok image, video, search, code execution, and TTS setup
- [Configuration Reference](/gateway/configuration-reference#agent-defaults) — `imageGenerationModel` config
- [Models](/concepts/models) — model configuration and failover

View File

@@ -139,6 +139,11 @@ Per-agent override: `agents.list[].tools.profile`.
| `messaging` | `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status` |
| `minimal` | `session_status` only |
The `coding` and `messaging` profiles also allow configured bundle MCP tools
under the plugin key `bundle-mcp`. Add `tools.deny: ["bundle-mcp"]` when you
want a profile to keep its normal built-ins but hide all configured MCP tools.
The `minimal` profile does not include bundle MCP tools.
### Tool groups
Use `group:*` shorthands in allow/deny lists:

View File

@@ -15,10 +15,10 @@ OpenClaw generates images, videos, and music, understands inbound media (images,
| Capability | Tool | Providers | What it does |
| -------------------- | ---------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| Image generation | `image_generate` | ComfyUI, fal, Google, MiniMax, OpenAI, Vydra | Creates or edits images from text prompts or references |
| Image generation | `image_generate` | ComfyUI, fal, Google, MiniMax, OpenAI, Vydra, xAI | Creates or edits images from text prompts or references |
| Video generation | `video_generate` | Alibaba, BytePlus, ComfyUI, fal, Google, MiniMax, OpenAI, Qwen, Runway, Together, Vydra, xAI | Creates videos from text, images, or existing videos |
| Music generation | `music_generate` | ComfyUI, Google, MiniMax | Creates music or audio tracks from text prompts |
| Text-to-speech (TTS) | `tts` | ElevenLabs, Microsoft, MiniMax, OpenAI | Converts outbound replies to spoken audio |
| Text-to-speech (TTS) | `tts` | ElevenLabs, Microsoft, MiniMax, OpenAI, xAI | Converts outbound replies to spoken audio |
| Media understanding | (automatic) | Any vision/audio-capable model provider, plus CLI fallbacks | Summarizes inbound images, audio, and video |
## Provider capability matrix
@@ -31,17 +31,18 @@ This table shows which providers support which media capabilities across the pla
| BytePlus | | Yes | | | | |
| ComfyUI | Yes | Yes | Yes | | | |
| Deepgram | | | | | Yes | |
| ElevenLabs | | | | Yes | | |
| ElevenLabs | | | | Yes | Yes | |
| fal | Yes | Yes | | | | |
| Google | Yes | Yes | Yes | | | Yes |
| Microsoft | | | | Yes | | |
| MiniMax | Yes | Yes | Yes | Yes | | |
| Mistral | | | | | Yes | |
| OpenAI | Yes | Yes | | Yes | Yes | Yes |
| Qwen | | Yes | | | | |
| Runway | | Yes | | | | |
| Together | | Yes | | | | |
| Vydra | Yes | Yes | | | | |
| xAI | | Yes | | | | |
| xAI | Yes | Yes | | Yes | Yes | Yes |
<Note>
Media understanding uses any vision-capable or audio-capable model registered in your provider config. The table above highlights providers with dedicated media-understanding support; most LLM providers with multimodal models (Anthropic, Google, OpenAI, etc.) can also understand inbound media when configured as the active reply model.
@@ -51,6 +52,19 @@ Media understanding uses any vision-capable or audio-capable model registered in
Video and music generation run as background tasks because provider processing typically takes 30 seconds to several minutes. When the agent calls `video_generate` or `music_generate`, OpenClaw submits the request to the provider, returns a task ID immediately, and tracks the job in the task ledger. The agent continues responding to other messages while the job runs. When the provider finishes, OpenClaw wakes the agent so it can post the finished media back into the original channel. Image generation and TTS are synchronous and complete inline with the reply.
Deepgram, ElevenLabs, Mistral, OpenAI, and xAI can all transcribe inbound
audio through the batch `tools.media.audio` path when configured. Deepgram,
ElevenLabs, Mistral, OpenAI, and xAI also register Voice Call streaming STT
providers, so live phone audio can be forwarded to the selected vendor
without waiting for a completed recording.
OpenAI maps to OpenClaw's image, video, batch TTS, batch STT, Voice Call
streaming STT, realtime voice, and memory embedding surfaces. xAI currently
maps to OpenClaw's image, video, search, code-execution, batch TTS, batch STT,
and Voice Call streaming STT surfaces. xAI Realtime voice is an upstream
capability, but it is not registered in OpenClaw until the shared realtime
voice contract can represent it.
## Quick links
- [Image Generation](/tools/image-generation) -- generating and editing images

View File

@@ -234,8 +234,8 @@ openclaw plugins install <plugin> --marketplace <source>
openclaw plugins install <plugin> --marketplace https://github.com/<owner>/<repo>
openclaw plugins install <spec> --pin # record exact resolved npm spec
openclaw plugins install <spec> --dangerously-force-unsafe-install
openclaw plugins update <id> # update one plugin
openclaw plugins update <id> --dangerously-force-unsafe-install
openclaw plugins update <id-or-npm-spec> # update one plugin
openclaw plugins update <id-or-npm-spec> --dangerously-force-unsafe-install
openclaw plugins update --all # update all
openclaw plugins uninstall <id> # remove config/install records
openclaw plugins uninstall <id> --keep-files
@@ -250,9 +250,18 @@ Bundled plugins ship with OpenClaw. Many are enabled by default (for example
bundled model providers, bundled speech providers, and the bundled browser
plugin). Other bundled plugins still need `openclaw plugins enable <id>`.
`--force` overwrites an existing installed plugin or hook pack in place.
It is not supported with `--link`, which reuses the source path instead of
copying over a managed install target.
`--force` overwrites an existing installed plugin or hook pack in place. Use
`openclaw plugins update <id-or-npm-spec>` for routine upgrades of tracked npm
plugins. It is not supported with `--link`, which reuses the source path instead
of copying over a managed install target.
`openclaw plugins update <id-or-npm-spec>` applies to tracked installs. Passing
an npm package spec with a dist-tag or exact version resolves the package name
back to the tracked plugin record and records the new spec for future updates.
Passing the package name without a version moves an exact pinned install back to
the registry's default release line. If the installed npm plugin already matches
the resolved version and recorded artifact identity, OpenClaw skips the update
without downloading, reinstalling, or rewriting config.
`--pin` is npm-only. It is not supported with `--marketplace`, because
marketplace installs persist marketplace source metadata instead of an npm spec.

View File

@@ -9,7 +9,7 @@ title: "Text-to-Speech"
# Text-to-speech (TTS)
OpenClaw can convert outbound replies into audio using ElevenLabs, Google Gemini, Microsoft, MiniMax, or OpenAI.
OpenClaw can convert outbound replies into audio using ElevenLabs, Google Gemini, Microsoft, MiniMax, OpenAI, or xAI.
It works anywhere OpenClaw can send audio.
## Supported services
@@ -19,6 +19,7 @@ It works anywhere OpenClaw can send audio.
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`)
- **MiniMax** (primary or fallback provider; uses the T2A v2 API)
- **OpenAI** (primary or fallback provider; also used for summaries)
- **xAI** (primary or fallback provider; uses the xAI TTS API)
### Microsoft speech notes
@@ -35,12 +36,13 @@ or ElevenLabs.
## Optional keys
If you want OpenAI, ElevenLabs, Google Gemini, or MiniMax:
If you want OpenAI, ElevenLabs, Google Gemini, MiniMax, or xAI:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `GEMINI_API_KEY` (or `GOOGLE_API_KEY`)
- `MINIMAX_API_KEY`
- `OPENAI_API_KEY`
- `XAI_API_KEY`
Microsoft speech does **not** require an API key.
@@ -57,6 +59,7 @@ so that provider must also be authenticated if you enable summaries.
- [MiniMax T2A v2 API](https://platform.minimaxi.com/document/T2A%20V2)
- [node-edge-tts](https://github.com/SchneeHertz/node-edge-tts)
- [Microsoft Speech output formats](https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech#audio-outputs)
- [xAI Text to Speech](https://docs.x.ai/developers/rest-api-reference/inference/voice#text-to-speech-rest)
## Is it enabled by default?
@@ -198,6 +201,33 @@ by the bundled Google image-generation provider. Resolution order is
`messages.tts.providers.google.apiKey` -> `models.providers.google.apiKey` ->
`GEMINI_API_KEY` -> `GOOGLE_API_KEY`.
### xAI primary
```json5
{
messages: {
tts: {
auto: "always",
provider: "xai",
providers: {
xai: {
apiKey: "xai_api_key",
voiceId: "eve",
language: "en",
responseFormat: "mp3",
speed: 1.0,
},
},
},
},
}
```
xAI TTS uses the same `XAI_API_KEY` path as the bundled Grok model provider.
Resolution order is `messages.tts.providers.xai.apiKey` -> `XAI_API_KEY`.
Current live voices are `ara`, `eve`, `leo`, `rex`, `sal`, and `una`; `eve` is
the default. `language` accepts a BCP-47 tag or `auto`.
### Disable Microsoft speech
```json5
@@ -300,6 +330,12 @@ Then run:
- `providers.google.voiceName`: Gemini prebuilt voice name (default `Kore`; `voice` is also accepted).
- `providers.google.baseUrl`: override the Gemini API base URL. Only `https://generativelanguage.googleapis.com` is accepted.
- If `messages.tts.providers.google.apiKey` is omitted, TTS can reuse `models.providers.google.apiKey` before env fallback.
- `providers.xai.apiKey`: xAI TTS API key (env: `XAI_API_KEY`).
- `providers.xai.baseUrl`: override the xAI TTS base URL (default `https://api.x.ai/v1`, env: `XAI_BASE_URL`).
- `providers.xai.voiceId`: xAI voice id (default `eve`; current live voices: `ara`, `eve`, `leo`, `rex`, `sal`, `una`).
- `providers.xai.language`: BCP-47 language code or `auto` (default `en`).
- `providers.xai.responseFormat`: `mp3`, `wav`, `pcm`, `mulaw`, or `alaw` (default `mp3`).
- `providers.xai.speed`: provider-native speed override.
- `providers.microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `providers.microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `providers.microsoft.lang`: language code (e.g. `en-US`).
@@ -335,7 +371,7 @@ Here you go.
Available directive keys (when enabled):
- `provider` (registered speech provider id, for example `openai`, `elevenlabs`, `google`, `minimax`, or `microsoft`; requires `allowProvider: true`)
- `voice` (OpenAI voice), `voiceName` / `voice_name` / `google_voice` (Google voice), or `voiceId` (ElevenLabs / MiniMax)
- `voice` (OpenAI voice), `voiceName` / `voice_name` / `google_voice` (Google voice), or `voiceId` (ElevenLabs / MiniMax / xAI)
- `model` (OpenAI TTS model, ElevenLabs model id, or MiniMax model) or `google_model` (Google TTS model)
- `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost`
- `vol` / `volume` (MiniMax volume, 0-10)
@@ -397,6 +433,7 @@ These override `messages.tts.*` for that host.
- 44.1kHz / 128kbps is the default balance for speech clarity.
- **MiniMax**: MP3 (`speech-2.8-hd` model, 32kHz sample rate). Voice-note format not natively supported; use OpenAI or ElevenLabs for guaranteed Opus voice messages.
- **Google Gemini**: Gemini API TTS returns raw 24kHz PCM. OpenClaw wraps it as WAV for audio attachments and returns PCM directly for Talk/telephony. Native Opus voice-note format is not supported by this path.
- **xAI**: MP3 by default; `responseFormat` may be `mp3`, `wav`, `pcm`, `mulaw`, or `alaw`. OpenClaw uses xAI's batch REST TTS endpoint and returns a complete audio attachment; xAI's streaming TTS WebSocket is not used by this provider path. Native Opus voice-note format is not supported by this path.
- **Microsoft**: uses `microsoft.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
- The bundled transport accepts an `outputFormat`, but not all formats are available from the service.
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus).

View File

@@ -116,6 +116,10 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
## Auto-detection
## Native OpenAI web search
Direct OpenAI Responses models use OpenAI's hosted `web_search` tool automatically when OpenClaw web search is enabled and no managed provider is pinned. This is provider-owned behavior in the bundled OpenAI plugin and only applies to native OpenAI API traffic, not OpenAI-compatible proxy base URLs or Azure routes. Set `tools.web.search.provider` to another provider such as `brave` to keep the managed `web_search` tool for OpenAI models, or set `tools.web.search.enabled: false` to disable both managed search and native OpenAI search.
## Native Codex web search
Codex-capable models can optionally use the provider-native Responses `web_search` tool instead of OpenClaw's managed `web_search` function.

View File

@@ -1,4 +1,7 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
import setupPlugin from "./setup-api.js";
const { createAcpxRuntimeServiceMock, tryDispatchAcpReplyHookMock } = vi.hoisted(() => ({
createAcpxRuntimeServiceMock: vi.fn(),
@@ -15,6 +18,24 @@ vi.mock("./runtime-api.js", () => ({
import plugin from "./index.js";
type AcpxAutoEnableProbe = Parameters<OpenClawPluginApi["registerAutoEnableProbe"]>[0];
function registerAcpxAutoEnableProbe(): AcpxAutoEnableProbe {
const probes: AcpxAutoEnableProbe[] = [];
setupPlugin.register(
createTestPluginApi({
registerAutoEnableProbe(probe) {
probes.push(probe);
},
}),
);
const probe = probes[0];
if (!probe) {
throw new Error("expected ACPX setup plugin to register an auto-enable probe");
}
return probe;
}
describe("acpx plugin", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -38,4 +59,61 @@ describe("acpx plugin", () => {
expect(api.registerService).toHaveBeenCalledWith(service);
expect(api.on).toHaveBeenCalledWith("reply_dispatch", tryDispatchAcpReplyHookMock);
});
it("preserves the ACP reply_dispatch runtime path through the registered hook", async () => {
const service = { id: "acpx-service", start: vi.fn() };
createAcpxRuntimeServiceMock.mockReturnValue(service);
tryDispatchAcpReplyHookMock.mockResolvedValue({
handled: true,
queuedFinal: true,
counts: { tool: 1, block: 0, final: 1 },
});
const on = vi.fn();
const api = createTestPluginApi({
pluginConfig: { stateDir: "/tmp/acpx" },
registerService: vi.fn(),
on,
});
plugin.register(api);
const hook = on.mock.calls.find(([hookName]) => hookName === "reply_dispatch")?.[1];
if (!hook) {
throw new Error("expected reply_dispatch hook to be registered");
}
const event = {
ctx: { raw: "reply ctx" },
runId: "run-1",
sessionKey: "agent:test:session",
inboundAudio: false,
shouldRouteToOriginating: false,
shouldSendToolSummaries: true,
sendPolicy: "allow",
};
const ctx = {
cfg: {},
dispatcher: { dispatch: vi.fn(), getQueuedCounts: vi.fn(), getFailedCounts: vi.fn() },
recordProcessed: vi.fn(),
markIdle: vi.fn(),
};
await expect(hook(event, ctx)).resolves.toEqual({
handled: true,
queuedFinal: true,
counts: { tool: 1, block: 0, final: 1 },
});
expect(tryDispatchAcpReplyHookMock).toHaveBeenCalledWith(event, ctx);
});
it("declares setup auto-enable reasons for ACPX-owned ACP config", () => {
const probe = registerAcpxAutoEnableProbe();
expect(probe({ config: { acp: { enabled: true } }, env: {} })).toBe("ACP runtime configured");
expect(probe({ config: { acp: { backend: "acpx" } }, env: {} })).toBe("ACP runtime configured");
expect(probe({ config: { acp: { enabled: true, backend: "custom-runtime" } }, env: {} })).toBe(
null,
);
});
});

View File

@@ -31,6 +31,9 @@
"pluginToolsMcpBridge": {
"type": "boolean"
},
"openClawToolsMcpBridge": {
"type": "boolean"
},
"strictWindowsCmdWrapper": {
"type": "boolean"
},
@@ -109,6 +112,11 @@
"help": "Default off. When enabled, inject the built-in OpenClaw plugin-tools MCP server into embedded ACP sessions so ACP agents can call plugin-registered tools.",
"advanced": true
},
"openClawToolsMcpBridge": {
"label": "OpenClaw Tools MCP Bridge",
"help": "Default off. When enabled, inject the built-in OpenClaw core-tools MCP server into embedded ACP sessions so ACP agents can call selected built-in tools such as cron.",
"advanced": true
},
"strictWindowsCmdWrapper": {
"label": "Strict Windows cmd Wrapper",
"help": "Legacy compatibility field. The current embedded acpx/runtime package uses its own Windows command resolution behavior. Setting this to false is accepted for compatibility and logged as ignored.",

View File

@@ -0,0 +1,109 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
import { resolveAcpxPluginConfig } from "./config.js";
const tempDirs: string[] = [];
const previousEnv = {
CODEX_HOME: process.env.CODEX_HOME,
OPENCLAW_AGENT_DIR: process.env.OPENCLAW_AGENT_DIR,
PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,
};
async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-codex-auth-"));
tempDirs.push(dir);
return dir;
}
function restoreEnv(name: keyof typeof previousEnv): void {
const value = previousEnv[name];
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
function unquoteCommandPath(command: string): string {
return command.replace(/^'|'$/g, "").replace(/'\\''/g, "'");
}
afterEach(async () => {
restoreEnv("CODEX_HOME");
restoreEnv("OPENCLAW_AGENT_DIR");
restoreEnv("PI_CODING_AGENT_DIR");
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
});
describe("prepareAcpxCodexAuthConfig", () => {
it("wraps built-in Codex ACP with an isolated CODEX_HOME copy", async () => {
const root = await makeTempDir();
const sourceCodexHome = path.join(root, "source-codex");
const agentDir = path.join(root, "agent");
const stateDir = path.join(root, "state");
await fs.mkdir(sourceCodexHome, { recursive: true });
await fs.writeFile(
path.join(sourceCodexHome, "auth.json"),
`${JSON.stringify({ auth_mode: "apikey", OPENAI_API_KEY: "test-api-key" }, null, 2)}\n`,
);
await fs.writeFile(path.join(sourceCodexHome, "config.toml"), 'model = "gpt-5.4"\n');
process.env.CODEX_HOME = sourceCodexHome;
process.env.OPENCLAW_AGENT_DIR = agentDir;
delete process.env.PI_CODING_AGENT_DIR;
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
});
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
});
const wrapperPath = unquoteCommandPath(resolved.agents.codex ?? "");
expect(wrapperPath).toBe(path.join(stateDir, "acpx", "codex-acp-wrapper.mjs"));
await expect(fs.access(wrapperPath)).resolves.toBeUndefined();
const isolatedAuthPath = path.join(agentDir, "acp-auth", "codex-source", "auth.json");
const copiedAuth = JSON.parse(await fs.readFile(isolatedAuthPath, "utf8")) as {
auth_mode?: string;
OPENAI_API_KEY?: string;
};
expect(copiedAuth).toEqual({ auth_mode: "apikey", OPENAI_API_KEY: "test-api-key" });
expect((await fs.stat(isolatedAuthPath)).mode & 0o777).toBe(0o600);
await expect(
fs.readFile(path.join(agentDir, "acp-auth", "codex-source", "config.toml"), "utf8"),
).resolves.toBe('model = "gpt-5.4"\n');
const wrapper = await fs.readFile(wrapperPath, "utf8");
expect(wrapper).toContain(`CODEX_HOME: ${JSON.stringify(path.dirname(isolatedAuthPath))}`);
expect(wrapper).toContain("delete env[key]");
expect(wrapper).not.toContain("test-api-key");
});
it("does not override an explicitly configured Codex agent command", async () => {
const root = await makeTempDir();
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {
agents: {
codex: {
command: "custom-codex-acp",
},
},
},
workspaceDir: root,
});
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir: path.join(root, "state"),
});
expect(resolved.agents.codex).toBe("custom-codex-acp");
});
});

View File

@@ -0,0 +1,157 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveOpenClawAgentDir } from "openclaw/plugin-sdk/provider-auth";
import { prepareCodexAuthBridge } from "openclaw/plugin-sdk/provider-auth-runtime";
import { writePrivateSecretFileAtomic } from "openclaw/plugin-sdk/secret-file-runtime";
import type { PluginLogger } from "../runtime-api.js";
import type { ResolvedAcpxPluginConfig } from "./config.js";
const CODEX_AGENT_ID = "codex";
const DEFAULT_CODEX_AUTH_PROFILE_ID = "openai-codex:default";
const CODEX_AUTH_ENV_CLEAR_KEYS = ["OPENAI_API_KEY"];
type PreparedAcpxCodexAuth = {
codexHome: string;
clearEnv: string[];
};
function resolveSourceCodexHome(env: NodeJS.ProcessEnv = process.env): string {
const configured = env.CODEX_HOME?.trim();
if (configured) {
if (configured === "~") {
return os.homedir();
}
if (configured.startsWith("~/")) {
return path.join(os.homedir(), configured.slice(2));
}
return path.resolve(configured);
}
return path.join(os.homedir(), ".codex");
}
async function readOptionalFile(filePath: string): Promise<string | undefined> {
try {
return await fs.readFile(filePath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return undefined;
}
throw error;
}
}
async function prepareCopiedCodexHome(params: {
agentDir: string;
sourceCodexHome: string;
}): Promise<PreparedAcpxCodexAuth | null> {
const authJson = await readOptionalFile(path.join(params.sourceCodexHome, "auth.json"));
if (!authJson) {
return null;
}
const codexHome = path.join(params.agentDir, "acp-auth", "codex-source");
await writePrivateSecretFileAtomic({
rootDir: params.agentDir,
filePath: path.join(codexHome, "auth.json"),
content: authJson,
});
const configToml = await readOptionalFile(path.join(params.sourceCodexHome, "config.toml"));
if (configToml) {
await writePrivateSecretFileAtomic({
rootDir: params.agentDir,
filePath: path.join(codexHome, "config.toml"),
content: configToml,
});
}
return {
codexHome,
clearEnv: [...CODEX_AUTH_ENV_CLEAR_KEYS],
};
}
function shellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
async function writeCodexAcpWrapper(params: {
wrapperPath: string;
codexHome: string;
clearEnv: string[];
}): Promise<string> {
await fs.mkdir(path.dirname(params.wrapperPath), { recursive: true, mode: 0o700 });
const content = `#!/usr/bin/env node
import { spawn } from "node:child_process";
const env = { ...process.env, CODEX_HOME: ${JSON.stringify(params.codexHome)} };
for (const key of ${JSON.stringify(params.clearEnv)}) {
delete env[key];
}
const child = spawn("npx", ["@zed-industries/codex-acp@^0.11.1"], {
stdio: "inherit",
env,
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
child.on("error", (error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
`;
await fs.writeFile(params.wrapperPath, content, { mode: 0o700 });
await fs.chmod(params.wrapperPath, 0o700);
return shellArg(params.wrapperPath);
}
export async function prepareAcpxCodexAuthConfig(params: {
pluginConfig: ResolvedAcpxPluginConfig;
stateDir: string;
logger?: PluginLogger;
}): Promise<ResolvedAcpxPluginConfig> {
if (params.pluginConfig.agents[CODEX_AGENT_ID]) {
return params.pluginConfig;
}
const agentDir = resolveOpenClawAgentDir();
const sourceCodexHome = resolveSourceCodexHome();
const bridge =
(await prepareCodexAuthBridge({
agentDir,
bridgeDir: "acp-auth",
profileId: DEFAULT_CODEX_AUTH_PROFILE_ID,
sourceCodexHome,
})) ??
(await prepareCopiedCodexHome({
agentDir,
sourceCodexHome,
}));
if (!bridge) {
params.logger?.debug?.("codex ACP auth bridge skipped: no Codex auth source found");
return params.pluginConfig;
}
const wrapperCommand = await writeCodexAcpWrapper({
wrapperPath: path.join(params.stateDir, "acpx", "codex-acp-wrapper.mjs"),
codexHome: bridge.codexHome,
clearEnv: bridge.clearEnv,
});
return {
...params.pluginConfig,
agents: {
...params.pluginConfig.agents,
[CODEX_AGENT_ID]: wrapperCommand,
},
};
}

View File

@@ -30,6 +30,7 @@ export type AcpxPluginConfig = {
permissionMode?: AcpxPermissionMode;
nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
pluginToolsMcpBridge?: boolean;
openClawToolsMcpBridge?: boolean;
strictWindowsCmdWrapper?: boolean;
timeoutSeconds?: number;
queueOwnerTtlSeconds?: number;
@@ -44,6 +45,7 @@ export type ResolvedAcpxPluginConfig = {
permissionMode: AcpxPermissionMode;
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
pluginToolsMcpBridge: boolean;
openClawToolsMcpBridge: boolean;
strictWindowsCmdWrapper: boolean;
timeoutSeconds?: number;
queueOwnerTtlSeconds: number;
@@ -91,6 +93,9 @@ export const AcpxPluginConfigSchema = z.strictObject({
})
.optional(),
pluginToolsMcpBridge: z.boolean({ error: "pluginToolsMcpBridge must be a boolean" }).optional(),
openClawToolsMcpBridge: z
.boolean({ error: "openClawToolsMcpBridge must be a boolean" })
.optional(),
strictWindowsCmdWrapper: z
.boolean({ error: "strictWindowsCmdWrapper must be a boolean" })
.optional(),

View File

@@ -73,6 +73,21 @@ describe("embedded acpx plugin config", () => {
expect(server.args?.length).toBeGreaterThan(0);
});
it("injects the built-in OpenClaw tools MCP server only when explicitly enabled", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {
openClawToolsMcpBridge: true,
},
workspaceDir: "/tmp/openclaw-acpx",
});
const server = resolved.mcpServers["openclaw-tools"];
expect(server).toBeDefined();
expect(server.command).toBe(process.execPath);
expect(Array.isArray(server.args)).toBe(true);
expect(server.args?.length).toBeGreaterThan(0);
});
it("keeps the runtime json schema in sync with the manifest config schema", () => {
const pluginRoot = resolveAcpxPluginRoot();
const manifest = JSON.parse(
@@ -91,6 +106,7 @@ describe("embedded acpx plugin config", () => {
}),
agents: expect.any(Object),
mcpServers: expect.any(Object),
openClawToolsMcpBridge: expect.any(Object),
}),
});
});

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { formatPluginConfigIssue } from "openclaw/plugin-sdk/extension-shared";
@@ -25,6 +26,8 @@ export {
} from "./config-schema.js";
export const ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME = "openclaw-plugin-tools";
export const ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME = "openclaw-tools";
const requireFromHere = createRequire(import.meta.url);
function isAcpxPluginRoot(dir: string): boolean {
return (
@@ -140,6 +143,14 @@ function resolveOpenClawRoot(currentRoot: string): string {
return path.resolve(currentRoot, "..");
}
function resolveTsxImportSpecifier(): string {
try {
return requireFromHere.resolve("tsx");
} catch {
return "tsx";
}
}
export function resolvePluginToolsMcpServerConfig(
moduleUrl: string = import.meta.url,
): McpServerConfig {
@@ -155,25 +166,56 @@ export function resolvePluginToolsMcpServerConfig(
const sourceEntry = path.join(openClawRoot, "src", "mcp", "plugin-tools-serve.ts");
return {
command: process.execPath,
args: ["--import", "tsx", sourceEntry],
args: ["--import", resolveTsxImportSpecifier(), sourceEntry],
};
}
export function resolveOpenClawToolsMcpServerConfig(
moduleUrl: string = import.meta.url,
): McpServerConfig {
const pluginRoot = resolveAcpxPluginRoot(moduleUrl);
const openClawRoot = resolveOpenClawRoot(pluginRoot);
const distEntry = path.join(openClawRoot, "dist", "mcp", "openclaw-tools-serve.js");
if (fs.existsSync(distEntry)) {
return {
command: process.execPath,
args: [distEntry],
};
}
const sourceEntry = path.join(openClawRoot, "src", "mcp", "openclaw-tools-serve.ts");
return {
command: process.execPath,
args: ["--import", resolveTsxImportSpecifier(), sourceEntry],
};
}
function resolveConfiguredMcpServers(params: {
mcpServers?: Record<string, McpServerConfig>;
pluginToolsMcpBridge: boolean;
openClawToolsMcpBridge: boolean;
moduleUrl?: string;
}): Record<string, McpServerConfig> {
const resolved = { ...params.mcpServers };
if (!params.pluginToolsMcpBridge) {
return resolved;
}
if (resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]) {
if (params.pluginToolsMcpBridge && resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]) {
throw new Error(
`mcpServers.${ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME} is reserved when pluginToolsMcpBridge=true`,
);
}
resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME] = resolvePluginToolsMcpServerConfig(params.moduleUrl);
if (params.openClawToolsMcpBridge && resolved[ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME]) {
throw new Error(
`mcpServers.${ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME} is reserved when openClawToolsMcpBridge=true`,
);
}
if (params.pluginToolsMcpBridge) {
resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME] = resolvePluginToolsMcpServerConfig(
params.moduleUrl,
);
}
if (params.openClawToolsMcpBridge) {
resolved[ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME] = resolveOpenClawToolsMcpServerConfig(
params.moduleUrl,
);
}
return resolved;
}
@@ -204,9 +246,11 @@ export function resolveAcpxPluginConfig(params: {
const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
const stateDir = path.resolve(normalized.stateDir?.trim() || path.join(workspaceDir, "state"));
const pluginToolsMcpBridge = normalized.pluginToolsMcpBridge === true;
const openClawToolsMcpBridge = normalized.openClawToolsMcpBridge === true;
const mcpServers = resolveConfiguredMcpServers({
mcpServers: normalized.mcpServers,
pluginToolsMcpBridge,
openClawToolsMcpBridge,
moduleUrl: params.moduleUrl,
});
const agents = Object.fromEntries(
@@ -224,6 +268,7 @@ export function resolveAcpxPluginConfig(params: {
nonInteractivePermissions:
normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
pluginToolsMcpBridge,
openClawToolsMcpBridge,
strictWindowsCmdWrapper:
normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER,
timeoutSeconds: normalized.timeoutSeconds ?? DEFAULT_ACPX_TIMEOUT_SECONDS,

View File

@@ -6,6 +6,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const { runtimeRegistry } = vi.hoisted(() => ({
runtimeRegistry: new Map<string, { runtime: unknown; healthy?: () => boolean }>(),
}));
const { prepareAcpxCodexAuthConfigMock } = vi.hoisted(() => ({
prepareAcpxCodexAuthConfigMock: vi.fn(
async ({ pluginConfig }: { pluginConfig: unknown }) => pluginConfig,
),
}));
vi.mock("../runtime-api.js", () => ({
getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id),
@@ -24,6 +29,10 @@ vi.mock("./runtime.js", () => ({
createFileSessionStore: vi.fn(() => ({})),
}));
vi.mock("./codex-auth-bridge.js", () => ({
prepareAcpxCodexAuthConfig: prepareAcpxCodexAuthConfigMock,
}));
import { getAcpRuntimeBackend } from "../runtime-api.js";
import { createAcpxRuntimeService } from "./service.js";
@@ -37,6 +46,7 @@ async function makeTempDir(): Promise<string> {
afterEach(async () => {
runtimeRegistry.clear();
prepareAcpxCodexAuthConfigMock.mockClear();
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
for (const dir of tempDirs.splice(0)) {

View File

@@ -7,6 +7,7 @@ import type {
PluginLogger,
} from "../runtime-api.js";
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../runtime-api.js";
import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
import {
resolveAcpxPluginConfig,
toAcpMcpServers,
@@ -97,10 +98,15 @@ export function createAcpxRuntimeService(
return;
}
const pluginConfig = resolveAcpxPluginConfig({
const basePluginConfig = resolveAcpxPluginConfig({
rawConfig: params.pluginConfig,
workspaceDir: ctx.workspaceDir,
});
const pluginConfig = await prepareAcpxCodexAuthConfig({
pluginConfig: basePluginConfig,
stateDir: ctx.stateDir,
logger: ctx.logger,
});
await fs.mkdir(pluginConfig.stateDir, { recursive: true });
warnOnIgnoredLegacyCompatibilityConfig({
pluginConfig,

View File

@@ -42,10 +42,36 @@ describe("active-memory plugin", () => {
const runEmbeddedPiAgent = vi.fn();
let stateDir = "";
let configFile: Record<string, unknown> = {};
let pluginConfig: Record<string, unknown> = {
agents: ["main"],
logging: true,
};
const syncRuntimePluginConfig = (nextPluginConfig: Record<string, unknown>) => {
pluginConfig = nextPluginConfig;
const plugins = configFile.plugins as Record<string, unknown> | undefined;
const entries = plugins?.entries as Record<string, unknown> | undefined;
const existingEntry = entries?.["active-memory"] as Record<string, unknown> | undefined;
configFile = {
...configFile,
plugins: {
...plugins,
entries: {
...entries,
"active-memory": {
...existingEntry,
enabled: true,
config: nextPluginConfig,
},
},
},
};
};
const api: any = {
pluginConfig: {
agents: ["main"],
logging: true,
get pluginConfig() {
return pluginConfig;
},
set pluginConfig(nextPluginConfig: Record<string, unknown>) {
syncRuntimePluginConfig(nextPluginConfig);
},
config: {},
id: "active-memory",
@@ -93,10 +119,10 @@ describe("active-memory plugin", () => {
},
},
};
api.pluginConfig = {
syncRuntimePluginConfig({
agents: ["main"],
logging: true,
};
});
api.config = {
agents: {
defaults: {
@@ -310,6 +336,56 @@ describe("active-memory plugin", () => {
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
});
it("uses live runtime config for before_prompt_build enablement", async () => {
configFile = {
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
enabled: false,
agents: ["main"],
},
},
},
},
};
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order after a live config disable?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:live-config-disable",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("fails closed when the live active-memory plugin entry is removed", async () => {
configFile = {
plugins: {
entries: {},
},
};
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order after active memory is removed?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:live-config-removed",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("does not run for agents that are not explicitly targeted", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },

View File

@@ -9,6 +9,8 @@ import {
resolveAgentWorkspaceDir,
} from "openclaw/plugin-sdk/agent-runtime";
import {
resolveLivePluginConfigObject,
resolvePluginConfigObject,
resolveSessionStoreEntry,
updateSessionStore,
type OpenClawConfig,
@@ -573,14 +575,10 @@ function isActiveMemoryGloballyEnabled(cfg: OpenClawConfig): boolean {
if (entry?.enabled === false) {
return false;
}
const pluginConfig = asRecord(entry?.config);
const pluginConfig = resolvePluginConfigObject(cfg, "active-memory");
return pluginConfig?.enabled !== false;
}
function resolveActiveMemoryPluginConfigFromConfig(cfg: OpenClawConfig): unknown {
return asRecord(cfg.plugins?.entries?.["active-memory"])?.config;
}
function updateActiveMemoryGlobalEnabledInConfig(
cfg: OpenClawConfig,
enabled: boolean,
@@ -1886,11 +1884,15 @@ export default definePluginEntry({
};
warnDeprecatedModelFallbackPolicy(api.pluginConfig);
const refreshLiveConfigFromRuntime = () => {
const livePluginConfig =
resolveActiveMemoryPluginConfigFromConfig(api.runtime.config.loadConfig()) ??
api.pluginConfig;
config = normalizePluginConfig(livePluginConfig);
warnDeprecatedModelFallbackPolicy(livePluginConfig);
const livePluginConfig = resolveLivePluginConfigObject(
api.runtime.config?.loadConfig,
"active-memory",
api.pluginConfig as Record<string, unknown>,
);
config = normalizePluginConfig(livePluginConfig ?? { enabled: false });
if (livePluginConfig) {
warnDeprecatedModelFallbackPolicy(livePluginConfig);
}
};
api.registerCommand({
name: "active-memory",
@@ -1961,6 +1963,7 @@ export default definePluginEntry({
api.on("before_prompt_build", async (event, ctx) => {
try {
refreshLiveConfigFromRuntime();
const resolvedAgentId = resolveStatusUpdateAgentId(ctx);
const resolvedSessionKey =
ctx.sessionKey?.trim() ||

View File

@@ -0,0 +1,7 @@
{
"specs": [
"@anthropic-ai/sdk@0.81.0",
"@aws/bedrock-token-generator@^1.1.0",
"@mariozechner/pi-ai@0.68.1"
]
}

View File

@@ -1,9 +1,12 @@
export {
discoverMantleModels,
generateBearerTokenFromIam,
getCachedIamToken,
MANTLE_IAM_TOKEN_MARKER,
mergeImplicitMantleProvider,
resetIamTokenCacheForTest,
resetMantleDiscoveryCacheForTest,
resolveImplicitMantleProvider,
resolveMantleBearerToken,
resolveMantleRuntimeBearerToken,
} from "./discovery.js";

View File

@@ -1,21 +1,21 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
const {
discoverMantleModels,
generateBearerTokenFromIam,
getCachedIamToken,
MANTLE_IAM_TOKEN_MARKER,
mergeImplicitMantleProvider,
resetIamTokenCacheForTest,
resetMantleDiscoveryCacheForTest,
resolveMantleBearerToken,
resolveImplicitMantleProvider,
} from "./api.js";
resolveMantleBearerToken,
resolveMantleRuntimeBearerToken,
} = await import("./api.js");
const mocks = vi.hoisted(() => ({
getTokenProvider: vi.fn(),
}));
vi.mock("@aws/bedrock-token-generator", () => ({
getTokenProvider: mocks.getTokenProvider,
}));
function createTokenProviderFactory(tokenProvider: () => Promise<string>) {
return vi.fn(() => tokenProvider);
}
describe("bedrock mantle discovery", () => {
const originalEnv = process.env;
@@ -23,7 +23,6 @@ describe("bedrock mantle discovery", () => {
beforeEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
mocks.getTokenProvider.mockReset();
resetMantleDiscoveryCacheForTest();
resetIamTokenCacheForTest();
});
@@ -62,12 +61,15 @@ describe("bedrock mantle discovery", () => {
it("generates token from IAM credentials when token generation succeeds", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-generated"); // pragma: allowlist secret
mocks.getTokenProvider.mockReturnValue(tokenProvider);
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
const token = await generateBearerTokenFromIam({ region: "us-east-1" });
const token = await generateBearerTokenFromIam({
region: "us-east-1",
tokenProviderFactory,
});
expect(token).toBe("bedrock-api-key-generated");
expect(mocks.getTokenProvider).toHaveBeenCalledWith({
expect(tokenProviderFactory).toHaveBeenCalledWith({
region: "us-east-1",
expiresInSeconds: 7200,
});
@@ -76,12 +78,20 @@ describe("bedrock mantle discovery", () => {
it("caches generated IAM tokens within TTL", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-cached"); // pragma: allowlist secret
mocks.getTokenProvider.mockReturnValue(tokenProvider);
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
let now = 1000;
const t1 = await generateBearerTokenFromIam({ region: "us-east-1", now: () => now });
now += 1800_000; // 30 min — within 1hr cache TTL
const t2 = await generateBearerTokenFromIam({ region: "us-east-1", now: () => now });
const t1 = await generateBearerTokenFromIam({
region: "us-east-1",
now: () => now,
tokenProviderFactory,
});
now += 1800_000; // 30 min — within 2hr cache TTL
const t2 = await generateBearerTokenFromIam({
region: "us-east-1",
now: () => now,
tokenProviderFactory,
});
expect(t1).toEqual(t2);
expect(tokenProvider).toHaveBeenCalledTimes(1);
@@ -92,18 +102,26 @@ describe("bedrock mantle discovery", () => {
.fn<() => Promise<string>>()
.mockResolvedValueOnce("bedrock-api-key-east") // pragma: allowlist secret
.mockResolvedValueOnce("bedrock-api-key-west"); // pragma: allowlist secret
mocks.getTokenProvider.mockReturnValue(tokenProvider);
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
const east = await generateBearerTokenFromIam({ region: "us-east-1", now: () => 1000 });
const west = await generateBearerTokenFromIam({ region: "us-west-2", now: () => 2000 });
const east = await generateBearerTokenFromIam({
region: "us-east-1",
now: () => 1000,
tokenProviderFactory,
});
const west = await generateBearerTokenFromIam({
region: "us-west-2",
now: () => 2000,
tokenProviderFactory,
});
expect(east).toBe("bedrock-api-key-east");
expect(west).toBe("bedrock-api-key-west");
expect(mocks.getTokenProvider).toHaveBeenNthCalledWith(1, {
expect(tokenProviderFactory).toHaveBeenNthCalledWith(1, {
region: "us-east-1",
expiresInSeconds: 7200,
});
expect(mocks.getTokenProvider).toHaveBeenNthCalledWith(2, {
expect(tokenProviderFactory).toHaveBeenNthCalledWith(2, {
region: "us-west-2",
expiresInSeconds: 7200,
});
@@ -111,11 +129,44 @@ describe("bedrock mantle discovery", () => {
});
it("returns undefined when IAM token generation fails", async () => {
mocks.getTokenProvider.mockImplementation(() => {
const tokenProviderFactory = vi.fn(() => {
throw new Error("no credentials");
});
await expect(generateBearerTokenFromIam({ region: "us-east-1" })).resolves.toBeUndefined();
await expect(
generateBearerTokenFromIam({ region: "us-east-1", tokenProviderFactory }),
).resolves.toBeUndefined();
});
it("getCachedIamToken returns cached token when valid", async () => {
const tokenProvider = vi.fn(async () => "bedrock-cached-token"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
// Generate a token to populate the cache
await generateBearerTokenFromIam({ region: "us-east-1", tokenProviderFactory });
// Sync read should return the cached token
expect(getCachedIamToken("us-east-1")).toBe("bedrock-cached-token");
});
it("getCachedIamToken returns undefined when cache is empty", () => {
expect(getCachedIamToken("us-east-1")).toBeUndefined();
});
it("getCachedIamToken returns undefined when cache is expired", async () => {
const tokenProvider = vi.fn(async () => "bedrock-expired-token"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
// Generate with a time far in the past so it's already expired
await generateBearerTokenFromIam({
region: "us-east-1",
now: () => 1000,
tokenProviderFactory,
});
// The cache entry exists but expiresAt is 1000 + 3600000 = 3601000
// Current Date.now() is way past that, so it should be expired
expect(getCachedIamToken("us-east-1")).toBeUndefined();
});
// ---------------------------------------------------------------------------
@@ -349,16 +400,26 @@ describe("bedrock mantle discovery", () => {
expect(provider?.api).toBe("openai-completions");
expect(provider?.auth).toBe("api-key");
expect(provider?.apiKey).toBe("env:AWS_BEARER_TOKEN_BEDROCK");
expect(provider?.models).toHaveLength(1);
expect(provider?.models).toHaveLength(2);
expect(
provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7"),
).toMatchObject({
api: "anthropic-messages",
reasoning: false,
});
expect(
provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7"),
).not.toHaveProperty("baseUrl");
});
it("returns null when no auth is available", async () => {
mocks.getTokenProvider.mockImplementation(() => {
const tokenProviderFactory = vi.fn(() => {
throw new Error("no credentials");
});
const provider = await resolveImplicitMantleProvider({
env: {} as NodeJS.ProcessEnv,
tokenProviderFactory,
});
expect(provider).toBeNull();
@@ -366,13 +427,13 @@ describe("bedrock mantle discovery", () => {
it("uses a generated IAM token when no explicit token is set", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-iam"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: "openai.gpt-oss-120b", object: "model" }],
}),
});
mocks.getTokenProvider.mockReturnValue(tokenProvider);
const provider = await resolveImplicitMantleProvider({
env: {
@@ -380,10 +441,11 @@ describe("bedrock mantle discovery", () => {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
fetchFn: mockFetch as unknown as typeof fetch,
tokenProviderFactory,
});
expect(provider).not.toBeNull();
expect(provider?.apiKey).toBe("bedrock-api-key-iam");
expect(provider?.apiKey).toBe(MANTLE_IAM_TOKEN_MARKER);
expect(tokenProvider).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
"https://bedrock-mantle.us-east-1.api.aws/v1/models",
@@ -395,6 +457,52 @@ describe("bedrock mantle discovery", () => {
);
});
it("resolves Mantle runtime auth from the cached IAM token marker", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-runtime"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
await generateBearerTokenFromIam({
region: "us-east-1",
now: () => 1000,
tokenProviderFactory,
});
await expect(
resolveMantleRuntimeBearerToken({
apiKey: MANTLE_IAM_TOKEN_MARKER,
env: {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
now: () => 2000,
tokenProviderFactory,
}),
).resolves.toMatchObject({
apiKey: "bedrock-api-key-runtime",
expiresAt: 1000 + 7200_000,
});
expect(tokenProvider).toHaveBeenCalledTimes(1);
});
it("generates a fresh Mantle runtime IAM token when the cache is cold", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-fresh"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
await expect(
resolveMantleRuntimeBearerToken({
apiKey: MANTLE_IAM_TOKEN_MARKER,
env: {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
now: () => 5000,
tokenProviderFactory,
}),
).resolves.toMatchObject({
apiKey: "bedrock-api-key-fresh",
expiresAt: 5000 + 7200_000,
});
expect(tokenProvider).toHaveBeenCalledTimes(1);
});
it("returns null for unsupported regions", async () => {
const provider = await resolveImplicitMantleProvider({
env: {

View File

@@ -18,6 +18,7 @@ const DEFAULT_COST = {
const DEFAULT_CONTEXT_WINDOW = 32000;
const DEFAULT_MAX_TOKENS = 4096;
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600; // 1 hour
export const MANTLE_IAM_TOKEN_MARKER = "__amazon_bedrock_mantle_iam__";
// ---------------------------------------------------------------------------
// Mantle region & endpoint helpers
@@ -51,6 +52,17 @@ function isSupportedRegion(region: string): boolean {
// ---------------------------------------------------------------------------
export type MantleBearerTokenProvider = () => Promise<string>;
export type MantleBearerTokenProviderFactory = (opts?: {
region?: string;
expiresInSeconds?: number;
}) => MantleBearerTokenProvider;
async function loadMantleBearerTokenProviderFactory(): Promise<MantleBearerTokenProviderFactory> {
const { getTokenProvider } = (await import("@aws/bedrock-token-generator")) as {
getTokenProvider: MantleBearerTokenProviderFactory;
};
return getTokenProvider;
}
/**
* Resolve a bearer token for Mantle authentication.
@@ -69,7 +81,22 @@ export function resolveMantleBearerToken(env: NodeJS.ProcessEnv = process.env):
/** Token cache for IAM-derived bearer tokens, keyed by region. */
const iamTokenCache = new Map<string, { token: string; expiresAt: number }>();
const IAM_TOKEN_TTL_MS = 3600_000; // Refresh every 1 hour (tokens valid up to 12h)
const IAM_TOKEN_TTL_MS = 7200_000; // Matches the 2h token lifetime we request below.
function resolveMantleRegion(env: NodeJS.ProcessEnv): string {
return env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
}
function getCachedIamTokenEntry(
region: string,
now: number = Date.now(),
): { token: string; expiresAt: number } | undefined {
const cached = iamTokenCache.get(region);
if (cached && cached.expiresAt > now) {
return cached;
}
return undefined;
}
/**
* Generate a bearer token from IAM credentials using `@aws/bedrock-token-generator`.
@@ -80,21 +107,18 @@ const IAM_TOKEN_TTL_MS = 3600_000; // Refresh every 1 hour (tokens valid up to 1
export async function generateBearerTokenFromIam(params: {
region: string;
now?: () => number;
tokenProviderFactory?: MantleBearerTokenProviderFactory;
}): Promise<string | undefined> {
const now = params.now?.() ?? Date.now();
const cached = iamTokenCache.get(params.region);
const cached = getCachedIamTokenEntry(params.region, now);
if (cached && cached.expiresAt > now) {
if (cached) {
return cached.token;
}
try {
const { getTokenProvider } = (await import("@aws/bedrock-token-generator")) as {
getTokenProvider: (opts?: {
region?: string;
expiresInSeconds?: number;
}) => () => Promise<string>;
};
const getTokenProvider =
params.tokenProviderFactory ?? (await loadMantleBearerTokenProviderFactory());
const token = await getTokenProvider({
region: params.region,
expiresInSeconds: 7200, // 2 hours
@@ -110,6 +134,48 @@ export async function generateBearerTokenFromIam(params: {
}
}
/**
* Read a cached IAM bearer token for the given region (sync, no generation).
*
* Returns the token if it exists and has not expired, undefined otherwise.
* Used by Mantle runtime auth and tests to inspect the current cache.
*/
export function getCachedIamToken(region: string): string | undefined {
return getCachedIamTokenEntry(region)?.token;
}
export async function resolveMantleRuntimeBearerToken(params: {
apiKey: string;
env?: NodeJS.ProcessEnv;
now?: () => number;
tokenProviderFactory?: MantleBearerTokenProviderFactory;
}): Promise<{ apiKey: string; expiresAt?: number } | undefined> {
if (params.apiKey !== MANTLE_IAM_TOKEN_MARKER) {
return { apiKey: params.apiKey };
}
const now = params.now?.() ?? Date.now();
const region = resolveMantleRegion(params.env ?? process.env);
const cached = getCachedIamTokenEntry(region, now);
if (cached) {
return {
apiKey: cached.token,
expiresAt: cached.expiresAt,
};
}
const token = await generateBearerTokenFromIam({
region,
now: params.now,
tokenProviderFactory: params.tokenProviderFactory,
});
if (!token) {
return undefined;
}
const refreshed = getCachedIamTokenEntry(region, now);
return {
apiKey: refreshed?.token ?? token,
expiresAt: refreshed?.expiresAt ?? now + IAM_TOKEN_TTL_MS,
};
}
/** Reset the IAM token cache (for testing). */
export function resetIamTokenCacheForTest(): void {
iamTokenCache.clear();
@@ -257,9 +323,10 @@ export async function discoverMantleModels(params: {
export async function resolveImplicitMantleProvider(params: {
env?: NodeJS.ProcessEnv;
fetchFn?: typeof fetch;
tokenProviderFactory?: MantleBearerTokenProviderFactory;
}): Promise<ModelProviderConfig | null> {
const env = params.env ?? process.env;
const region = env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
const region = resolveMantleRegion(env);
const explicitBearerToken = resolveMantleBearerToken(env);
if (!isSupportedRegion(region)) {
@@ -268,7 +335,12 @@ export async function resolveImplicitMantleProvider(params: {
}
// Try explicit token first, then generate from IAM credentials
const bearerToken = explicitBearerToken ?? (await generateBearerTokenFromIam({ region }));
const bearerToken =
explicitBearerToken ??
(await generateBearerTokenFromIam({
region,
tokenProviderFactory: params.tokenProviderFactory,
}));
if (!bearerToken) {
return null;
@@ -286,12 +358,35 @@ export async function resolveImplicitMantleProvider(params: {
log.debug?.("Mantle provider resolved", { region, modelCount: models.length });
// Append Claude models available on Mantle's Anthropic Messages endpoint.
// Opus 4.7 currently needs the provider-owned bearer-auth path here, but we
// keep reasoning off until the underlying Anthropic transport learns Opus 4.7
// adaptive thinking semantics.
const claudeModels: ModelDefinitionConfig[] = [
{
id: "anthropic.claude-opus-4-7",
name: "Claude Opus 4.7",
api: "anthropic-messages" as const,
reasoning: false,
input: ["text", "image"],
cost: {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
},
contextWindow: 1_000_000,
maxTokens: 128_000,
},
];
const allModels = [...models, ...claudeModels];
return {
baseUrl: `${mantleEndpoint(region)}/v1`,
api: "openai-completions",
auth: "api-key",
apiKey: explicitBearerToken ? "env:AWS_BEARER_TOKEN_BEDROCK" : bearerToken,
models,
apiKey: explicitBearerToken ? "env:AWS_BEARER_TOKEN_BEDROCK" : MANTLE_IAM_TOKEN_MARKER,
models: allModels,
};
}

View File

@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import bedrockMantlePlugin from "./index.js";
describe("amazon-bedrock-mantle provider plugin", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("registers with correct provider ID and label", async () => {
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
expect(provider.id).toBe("amazon-bedrock-mantle");
@@ -20,5 +24,32 @@ describe("amazon-bedrock-mantle provider plugin", () => {
expect(
provider.classifyFailoverReason?.({ errorMessage: "some other error" } as never),
).toBeUndefined();
expect(provider.classifyFailoverReason?.({ errorMessage: "overloaded_error" } as never)).toBe(
"overloaded",
);
});
it("provides a custom stream only for Mantle Anthropic models", async () => {
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
expect(
typeof provider.createStreamFn?.({
provider: "amazon-bedrock-mantle",
modelId: "anthropic.claude-opus-4-7",
model: {
api: "anthropic-messages",
},
} as never),
).toBe("function");
expect(
provider.createStreamFn?.({
provider: "amazon-bedrock-mantle",
modelId: "openai.gpt-oss-120b",
model: {
api: "openai-completions",
},
} as never),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,106 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import {
createMantleAnthropicStreamFn,
resolveMantleAnthropicBaseUrl,
} from "./mantle-anthropic.runtime.js";
function createTestModel(): Model<Api> {
return {
id: "anthropic.claude-opus-4-7",
name: "Claude Opus 4.7",
provider: "amazon-bedrock-mantle",
api: "anthropic-messages",
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
headers: {
"X-Test": "model-header",
},
reasoning: false,
input: ["text", "image"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 1_000_000,
maxTokens: 128_000,
} as Model<Api>;
}
function createTestDeps() {
return {
createClient: vi.fn((options: unknown) => ({ options }) as never),
stream: vi.fn(),
};
}
describe("createMantleAnthropicStreamFn", () => {
it("uses authToken bearer auth for Mantle Anthropic requests", () => {
const stream = { kind: "anthropic-stream" };
const model = createTestModel();
const context = { messages: [] };
const deps = createTestDeps();
deps.stream.mockReturnValue(stream as never);
const result = createMantleAnthropicStreamFn(deps)(model, context, {
apiKey: "bedrock-bearer-token",
headers: {
"X-Caller": "caller-header",
},
});
expect(result).toBe(stream);
expect(deps.createClient).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: null,
authToken: "bedrock-bearer-token",
baseURL: "https://bedrock-mantle.us-east-1.api.aws/anthropic",
defaultHeaders: expect.objectContaining({
accept: "application/json",
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
"X-Test": "model-header",
"X-Caller": "caller-header",
}),
}),
);
expect(deps.stream).toHaveBeenCalledWith(
model,
context,
expect.objectContaining({
client: expect.objectContaining({
options: expect.objectContaining({
authToken: "bedrock-bearer-token",
}),
}),
thinkingEnabled: false,
}),
);
});
it("omits unsupported Opus 4.7 sampling and reasoning overrides", () => {
const model = createTestModel();
const context = { messages: [] };
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn(deps)(model, context, {
apiKey: "bedrock-bearer-token",
temperature: 0.2,
reasoning: "high",
});
expect(deps.stream).toHaveBeenCalledWith(
model,
context,
expect.objectContaining({
temperature: undefined,
thinkingEnabled: false,
}),
);
});
it("normalizes Mantle provider URLs to the Anthropic endpoint", () => {
expect(resolveMantleAnthropicBaseUrl("https://bedrock-mantle.us-east-1.api.aws/v1")).toBe(
"https://bedrock-mantle.us-east-1.api.aws/anthropic",
);
expect(
resolveMantleAnthropicBaseUrl("https://bedrock-mantle.us-east-1.api.aws/anthropic/"),
).toBe("https://bedrock-mantle.us-east-1.api.aws/anthropic");
});
});

View File

@@ -0,0 +1,123 @@
import Anthropic from "@anthropic-ai/sdk";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Api, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
import { streamAnthropic } from "@mariozechner/pi-ai/anthropic";
const MANTLE_ANTHROPIC_BETA = "fine-grained-tool-streaming-2025-05-14";
type AnthropicOptions = ConstructorParameters<typeof Anthropic>[0];
export function resolveMantleAnthropicBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
if (trimmed.endsWith("/anthropic")) {
return trimmed;
}
if (trimmed.endsWith("/v1")) {
return `${trimmed.slice(0, -"/v1".length)}/anthropic`;
}
return `${trimmed}/anthropic`;
}
function requiresDefaultSampling(modelId: string): boolean {
return modelId.includes("claude-opus-4-7");
}
function mergeHeaders(
...headerSources: Array<Record<string, string> | undefined>
): Record<string, string> {
const merged: Record<string, string> = {};
for (const headers of headerSources) {
if (headers) {
Object.assign(merged, headers);
}
}
return merged;
}
function buildMantleAnthropicBaseOptions(
model: Model<Api>,
options: SimpleStreamOptions | undefined,
apiKey: string,
) {
return {
temperature: requiresDefaultSampling(model.id) ? undefined : options?.temperature,
maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32_000),
signal: options?.signal,
apiKey,
cacheRetention: options?.cacheRetention,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
maxRetryDelayMs: options?.maxRetryDelayMs,
metadata: options?.metadata,
};
}
function adjustMaxTokensForThinking(
baseMaxTokens: number,
modelMaxTokens: number,
reasoningLevel: NonNullable<SimpleStreamOptions["reasoning"]>,
customBudgets?: SimpleStreamOptions["thinkingBudgets"],
): { maxTokens: number; thinkingBudget: number } {
const defaultBudgets = {
minimal: 1024,
low: 2048,
medium: 8192,
high: 16384,
xhigh: 16384,
} as const;
const budgets = { ...defaultBudgets, ...customBudgets };
const minOutputTokens = 1024;
let thinkingBudget = budgets[reasoningLevel];
const maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens);
if (maxTokens <= thinkingBudget) {
thinkingBudget = Math.max(0, maxTokens - minOutputTokens);
}
return { maxTokens, thinkingBudget };
}
export function createMantleAnthropicStreamFn(deps?: {
createClient?: (options: AnthropicOptions) => Anthropic;
stream?: typeof streamAnthropic;
}): StreamFn {
return (model, context, options) => {
const apiKey = options?.apiKey ?? "";
const createClient = deps?.createClient ?? ((clientOptions) => new Anthropic(clientOptions));
const stream = deps?.stream ?? streamAnthropic;
const client = createClient({
apiKey: null,
authToken: apiKey,
baseURL: resolveMantleAnthropicBaseUrl(model.baseUrl),
dangerouslyAllowBrowser: true,
defaultHeaders: mergeHeaders(
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
"anthropic-beta": MANTLE_ANTHROPIC_BETA,
},
model.headers,
options?.headers,
),
});
const base = buildMantleAnthropicBaseOptions(model, options, apiKey);
if (!options?.reasoning || requiresDefaultSampling(model.id)) {
return stream(model as Model<"anthropic-messages">, context, {
...base,
client,
thinkingEnabled: false,
});
}
const adjusted = adjustMaxTokensForThinking(
base.maxTokens || 0,
model.maxTokens,
options.reasoning,
options.thinkingBudgets,
);
return stream(model as Model<"anthropic-messages">, context, {
...base,
client,
maxTokens: adjusted.maxTokens,
thinkingEnabled: true,
thinkingBudgetTokens: adjusted.thinkingBudget,
});
};
}

View File

@@ -5,7 +5,9 @@
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
"type": "module",
"dependencies": {
"@aws/bedrock-token-generator": "^1.1.0"
"@anthropic-ai/sdk": "0.81.0",
"@aws/bedrock-token-generator": "^1.1.0",
"@mariozechner/pi-ai": "0.68.1"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -3,7 +3,9 @@ import {
mergeImplicitMantleProvider,
resolveImplicitMantleProvider,
resolveMantleBearerToken,
resolveMantleRuntimeBearerToken,
} from "./discovery.js";
import { createMantleAnthropicStreamFn } from "./mantle-anthropic.runtime.js";
export function registerBedrockMantlePlugin(api: OpenClawPluginApi): void {
const providerId = "amazon-bedrock-mantle";
@@ -31,14 +33,21 @@ export function registerBedrockMantlePlugin(api: OpenClawPluginApi): void {
},
},
resolveConfigApiKey: ({ env }) =>
resolveMantleBearerToken(env) ? "AWS_BEARER_TOKEN_BEDROCK" : undefined,
resolveMantleBearerToken(env) ? "env:AWS_BEARER_TOKEN_BEDROCK" : undefined,
prepareRuntimeAuth: async ({ apiKey, env }) =>
await resolveMantleRuntimeBearerToken({
apiKey,
env,
}),
createStreamFn: ({ model }) =>
model.api === "anthropic-messages" ? createMantleAnthropicStreamFn() : undefined,
matchesContextOverflowError: ({ errorMessage }) =>
/context_length_exceeded|max.*tokens.*exceeded/i.test(errorMessage),
classifyFailoverReason: ({ errorMessage }) => {
if (/rate_limit|too many requests|429/i.test(errorMessage)) {
return "rate_limit";
}
if (/overloaded|503/i.test(errorMessage)) {
if (/overloaded|503|service.*unavailable/i.test(errorMessage)) {
return "overloaded";
}
return undefined;

View File

@@ -0,0 +1,7 @@
{
"specs": [
"@aws-sdk/client-bedrock-runtime@3.1032.0",
"@aws-sdk/client-bedrock@3.1032.0",
"@aws-sdk/credential-provider-node@3.972.32"
]
}

View File

@@ -87,7 +87,7 @@ describe("bedrock discovery", () => {
name: "Claude 3.7 Sonnet",
reasoning: false,
input: ["text", "image"],
contextWindow: 32000,
contextWindow: 200000,
maxTokens: 4096,
});
});
@@ -104,7 +104,11 @@ describe("bedrock discovery", () => {
});
it("uses configured defaults for context and max tokens", async () => {
mockSingleActiveSummary();
mockSingleActiveSummary({
modelId: "example.unknown-text-v1:0",
modelName: "Example Unknown Text",
providerName: "example",
});
const models = await discoverBedrockModels({
region: "us-east-1",
@@ -114,6 +118,69 @@ describe("bedrock discovery", () => {
expect(models[0]).toMatchObject({ contextWindow: 64000, maxTokens: 8192 });
});
it("keeps the conservative fallback for unknown inference profiles", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "jp.example.unknown-text-v1:0",
inferenceProfileName: "JP Example Unknown Text",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn:
"arn:aws:bedrock:ap-northeast-1::foundation-model/example.unknown-text-v1:0",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "ap-northeast-1", clientFactory });
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
id: "jp.example.unknown-text-v1:0",
contextWindow: 32000,
maxTokens: 4096,
input: ["text"],
});
});
it("normalizes region-prefixed versioned model ids when resolving context windows", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "jp.anthropic.claude-sonnet-4-6-v1:0",
inferenceProfileName: "JP Claude Sonnet 4.6",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn:
"arn:aws:bedrock:ap-northeast-1::foundation-model/anthropic.claude-sonnet-4-6-v1:0",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "ap-northeast-1", clientFactory });
expect(models[0]).toMatchObject({
id: "jp.anthropic.claude-sonnet-4-6-v1:0",
contextWindow: 1_000_000,
});
});
it("caches results when refreshInterval is enabled", async () => {
mockSingleActiveSummary();
@@ -252,7 +319,7 @@ describe("bedrock discovery", () => {
expect(usProfile).toMatchObject({
name: "US Anthropic Claude Sonnet 4.6",
input: ["text", "image"],
contextWindow: 32000,
contextWindow: 1000000,
maxTokens: 4096,
});
expect(euProfile).toMatchObject({ input: ["text", "image"] });
@@ -356,11 +423,43 @@ describe("bedrock discovery", () => {
expect(profile).toMatchObject({
id: "us.my-prod-profile",
input: ["text", "image"],
contextWindow: 32000,
contextWindow: 1000000,
maxTokens: 4096,
});
});
it("uses the resolved base model id for application-profile context fallback", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "us.my-prod-profile",
inferenceProfileName: "Prod Claude Profile",
status: "ACTIVE",
type: "APPLICATION",
models: [
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1:0",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
expect(models[0]).toMatchObject({
id: "us.my-prod-profile",
contextWindow: 1_000_000,
maxTokens: 4096,
input: ["text"],
});
});
it("merges implicit Bedrock models into explicit provider overrides", () => {
expect(
mergeImplicitBedrockProvider({
@@ -433,4 +532,63 @@ describe("bedrock discovery", () => {
expect(legacyEnabled?.baseUrl).toBe("https://bedrock-runtime.us-west-2.amazonaws.com");
expect(sendMock).toHaveBeenCalledTimes(4);
});
// Ported from #65449 by @alickgithub2 — extended to also cover apac. prefix
it("resolves au. and apac. prefixes for regional inference profiles", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-sonnet-4-6",
modelName: "Claude Sonnet 4.6",
providerName: "anthropic",
inputModalities: ["TEXT", "IMAGE"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "au.anthropic.claude-sonnet-4-6",
inferenceProfileName: "AU Anthropic Claude Sonnet 4.6",
inferenceProfileArn:
"arn:aws:bedrock:ap-southeast-2::inference-profile/au.anthropic.claude-sonnet-4-6",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [], // no ARNs — forces the prefix-regex fallback
},
{
inferenceProfileId: "apac.anthropic.claude-sonnet-4-6",
inferenceProfileName: "APAC Anthropic Claude Sonnet 4.6",
inferenceProfileArn:
"arn:aws:bedrock:ap-northeast-1::inference-profile/apac.anthropic.claude-sonnet-4-6",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [],
},
],
});
const models = await discoverBedrockModels({ region: "ap-southeast-2", clientFactory });
// Foundation model + 2 regional inference profiles
expect(models).toHaveLength(3);
const auProfile = models.find((m) => m.id === "au.anthropic.claude-sonnet-4-6");
expect(auProfile).toMatchObject({
id: "au.anthropic.claude-sonnet-4-6",
name: "AU Anthropic Claude Sonnet 4.6",
input: ["text", "image"],
});
const apacProfile = models.find((m) => m.id === "apac.anthropic.claude-sonnet-4-6");
expect(apacProfile).toMatchObject({
id: "apac.anthropic.claude-sonnet-4-6",
name: "APAC Anthropic Claude Sonnet 4.6",
input: ["text", "image"],
});
});
});

View File

@@ -21,8 +21,121 @@ import {
const log = createSubsystemLogger("bedrock-discovery");
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600;
const DEFAULT_CONTEXT_WINDOW = 32000;
const DEFAULT_CONTEXT_WINDOW = 32_000;
const DEFAULT_MAX_TOKENS = 4096;
// ---------------------------------------------------------------------------
// Known model context windows (Bedrock API does not expose token limits)
// ---------------------------------------------------------------------------
/**
* Bedrock's ListFoundationModels and GetFoundationModel APIs return no token
* limit information — only model ID, name, modalities, and lifecycle status.
* There is currently no Bedrock API to discover context windows or max output
* tokens programmatically.
*
* This map provides correct context window values for known models so that
* session management, compaction thresholds, and context overflow detection
* work correctly. If AWS adds token metadata to the API in the future, this
* table should become a fallback rather than the primary source.
*
* Inference profile prefixes (us., eu., ap., global.) are stripped before lookup.
*
* Sources: https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html
* https://platform.claude.com/docs/en/about-claude/models
*/
const KNOWN_CONTEXT_WINDOWS: Record<string, number> = {
// Anthropic Claude
"anthropic.claude-3-7-sonnet-20250219-v1:0": 200_000,
"anthropic.claude-opus-4-7": 1_000_000,
"anthropic.claude-opus-4-6-v1": 1_000_000,
"anthropic.claude-opus-4-6-v1:0": 1_000_000,
"anthropic.claude-sonnet-4-6": 1_000_000,
"anthropic.claude-sonnet-4-6-v1:0": 1_000_000,
"anthropic.claude-sonnet-4-5-20250929-v1:0": 200_000,
"anthropic.claude-sonnet-4-20250514-v1:0": 200_000,
"anthropic.claude-opus-4-5-20251101-v1:0": 200_000,
"anthropic.claude-opus-4-1-20250805-v1:0": 200_000,
"anthropic.claude-haiku-4-5-20251001-v1:0": 200_000,
"anthropic.claude-3-5-haiku-20241022-v1:0": 200_000,
"anthropic.claude-3-haiku-20240307-v1:0": 200_000,
// Amazon Nova
"amazon.nova-premier-v1:0": 1_000_000,
"amazon.nova-pro-v1:0": 300_000,
"amazon.nova-lite-v1:0": 300_000,
"amazon.nova-micro-v1:0": 128_000,
"amazon.nova-2-lite-v1:0": 300_000,
// MiniMax
"minimax.minimax-m2.5": 1_000_000,
"minimax.minimax-m2.1": 1_000_000,
"minimax.minimax-m2": 1_000_000,
// Meta Llama 4
"meta.llama4-maverick-17b-instruct-v1:0": 1_000_000,
"meta.llama4-scout-17b-instruct-v1:0": 512_000,
// Meta Llama 3
"meta.llama3-3-70b-instruct-v1:0": 128_000,
"meta.llama3-2-90b-instruct-v1:0": 128_000,
"meta.llama3-2-11b-instruct-v1:0": 128_000,
"meta.llama3-2-3b-instruct-v1:0": 128_000,
"meta.llama3-2-1b-instruct-v1:0": 128_000,
"meta.llama3-1-405b-instruct-v1:0": 128_000,
"meta.llama3-1-70b-instruct-v1:0": 128_000,
"meta.llama3-1-8b-instruct-v1:0": 128_000,
// NVIDIA Nemotron
"nvidia.nemotron-super-3-120b": 256_000,
"nvidia.nemotron-nano-3-30b": 128_000,
"nvidia.nemotron-nano-12b-v2": 128_000,
"nvidia.nemotron-nano-9b-v2": 128_000,
// Mistral
"mistral.mistral-large-3-675b-instruct": 128_000,
"mistral.mistral-large-2407-v1:0": 128_000,
"mistral.mistral-small-2402-v1:0": 32_000,
// DeepSeek
"deepseek.r1-v1:0": 128_000,
"deepseek.v3.2": 128_000,
// Cohere
"cohere.command-r-plus-v1:0": 128_000,
"cohere.command-r-v1:0": 128_000,
// AI21
"ai21.jamba-1-5-large-v1:0": 256_000,
"ai21.jamba-1-5-mini-v1:0": 256_000,
// Google Gemma
"google.gemma-3-27b-it": 128_000,
"google.gemma-3-12b-it": 128_000,
"google.gemma-3-4b-it": 128_000,
// GLM
"zai.glm-5": 128_000,
"zai.glm-4.7": 128_000,
"zai.glm-4.7-flash": 128_000,
// Qwen
"qwen.qwen3-coder-next": 256_000,
"qwen.qwen3-coder-30b-a3b-v1:0": 256_000,
"qwen.qwen3-32b-v1:0": 128_000,
"qwen.qwen3-vl-235b-a22b": 128_000,
};
/**
* Resolve the real context window for a Bedrock model ID.
* Strips inference profile prefixes (us., eu., ap., global.) before lookup.
*/
function resolveKnownContextWindow(modelId: string): number | undefined {
const stripped = modelId.replace(/^(?:us|eu|ap|apac|au|jp|global)\./, "");
const candidates = [modelId, stripped];
for (const candidate of candidates) {
if (KNOWN_CONTEXT_WINDOWS[candidate] !== undefined) {
return KNOWN_CONTEXT_WINDOWS[candidate];
}
const withoutVersionSuffix = candidate.replace(/:0$/, "");
if (
withoutVersionSuffix !== candidate &&
KNOWN_CONTEXT_WINDOWS[withoutVersionSuffix] !== undefined
) {
return KNOWN_CONTEXT_WINDOWS[withoutVersionSuffix];
}
}
return undefined;
}
const DEFAULT_COST = {
input: 0,
output: 0,
@@ -163,7 +276,7 @@ function toModelDefinition(
reasoning: inferReasoningSupport(summary),
input: mapInputModalities(summary),
cost: DEFAULT_COST,
contextWindow: defaults.contextWindow,
contextWindow: resolveKnownContextWindow(id) ?? defaults.contextWindow,
maxTokens: defaults.maxTokens,
};
}
@@ -192,7 +305,7 @@ function resolveBaseModelId(profile: InferenceProfileSummary): string | undefine
}
if (profile.type === "SYSTEM_DEFINED") {
const id = profile.inferenceProfileId ?? "";
const prefixMatch = /^(?:us|eu|ap|jp|global)\.(.+)$/i.exec(id);
const prefixMatch = /^(?:us|eu|ap|apac|au|jp|global)\.(.+)$/i.exec(id);
if (prefixMatch) {
return prefixMatch[1];
}
@@ -282,7 +395,10 @@ function resolveInferenceProfiles(
reasoning: baseModel?.reasoning ?? false,
input: baseModel?.input ?? ["text"],
cost: baseModel?.cost ?? DEFAULT_COST,
contextWindow: baseModel?.contextWindow ?? defaults.contextWindow,
contextWindow:
baseModel?.contextWindow ??
resolveKnownContextWindow(baseModelId ?? profile.inferenceProfileId ?? "") ??
defaults.contextWindow,
maxTokens: baseModel?.maxTokens ?? defaults.maxTokens,
});
}

View File

@@ -1,12 +1,43 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../src/config/config.js";
import { buildPluginApi } from "../../src/plugins/api-builder.js";
import type { PluginRuntime } from "../../src/plugins/runtime/types.js";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import amazonBedrockPlugin from "./index.js";
type InferenceProfileResult = { models?: Array<{ modelArn?: string }> } | Error;
const inferenceProfileResults: InferenceProfileResult[] = [];
const bedrockClientConfigs: Array<Record<string, unknown>> = [];
const sendGetInferenceProfile = vi.fn(async () => {
const next = inferenceProfileResults.shift();
if (next instanceof Error) {
throw next;
}
return next ?? { models: [] };
});
vi.mock("@aws-sdk/client-bedrock", () => {
class GetInferenceProfileCommand {
constructor(readonly input: { inferenceProfileIdentifier: string }) {}
}
class BedrockClient {
constructor(config: Record<string, unknown> = {}) {
bedrockClientConfigs.push(config);
}
send = sendGetInferenceProfile;
}
return {
BedrockClient,
GetInferenceProfileCommand,
};
});
type RegisteredProviderPlugin = Awaited<ReturnType<typeof registerSingleProviderPlugin>>;
/** Register the amazon-bedrock plugin with an optional pluginConfig override. */
@@ -58,6 +89,22 @@ const ANTHROPIC_MODEL_DESCRIPTOR = {
id: ANTHROPIC_MODEL,
} as never;
const APP_INFERENCE_PROFILE_ARN =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-claude-profile";
const APP_INFERENCE_PROFILE_DESCRIPTOR = {
api: "openai-completions",
provider: "amazon-bedrock",
id: APP_INFERENCE_PROFILE_ARN,
} as never;
function makeAppInferenceProfileDescriptor(modelId: string): never {
return {
api: "openai-completions",
provider: "amazon-bedrock",
id: modelId,
} as never;
}
/**
* Call wrapStreamFn and then invoke the returned stream function, capturing
* the payload via the onPayload hook that streamWithPayloadPatch installs.
@@ -92,6 +139,12 @@ function callWrappedStream(
}
describe("amazon-bedrock provider plugin", () => {
beforeEach(() => {
inferenceProfileResults.length = 0;
bedrockClientConfigs.length = 0;
sendGetInferenceProfile.mockClear();
});
it("marks Claude 4.6 Bedrock models as adaptive by default", async () => {
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);
@@ -302,4 +355,347 @@ describe("amazon-bedrock provider plugin", () => {
expect(result).toMatchObject({ cacheRetention: "none" });
});
});
describe("application inference profile cache point injection", () => {
/**
* Invoke wrapStreamFn with a payload containing system/messages, then
* trigger onPayload to capture the patched payload.
*/
async function callWrappedStreamWithPayload(
provider: RegisteredProviderPlugin,
modelId: string,
modelDescriptor: never,
options: Record<string, unknown>,
payload: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const wrapped = provider.wrapStreamFn?.({
provider: "amazon-bedrock",
modelId,
streamFn: spyStreamFn,
} as never);
const result = wrapped?.(
modelDescriptor,
{ messages: [] } as never,
options,
) as unknown as Record<string, unknown>;
if (typeof result?.onPayload === "function") {
await (
result.onPayload as (p: Record<string, unknown>, model: unknown) => Promise<unknown>
)(payload, modelDescriptor);
}
return payload;
}
it("injects cache points for application inference profile ARNs", async () => {
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
{ cacheRetention: "short" },
payload,
);
const system = payload.system as Array<Record<string, unknown>>;
expect(system).toHaveLength(2);
expect(system[1]).toEqual({ cachePoint: { type: "default" } });
const messages = payload.messages as Array<{
role: string;
content: Array<Record<string, unknown>>;
}>;
const lastUserContent = messages[0].content;
expect(lastUserContent).toHaveLength(2);
expect(lastUserContent[1]).toEqual({ cachePoint: { type: "default" } });
});
it("uses long TTL when cacheRetention is 'long'", async () => {
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
{ cacheRetention: "long" },
payload,
);
const system = payload.system as Array<Record<string, unknown>>;
expect(system[1]).toEqual({ cachePoint: { type: "default", ttl: "1h" } });
});
it("does not inject cache points when cacheRetention is 'none'", async () => {
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
{ cacheRetention: "none" },
payload,
);
const system = payload.system as Array<Record<string, unknown>>;
expect(system).toHaveLength(1);
});
it("does not double-inject cache points if already present", async () => {
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }, { cachePoint: { type: "default" } }],
messages: [
{ role: "user", content: [{ text: "Hello" }, { cachePoint: { type: "default" } }] },
],
};
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
{ cacheRetention: "short" },
payload,
);
const system = payload.system as Array<Record<string, unknown>>;
expect(system).toHaveLength(2);
const messages = payload.messages as Array<{
role: string;
content: Array<Record<string, unknown>>;
}>;
expect(messages[0].content).toHaveLength(2);
});
it("does not inject cache points for regular Anthropic model IDs (pi-ai handles them)", async () => {
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
// Regular model IDs contain "claude" so pi-ai handles caching natively.
// wrapStreamFn should not install an onPayload hook for these.
const wrapped = provider.wrapStreamFn?.({
provider: "amazon-bedrock",
modelId: ANTHROPIC_MODEL,
streamFn: spyStreamFn,
} as never);
const result = wrapped?.(ANTHROPIC_MODEL_DESCRIPTOR, { messages: [] } as never, {
cacheRetention: "short",
}) as unknown as Record<string, unknown>;
// For regular Anthropic models, no onPayload should be installed for cache injection.
if (typeof result?.onPayload === "function") {
(result.onPayload as (p: Record<string, unknown>) => void)(payload);
}
const system = payload.system as Array<Record<string, unknown>>;
expect(system).toHaveLength(1);
});
it("does not inject cache points for older Claude models not in pi-ai's cache list", async () => {
const provider = await registerWithConfig(undefined);
const oldClaudeModel = "anthropic.claude-3-opus-20240229-v1:0";
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
// Claude 3 Opus is not in pi-ai's supportsPromptCaching list, but it's
// also not an application inference profile — we should not inject.
const wrapped = provider.wrapStreamFn?.({
provider: "amazon-bedrock",
modelId: oldClaudeModel,
streamFn: spyStreamFn,
} as never);
const result = wrapped?.({ id: oldClaudeModel } as never, { messages: [] } as never, {
cacheRetention: "short",
}) as unknown as Record<string, unknown>;
if (typeof result?.onPayload === "function") {
(result.onPayload as (p: Record<string, unknown>) => void)(payload);
}
const system = payload.system as Array<Record<string, unknown>>;
expect(system).toHaveLength(1);
});
it("defaults to 'short' cache retention when not explicitly set", async () => {
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
{},
payload,
);
const system = payload.system as Array<Record<string, unknown>>;
expect(system).toHaveLength(2);
// Default is "short" which means no ttl field
expect(system[1]).toEqual({ cachePoint: { type: "default" } });
});
it("injects cache point only on last USER message", async () => {
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [
{ role: "user", content: [{ text: "First question" }] },
{ role: "assistant", content: [{ text: "Answer" }] },
{ role: "user", content: [{ text: "Follow-up" }] },
],
};
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
{ cacheRetention: "short" },
payload,
);
const messages = payload.messages as Array<{
role: string;
content: Array<Record<string, unknown>>;
}>;
// First user message should NOT have a cache point
expect(messages[0].content).toHaveLength(1);
// Assistant message untouched
expect(messages[1].content).toHaveLength(1);
// Last user message should have a cache point
expect(messages[2].content).toHaveLength(2);
expect(messages[2].content[1]).toEqual({ cachePoint: { type: "default" } });
});
it("injects cache points for opaque application inference profile ARNs after profile lookup", async () => {
const modelId =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/z27qyso459da";
inferenceProfileResults.push({
models: [
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6-20250514-v1:0",
},
],
});
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
await callWrappedStreamWithPayload(
provider,
modelId,
makeAppInferenceProfileDescriptor(modelId),
{ cacheRetention: "short" },
payload,
);
const system = payload.system as Array<Record<string, unknown>>;
expect(system[1]).toEqual({ cachePoint: { type: "default" } });
expect(sendGetInferenceProfile).toHaveBeenCalledTimes(1);
expect(bedrockClientConfigs).toEqual([{ region: "us-east-1" }]);
});
it("does not inject cache points when any resolved profile target is not cacheable", async () => {
const modelId =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/z27qyso459db";
inferenceProfileResults.push({
models: [
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6-20250514-v1:0",
},
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-opus-20240229-v1:0",
},
],
});
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
await callWrappedStreamWithPayload(
provider,
modelId,
makeAppInferenceProfileDescriptor(modelId),
{ cacheRetention: "short" },
payload,
);
expect(payload.system).toEqual([{ text: "You are helpful." }]);
expect(payload.messages).toEqual([{ role: "user", content: [{ text: "Hello" }] }]);
});
it("retries opaque profile lookup after a transient failure instead of caching the fallback", async () => {
const modelId =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/z27qyso459dc";
inferenceProfileResults.push(new Error("throttled"), {
models: [
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6-20250514-v1:0",
},
],
});
const provider = await registerWithConfig(undefined);
const firstPayload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
const secondPayload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello again" }] }],
};
await callWrappedStreamWithPayload(
provider,
modelId,
makeAppInferenceProfileDescriptor(modelId),
{ cacheRetention: "short" },
firstPayload,
);
await callWrappedStreamWithPayload(
provider,
modelId,
makeAppInferenceProfileDescriptor(modelId),
{ cacheRetention: "short" },
secondPayload,
);
expect(firstPayload.system).toEqual([{ text: "You are helpful." }]);
expect(secondPayload.system).toEqual([
{ text: "You are helpful." },
{ cachePoint: { type: "default" } },
]);
expect(sendGetInferenceProfile).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -62,6 +62,179 @@ function createGuardrailWrapStreamFn(
};
}
/**
* Mirrors the shipped pi-ai Bedrock `supportsPromptCaching` matcher.
* Keep this in sync with node_modules/@mariozechner/pi-ai/dist/providers/amazon-bedrock.js.
*/
function matchesPiAiPromptCachingModelId(modelId: string): boolean {
const id = modelId.toLowerCase();
if (!id.includes("claude")) {
return false;
}
// Claude 4.x
if (id.includes("-4-") || id.includes("-4.")) {
return true;
}
// Claude 3.7 Sonnet
if (id.includes("claude-3-7-sonnet")) {
return true;
}
// Claude 3.5 Haiku
if (id.includes("claude-3-5-haiku")) {
return true;
}
return false;
}
function piAiWouldInjectCachePoints(modelId: string): boolean {
return matchesPiAiPromptCachingModelId(modelId);
}
/**
* Detect Bedrock application inference profile ARNs — these are the only IDs
* where pi-ai's model-name-based checks fail because the ARN is opaque.
* System-defined profiles (us., eu., global.) and base model IDs always
* contain the model name and are handled by pi-ai natively.
*/
const BEDROCK_APP_INFERENCE_PROFILE_RE =
/^arn:aws(-cn|-us-gov)?:bedrock:.*:application-inference-profile\//i;
function isBedrockAppInferenceProfile(modelId: string): boolean {
return BEDROCK_APP_INFERENCE_PROFILE_RE.test(modelId);
}
/**
* pi-ai's internal `supportsPromptCaching` checks `model.id` for specific Claude
* model name patterns, which fails for application inference profile ARNs (opaque
* IDs that may not contain the model name). When OpenClaw's `isAnthropicBedrockModel`
* identifies the model but pi-ai won't inject cache points, we do it via onPayload.
*
* Gated to application inference profile ARNs only — regular Claude model IDs and
* system-defined inference profiles (us.anthropic.claude-*) are left to pi-ai.
*/
function needsCachePointInjection(modelId: string): boolean {
// Only target application inference profile ARNs.
if (!isBedrockAppInferenceProfile(modelId)) {
return false;
}
// If pi-ai would already inject cache points, skip.
if (piAiWouldInjectCachePoints(modelId)) {
return false;
}
// Check if OpenClaw identifies this as an Anthropic model via the ARN heuristic.
if (isAnthropicBedrockModel(modelId)) {
return true;
}
return false;
}
/**
* Extract the region from a Bedrock ARN.
* e.g. "arn:aws:bedrock:us-east-1:123:application-inference-profile/abc" → "us-east-1"
*/
function extractRegionFromArn(arn: string): string | undefined {
const parts = arn.split(":");
// ARN format: arn:partition:service:region:account:resource
return parts.length >= 4 && parts[3] ? parts[3] : undefined;
}
/**
* Check if a resolved foundation model ARN supports prompt caching using the
* same matcher pi-ai uses for direct model IDs.
*/
function resolvedModelSupportsCaching(modelArn: string): boolean {
return matchesPiAiPromptCachingModelId(modelArn);
}
/**
* Resolve the underlying foundation model for an application inference profile
* via GetInferenceProfile. Results are cached so we only call the API once per
* profile ARN. Returns true if the underlying model supports prompt caching.
*
* Region is extracted from the profile ARN itself to avoid mismatches when
* the OpenClaw config region differs from the profile's home region.
*/
const appProfileCacheEligibleCache = new Map<string, boolean>();
async function resolveAppProfileCacheEligible(
modelId: string,
fallbackRegion: string | undefined,
): Promise<boolean> {
if (appProfileCacheEligibleCache.has(modelId)) {
return appProfileCacheEligibleCache.get(modelId)!;
}
try {
const { BedrockClient, GetInferenceProfileCommand } = await import("@aws-sdk/client-bedrock");
const region = extractRegionFromArn(modelId) ?? fallbackRegion;
const client = new BedrockClient(region ? { region } : {});
const resp = await client.send(
new GetInferenceProfileCommand({ inferenceProfileIdentifier: modelId }),
);
const models = resp.models ?? [];
const eligible =
models.length > 0 &&
models.every((m: { modelArn?: string }) => resolvedModelSupportsCaching(m.modelArn ?? ""));
appProfileCacheEligibleCache.set(modelId, eligible);
return eligible;
} catch {
// Transient failures (throttling, network, IAM) should not be cached —
// return the heuristic fallback but allow retry on the next request.
return isAnthropicBedrockModel(modelId);
}
}
type BedrockCachePoint = { cachePoint: { type: "default"; ttl?: string } };
type BedrockContentBlock = Record<string, unknown>;
type BedrockMessage = { role?: string; content?: BedrockContentBlock[] };
function hasCachePoint(blocks: BedrockContentBlock[] | undefined): boolean {
return blocks?.some((b) => b.cachePoint != null) === true;
}
function makeCachePoint(cacheRetention: string | undefined): BedrockCachePoint {
return {
cachePoint: {
type: "default",
...(cacheRetention === "long" ? { ttl: "1h" } : {}),
},
};
}
/**
* Inject Bedrock Converse cache points into the payload when pi-ai skipped them
* because it didn't recognize the model ID (application inference profiles).
*/
function injectBedrockCachePoints(
payload: Record<string, unknown>,
cacheRetention: string | undefined,
): void {
if (!cacheRetention || cacheRetention === "none") {
return;
}
const point = makeCachePoint(cacheRetention);
// Inject into system prompt if missing.
const system = payload.system as BedrockContentBlock[] | undefined;
if (Array.isArray(system) && system.length > 0 && !hasCachePoint(system)) {
system.push(point);
}
// Inject into the last user message if missing.
// Bedrock Converse uses lowercase roles ("user" / "assistant").
const messages = payload.messages as BedrockMessage[] | undefined;
if (Array.isArray(messages) && messages.length > 0) {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "user" && Array.isArray(msg.content)) {
if (!hasCachePoint(msg.content)) {
msg.content.push(point);
}
break;
}
}
}
}
export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
// Keep registration-local constants inside the function so partial module
// initialization during test bootstrap cannot trip TDZ reads.
@@ -81,8 +254,17 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
api.registerMemoryEmbeddingProvider(bedrockMemoryEmbeddingProviderAdapter);
const baseWrapStreamFn = ({ modelId, streamFn }: { modelId: string; streamFn?: StreamFn }) =>
isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn);
const baseWrapStreamFn = ({ modelId, streamFn }: { modelId: string; streamFn?: StreamFn }) => {
if (isAnthropicBedrockModel(modelId)) {
return streamFn;
}
// For app inference profiles with opaque IDs, don't force cacheRetention: "none"
// yet — we may resolve them as Claude later via GetInferenceProfile.
if (isBedrockAppInferenceProfile(modelId)) {
return streamFn;
}
return createBedrockNoCacheWrapper(streamFn);
};
const cacheWrapStreamFn =
guardrail?.guardrailIdentifier && guardrail?.guardrailVersion
@@ -161,23 +343,61 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
// Apply cache + guardrail wrapping.
const wrapped = cacheWrapStreamFn({ modelId, streamFn });
const region = resolveBedrockRegion(config) ?? extractRegionFromBaseUrl(model?.baseUrl);
const mayNeedCacheInjection =
isBedrockAppInferenceProfile(modelId) && !piAiWouldInjectCachePoints(modelId);
if (!region) {
// For known Anthropic models (heuristic match), enable injection immediately.
// For opaque profile IDs, we'll resolve via GetInferenceProfile on first call.
const heuristicMatch = needsCachePointInjection(modelId);
if (!region && !mayNeedCacheInjection) {
return wrapped;
}
// Wrap to inject the region into every stream call so pi-ai's Bedrock
// client connects to the right region for inference profile IDs.
const underlying = wrapped ?? streamFn;
if (!underlying) {
return wrapped;
}
return (streamModel, context, options) => {
// pi-ai's bedrock provider reads `options.region` at runtime but the
// StreamFn type does not declare it. Merge via Object.assign to avoid
// an unsafe type assertion.
const merged = Object.assign({}, options, { region });
return underlying(streamModel, context, merged);
const merged = Object.assign({}, options, region ? { region } : {});
if (!mayNeedCacheInjection) {
return underlying(streamModel, context, merged);
}
// Use the cacheRetention from options if explicitly set.
// When undefined, default to "short" to match pi-ai's internal default.
// Note: if the user set cacheRetention: "none" but the opaque ARN wasn't
// recognized by resolveAnthropicCacheRetentionFamily, the value may have
// been dropped upstream. This is a known limitation — the proper fix is
// to also teach resolveAnthropicCacheRetentionFamily about opaque profiles
// (tracked separately). In practice, users with app inference profiles
// want caching enabled, so defaulting to "short" is the safer behavior.
const cacheRetention =
typeof merged.cacheRetention === "string" ? merged.cacheRetention : "short";
if (heuristicMatch) {
// Fast path: ARN heuristic already identified this as Claude.
return streamWithPayloadPatch(underlying, streamModel, context, merged, (payload) => {
injectBedrockCachePoints(payload, cacheRetention);
});
}
// Slow path: opaque profile ID — resolve underlying model via API (cached).
// pi-ai's onPayload supports async, so we await the resolution inline.
const originalOnPayload = merged.onPayload as
| ((payload: unknown, model: unknown) => unknown)
| undefined;
return underlying(streamModel, context, {
...merged,
onPayload: async (payload: unknown, payloadModel: unknown) => {
const eligible = await resolveAppProfileCacheEligible(modelId, region);
if (eligible && payload && typeof payload === "object") {
injectBedrockCachePoints(payload as Record<string, unknown>, cacheRetention);
}
return originalOnPayload?.(payload, payloadModel);
},
});
};
},
matchesContextOverflowError: ({ errorMessage }) =>

View File

@@ -0,0 +1,3 @@
{
"specs": ["@mariozechner/pi-ai@0.68.1"]
}

View File

@@ -53,6 +53,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
"{sessionId}",
],
output: "jsonl",
liveSession: "claude-stdio",
input: "stdin",
modelArg: "--model",
modelAliases: CLAUDE_CLI_MODEL_ALIASES,

View File

@@ -107,6 +107,21 @@ describe("normalizeClaudeBackendConfig", () => {
"--permission-mode",
"bypassPermissions",
]);
expect(normalized.output).toBe("jsonl");
expect(normalized.liveSession).toBe("claude-stdio");
expect(normalized.input).toBe("stdin");
});
it("does not infer live stdio when explicit transport overrides are incompatible", () => {
const normalized = normalizeClaudeBackendConfig({
command: "claude",
output: "json",
input: "arg",
});
expect(normalized.output).toBe("json");
expect(normalized.liveSession).toBeUndefined();
expect(normalized.input).toBe("arg");
});
it("is wired through the anthropic cli backend normalize hook", () => {
@@ -129,12 +144,16 @@ describe("normalizeClaudeBackendConfig", () => {
expect(normalized?.resumeArgs).toContain("bypassPermissions");
expect(normalized?.resumeArgs).toContain("--setting-sources");
expect(normalized?.resumeArgs).toContain("user");
expect(normalized?.liveSession).toBe("claude-stdio");
});
it("leaves claude cli subscription-managed, restricts setting sources, and clears inherited env overrides", () => {
const backend = buildAnthropicCliBackend();
expect(backend.config.env).toBeUndefined();
expect(backend.config.liveSession).toBe("claude-stdio");
expect(backend.config.output).toBe("jsonl");
expect(backend.config.input).toBe("stdin");
expect(backend.config.args).toContain("--setting-sources");
expect(backend.config.args).toContain("user");
expect(backend.config.resumeArgs).toContain("--setting-sources");

View File

@@ -135,9 +135,15 @@ export function normalizeClaudeSettingSourcesArgs(args?: string[]): string[] | u
}
export function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig {
const output = config.output ?? "jsonl";
const input = config.input ?? "stdin";
return {
...config,
args: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.args)),
resumeArgs: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.resumeArgs)),
output,
liveSession:
config.liveSession ?? (output === "jsonl" && input === "stdin" ? "claude-stdio" : undefined),
input,
};
}

View File

@@ -223,6 +223,8 @@ describe("anthropic provider replay hooks", () => {
id: "claude-opus-4-7",
api: "anthropic-messages",
reasoning: true,
contextWindow: 1_048_576,
contextTokens: 1_048_576,
});
expect(
provider.resolveThinkingProfile?.({
@@ -252,6 +254,37 @@ describe("anthropic provider replay hooks", () => {
).toBe(false);
});
it("normalizes exact claude opus 4.7 variants to 1M context", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
for (const [runtimeProvider, modelId] of [
["anthropic", "claude-opus-4-7"],
["claude-cli", "claude-opus-4.7-20260219"],
] as const) {
expect(
provider.normalizeResolvedModel?.({
provider: runtimeProvider,
modelId,
model: {
id: modelId,
name: "Claude Opus 4.7",
provider: runtimeProvider,
api: "anthropic-messages",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
contextTokens: 200_000,
maxTokens: 32_000,
},
} as never),
).toMatchObject({
contextWindow: 1_048_576,
contextTokens: 1_048_576,
});
}
});
it("resolves claude-cli synthetic oauth auth", async () => {
readClaudeCliCredentialsForRuntimeMock.mockReset();
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({

View File

@@ -4,6 +4,7 @@ import type {
ProviderAuthContext,
ProviderAuthMethodNonInteractiveContext,
ProviderResolveDynamicModelContext,
ProviderNormalizeResolvedModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import {
@@ -44,6 +45,7 @@ const PROVIDER_ID = "anthropic";
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-opus-4-7";
const ANTHROPIC_OPUS_47_MODEL_ID = "claude-opus-4-7";
const ANTHROPIC_OPUS_47_DOT_MODEL_ID = "claude-opus-4.7";
const ANTHROPIC_OPUS_47_CONTEXT_TOKENS = 1_048_576;
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
const ANTHROPIC_OPUS_47_TEMPLATE_MODEL_IDS = [
@@ -282,6 +284,75 @@ function supportsAnthropicAdaptiveThinking(modelId: string): boolean {
return shouldUseAnthropicAdaptiveThinkingDefault(modelId) || isAnthropicOpus47Model(modelId);
}
function hasConfiguredModelContextOverride(
config: ProviderNormalizeResolvedModelContext["config"],
provider: string,
modelId: string,
): boolean {
const providers = config?.models?.providers;
if (!providers || typeof providers !== "object") {
return false;
}
const normalizedProvider = normalizeLowercaseStringOrEmpty(provider);
const normalizedModelId = normalizeLowercaseStringOrEmpty(modelId);
for (const [providerId, providerConfig] of Object.entries(providers)) {
if (normalizeLowercaseStringOrEmpty(providerId) !== normalizedProvider) {
continue;
}
if (!Array.isArray(providerConfig?.models)) {
continue;
}
for (const model of providerConfig.models) {
if (
normalizeLowercaseStringOrEmpty(typeof model?.id === "string" ? model.id : "") !==
normalizedModelId
) {
continue;
}
if (
(typeof model?.contextTokens === "number" && model.contextTokens > 0) ||
(typeof model?.contextWindow === "number" && model.contextWindow > 0)
) {
return true;
}
}
}
return false;
}
function applyAnthropicOpus47ContextWindow(params: {
config?: ProviderNormalizeResolvedModelContext["config"];
provider: string;
modelId: string;
model: ProviderRuntimeModel;
}): ProviderRuntimeModel | undefined {
if (!isAnthropicOpus47Model(params.modelId)) {
return undefined;
}
if (hasConfiguredModelContextOverride(params.config, params.provider, params.modelId)) {
return undefined;
}
const nextContextWindow = Math.max(
params.model.contextWindow ?? 0,
ANTHROPIC_OPUS_47_CONTEXT_TOKENS,
);
const nextContextTokens =
typeof params.model.contextTokens === "number"
? Math.max(params.model.contextTokens, ANTHROPIC_OPUS_47_CONTEXT_TOKENS)
: ANTHROPIC_OPUS_47_CONTEXT_TOKENS;
if (
nextContextWindow === params.model.contextWindow &&
nextContextTokens === params.model.contextTokens
) {
return undefined;
}
return {
...params.model,
contextWindow: nextContextWindow,
contextTokens: nextContextTokens,
};
}
function matchesAnthropicModernModel(modelId: string): boolean {
const lower = normalizeLowercaseStringOrEmpty(modelId);
return ANTHROPIC_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix));
@@ -486,7 +557,21 @@ export function buildAnthropicProvider(): ProviderPlugin {
normalizeConfig: ({ provider, providerConfig }) =>
normalizeAnthropicProviderConfigForProvider({ provider, providerConfig }),
applyConfigDefaults: ({ config, env }) => applyAnthropicConfigDefaults({ config, env }),
resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx),
resolveDynamicModel: (ctx) => {
const model = resolveAnthropicForwardCompatModel(ctx);
if (!model) {
return undefined;
}
return (
applyAnthropicOpus47ContextWindow({
config: ctx.config,
provider: ctx.provider,
modelId: ctx.modelId,
model,
}) ?? model
);
},
normalizeResolvedModel: (ctx) => applyAnthropicOpus47ContextWindow(ctx),
resolveSyntheticAuth: ({ provider }) =>
normalizeLowercaseStringOrEmpty(provider) === CLAUDE_CLI_BACKEND_ID
? resolveClaudeCliSyntheticAuth()

View File

@@ -35,7 +35,13 @@
"imessage"
],
"systemImage": "bubble.left.and.text.bubble.right",
"order": 75
"order": 75,
"cliAddOptions": [
{
"flags": "--webhook-path <path>",
"description": "BlueBubbles webhook path"
}
]
},
"install": {
"npmSpec": "@openclaw/bluebubbles",

View File

@@ -1,14 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
getSessionBindingService,
isPluginOwnedSessionBindingRecord,
resolveConfiguredBindingRoute,
resolveRuntimeConversationBindingRoute,
} from "openclaw/plugin-sdk/conversation-runtime";
import {
deriveLastRoutePolicy,
resolveAgentIdFromSessionKey,
resolveAgentRoute,
} from "openclaw/plugin-sdk/routing";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveBlueBubblesInboundConversationId } from "./conversation-id.js";
@@ -53,31 +48,21 @@ export function resolveBlueBubblesConversationRoute(params: {
},
}).route;
const runtimeBinding = getSessionBindingService().resolveByConversation({
channel: "bluebubbles",
accountId: params.accountId,
conversationId,
const runtimeRoute = resolveRuntimeConversationBindingRoute({
route,
conversation: {
channel: "bluebubbles",
accountId: params.accountId,
conversationId,
},
});
const boundSessionKey = runtimeBinding?.targetSessionKey?.trim();
if (!runtimeBinding || !boundSessionKey) {
return route;
}
getSessionBindingService().touch(runtimeBinding.bindingId);
if (isPluginOwnedSessionBindingRecord(runtimeBinding)) {
route = runtimeRoute.route;
if (runtimeRoute.bindingRecord && !runtimeRoute.boundSessionKey) {
logVerbose(`bluebubbles: plugin-bound conversation ${conversationId}`);
return route;
} else if (runtimeRoute.boundSessionKey) {
logVerbose(
`bluebubbles: routed via bound conversation ${conversationId} -> ${runtimeRoute.boundSessionKey}`,
);
}
logVerbose(`bluebubbles: routed via bound conversation ${conversationId} -> ${boundSessionKey}`);
return {
...route,
sessionKey: boundSessionKey,
agentId: resolveAgentIdFromSessionKey(boundSessionKey),
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: boundSessionKey,
mainSessionKey: route.mainSessionKey,
}),
matchedBy: "binding.channel",
};
return route;
}

View File

@@ -0,0 +1,3 @@
{
"specs": ["@sinclair/typebox@0.34.49"]
}

View File

@@ -7,6 +7,9 @@ import {
registerBrowserPlugin,
} from "./plugin-registration.js";
import type { OpenClawPluginApi } from "./runtime-api.js";
import setupPlugin from "./setup-api.js";
type BrowserAutoEnableProbe = Parameters<OpenClawPluginApi["registerAutoEnableProbe"]>[0];
const runtimeApiMocks = vi.hoisted(() => ({
createBrowserPluginService: vi.fn(() => ({ id: "browser-control", start: vi.fn() })),
@@ -51,6 +54,22 @@ function createApi() {
return { api, registerCli, registerGatewayMethod, registerService, registerTool };
}
function registerBrowserAutoEnableProbe(): BrowserAutoEnableProbe {
const probes: BrowserAutoEnableProbe[] = [];
setupPlugin.register(
createTestPluginApi({
registerAutoEnableProbe(probe) {
probes.push(probe);
},
}),
);
const probe = probes[0];
if (!probe) {
throw new Error("expected browser setup plugin to register an auto-enable probe");
}
return probe;
}
describe("browser plugin", () => {
it("exposes static browser metadata on the plugin definition", () => {
expect(browserPluginReload).toEqual({ restartPrefixes: ["browser"] });
@@ -86,4 +105,18 @@ describe("browser plugin", () => {
agentSessionKey: "agent:main:webchat:direct:123",
});
});
it("declares setup auto-enable reasons for browser config surfaces", () => {
const probe = registerBrowserAutoEnableProbe();
expect(probe({ config: { browser: { defaultProfile: "openclaw" } }, env: {} })).toBe(
"browser configured",
);
expect(probe({ config: { tools: { alsoAllow: ["browser"] } }, env: {} })).toBe(
"browser tool referenced",
);
expect(
probe({ config: { browser: { defaultProfile: "openclaw", enabled: false } }, env: {} }),
).toBeNull();
});
});

View File

@@ -76,7 +76,7 @@ export async function requirePwAi(
501,
[
`Playwright is not available in this gateway build; '${feature}' is unsupported.`,
"Install the full Playwright package (not playwright-core) and restart the gateway, or reinstall with browser support.",
"Repair the bundled browser plugin runtime dependencies so playwright-core is installed, then restart the gateway. In Docker, also install Chromium with the bundled playwright-core CLI.",
"Docs: /tools/browser#playwright-requirement",
].join("\n"),
);

View File

@@ -9,11 +9,50 @@ export function installAgentContractHooks() {
installBrowserControlServerHooks();
}
function isTransientStartupFetchError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
const record = error as { code?: unknown; cause?: unknown };
if (record.code === "ECONNRESET" || record.code === "ECONNREFUSED") {
return true;
}
return isTransientStartupFetchError(record.cause);
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function postStartWithRetry(params: {
fetch: ReturnType<typeof getBrowserTestFetch>;
url: string;
}): Promise<void> {
const delaysMs = [0, 25, 50, 100, 200] as const;
let lastError: unknown;
for (const delayMs of delaysMs) {
if (delayMs > 0) {
await sleep(delayMs);
}
try {
const response = await params.fetch(params.url, { method: "POST" });
await response.json();
return;
} catch (error) {
lastError = error;
if (!isTransientStartupFetchError(error)) {
throw error;
}
}
}
throw lastError;
}
export async function startServerAndBase(): Promise<string> {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
await postStartWithRetry({ fetch: realFetch, url: `${base}/start` });
return base;
}

View File

@@ -0,0 +1,3 @@
{
"specs": ["@mariozechner/pi-coding-agent@0.68.1", "ws@^8.20.0", "zod@^4.3.6"]
}

View File

@@ -34,6 +34,11 @@
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": ["yolo", "guardian"],
"default": "yolo"
},
"transport": {
"type": "string",
"enum": ["stdio", "websocket"],
@@ -66,19 +71,19 @@
"approvalPolicy": {
"type": "string",
"enum": ["never", "on-request", "on-failure", "untrusted"],
"default": "on-request"
"default": "never"
},
"sandbox": {
"type": "string",
"enum": ["read-only", "workspace-write", "danger-full-access"],
"default": "workspace-write"
"default": "danger-full-access"
},
"approvalsReviewer": {
"type": "string",
"enum": ["user", "guardian_subagent"],
"default": "user"
},
"serviceTier": { "type": "string" }
"serviceTier": { "type": ["string", "null"], "enum": ["fast", "flex", null] }
}
}
}
@@ -102,6 +107,11 @@
"help": "Runtime controls for connecting to Codex app-server.",
"advanced": true
},
"appServer.mode": {
"label": "Execution Mode",
"help": "Use yolo for unchained local execution or guardian for Codex guardian-reviewed approvals.",
"advanced": true
},
"appServer.transport": {
"label": "Transport",
"help": "Use stdio to spawn Codex locally, or websocket to connect to an already-running app-server.",
@@ -155,7 +165,7 @@
},
"appServer.serviceTier": {
"label": "Service Tier",
"help": "Optional Codex service tier passed when starting or resuming threads.",
"help": "Optional Codex app-server service tier. Use fast, flex, or null.",
"advanced": true
}
}

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