Compare commits

...

676 Commits

Author SHA1 Message Date
Vincent Koc
c642e1da24 fix(gateway): guard node approval policy writes 2026-06-01 01:52:02 +01:00
Ted Li
6316648bab fix(openai): keep stop-finished tool calls
Preserve silent structured OpenAI-compatible tool calls when providers stream tool_calls but finish with finish_reason stop, while keeping visible-text stop responses and unfinished streams from executing spurious tool calls.

Fixes #88791.

Verification:
- pnpm tsgo:prod
- node scripts/run-vitest.mjs src/llm/providers/openai-completions.test.ts src/agents/openai-transport-stream.test.ts
- loopback OpenAI-compatible SSE proof against createOpenAICompletionsTransportStreamFn
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
2026-05-31 19:41:23 -04:00
Gio Della-Libera
bf777b9af2 fix(doctor): quiet tool policy audits during probes
Keep runtime tool-policy removal audits at the normal info level, but lower diagnostic-only doctor tool-schema probes to debug so expected profile filtering does not clutter normal doctor output.

Also updates current-base test expectations for the Talk custom select and a promise-executor lint rule so the PR remains green on the latest base.

Fixes #87798.

Proof:
- CI https://github.com/openclaw/openclaw/actions/runs/26727664397
- Real behavior proof https://github.com/openclaw/openclaw/actions/runs/26727667473
- Local focused Vitest, broad lint, touched-file format/lint, and autoreview clean.

Co-authored-by: Gio Della-Libera <40915808+giodl73-repo@users.noreply.github.com>
2026-05-31 19:37:13 -04:00
Peter Steinberger
fba9eac7eb fix(google): register Vertex static catalog rows 2026-06-01 00:36:31 +01:00
Peter Steinberger
5965522af5 fix(copilot): preserve Claude 1M capabilities 2026-06-01 00:36:31 +01:00
Peter Steinberger
f18fd2094f fix(agents): match provider-scoped context ids 2026-06-01 00:36:30 +01:00
Peter Steinberger
770ee8eba6 fix(models): refresh provider catalog metadata 2026-06-01 00:36:30 +01:00
Vincent Koc
b891d42f3a refactor: share talk session turn handling 2026-06-01 01:32:06 +02:00
Vincent Koc
705bdcec70 fix(gateway): harden MCP loopback tool schemas 2026-05-31 19:30:02 -04:00
github-actions[bot]
db7aff8843 chore(ui): refresh fa control ui locale 2026-05-31 23:23:45 +00:00
github-actions[bot]
d30329fb0e chore(ui): refresh nl control ui locale 2026-05-31 23:23:37 +00:00
github-actions[bot]
c7f3d60722 chore(ui): refresh vi control ui locale 2026-05-31 23:23:13 +00:00
github-actions[bot]
0ffaeb1273 chore(ui): refresh th control ui locale 2026-05-31 23:23:06 +00:00
github-actions[bot]
c43a571170 chore(ui): refresh pl control ui locale 2026-05-31 23:23:02 +00:00
github-actions[bot]
dd8b9bdcb8 chore(ui): refresh id control ui locale 2026-05-31 23:22:48 +00:00
github-actions[bot]
399f55e511 chore(ui): refresh uk control ui locale 2026-05-31 23:22:29 +00:00
github-actions[bot]
7e654b40b8 chore(ui): refresh tr control ui locale 2026-05-31 23:22:18 +00:00
github-actions[bot]
7b119ec60d chore(ui): refresh it control ui locale 2026-05-31 23:22:15 +00:00
github-actions[bot]
c1fffe1074 chore(ui): refresh ar control ui locale 2026-05-31 23:22:05 +00:00
github-actions[bot]
530f3aaab7 chore(ui): refresh fr control ui locale 2026-05-31 23:21:43 +00:00
github-actions[bot]
3ec1a25de4 chore(ui): refresh ja-JP control ui locale 2026-05-31 23:21:30 +00:00
github-actions[bot]
5a6ec67eb0 chore(ui): refresh es control ui locale 2026-05-31 23:21:27 +00:00
github-actions[bot]
0fdca6974d chore(ui): refresh ko control ui locale 2026-05-31 23:21:24 +00:00
Jerry-Xin
dc344a33fb fix(cron): retire MCP runtimes on isolated cron cleanup
Retire isolated cron session MCP runtimes on timeout and dispose so orphaned MCP servers do not accumulate after cron cleanup. Bound MCP session disposal to 5 seconds and force-close hung transports, including streamable-HTTP DELETE hangs, to prefer gateway availability over unbounded teardown.

Fixes #87821.
PR: #87981.
Proof: latest Real behavior proof check passed after body fix; local autoreview clean with focused cron/gateway/MCP tests covering 108 tests.

Co-authored-by: 忻役 <xinyi@mininglamp.com>
Co-authored-by: Jerry-Xin <jerryxin0@gmail.com>
2026-06-01 00:21:14 +01:00
github-actions[bot]
e4a766f2f4 chore(ui): refresh zh-TW control ui locale 2026-05-31 23:20:54 +00:00
github-actions[bot]
ad07ba141d chore(ui): refresh pt-BR control ui locale 2026-05-31 23:20:46 +00:00
github-actions[bot]
bd78737f94 chore(ui): refresh de control ui locale 2026-05-31 23:20:41 +00:00
github-actions[bot]
5f6e608c60 chore(ui): refresh zh-CN control ui locale 2026-05-31 23:20:37 +00:00
Vincent Koc
ddbd16a04a fix(ui): honor chromium executable override 2026-06-01 00:20:10 +01:00
Vincent Koc
03151a2ebe test(release): repair stale e2e mocks 2026-06-01 00:20:10 +01:00
Vincent Koc
1b69e7a005 fix(plugin-sdk): keep llm core alias on source graph 2026-06-01 00:20:10 +01:00
Vincent Koc
227530f906 test(imessage): align service-qualified target expectations 2026-06-01 00:20:10 +01:00
Vincent Koc
6df3fd5730 fix(gateway): list commands from gateway plugin registry 2026-06-01 00:20:10 +01:00
Vincent Koc
7c315252d6 test(whatsapp): wait on inbox delivery in monitor helper 2026-06-01 00:20:10 +01:00
Vincent Koc
0d7abcc94f test(telegram): exercise blocked spooled timeout lane 2026-06-01 00:20:09 +01:00
Vincent Koc
344773ba09 fix(openrouter): cap music stream request timeouts 2026-06-01 00:20:09 +01:00
Vincent Koc
ae4550f48b test(qa-lab): preserve cleanup phase labels 2026-06-01 00:20:09 +01:00
Vincent Koc
fdd02444b7 ci: add ARM Testbox lane 2026-06-01 00:20:09 +01:00
Peter Steinberger
3491834d49 Migrate iMessage monitor state to SQLite (#88797)
* refactor: move imessage monitor state to sqlite

* test: use OpenClaw temp root in iMessage state helper

* test: avoid pending promise lint in chat tests

* test: harden gateway ci flakes

* test: align session list merge expectation
2026-06-01 00:19:51 +01:00
Vincent Koc
12cf34a8ea refactor: share send inflight helpers 2026-06-01 01:18:38 +02:00
Peter Steinberger
d328a0d7a0 feat: calm chat composer controls 2026-06-01 00:18:04 +01:00
colmbrogan
421ad93203 fix(imessage): tolerate self-chat timestamp skew
Fixes iMessage self-chat reflection dedupe when reflected rows arrive with sub-second `created_at` skew, while keeping ambiguous normal-DM suppression exact-match only.

Maintainer follow-ups scoped skew tolerance to confirmed self-chat remembered rows and bounded cache cleanup so TTL-only expiry cannot leave the insertion-order queue growing indefinitely.

Verification:
- `node scripts/run-vitest.mjs extensions/imessage/src/monitor/self-chat-cache.test.ts extensions/imessage/src/monitor/self-chat-dedupe.test.ts extensions/imessage/src/monitor/inbound-processing.test.ts`
- `pnpm oxlint extensions/imessage/src/monitor/self-chat-cache.ts extensions/imessage/src/monitor/self-chat-cache.test.ts extensions/imessage/src/monitor/self-chat-dedupe.test.ts`
- `git diff --check origin/main...HEAD`
- autoreview clean on branch tip
- CI run 26727192244 green; Real behavior proof run 26727196218 green

Co-authored-by: Colm O Brogain <73212305+colmbrogan@users.noreply.github.com>
2026-06-01 00:14:47 +01:00
Vincent Koc
dc05f598bb fix(doctor): report runtime tool schema errors 2026-06-01 00:14:36 +01:00
Alix-007
3171278372 fix(gateway): hide phantom agent store rows from sessions.list
Fixes #57376.

Hide placeholder agent store keys from sessions.list while preserving real agent-scoped sessions.

Co-authored-by: Alix-007 <li.long15@xydigit.com>
2026-06-01 00:14:09 +01:00
Feelw00
01193dea26 fix: make task persistence failures explicit
Preserve task and TaskFlow durability by persisting before in-memory registry mutation and surfacing explicit persistence failures instead of reporting fake success.

Adds non-throwing try-create runtime helpers while keeping existing throwing public create APIs compatible. Maintainer follow-up keeps task/TaskFlow sync repair bounded, prevents split task/delivery-state writes, and keeps CI green on the current base.

Thanks @Feelw00.
2026-06-01 00:12:28 +01:00
Coder
cb9847968a fix(subagents): roll token usage formatters over to m
Roll both subagent token usage formatters over to the million unit when rounded thousands reach the next unit.

The original fix covers `formatTokenShort`, which feeds the subagent list usage line. The maintainer follow-up applies the same unit-boundary rule to compact subagent announcement stats, preserving that formatter's one-decimal style while preventing `1000.0k` output.

Verification:
- focused runtime probe for list and compact announce stats at 999,999 tokens
- `oxfmt --check` on touched formatter/test files
- `git diff --check origin/main..HEAD`
- `node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test-pr88209.tsbuildinfo`
- autoreview local closeout clean
- exact-head CI passed for Real behavior proof, check-test-types, check-prod-types, check-guards, security-fast, and preflight

Known unrelated current-main reds at merge: `check-lint`, `checks-node-agentic-gateway-methods`, and `checks-node-agentic-control-plane-agent-chat`.

Co-authored-by: coder999999999 <coder999999999@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 00:07:45 +01:00
Vincent Koc
54987715f3 fix(ci): repair main lint and gateway session tests 2026-06-01 00:05:41 +01:00
Silvester
0c74f18a1c fix(microsoft-foundry): skip DeepSeek V4 thinking params on Foundry fallback
Skip the generic DeepSeek V4 OpenAI-compatible `thinking` payload wrapper for Microsoft Foundry fallback models. Foundry's OpenAI-compatible gateway rejects the non-standard top-level `thinking` argument, while the rest of the DeepSeek proxy path still keeps the wrapper.

Proof:
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --parallel-tests "node scripts/run-vitest.mjs src/agents/embedded-agent-runner-extraparams.test.ts"
- node scripts/run-vitest.mjs src/agents/embedded-agent-runner-extraparams.test.ts passed, 130/130
- CI run 26681069909 passed for c950ac112e

Thanks @silvesterxm.
2026-06-01 00:03:32 +01:00
Vincent Koc
59122812c0 refactor: share agent id resolver 2026-06-01 01:03:07 +02:00
Alix-007
bc95af1b7c fix(memory-core): stop dream diary fallback leaks
Stop memory-core dream diary fallback paths from persisting raw memory staging snippets or promotions into DREAMS.md when narrative generation times out, returns empty output, or fails in request-scoped runtime. Successful generated narratives are unchanged.

Maintainer fixup: align current gateway session-list tests with the full loadSessionEntry mock shape and model-derived context token behavior on main.

Fixes #88391

Co-authored-by: Alix-007 <li.long15@xydigit.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 00:00:22 +01:00
yozakura-ava
144405e562 fix(agents): cap bootstrap snapshot cache
Cap the in-memory workspace bootstrap snapshot cache to 64 session keys so long-lived gateway processes do not retain one loaded bundle per distinct session key indefinitely. Older entries are evicted while active keys continue refreshing against the guarded workspace loader.

Verification:
- node scripts/run-vitest.mjs src/agents/bootstrap-cache.test.ts

PR: #88149
2026-05-31 23:56:47 +01:00
Vincent Koc
290b19275b refactor: share cron request helpers 2026-06-01 00:53:00 +02:00
Rain
72f74b33e1 fix(agents): guard transport payload sanitizer against non-string input
sanitizeTransportPayloadText() called text.replace() directly, so runtime-undefined content from malformed replay/error handling could crash embedded agent transport serialization with "Cannot read properties of undefined (reading 'replace')".

Return an empty string for non-string runtime payloads at the shared sanitizer boundary, preserving existing unpaired-surrogate cleanup for strings. Empty values still degrade through sanitizeNonEmptyTransportPayloadText() to "(no output)" where that non-empty fallback is required.

Proof:
- pnpm test src/agents/transport-stream-shared.test.ts
- pnpm exec oxfmt --check --threads=1 src/agents/transport-stream-shared.ts src/agents/transport-stream-shared.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --parallel-tests "pnpm test src/agents/transport-stream-shared.test.ts"

Fixes #60113

Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 23:45:39 +01:00
Vincent Koc
bb673f47b2 refactor: share agent run snapshot shape 2026-06-01 00:39:13 +02:00
Vincent Koc
16ef9c1435 fix(ci): use reliable Azure Crabbox capacity 2026-06-01 00:38:11 +02:00
Peter Steinberger
2b30951b80 feat: calm composer controls (#88772) 2026-05-31 23:37:27 +01:00
Peter Steinberger
56b8030cd9 fix(qa-lab): avoid returning timer from promise executor 2026-05-31 23:34:13 +01:00
DocNR
5706619068 fix(nostr): decode npub allowFrom entries to hex correctly
Fix Nostr allowFrom npub normalization by returning the decoded hex string from nostr-tools instead of iterating the hex string as bytes.

Proof:
- node scripts/run-vitest.mjs extensions/nostr/src/nostr-bus.test.ts
- PR CI green at head 7c3433435b

Co-authored-by: DocNR <danieljwyler@gmail.com>
2026-05-31 23:33:45 +01:00
Vincent Koc
edc0a22179 fix(agents): quarantine tools before schema normalization 2026-05-31 23:33:03 +01:00
Peter Steinberger
2682c02774 perf: hydrate chat history session metadata
Use chat.history metadata to hydrate TUI and web startup state without the extra sessions.list refresh, with guards for aliases, stale active rows, blank-session defaults, and lightweight TUI usage metadata.
2026-05-31 23:31:15 +01:00
Vincent Koc
59683978e1 refactor: share voice-call config extraction 2026-06-01 00:19:33 +02:00
Peter Steinberger
c8f8907f15 fix(feishu): guard webhook readiness fetch 2026-05-31 23:18:09 +01:00
Vincent Koc
8eb1838dfa refactor: share web login unavailable response 2026-06-01 00:13:34 +02:00
Jason O'Neal
01f6ad6056 fix: suppress raw provider errors in channel delivery
Fixes #69737.

Suppresses raw and raw-derived provider error text at the user-facing assistant lifecycle and reply-payload boundaries, including structured provider payloads, escaped JSON payloads, and aborted turns carrying provider failures. Keeps safe schema rejection and rate-limit guidance while preserving internal diagnostics.

Proof:
- OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts src/agents/embedded-agent-runner/run/payloads.errors.test.ts src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts src/agents/embedded-agent-helpers/errors.test.ts
- oxfmt --check on touched files
- git diff --check origin/main...HEAD
- autoreview --mode branch --base origin/main clean
- GitHub exact-head checks green on b46e197f62

Co-authored-by: Jason O'Neal <jason.allen.oneal@gmail.com>
2026-05-31 23:10:46 +01:00
Peter Steinberger
b7f657b3b0 chore(lint): fix app render promise executor 2026-05-31 23:10:00 +01:00
Peter Steinberger
22cb7fb6b7 chore(lint): enable no-promise-executor-return 2026-05-31 23:06:13 +01:00
Vincent Koc
48afba96a3 refactor: share agents handler helpers 2026-06-01 00:02:22 +02:00
github-actions[bot]
470a1ae8d1 chore(ui): refresh nl control ui locale 2026-05-31 21:59:56 +00:00
github-actions[bot]
a2acfc5049 chore(ui): refresh fa control ui locale 2026-05-31 21:59:48 +00:00
github-actions[bot]
fe8c781d67 chore(ui): refresh vi control ui locale 2026-05-31 21:59:18 +00:00
github-actions[bot]
ac2484f23e chore(ui): refresh pl control ui locale 2026-05-31 21:59:13 +00:00
github-actions[bot]
cabfbdfe0d chore(ui): refresh id control ui locale 2026-05-31 21:59:11 +00:00
github-actions[bot]
5e2472567a chore(ui): refresh th control ui locale 2026-05-31 21:59:07 +00:00
github-actions[bot]
79c4ac73d7 chore(ui): refresh tr control ui locale 2026-05-31 21:58:33 +00:00
github-actions[bot]
2a1882ebcc chore(ui): refresh uk control ui locale 2026-05-31 21:58:31 +00:00
github-actions[bot]
3bb04b67e9 chore(ui): refresh it control ui locale 2026-05-31 21:58:25 +00:00
github-actions[bot]
cd0a7b10e2 chore(ui): refresh ar control ui locale 2026-05-31 21:58:21 +00:00
github-actions[bot]
bc45c36dbc chore(ui): refresh fr control ui locale 2026-05-31 21:57:51 +00:00
github-actions[bot]
7184522fae chore(ui): refresh ko control ui locale 2026-05-31 21:57:45 +00:00
github-actions[bot]
aa74d93aff chore(ui): refresh es control ui locale 2026-05-31 21:57:40 +00:00
github-actions[bot]
be0d3489a6 chore(ui): refresh ja-JP control ui locale 2026-05-31 21:57:38 +00:00
github-actions[bot]
f06b4b9aab chore(ui): refresh pt-BR control ui locale 2026-05-31 21:57:10 +00:00
github-actions[bot]
0700f13d62 chore(ui): refresh zh-TW control ui locale 2026-05-31 21:57:05 +00:00
github-actions[bot]
3c6c247e0a chore(ui): refresh de control ui locale 2026-05-31 21:57:01 +00:00
github-actions[bot]
2e42b1372e chore(ui): refresh zh-CN control ui locale 2026-05-31 21:56:58 +00:00
Shakker
f78bb34cb4 fix: translate Skill Workshop locale strings 2026-05-31 22:55:03 +01:00
Shakker
85c7490f72 fix: refresh Skill Workshop i18n outputs 2026-05-31 22:55:03 +01:00
Shakker
63d93db867 fix: refresh Skill Workshop protocol models 2026-05-31 22:55:03 +01:00
Shakker
2976db4b2c fix: address Skill Workshop UI check failures 2026-05-31 22:55:03 +01:00
Shakker
025bb01268 fix: constrain Skill Workshop navigation 2026-05-31 22:55:03 +01:00
Shakker
7a292bb16e fix: improve Skill Workshop empty states 2026-05-31 22:55:03 +01:00
Shakker
a9e3eade5d fix: tighten Skill Workshop today actions 2026-05-31 22:55:03 +01:00
Shakker
3733cd8d63 fix: clarify Skill Workshop proposal preview 2026-05-31 22:55:03 +01:00
Shakker
190f935b53 fix: address Skill Workshop review findings 2026-05-31 22:55:03 +01:00
Shakker
c21e16c73d fix: add Skill Workshop empty state 2026-05-31 22:55:03 +01:00
Shakker
d52f1ea5ec fix: tighten Skill Workshop today actions 2026-05-31 22:55:03 +01:00
Shakker
13967e17e6 fix: distinguish created Skill Workshop proposals 2026-05-31 22:55:03 +01:00
Shakker
7ad2aa44dd fix: show assistant name in Skill Workshop 2026-05-31 22:55:03 +01:00
Shakker
874b3f921e fix: polish Skill Workshop revision handoff 2026-05-31 22:55:03 +01:00
Shakker
c11d5d6d65 feat: stage Skill Workshop revision handoff 2026-05-31 22:55:03 +01:00
Shakker
11631bf044 feat: animate Skill Workshop chat landing 2026-05-31 22:55:03 +01:00
Shakker
561e993282 fix: stabilize Skill Workshop revise handoff 2026-05-31 22:55:03 +01:00
Shakker
23bf48e69e feat: add reusable Control UI tooltip 2026-05-31 22:55:03 +01:00
Shakker
7d65ea3513 feat: style Skill Workshop revision controls 2026-05-31 22:55:03 +01:00
Shakker
bfac12a184 feat: route Skill Workshop revisions through reusable sessions 2026-05-31 22:55:03 +01:00
Shakker
cdcc151145 feat: attach agent session origin to workshop tool 2026-05-31 22:55:03 +01:00
Shakker
7681b95199 feat: persist Skill Workshop proposal origin 2026-05-31 22:55:03 +01:00
Shakker
caa08a6dc0 feat: show real Skill Workshop proposals 2026-05-31 22:55:03 +01:00
Shakker
4339d7c1d8 feat: add Skill Workshop revision dialog 2026-05-31 22:55:03 +01:00
Shakker
aa187c6496 feat: add Skill Workshop today view 2026-05-31 22:55:03 +01:00
Shakker
34010894c1 feat: preview Skill Workshop actions 2026-05-31 22:55:03 +01:00
Shakker
c74bb4475a feat: resize Skill Workshop proposal list 2026-05-31 22:55:03 +01:00
Shakker
299a023bd1 fix: track reviewed workshop proposals 2026-05-31 22:55:03 +01:00
Shakker
0c852036c7 fix: refine Skill Workshop action bar 2026-05-31 22:55:03 +01:00
Shakker
9cc759dd37 fix: hide Skill Workshop actions after pending 2026-05-31 22:55:03 +01:00
Shakker
d1378650bb fix: keep file preview row focus clean 2026-05-31 22:55:03 +01:00
Shakker
40f99e474a fix: keep file preview keyboard focus modal 2026-05-31 22:55:03 +01:00
Shakker
dc71b5867e fix: align live tool stream labels 2026-05-31 22:55:03 +01:00
Shakker
fd2c65f59b refactor: extract file preview modal component 2026-05-31 22:55:03 +01:00
Shakker
575f74293e feat: search Skill Workshop preview files 2026-05-31 22:55:03 +01:00
Shakker
b27ae3f6e7 fix: remove Skill Workshop modal search focus chrome 2026-05-31 22:55:03 +01:00
Shakker
b388d3dc71 style: add Skill Workshop file preview modal 2026-05-31 22:55:03 +01:00
Shakker
01b7ef9e88 feat: add Skill Workshop file preview modal 2026-05-31 22:55:03 +01:00
Shakker
4b89def277 fix: align Skill Workshop pane surface 2026-05-31 22:55:03 +01:00
Shakker
fabd9469cd fix: tighten Skill Workshop page spacing 2026-05-31 22:55:03 +01:00
Shakker
d3025b4007 fix: resolve Control UI public assets from base path 2026-05-31 22:55:03 +01:00
Shakker
c06096eabc fix: keep Control UI logo root-relative 2026-05-31 22:55:03 +01:00
Shakker
9577e0be5a feat: style Skill Workshop UI 2026-05-31 22:55:03 +01:00
Shakker
b12724b79b feat: add Skill Workshop demo view 2026-05-31 22:55:03 +01:00
Shakker
0de60cec12 feat: add Skill Workshop navigation tab 2026-05-31 22:55:03 +01:00
Vincent Koc
c6232347dc refactor: share exec approvals node invoke 2026-05-31 23:50:30 +02:00
xin zhuang
b73e135f97 fix: resolve google provider default API to google-generative-ai (#88480) (#88512)
When a configured Google provider/model row had no explicit
but had a baseUrl set, the fallback defaulted to openai-completions,
causing Gemini requests to route through the OpenAI Responses
transport instead of the native @google/genai transport.

Made resolveConfiguredProviderDefaultApi provider-aware: for the
google provider, the default API is now google-generative-ai.

Root cause: the generic fallback assumed any provider with a baseUrl
should use openai-completions, which is incorrect for Google's native
Gemini API.

Co-authored-by: xin <1052326311+xin@users.noreply.github.com>
2026-05-31 22:48:48 +01:00
github-actions[bot]
9b6c981260 chore(ui): refresh fa control ui locale 2026-05-31 21:46:04 +00:00
github-actions[bot]
02ac0ec48b chore(ui): refresh nl control ui locale 2026-05-31 21:46:00 +00:00
github-actions[bot]
d8329dedf6 chore(ui): refresh pl control ui locale 2026-05-31 21:45:39 +00:00
github-actions[bot]
b86e8bf359 chore(ui): refresh id control ui locale 2026-05-31 21:45:30 +00:00
github-actions[bot]
3bb9224836 chore(ui): refresh vi control ui locale 2026-05-31 21:45:24 +00:00
github-actions[bot]
fdc10a64e9 chore(ui): refresh th control ui locale 2026-05-31 21:45:20 +00:00
github-actions[bot]
87174c80b6 chore(ui): refresh uk control ui locale 2026-05-31 21:44:45 +00:00
github-actions[bot]
97c040f946 chore(ui): refresh it control ui locale 2026-05-31 21:44:40 +00:00
github-actions[bot]
f833e96a31 chore(ui): refresh tr control ui locale 2026-05-31 21:44:38 +00:00
github-actions[bot]
9a32c0f85d chore(ui): refresh ar control ui locale 2026-05-31 21:44:35 +00:00
github-actions[bot]
d306f5bf2e chore(ui): refresh fr control ui locale 2026-05-31 21:44:02 +00:00
github-actions[bot]
65d5f7436c chore(ui): refresh ko control ui locale 2026-05-31 21:43:55 +00:00
github-actions[bot]
b78ce079a3 chore(ui): refresh ja-JP control ui locale 2026-05-31 21:43:51 +00:00
github-actions[bot]
6c6cf41b14 chore(ui): refresh es control ui locale 2026-05-31 21:43:42 +00:00
github-actions[bot]
0d79cbab4e chore(ui): refresh pt-BR control ui locale 2026-05-31 21:43:18 +00:00
github-actions[bot]
b04c3e96d6 chore(ui): refresh zh-CN control ui locale 2026-05-31 21:43:11 +00:00
github-actions[bot]
3854a61bea chore(ui): refresh de control ui locale 2026-05-31 21:43:07 +00:00
github-actions[bot]
0d07e30725 chore(ui): refresh zh-TW control ui locale 2026-05-31 21:43:01 +00:00
Ted Li
bfc151e9d3 fix(feishu): preserve long streaming replies
Preserve long Feishu streaming replies by falling oversized finals back to chunked message/static-card delivery instead of closing through an over-limit streaming CardKit payload.

Keeps late-final suppression after a streaming card closes, and uses markdown-aware chunking for static card fallback replies.

Fixes #88631.

Co-authored-by: Ted Li <tl2493@columbia.edu>
2026-05-31 22:41:38 +01:00
Peter Steinberger
b653d94918 chore(lint): enable no-useless-assignment 2026-05-31 22:40:48 +01:00
Andy Ye
49e5091f18 fix(update): recognize manual-update launchd jobs (#88764)
* Recognize manual update launchd jobs

* fix(update): avoid stale launchd false positives

* fix(update): filter stale doctor launchd checks

* fix(update): narrow manual launchd updater labels

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 22:39:37 +01:00
zhang-guiping
cbdb59b255 fix(agents): keep light isolated subagents lightweight
Keep native subagent spawns with `lightContext=true` and resolved isolated context out of context-engine pre-spawn preparation so they remain lightweight.

The normal isolated and forked context-engine lifecycle stays intact, and docs now call out the lightweight isolated exception.

Fixes #81214
2026-05-31 22:37:59 +01:00
Vincent Koc
2ac2a8d210 refactor: share channel operation validation 2026-05-31 23:35:19 +02:00
Shubhankar Tripathy
d042452d20 fix(logging): refresh file log hostname per write
Fix JSONL file-log hostnames getting pinned to `unknown` when the first hostname read returns an empty value. The logger now retries empty hostname reads and caches the first non-empty value, keeping the top-level `hostname` and `_meta.hostname` fields aligned.

Fixes #87258.
Thanks @lonexreb for the fix.

Verification:
- `node scripts/run-vitest.mjs src/logging/logger-redaction-behavior.test.ts src/logger.test.ts`
- `node_modules/.bin/oxfmt --check --threads=1 src/logging/logger.ts src/logging/logger-redaction-behavior.test.ts`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- `gh pr checks 88131 --watch=false`

Co-authored-by: lonexreb <reach2shubhankar@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 22:35:04 +01:00
Peter Steinberger
50f27ee91d docs: document code-mode MCP API files 2026-05-31 22:33:06 +01:00
charles-openclaw
84266cd30e fix(models): strip remaining provider self prefixes (#88781)
* fix(models): strip remaining provider self prefixes

* fix(models): keep catalog refs prefix-preserving

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 22:29:47 +01:00
David
61e9961abb fix(agents): expose session status route context
Expose session status route context so agents can distinguish session origin, active live route, and persisted delivery route.

Add maintainer fixup to keep active route metadata on the real live run key when policy and run keys differ.

Thanks @nxmxbbd.

Closes #84544
2026-05-31 22:25:47 +01:00
Ashd.LW.
7c04ce3a79 fix(daemon): preserve container service env across regen
Preserve the current container-related service opt-in environment when regenerating daemon service files, while continuing to drop stale or arbitrary `OPENCLAW_*` variables.

Verification:
- `git diff --check`
- `node scripts/run-vitest.mjs src/commands/daemon-install-helpers.test.ts -t "operator opt-in allowlist"`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --prompt "Review PR #82828 fixup for daemon service env preservation. Focus on whether the allowlist should include only current container opt-in env keys and whether tests cover stale/arbitrary OPENCLAW_* filtering."`
- GitHub CI on `2e4b7f7fccbc46541c9c0ac271b1c97f1a6aa071`

Co-authored-by: wAngByg <281221101+wAngByg@users.noreply.github.com>
2026-05-31 22:22:24 +01:00
Vincent Koc
2ff9e27d4e refactor: share skill proposal workspace handling 2026-05-31 23:21:27 +02:00
Peter Steinberger
5ee3e5d8c0 docs: require real Crabbox visual proof 2026-05-31 22:18:31 +01:00
waterblue
03dec8bb3a fix(openai): avoid replay ids when Responses store is disabled
Avoid replaying prior OpenAI Responses reasoning/message/function-call item ids when the outgoing request disables store, while preserving encrypted reasoning and normalized summary arrays for stateless replay. Keep explicit store-enabled OpenAI wrapper paths opted into item-id replay, and cover shared/simple Responses, ChatGPT/Codex Responses, and GitHub Copilot sanitizer behavior.

Regression tests cover store-disabled id omission, encrypted reasoning preservation, idless Copilot reasoning replay, and direct builder payloads. Local proof included focused Vitest, broad lint, broad test-types, bundled-extension lint, plugin boundary checks, autoreview clean, and live OpenAI Responses gpt-5.5 proof.

Co-authored-by: hang <zhanghang02@gmail.com>
2026-05-31 22:17:32 +01:00
Arnab Saha
5bc80dbe27 fix(diagnostics): carry session UUID on interactive dispatch events
Carry the canonical session UUID from the session store into interactive dispatch diagnostic lifecycle events, matching the cron path so downstream diagnostic consumers can join events back to the JSONL transcript id.

Guard native command redirects by only attaching the UUID when the lifecycle session key matches the session-store lookup key, avoiding a target UUID under a source conversation key.

Verification:
- `pnpm test src/auto-reply/reply/dispatch-from-config.test.ts -t "carries the session store UUID|does not stamp a command target"`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --prompt ...`
- synthetic merge-tree against current `origin/main`

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:15:15 +01:00
Peter Steinberger
8383e2e4d9 fix(doctor): keep post-upgrade JSON stable 2026-05-31 22:12:38 +01:00
Arnab Saha
7f93755206 fix(doctor): post-upgrade entry probe now delegates to install resolver
Address Codex review (P2 x3): replace the duplicate fs.access-based entry
checks in runPostUpgradeProbes with a call to
validatePackageExtensionEntriesForInstall so the doctor probe enforces the
same contract as plugin install/discovery:

- runtimeExtensions shape and length-mismatch validation
- plugin-root boundary enforcement (rejects absolute paths and ../ escapes)
- inferred dist/*.js peer for TypeScript entries; TS source-only entries
  without compiled output are now flagged

Adds 4 regression tests covering the boundary-escape, dist-peer accept,
TS-source-only reject, and runtimeExtensions length-mismatch cases.

Refs: https://github.com/openclaw/openclaw/pull/79260#issuecomment-4403594002
2026-05-31 22:12:38 +01:00
Arnab Saha
7dd1bd894b fix(doctor): drop unused listBuiltRuntimeEntryCandidates import and brace bare if continue 2026-05-31 22:12:38 +01:00
Arnab Saha
6ed6120977 docs(doctor): document --post-upgrade and --json flags 2026-05-31 22:12:38 +01:00
Arnab Saha
0f396368a9 fix(doctor): honor runtimeExtensions before flagging entry_unresolved 2026-05-31 22:12:38 +01:00
Arnab Saha
72679b16eb fix(doctor): resolve plugin index via state-dir helper 2026-05-31 22:12:38 +01:00
Arnab Saha
4a09fd43e2 docs(changelog): note doctor --post-upgrade --json 2026-05-31 22:12:38 +01:00
Arnab Saha
026ab6b882 feat(doctor): expose --post-upgrade and --json CLI flags 2026-05-31 22:12:38 +01:00
Arnab Saha
730492867f feat(doctor): branch into post-upgrade probe runner when --post-upgrade 2026-05-31 22:12:38 +01:00
Arnab Saha
ceda284845 feat(doctor): add plugin.manifest_drift post-upgrade probe 2026-05-31 22:12:38 +01:00
Arnab Saha
8da6b67607 fix(doctor): clean up post-upgrade probe test temp dirs and skip plugins with unreadable package.json 2026-05-31 22:12:38 +01:00
Arnab Saha
e0d3c78042 feat(doctor): add plugin.entry_unresolved post-upgrade probe 2026-05-31 22:12:38 +01:00
Arnab Saha
af7749123b feat(doctor): add post-upgrade finding types 2026-05-31 22:12:38 +01:00
alkor2000
9d97e683d4 feat(doctor): add disk space health check
Add a Doctor health contribution that checks free space on the partition containing the active OpenClaw state directory. Doctor now warns below 500 MB and reports critical below 100 MB so disk pressure is visible before config writes, session transcripts, or log rotation start failing.

The contribution reuses the shared `src/infra/disk-space.ts` probe, runs before state integrity, and is registered in the Doctor health conversion plan with focused coverage for thresholds, formatting, and note behavior.

PR: #59196
Proof: `pnpm test src/commands/doctor-disk-space.test.ts src/flows/doctor-health-conversion-plan.test.ts`; `git diff --check origin/main...HEAD`; `git merge-tree --write-tree origin/main refs/remotes/pr/59196`; GitHub CI run `26720861380`; Real behavior proof run `26720996848`.

Co-authored-by: alkor2000 <200923177@qq.com>
2026-05-31 22:09:36 +01:00
Vincent Koc
e2c745fc58 refactor: share agent wait terminal snapshot 2026-05-31 23:08:28 +02:00
Andy Ye
5df0ed3b9f fix(agents): publish owned announcement session writes
Forward prompt-submission owned session write publication into the embedded session lock controller so same-process announcement/completion writes can advance the requester fence while external edits still trigger takeover protection.

Adds regression coverage for a second controller publishing an owned announcement write and for preserving rejection of a later unowned edit.

Closes #88703.

Thanks @TurboTheTurtle.
2026-05-31 22:00:37 +01:00
Ted Li
e5acae4453 fix(ui): show Workboard comments in edit modal
Show existing Workboard card comments in the edit modal and allow operators to append a new comment through the existing `workboard.cards.comment` gateway method.

Refs #88592.

Verification:
- node scripts/run-vitest.mjs ui/src/ui/views/workboard.test.ts
- pnpm tsgo:test:ui
- git diff --check origin/main...HEAD
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main

Co-authored-by: Ted Li <tl2493@columbia.edu>
2026-05-31 21:54:26 +01:00
Peter Steinberger
8076eead77 test(channels): narrow pending ingress duplicate 2026-05-31 21:53:42 +01:00
Peter Steinberger
f6365d07c4 fix(agents): wait for cron media completions
Keep cron media generation detached while making cron runs wait for image/music/video completion before final closeout. Records async task IDs, falls back to the task registry for active run-scoped media work, handles timeout races, and scopes no-target generated-media delivery. Fixes #88001.
2026-05-31 21:51:38 +01:00
Sebastien Tardif
9a3e7d4f51 fix(hooks): pass media metadata to internal message_received hook
Forward canonical inbound media metadata to internal message:received hook consumers, matching the plugin received hook mapper and inbound-claim metadata path.

This fixes internal hook handlers losing mediaPath, mediaUrl, mediaType, mediaPaths, mediaUrls, and mediaTypes for received messages with attachments.

Verification:
- node scripts/run-vitest.mjs src/hooks/message-hook-mappers.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main

Refs: https://github.com/openclaw/openclaw/pull/88740
Thanks @SebTardif.
Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca>
2026-05-31 21:49:36 +01:00
Peter Steinberger
ce1165afda fix: repair providerless Codex session overrides
Co-authored-by: Earl Vanze <earlvanze@gmail.com>
2026-05-31 21:45:39 +01:00
brokemac79
90712f6d5e [codex] Surface disabled Codex plugin routes in doctor lint (#88761)
Merged via squash.

Prepared head SHA: 41bcde2d7d
Co-authored-by: brokemac79 <255583030+brokemac79@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-05-31 13:34:53 -07:00
ArthurNie
7c15c2765e fix(feishu): fallback when accepted turns send no visible reply (#87896)
* fix(feishu): fallback when accepted turns send no visible reply

* fix(feishu): cover no-visible-reply fallback gaps

* fix(feishu): mark media replies visible

* fix(feishu): honor suppressed delivery fallback

* test(auto-reply): trim fallback test churn

* fix(feishu): gate empty fallback eligibility

* test(auto-reply): expect fallback metadata after denied dispatch

* fix(feishu): fallback after failed visible final sends

* test(feishu): keep reply dispatcher mock shape aligned

* fix(auto-reply): respect silent policy for no-visible fallback

* fix(feishu): wait for streaming close before fallback

* fix(feishu): clear silent skip before later finals

* fix(feishu): preserve visible state across keepalives

* test(feishu): align lifecycle dispatcher mocks

* fix(feishu): require accepted streaming content for fallback

---------

Co-authored-by: ArthurNie <264332276+ArthurNie@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 21:33:13 +01:00
Peter Steinberger
e681569536 feat: add code-mode MCP API files
* feat: add code-mode MCP API files

* fix: satisfy code-mode MCP lint
2026-05-31 21:29:06 +01:00
Peter Steinberger
b0679d1f13 refactor(channels): store inbound queues in SQLite 2026-05-31 21:15:29 +01:00
Peter Steinberger
80b7f56603 ci: pin Azure crabbox lane to eastus2 2026-05-31 21:11:43 +01:00
Peter Steinberger
995a9bd702 chore(ui): refresh notification i18n metadata 2026-05-31 21:09:37 +01:00
Peter Steinberger
92b9cd21ec test: avoid positional CI check assertion 2026-05-31 16:00:04 -04:00
Peter Steinberger
d62bfab946 ci: split startup and shrinkwrap checks 2026-05-31 15:55:43 -04:00
Peter Steinberger
7aa309319f test(auto-reply): align debounce timer tests 2026-05-31 20:48:02 +01:00
Peter Steinberger
2df95c0b10 chore(lint): enable no-misused-promises 2026-05-31 20:42:13 +01:00
Peter Steinberger
6f58a71582 test(voice-call): install state runtime for events 2026-05-31 20:41:14 +01:00
stain lu
55fc3c10b0 fix(openai/tts): handle speed directives (#74089)
Adds OpenAI speech speed directive parsing with official OpenAI range validation and custom endpoint passthrough. Closes #12163.
2026-05-31 20:35:46 +01:00
Peter Steinberger
b4a6244ef4 ci: split agents core test shard 2026-05-31 15:35:36 -04:00
Peter Steinberger
6b2cb4db67 fix: polish notifications settings UI 2026-05-31 20:35:10 +01:00
Vincent Koc
0715081990 test(agents): narrow bundle mcp e2e setup 2026-05-31 21:31:52 +02:00
WT-WSL
462b52f62c fix(ci): guard workflow template injection
Guard the remaining Windows Testbox workflow ref logging against GitHub Actions template injection by moving `target_ref` through step env before PowerShell reads it.

Extend the local workflow check wrapper to run pinned `zizmor` across every workflow file, and keep Workflow Sanity's CI audit explicit with trusted-base pre-commit and zizmor configs for pull-request runs.

Thanks @WT-WSL for the original report and patch.

Co-authored-by: dev111-actor <captaintobb@outlook.com>
2026-05-31 20:28:40 +01:00
Peter Steinberger
118b9cacf6 refactor: split ACP manager session flows
Split ACP manager session-flow ownership into focused helpers for initialization, status reads, cancellation, and startup identity reconciliation.

Verification:
- `node scripts/run-oxlint.mjs src/acp/control-plane/manager.core.ts src/acp/control-plane/manager.initialize-session.ts src/acp/control-plane/manager.status.ts src/acp/control-plane/manager.cancel-session.ts src/acp/control-plane/manager.startup-identity-reconcile.ts src/acp/control-plane/manager.close-session.ts src/acp/control-plane/manager.turn-runner.ts src/acp/control-plane/manager.runtime-options-commands.ts src/acp/control-plane/manager.types.ts src/acp/control-plane/manager.test.ts src/acp/control-plane/manager.initialize-session.test.ts src/acp/control-plane/manager.cancel-session.test.ts src/acp/control-plane/manager.startup-identity-reconcile.test.ts src/acp/control-plane/manager.runtime-config.test.ts`
- `pnpm tsgo:prod`
- `pnpm test src/acp/control-plane/manager.test.ts src/acp/control-plane/manager.initialize-session.test.ts src/acp/control-plane/manager.cancel-session.test.ts src/acp/control-plane/manager.startup-identity-reconcile.test.ts src/acp/control-plane/manager.runtime-config.test.ts src/acp/control-plane/manager.runtime-handles.test.ts`
- `pnpm format:check src/acp/control-plane/manager.core.ts src/acp/control-plane/manager.initialize-session.ts src/acp/control-plane/manager.status.ts src/acp/control-plane/manager.cancel-session.ts src/acp/control-plane/manager.startup-identity-reconcile.ts src/acp/control-plane/manager.close-session.ts src/acp/control-plane/manager.turn-runner.ts src/acp/control-plane/manager.runtime-options-commands.ts src/acp/control-plane/manager.types.ts src/acp/control-plane/manager.test.ts src/acp/control-plane/manager.initialize-session.test.ts src/acp/control-plane/manager.cancel-session.test.ts src/acp/control-plane/manager.startup-identity-reconcile.test.ts src/acp/control-plane/manager.runtime-config.test.ts`
- `git diff --check`
- `pnpm check:test-types`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- GitHub PR checks for #88752 passed

Real behavior proof:
Behavior addressed: ACP manager session-flow ownership is split out of `AcpSessionManager` without changing initialization, status, cancel, startup identity reconciliation, close, turn, or runtime-option behavior.
Real environment tested: Local OpenClaw checkout, Node/pnpm repo toolchain, GitHub Actions PR CI.
Exact steps or command run after this patch: Focused ACP manager/runtime config/runtime handle tests plus prod/test type checks, lint, format check, diff check, autoreview, and PR CI.
Evidence after fix: All listed local commands passed, autoreview reported no accepted/actionable findings, and GitHub PR checks passed.
Observed result after fix: `manager.core.ts` is down to 612 LOC, with init/status/cancel/startup identity flows in focused modules and matching focused tests.
What was not tested: Live ACP backend session initialization/cancel/status against a real external ACP provider.
2026-05-31 20:26:04 +01:00
Peter Steinberger
8cfccca4de docs(changelog): refresh 2026.5.31 notes 2026-05-31 20:24:49 +01:00
Peter Steinberger
01603bbbf4 docs: require WebVNC screenshot verification 2026-05-31 20:21:47 +01:00
Carmen Fernández Ruiz
2e1ae531bd fix: skip disabled skill snapshot env overrides (#79173)
Co-authored-by: hera8939 <279459669+hera8939@users.noreply.github.com>
2026-05-31 20:20:13 +01:00
Peter Steinberger
9c6f7553be test(gateway): widen tailscale hostname mock 2026-05-31 20:13:34 +01:00
Peter Steinberger
ccb50f89da fix(plugins): clarify loader failure guidance 2026-05-31 15:12:22 -04:00
Peter Steinberger
7c5a412b38 fix(whatsapp): satisfy baileys audio peer 2026-05-31 20:10:28 +01:00
Simon Peck
6653193fdb fix(openai): avoid orphan Responses message id replay
Omit provider-owned OpenAI Responses assistant message ids unless the paired reasoning item was replayed immediately before the message. Preserve commentary/final_answer phase metadata so replay quality stays intact without sending orphan msg_* ids that OpenAI rejects.

Verification:
- node scripts/run-vitest.mjs src/agents/openai-transport-stream.test.ts src/agents/openai-responses.reasoning-replay.test.ts src/llm/providers/openai-responses-shared.test.ts
- node scripts/run-oxlint.mjs on touched files
- git diff --check
- live OpenAI Responses API proof with gpt-5.5
- autoreview clean
- PR CI clean on d6902ed1a0

Co-authored-by: latensified <880715+latensified@users.noreply.github.com>
2026-05-31 20:08:33 +01:00
Feelw00
7a3a52cda9 fix(agents): atomic auth.json writes
Persist agent auth files via atomic sibling-temp replacement instead of truncating `auth.json` in place, preventing crash-time credential lockout. Preserve the existing auth directory mode during replacement and keep the credential file at `0600`.

Proof:
- `node scripts/run-vitest.mjs src/agents/sessions/auth-storage.test.ts` passed, 2 tests.
- `git diff --check` passed.
- `autoreview --mode local` clean.
- `autoreview --mode branch --base origin/main` clean.
- GitHub checks green on head `3fb1d767e70118a0e8db5b0fd64d807d456721a8`.

Closes #88028.

Co-authored-by: Feelw00 <dhrtn1006@naver.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:06:50 +01:00
Peter Steinberger
fa2b2ffab4 fix(channels): recover failed progress draft starts (#88749) 2026-05-31 20:06:28 +01:00
charles-openclaw
a6f4de4a66 feat(gateway): support Tailscale Serve service names
Adds optional `gateway.tailscale.serviceName` support for Tailscale Serve so the Gateway Control UI can be exposed through a named Tailscale Service while existing hostname-based Serve and Funnel behavior stays unchanged.

The implementation validates `svc:<dns-label>`, passes the Service name to `tailscale serve`, clears named Service config with `tailscale serve clear <service>` when resetOnExit runs, and uses the derived Service hostname in startup logs, status output, and pairing URLs.

Verification:
- node scripts/run-vitest.mjs src/infra/tailscale.test.ts src/gateway/server-tailscale.test.ts src/config/config.gateway-tailscale-bind.test.ts src/gateway/startup-auth.test.ts src/commands/status.scan.shared.test.ts src/pairing/setup-code.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --parallel-tests "node scripts/run-vitest.mjs src/infra/tailscale.test.ts src/gateway/server-tailscale.test.ts src/config/config.gateway-tailscale-bind.test.ts src/gateway/startup-auth.test.ts src/commands/status.scan.shared.test.ts src/pairing/setup-code.test.ts"
- git diff --check
- git merge-tree --write-tree origin/main origin/pr/88691

Closes #88629.
Co-authored-by: Charles OpenClaw <charles-openclaw@9bcfae.inboxapi.ai>
2026-05-31 20:05:02 +01:00
Peter Steinberger
b02c448585 docs(plugins): add npm readmes for channel providers 2026-05-31 20:02:45 +01:00
Vladyslav Levchuk
a93240e2c6 fix(ui): show communication notifications tab (#74715)
Expose the existing virtual Communication > Notifications settings tab for Web Push controls, while keeping it out of the unscoped root settings view. Adds browser regression coverage for the scoped virtual tab.

Thanks @VladyslavLevchuk.

Co-authored-by: Vladyslav Levchuk <32742736+VladyslavLevchuk@users.noreply.github.com>
2026-05-31 19:58:55 +01:00
Peter Steinberger
720071a6c6 refactor: extract ACP runtime option commands
Extract ACP runtime-option command flows from `AcpSessionManager` into `manager.runtime-options-commands.ts`.

Verification:
- `pnpm format:fix src/acp/control-plane/manager.core.ts src/acp/control-plane/manager.runtime-options-commands.ts`
- `node scripts/run-oxlint.mjs src/acp/control-plane/manager.core.ts src/acp/control-plane/manager.runtime-options-commands.ts`
- `pnpm tsgo:prod`
- `pnpm test src/acp/control-plane/manager.runtime-config.test.ts src/acp/control-plane/manager.runtime-handles.test.ts src/acp/control-plane/manager.test.ts`
- `pnpm format:check src/acp/control-plane/manager.core.ts src/acp/control-plane/manager.runtime-options-commands.ts`
- `git diff --check`
- `pnpm check:test-types`
- `.agents/skills/autoreview/scripts/autoreview --mode local`
- GitHub PR checks for #88747 passed

Real behavior proof:
Behavior addressed: ACP runtime-option mutation ownership moved out of `AcpSessionManager` without changing set-mode, set-config-option, raw update, reset, persistence, or runtime-cache invalidation semantics.
Real environment tested: Local OpenClaw checkout, Node/pnpm repo toolchain, GitHub Actions PR CI.
Exact steps or command run after this patch: Focused ACP runtime config/handle/manager tests plus prod/test type checks, lint, format, diff check, autoreview, and PR CI.
Evidence after fix: All listed local commands passed, autoreview reported no accepted/actionable findings, and GitHub PR checks passed.
Observed result after fix: `manager.core.ts` is down to 885 LOC, with runtime-option command logic isolated in `manager.runtime-options-commands.ts`.
What was not tested: Live ACP backend mode/config option mutation against a real external ACP provider.
2026-05-31 19:56:43 +01:00
Roee Jukin
2155450ed7 fix(acp): prefer clean command text for local bypass
Prefer the clean channel command body when ACP decides whether an inbound message should bypass the agent loop for local OpenClaw commands.

This keeps envelope-wrapped channel text, such as WhatsApp display bodies, from hiding commands like /status when the channel already provided a normalized command body. The ACP runtime prefilter now uses the same command-text resolution as dispatch, and dispatch still requires registry-backed local commands before bypassing.

Co-authored-by: RoeeJ <RoeeJ@users.noreply.github.com>
2026-05-31 19:56:04 +01:00
Jason
e74931778c fix: preserve workspaces during state-only uninstall
Preserve workspace directories when `openclaw uninstall --state` removes local state, including configured workspaces and implicit per-agent workspaces resolved by the runtime. State-only uninstall now uses a cleanup plan that keeps those workspace roots unless `--workspace` is selected.

Fixes #75052.

Proof:
- `git diff --check origin/main...HEAD`
- `pnpm exec oxfmt --check --threads=1 src/commands/cleanup-utils.ts src/commands/cleanup-utils.test.ts src/commands/uninstall.ts src/commands/uninstall.test.ts docs/cli/uninstall.md docs/install/uninstall.md`
- `node scripts/run-vitest.mjs src/commands/uninstall.test.ts src/commands/cleanup-utils.test.ts src/commands/reset.test.ts src/commands/agents.delete.test.ts`
- `node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json --incremental --tsBuildInfoFile /tmp/openclaw-pr75061-core-test-final-rebase2.tsbuildinfo`
- `pnpm docs:list`
- `node scripts/check-docs-mdx.mjs docs/cli/uninstall.md docs/install/uninstall.md`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- CI: https://github.com/openclaw/openclaw/actions/runs/26721260691

Co-authored-by: Jason-Bai <boybai.work@gmail.com>
2026-05-31 19:54:34 +01:00
ArthurNie
9d54285b0d fix: force preflight compaction before oversized agent turns
Force required preflight context compaction before oversized turns can enter the agent runtime. Treat required preflight compaction as a hard gate: compact, skip only explicit harmless no-op reasons, or surface a visible recovery message when compaction cannot recover.

Fixes #87234.

Co-authored-by: ArthurNie <264332276+ArthurNie@users.noreply.github.com>
2026-05-31 19:48:49 +01:00
Peter Steinberger
3ff86f3350 refactor: migrate voice-call call logs through doctor (#88731) 2026-05-31 19:43:03 +01:00
Peter Steinberger
2f449285b9 refactor: extract ACP close session flow
Refactor ACP close-session ownership by extracting the runtime close/recovery lifecycle into `manager.close-session.ts`.

Verification:
- `pnpm test src/acp/control-plane/manager.test.ts src/acp/control-plane/manager.runtime-config.test.ts src/acp/control-plane/manager.runtime-handles.test.ts`
- `pnpm tsgo:prod`
- `pnpm check:test-types`
- `node scripts/run-oxlint.mjs src/acp/control-plane/manager.core.ts src/acp/control-plane/manager.close-session.ts`
- `pnpm format:check src/acp/control-plane/manager.core.ts src/acp/control-plane/manager.close-session.ts`
- `git diff --check`
- `.agents/skills/autoreview/scripts/autoreview --mode local`
- GitHub PR checks for #88744 passed

Real behavior proof:
Behavior addressed: ACP close-session ownership moved out of `AcpSessionManager` without changing close/recovery behavior.
Real environment tested: Local OpenClaw checkout, Node/pnpm repo toolchain, GitHub Actions PR CI.
Exact steps or command run after this patch: Focused ACP manager tests covering close-session behavior, runtime config, and runtime handles, plus prod/test type checks, lint, format, diff check, autoreview, and PR CI.
Evidence after fix: All listed local commands passed, autoreview reported no accepted/actionable findings, and GitHub PR checks passed.
Observed result after fix: `manager.core.ts` dropped from 1149 LOC to 1038 LOC while close-session runtime lifecycle handling lives in `manager.close-session.ts`.
What was not tested: Live ACP backend close/recovery against a real external ACP provider.
2026-05-31 19:42:46 +01:00
Peter Steinberger
465a5456fe fix(agents): preserve disabled subagent delivery state 2026-05-31 19:42:00 +01:00
Federico Kamelhar
ecbd97e968 fix(gateway): rate-limit bootstrap-token verification
Gateway/security: rate-limits pre-auth bootstrap-token verification and serializes per-IP attempts to prevent mutex-stall DoS while preserving device-token fallback.

Fixes #77978.

Co-authored-by: Federico Kamelhar <federico.kamelhar@oracle.com>
2026-05-31 19:40:22 +01:00
Peter Steinberger
ef04c72f08 docs: require live external API tests 2026-05-31 19:39:41 +01:00
Federico Kamelhar
e76df691fe fix(skills): bound watcher workspace state
Bounds skills watcher subscriptions and workspace snapshot-version state to active workspaces on the current `src/skills/runtime` implementation.

The fix keeps shared path watchers as the owner boundary, evicts idle workspace subscriptions after 1 hour without closing watchers still used by other workspaces, and clears per-workspace version keys only after preserving/advancing invalidation so cached skill snapshots cannot miss changes across teardown or re-enable.

Thanks @fede-kamel.

Fixes #77997.

Co-authored-by: Federico Kamelhar <federico.kamelhar@oracle.com>
2026-05-31 19:35:42 +01:00
Vincent Koc
f983111166 perf(scripts): parallelize test group reports 2026-05-31 20:32:54 +02:00
Vincent Koc
7e0d275f7a fix(agents): preserve skipped subagent delivery state 2026-05-31 19:30:32 +01:00
Peter Steinberger
faae7529fd refactor: extract ACP turn runner
Refactor ACP turn execution ownership by extracting the backend attempt and cleanup loop into `manager.turn-runner.ts`.

Verification:
- `pnpm test src/acp/control-plane/manager.test.ts src/acp/control-plane/manager.turn-results.test.ts src/acp/control-plane/manager.failover.test.ts src/acp/control-plane/manager.runtime-handles.test.ts src/acp/control-plane/manager.runtime-config.test.ts`
- `pnpm tsgo:prod`
- `pnpm check:test-types`
- `node scripts/run-oxlint.mjs src/acp/control-plane/manager.core.ts src/acp/control-plane/manager.turn-runner.ts`
- `pnpm format:check src/acp/control-plane/manager.core.ts src/acp/control-plane/manager.turn-runner.ts`
- `git diff --check`
- `.agents/skills/autoreview/scripts/autoreview --mode local`
- GitHub PR checks for #88739 passed

Real behavior proof:
Behavior addressed: ACP turn execution ownership moved out of `AcpSessionManager` without changing runtime behavior.
Real environment tested: Local OpenClaw checkout, Node/pnpm repo toolchain, GitHub Actions PR CI.
Exact steps or command run after this patch: Focused ACP manager tests covering turn results, failover, runtime handles, runtime config, plus prod/test type checks, lint, format, diff check, autoreview, and PR CI.
Evidence after fix: All listed local commands passed, autoreview reported no accepted/actionable findings, and GitHub PR checks passed.
Observed result after fix: `manager.core.ts` dropped from 1495 LOC to 1149 LOC while turn execution lives in `manager.turn-runner.ts`.
What was not tested: Live ACP backend process recovery against a real external ACP provider.
2026-05-31 19:29:47 +01:00
Jeff
01ef169004 fix(agents): sanitize raw HTTP 401 provider errors
Sanitize credential-shaped provider HTTP 401 failures in embedded-agent replies so chat users see a re-authentication hint instead of raw provider text such as `HTTP 401: "Invalid token"`.

The classifier now requires auth classification plus positive 401 evidence, and it stays narrow to credential-shaped failures so billing, scope, replay-invalid, schema, message-only auth, and plain 403 paths keep their existing behavior.

Fixes #56197. Thanks @lokamir.

Co-authored-by: jeffrey701 <jeffreyconradtucker@gmail.com>
2026-05-31 19:26:42 +01:00
zhang-guiping
2fbddce881 fix(cli): avoid catalog validation in agents add (#88314)
Fixes #76284.

Thanks @zhangguiping-xydt.

Co-authored-by: 张贵萍0668001030 <zhang.guiping@xydigit.com>
2026-05-31 19:22:16 +01:00
Ben Newell
a88e4fb7e0 fix(memory-core): preserve phase signals on read errors
Phase-signal store reads now recover only missing files and corrupt JSON. Nonrecoverable filesystem read failures propagate so dreaming aborts before overwriting existing phase-signal history with an empty replacement.

Fixes #77881.
Thanks @bennewell35.

Co-authored-by: bennewell35 <newelljben@gmail.com>
2026-05-31 19:18:56 +01:00
Peter Steinberger
90329e2848 refactor: extract ACP runtime resume state
Extract ACP runtime resume/discard recovery helpers from `AcpSessionManager` into `manager.runtime-resume-state.ts`, and share the manager session-meta writer callback type from `manager.types.ts`. Keeps close-time fresh-session recovery, early-turn retry, persisted resume identifier clearing, and discard-persistent-state behavior intact while reducing `manager.core.ts` from 1655 LOC to 1495 LOC.

Proof: focused ACP manager runtime-handle/runtime-config/turn-result tests, prod + test type checks, narrow oxlint, format check, diff check, autoreview clean, PR CI green.
2026-05-31 19:18:18 +01:00
Vincent Koc
454a69a048 test(gateway): align startup refactor expectations 2026-05-31 19:10:25 +01:00
Federico Kamelhar
78f2a89e95 fix(discord): bound REST entity cache growth
Bound DiscordEntityCache entries with a write-time expired-entry sweep and a default 5,000-entry cap while preserving current safe expiry timestamp normalization. This prevents high-cardinality Discord user/channel/guild/member fetches from retaining stale Map entries for the gateway lifetime.

Fixes #77975.
Thanks @fede-kamel.

Co-authored-by: Federico Kamelhar <federico.kamelhar@oracle.com>
2026-05-31 19:08:27 +01:00
Peter Steinberger
3613981579 test(gateway): refresh startup assertions 2026-05-31 19:07:31 +01:00
Sebuh Honarchian
a129b912a4 fix(gateway): guard direct session display names
Guard group display-name generation behind group/channel classification so direct Telegram sessions fall back to their explicit or origin labels. Keep session-list search aligned with that visible fallback.

Fixes #55354.
Thanks @sebuh-infsol.
2026-05-31 19:03:42 +01:00
Peter Steinberger
2a30b937cb refactor: extract ACP runtime handle ensure flow
Extract ACP runtime-handle ensure/reuse/recreate flow into `manager.runtime-handle-ensure.ts`. Keeps `AcpSessionManager` focused on orchestration while preserving backend resolution, resume identity retry, metadata persistence, cache replacement, and concurrency-limit behavior.

Proof: focused ACP manager runtime-handle/runtime-config/turn-result tests, narrow oxlint, prod + test type checks, autoreview clean, PR CI green.
2026-05-31 19:01:59 +01:00
Mert Başar
0ff5fe3a80 fix(auth): add force re-login recovery and fallback auth skips
Summary:
- Add forced provider re-login support that clears cached auth profiles before running provider login again.
- Add provider-auth remediation guidance and a session-scoped skip cache for known-bad fallback auth attempts.
- Wire session ids through agent command, auto-reply, and embedded compaction fallback callers so the skip cache applies on real run paths.
- Fail closed when forced auth profile removal cannot update the profile store.

Verification:
- Local format, lint, diff-check, focused Vitest shards, and autoreview passed.
- PR CI, CodeQL Security High, and Critical Quality agent-runtime-boundary passed on head 1b4e9e753e.

Co-authored-by: Mert Basar <MertBasar0@users.noreply.github.com>
2026-05-31 19:01:51 +01:00
Vincent Koc
db0209ac5d perf(scripts): parallelize remote core oxlint shards 2026-05-31 20:01:41 +02:00
Peter Steinberger
3bac0bcbfb fix(codex): stream final answer partials (#88730) 2026-05-31 19:00:44 +01:00
Youssef Hemimy
beb499b4d1 fix(approvals): interpolate request id in fallback command
Fix approval fallback text so exec and plugin approval messages render a concrete request id in the chat copy-paste command instead of the literal <id> placeholder.

This makes the Reply with: /approve ... line directly usable for owners while keeping the existing approval resolver contract unchanged.

Proof:
- git diff --check origin/main...HEAD
- pnpm test src/infra/exec-approval-forwarder.test.ts src/infra/plugin-approval-forwarder.test.ts src/plugin-sdk/approval-renderers.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- CI run 26720052738 passed

Thanks @itsuzef.
2026-05-31 18:59:43 +01:00
Peter Steinberger
7617d062fd chore(lint): fix rebased lint violations 2026-05-31 18:59:02 +01:00
Peter Steinberger
304e2c83c0 chore(lint): enable stricter oxlint rules 2026-05-31 18:59:02 +01:00
Peter Steinberger
cb569f6ad9 docs: clarify superseded PR close policy 2026-05-31 18:57:32 +01:00
Chunyue Wang
b8f25e9648 fix(memory): serialize qmd writes across processes (#85931)
Serialize QMD update and embed writes with one per-agent store lock so foreground memory search/index and gateway background QMD work do not write the same index.sqlite concurrently.

The embed path now waits for global embed capacity before taking the per-store lock, so queued embeds do not block same-agent foreground updates while no store write is active.

Fixes #66339
Thanks @openperf.

Co-authored-by: Chunyue Wang <16864032@qq.com>
2026-05-31 18:57:15 +01:00
Peter Steinberger
6b0ad98d62 test(extensions): update pairing challenge assertions 2026-05-31 18:56:20 +01:00
Alex Ho
d88767e819 fix(docker): refresh Node base image digests (#84988)
Refresh pinned node:24-bookworm and node:24-bookworm-slim manifest-list digests across the root, smoke, and e2e Dockerfiles. Update digest pin assertions to cover the plugin-binding e2e Dockerfile.

Verified with live Docker digest inspection, targeted Dockerfile tests, root base-runtime build, install-sh smoke build, and plugin-binding e2e build.

Thanks @LibraHo.
2026-05-31 18:55:33 +01:00
Yuval Dinodia
b988e2f92b fix(daemon): detect system-scope systemd gateway units on Linux
Detect OpenClaw gateway units installed in the system systemd scope, including marker-owned custom unit names such as `openclaw.service`. Route status/restart/stop through the system manager when appropriate, and show non-root users the matching `sudo systemctl ...` command instead of falling back to unmanaged process signaling.

Fixes #87577.
Thanks @yetval.

Verification:
- `node scripts/run-vitest.mjs src/daemon/systemd.test.ts src/cli/daemon-cli/lifecycle.test.ts src/daemon/inspect.test.ts src/cli/daemon-cli/lifecycle-core.test.ts src/cli/daemon-cli/status.gather.test.ts src/cli/daemon-cli/response.test.ts src/commands/doctor-gateway-daemon-flow.test.ts src/cli/update-cli/restart-helper.test.ts src/infra/outbound/message-action-runner.core-send.test.ts`
- AWS Crabbox `cbx_69f97dff5e5c`, run `run_a68431b3dad6`: exact SHA checkout, focused tests, real `/etc/systemd/system/openclaw.service` status/restart/stop proof.
2026-05-31 18:52:02 +01:00
Peter Steinberger
e014145ac1 docs: mention markdown host-local media sends (#79658) 2026-05-31 18:51:45 +01:00
Clever
14dbf80c74 Fix explicit text alias extension check 2026-05-31 18:51:45 +01:00
Clever
a9eefeea71 Remove changelog entry from text media PR 2026-05-31 18:51:45 +01:00
Clever
9f7eaf06e1 docs: clarify host-local text media boundary 2026-05-31 18:51:45 +01:00
Clever
7d3fc6f924 docs: update host-local media text policy 2026-05-31 18:51:45 +01:00
Clever
b454677874 Restrict plain text media sends to txt 2026-05-31 18:51:45 +01:00
Clever
d729811224 Add changelog for text document media sends 2026-05-31 18:51:45 +01:00
Clever
1e14f4400f Allow validated text document media sends 2026-05-31 18:51:45 +01:00
Peter Steinberger
d641126c1d feat(plugin-sdk): add typed presentation command actions (#88721)
* feat(plugin-sdk): add typed presentation command actions

* test: use shared env helper in telegram bot tests

* test: expect typed approval actions

* test: expect typed sdk approval actions
2026-05-31 18:48:45 +01:00
Peter Steinberger
4b1d2faa99 docs: harden Codex dependency review gate 2026-05-31 18:48:15 +01:00
Peter Steinberger
058152cf69 refactor: extract ACP manager runtime handle cache
Extract ACP manager runtime-handle cache ownership into a dedicated helper. Keeps the session manager focused on lifecycle orchestration while preserving cached handle reuse, close/clear, idle eviction, matching, and observability behavior.

Proof: focused ACP manager runtime-handle/runtime-config tests, narrow oxlint, pnpm check:test-types, autoreview clean, PR CI green.
2026-05-31 18:46:07 +01:00
Jerry-Xin
56362524ed fix(agents): prefer real tool results over repair synthetics
Ref #84134.

Prefer real tool results over generated missing-result placeholders during transcript repair, including late results after later assistant turns and explicitly marked custom-text repair placeholders. Keep real error outputs such as aborted when they are not generated repair placeholders.

Thanks @Jerry-Xin.

Co-authored-by: 忻役 <xinyi@mininglamp.com>
Co-authored-by: Jerry-Xin <jerryxin0@gmail.com>
2026-05-31 18:44:37 +01:00
Peter Steinberger
05b3f1c29d docs: require deeper PR review evidence 2026-05-31 18:42:39 +01:00
Sunjae Kim
201bf125af fix(session-store): rewrite generated transcript paths on rollover
Rewrite generated session transcript paths at the shared session-store merge boundary when a persisted session rolls from one session id to another. This prevents patches that carry a stale generated `sessionFile` from leaving a new logical session id attached to the old transcript file, while preserving custom transcript paths.

Refs #65564.

Proof:
- `node scripts/run-vitest.mjs src/config/sessions/sessions.test.ts`
- `node scripts/run-vitest.mjs src/agents/command/session-store.test.ts`
- `git diff --check origin/main...HEAD`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- CI run 26719583889 attempt 2

Co-authored-by: Sunjae Kim <sunjaekim@bigvalue.co.kr>
2026-05-31 18:41:56 +01:00
Peter Steinberger
db40fde88c fix: persist ACP metadata in SQLite (#88724)
* fix: persist acp metadata in sqlite

* test: align session store acp expectations
2026-05-31 18:38:51 +01:00
Peter Steinberger
cdff174ce6 docs: note OpenAI Codex canonical provider 2026-05-31 18:37:30 +01:00
Vincent Koc
1ba9af1693 fix(ui): improve danger callout contrast 2026-05-31 18:36:14 +01:00
Peter Steinberger
bb5b6f38f4 test: harden release CI ordering 2026-05-31 18:33:26 +01:00
Vincent Koc
a3fa5b6577 test(vitest): classify Crabbox shared dependencies 2026-05-31 19:31:17 +02:00
Peter Steinberger
7061c1e5fd docs: raise bulk close confirmation threshold 2026-05-31 18:29:31 +01:00
Peter Steinberger
af58ed9554 docs: require external api proof search 2026-05-31 18:27:49 +01:00
Peter Steinberger
090ca19c05 refactor: make Telegram message cache SQLite-only
Remove Telegram runtime JSON sidecar read/write fallback for the prompt-context message cache. Keep legacy sidecar parsing for doctor import into SQLite plugin state and update docs/tests to match.
2026-05-31 18:27:24 +01:00
zhang-guiping
b6e9473e9f fix(auth): skip Anthropic API keys for usage status
Fixes #85124.

Anthropic standard API keys no longer resolve as provider usage auth for `openclaw status --usage`, so valid inference keys are not sent to Anthropic's OAuth usage endpoint and surfaced as misleading invalid bearer-token errors.

The provider usage-auth SDK result now has an explicit handled/no-token shape so provider hooks can suppress generic fallback without widening the OAuth helper contract. Docs, Plugin SDK API baseline, and extension package-boundary cache inputs were updated with the new contract.

Thanks @zhangguiping-xydt.

Proof:
- node scripts/run-vitest.mjs src/infra/provider-usage.auth.normalizes-keys.test.ts src/infra/provider-usage.auth.plugin.test.ts extensions/anthropic/index.test.ts
- pnpm plugin-sdk:api:check
- pnpm plugin-sdk:check-exports
- git diff --check origin/main...HEAD
- pnpm docs:list
- pnpm run test:extensions:package-boundary:compile
- autoreview clean: no accepted/actionable findings
- PR CI rollup green: 131 success, 22 skipped, 1 neutral, 0 failures

Co-authored-by: 张贵萍0668001030 <zhang.guiping@xydigit.com>
2026-05-31 18:26:03 +01:00
Peter Steinberger
fbc611ab4c docs: require fresh autoreview before landing code 2026-05-31 18:25:11 +01:00
Peter Steinberger
2b4f3e47b6 test(msteams): add keyed store to file consent runtime stub 2026-05-31 18:24:51 +01:00
Peter Steinberger
1a65425a6e refactor: extract ACP translator session updates
Extract ACP translator session-update and event-ledger emission into a dedicated helper. Keeps translator orchestration intact while preserving replay, recording, and fallback behavior.\n\nProof: focused ACP translator tests, narrow oxlint, pnpm check:test-types, autoreview clean, PR CI green.
2026-05-31 18:24:26 +01:00
Rain
301f17fb58 fix(agents): validate context engine assemble result shape
Validate context-engine assemble results at the shared harness boundary before embedded or Codex runners consume them.

Malformed plugins that return an object without a `messages` array now throw a descriptive engine-scoped error and use the existing runner fallback to pipeline messages, rather than poisoning session state and crashing prompt assembly on `.length`.

Proof:
- `node scripts/run-vitest.mjs src/agents/harness/context-engine-lifecycle.test.ts`
- `node scripts/run-vitest.mjs src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts`
- `pnpm exec oxfmt --check src/agents/harness/context-engine-lifecycle.ts src/agents/harness/context-engine-lifecycle.test.ts`
- `git diff --check origin/main...HEAD`
- GitHub CI on `5b6b7b1bf69b8f30329fdf749161a192d3d016fe`: https://github.com/openclaw/openclaw/actions/runs/26719202811

Thanks @Pluviobyte.

Fixes #75541
2026-05-31 18:21:59 +01:00
Peter Steinberger
86ff92e7a8 docs: require best-fix PR review judgment 2026-05-31 13:21:15 -04:00
wAngByg
6c5cd7177f fix(doctor): detect stale gateway service version metadata 2026-05-31 18:17:30 +01:00
Peter Steinberger
6f2fbaaaf8 fix(gateway): track plugin subagent runs in agent handler
Plugin SDK subagent runs now register at the Gateway agent acceptance boundary so subagent_ended hooks fire without creating duplicate CLI task rows.

The registration stays best-effort: if the subagent registry cannot persist tracking state, the run still dispatches and falls back to the existing CLI task tracking path.

Closes #59164

Co-authored-by: Cornna <96944678+ymylive@users.noreply.github.com>
2026-05-31 18:16:00 +01:00
Peter Steinberger
21dcf2dd99 chore: stop tracking package dist output 2026-05-31 18:15:40 +01:00
Vincent Koc
938841cff3 fix(agents): count stream deltas incrementally
Count model stream diagnostic response bytes from snapshotless stream chunks, excluding accumulated partial snapshots on delta events. This avoids repeatedly serializing answer-so-far snapshots during streamed model calls and updates OTEL/docs wording for the new metric baseline.

Refs #86599.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-31 18:13:58 +01:00
Peter Steinberger
a053ae5d65 test: align release CI expectations 2026-05-31 18:13:02 +01:00
Peter Steinberger
33c246dbba refactor: move plugin state slices to sqlite
* refactor: move plugin state slices to sqlite

* fix: keep legacy plugin state migration out of runtime

* fix: add doctor migrations for plugin sqlite state

* fix: preserve teams feedback learning migration keys

* fix: merge teams legacy feedback learnings

* fix: guard doctor imports against plugin state caps

* fix: leave lossy teams learning filenames unmigrated

* fix: preserve teams feedback learning scope

* fix: load plugin doctor contracts from package dist

* fix: satisfy plugin state migration gates
2026-05-31 18:09:27 +01:00
Peter Steinberger
12d4dda1bb perf(plugins): avoid duplicate provider hook load probes
Avoid duplicate provider hook load probes.

Summary:
- Route provider hook-list resolution through the existing provider resolver skip path instead of pre-checking provider load state separately.
- Preserve the provider runtime in-flight/reentrant guard because existing tests prove it prevents cached misses and nested provider-load recursion.

Verification:
- node scripts/run-vitest.mjs src/plugins/providers.runtime.consult-current-snapshot.test.ts
- node scripts/run-vitest.mjs src/plugins/provider-runtime.test.ts
- node scripts/run-vitest.mjs src/plugins/providers.test.ts
- pnpm exec oxfmt --check src/plugins/providers.runtime.ts src/plugins/provider-hook-runtime.ts
- git diff --check
- pnpm changed:lanes --json
- autoreview --mode local --prompt-file /tmp/provider-hotpath-cleanup-review.md
- Live E2E: https://github.com/openclaw/openclaw/actions/runs/26718818705
2026-05-31 18:08:13 +01:00
Peter Steinberger
f80a1e9e85 refactor: clean up ACP translator and manager tests (#88677)
* test: split ACP translator bridge coverage

* refactor: extract ACP translator session helpers

* refactor: extract ACP manager backend failover helpers

* test: split ACP manager failover coverage

* test: split ACP manager runtime config coverage

* test: split ACP manager turn result coverage

* test: split ACP manager runtime handle coverage

* test: keep ACP manager helpers within task boundaries

* ci: split gateway runtime state test shard
2026-05-31 18:04:28 +01:00
Sebastien Tardif
66bbcfdade fix(telegram): handle ENOENT race in spool drain recovery rename
Handle the Telegram isolated-polling spool recovery race where a stale `.processing` claim can disappear between discovery and the final rename back to pending. Recovery now treats `ENOENT` as benign and mirrors the existing duplicate-pending cleanup path for `EEXIST`, avoiding noisy drain-failure logs and spurious failure counters without changing claim ownership semantics.

Adds a regression test that removes the claim from inside `shouldRecover`, after recovery has discovered the entry and before the final rename path, so the old code would hit the reported `ENOENT` window.

Fixes #87847

Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca>
2026-05-31 18:02:55 +01:00
alkor2000
3ceaafb2b3 fix: extend CA bundle auto-injection to all 8 Node version managers
Expand Linux CA bundle auto-injection to recognize fnm, Volta, asdf, mise, n, nodenv, nodebrew, and nvs paths in addition to nvm. Adds regression coverage for the new version-manager path layouts.

Fixes #59494.
Thanks @alkor2000.

Co-authored-by: alkor2000 <200923177@qq.com>
2026-05-31 18:02:34 +01:00
Vincent Koc
01a5e492b7 test(discord): fast-forward voice fallback timers 2026-05-31 19:02:16 +02:00
Peter Steinberger
772d13c19d fix: handle iOS global agent transcripts 2026-05-31 18:01:17 +01:00
Vincent Koc
0f6be951e0 fix(agents): avoid full stream replay on text deltas (#88252)
Prevent streaming assistant text updates from reparsing the full accumulated reply for plain deltas, avoiding repeated work for small-model streams while preserving full cleanup for directives, media, and final events.

Also load the normal Control UI Vite config in the mock browser server so browser E2E uses the same workspace aliases as dev.

Thanks @vincentkoc.
2026-05-31 17:59:45 +01:00
alkor2000
723d09ff85 fix(cli): extend holiday tagline dates through 2030
Extend the CLI holiday tagline tables for Lunar New Year, Eid al-Fitr, Easter, Diwali, and Hanukkah through 2030 so those taglines do not silently disappear after 2027.

Maintainer fixup: corrected the 2030 Diwali row to October 25 and added explicit regression coverage for that date.

Verification:
- node scripts/run-vitest.mjs src/cli/tagline.test.ts
- Direct pickTagline() probe confirmed 2030-10-25 activates Diwali and 2030-10-26 does not.

Co-authored-by: alkor2000 <200923177@qq.com>
2026-05-31 17:59:43 +01:00
Peter Steinberger
0ee5f47fba fix(feishu): enforce bitable account gates 2026-05-31 17:51:35 +01:00
OpenClaw Updater
73bb84e4bf fix: preserve explicit Feishu bitable gates 2026-05-31 17:51:35 +01:00
Gorin Lee
5cfb578cba plugin: gate Feishu bitable tools by config 2026-05-31 17:51:35 +01:00
Peter Steinberger
4150c6ff82 feat: add typed MCP code-mode API (#88678)
* feat: add typed MCP code-mode API

* fix: stabilize code-mode namespace drain

* fix: preserve code-mode run cap

* fix: reserve code-mode snapshot capacity
2026-05-31 17:51:22 +01:00
Peter Steinberger
d1b514af2e fix: remove webchat config surface 2026-05-31 12:49:18 -04:00
Masato Hoshino
3ef02ca818 fix(plugins): reuse current metadata snapshot in provider hot paths
Refactor provider metadata lookup so hot paths consult the current process snapshot before falling back to a metadata load.

Centralize provider metadata lookup in the provider runtime and update the focused tests/mocks that exercise embedded-agent and provider loading paths.

Verification:
- node scripts/run-vitest.mjs src/plugins/providers.runtime.consult-current-snapshot.test.ts
- node scripts/run-vitest.mjs src/agents/embedded-agent-runner/run/attempt.cwd-split.test.ts
- node scripts/run-vitest.mjs src/plugins/providers.test.ts
- autoreview --mode branch --base origin/main
- CPU profile loop: current-snapshot resolve 0.459 us/call vs warm direct metadata load 131.493 us/call
- GitHub CI on 728bd53510

Co-authored-by: masatohoshino <g515hoshino@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:48:58 +01:00
Jerry-Xin
4e84d0eaa5 fix(auto-reply): track memory flush failure exhaustion
Add durable memoryFlush failure metadata and lifecycle events so provider failures during memory flush no longer leave a session with no recorded recovery state.

After three consecutive non-abort flush failures, mark the current compaction cycle as exhausted so later messages can proceed without deleting transcript history. Successful flushes clear the failure metadata, and plugin session-entry slot reservations now protect the new fields.

Release-note: memoryFlush sessions can now fail open after repeated provider-side flush failures instead of retrying indefinitely before normal replies.

Refs #85645

Co-authored-by: 忻役 <xinyi@mininglamp.com>
2026-05-31 17:47:12 +01:00
Peter Steinberger
5bce222b0c docs(agents): require related issue search 2026-05-31 17:46:19 +01:00
Peter Steinberger
1af4c035e4 refactor: move delivery queues to SQLite (#88665)
* refactor: move delivery queues to sqlite

* fix: satisfy delivery queue sqlite boundaries

* test: remove stale reasoning replay assertion

* fix: migrate failed delivery queue entries

* test: stabilize exec shell snapshot mocks

* fix: clean legacy delivery queue markers
2026-05-31 17:43:03 +01:00
Vincent Koc
c7b190beec fix(ollama): yield during dense stream processing (#87818)
Co-authored-by: uday <udaymanish.thumma@gmail.com>
2026-05-31 17:38:13 +01:00
Yuval Dinodia
be29096081 fix(agents): resolve Codex static-catalog cold start
Fixes Codex/plugin-harness cold starts for exact static-catalog model ids such as openai/gpt-5.3-codex without adding a second resolver retry loop. The embedded runner now performs the normal provider-runtime attempt with agent discovery skipped, then consults the bundled static catalog before falling back to generic configured-provider synthesis when plugin harness owns transport.

The OpenAI static catalog row carries the Codex ChatGPT transport metadata, dynamic provider metadata still wins for runtime-owned models, and focused regression coverage exercises both paths.

Fixes #88510.

Co-authored-by: yetval <yetvald@gmail.com>
2026-05-31 17:38:10 +01:00
litang9
d446c26acb feat(deepseek): show provider balance in usage status
Show DeepSeek API-key account balance in status/auth-status usage surfaces by adding a summary-only provider usage snapshot path, a DeepSeek balance fetcher, SDK/docs coverage, and focused regression tests.

Maintainer verification accepted the additive provider-usage/status contract and the DeepSeek balance visibility boundary for authenticated status surfaces.

Proof:
- Live DeepSeek balance proof via 1Password-backed DEEPSEEK_API_KEY against https://api.deepseek.com/user/balance; key and balance amount redacted.
- GitHub CI run 26717953383 passed on the current head.
- Real behavior proof run 26718215605 passed after the PR body was refreshed.
- Local clean PR clone: git diff --check; node --max-old-space-size=8192 --import tsx scripts/generate-plugin-sdk-api-baseline.ts --check; node scripts/run-vitest.mjs run src/agents/bash-tools.exec.path.test.ts.

Co-authored-by: Alex Tang <tangli1987118@hotmail.com>
Co-authored-by: litang9 <141409885+litang9@users.noreply.github.com>
2026-05-31 17:35:41 +01:00
vortexopenclaw
fa0a323ebd fix(secrets): treat Codex app-server marker as non-secret
Treat the synthetic Codex app-server auth marker as a core non-secret marker so secrets audit does not flag it when bundled plugin discovery is disabled.\n\nVerified with focused model-auth marker tests, isolated secrets-audit CLI proof, autoreview, and green CI.\n\nThanks @vortexopenclaw.
2026-05-31 17:35:13 +01:00
Vincent Koc
dd79c8836a perf(scripts): parallelize startup metadata help rendering 2026-05-31 18:35:01 +02:00
Peter Steinberger
2e3650d5b3 fix: inset iOS onboarding action buttons 2026-05-31 17:31:17 +01:00
Peter Steinberger
d76627f232 ci: add crabbox prewarm jobs 2026-05-31 17:30:26 +01:00
Ron Cohen
5152d8beb4 fix(whatsapp): suppress silent-run typing indicators
Suppress WhatsApp typing indicators only for silent message-tool-only unmentioned group runs. Automatic visible replies and authorized group commands still show composing normally.

Fixes the autoreview regression risk by narrowing suppressTyping and adding coverage for both silent and visible group paths.

Proof:
- pnpm test src/auto-reply/reply/reply-utils.test.ts extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode local
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- CI run 26717880577 green

Thanks @Bluetegu.
2026-05-31 17:28:58 +01:00
Yuval Dinodia
a6ee3dbbdd fix(ios): update group chats in realtime
Subscribe the iOS gateway chat transport to per-session transcript events so group chats update when other clients send messages. Constrain local user echo adoption to the optimistic row tied to the still-pending send run, so repeated same-content user messages from other clients append instead of replacing history.

Fixes #80231.

Co-authored-by: Yuval Dinodia <yetvald@gmail.com>
2026-05-31 17:24:59 +01:00
Gavin Zeng
4ab2eb45d0 fix(doctor): repair stale session snapshot paths
Fixes #85689.

Summary:
- Repair stale bundled skill paths in inline prompts, prompt blobs, resolved skill metadata, and resolved skill sourceInfo metadata.
- Keep repair scoped to cached snapshot fields and preserve unrelated session content.
- Replace the root reproduction script with colocated Vitest coverage.

Verification:
- pnpm test src/commands/doctor-session-snapshots.test.ts -- --reporter=verbose
- pnpm check:test-types
- pnpm lint --threads=8
- pnpm dup:check:coverage
- pnpm tsgo:prod
- pnpm check:changed (Testbox tbx_01kszd25ad7x81j0f1r7kfsqc6, Actions run 26717761222)
- PR CI green on 540b1a387e

Co-authored-by: GavinZ <zengganghui@zgh123.space>
2026-05-31 17:24:29 +01:00
samzong
5b310a7b27 fix(agents): release abandoned provider streams
Fix streamed provider cleanup so abandoned managed fetch bodies no longer keep undici sockets open, and cancel Anthropic/Gemini SSE readers deterministically when parsing exits early.

Keep the FinalizationRegistry abort path as a last-resort GC safety net for unmanaged/abandoned responses, while parser-owned paths cancel readers explicitly on thrown errors or malformed events.

Also records the browser-only Control UI redactor alias in the optional deadcode allowlist and keeps mocked exec supervisor tests off shell snapshot wrapping after the branch was rebased onto default shell snapshots.

Fixes #67461

Verification:
- node scripts/run-vitest.mjs src/agents/provider-transport-fetch.test.ts src/agents/anthropic-transport-stream.test.ts extensions/google/transport-stream.test.ts src/agents/bash-tools.test.ts src/agents/bash-tools.exec.path.test.ts test/scripts/test-live-shard.test.ts
- pnpm check:test-types
- node scripts/run-oxlint-shards.mjs --threads=8
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --parallel-tests "node scripts/run-vitest.mjs src/agents/provider-transport-fetch.test.ts src/agents/anthropic-transport-stream.test.ts extensions/google/transport-stream.test.ts src/agents/bash-tools.test.ts src/agents/bash-tools.exec.path.test.ts test/scripts/test-live-shard.test.ts"
- git diff --check origin/main...HEAD
- PR CI on a1db789652

Co-authored-by: samzong <samzong.lu@gmail.com>
Signed-off-by: samzong <samzong.lu@gmail.com>
2026-05-31 17:17:53 +01:00
Peter Steinberger
31c83c6be1 chore(plugin-sdk): refresh API baseline 2026-05-31 17:17:02 +01:00
Peter Steinberger
fbfbe45fc6 fix(agents): use static shell snapshot temp prefix 2026-05-31 17:12:24 +01:00
Mike Harrison
63d0c1d513 fix(slack): keep progress drafts in one message (#85612)
Keep Slack progress-mode drafts on one rolling preview message across assistant and reasoning boundaries while preserving boundary cleanup and the latest visible tool-progress lines. Partial/replace modes still start a fresh draft at assistant boundaries.

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 17:07:41 +01:00
Peter Steinberger
71a516d644 ci: narrow legacy webchat migration value 2026-05-31 12:07:36 -04:00
Syu
63621eead2 fix(discord): route thread bindings to plugin owners
Route Discord thread follow-up messages to plugin-owned bindings by the raw thread id while retaining parent channel fallback matching. This fixes `/codex bind` follow-ups in Discord threads being claimed by the parent OpenClaw route instead of the bound Codex session.

Verification:
- `node scripts/run-vitest.mjs extensions/discord/src/channel.conversation.test.ts src/hooks/message-hook-mappers.test.ts extensions/discord/src/monitor/message-handler.process.test.ts -t "prefers bound session keys|passes Discord thread parent only|routes Discord thread plugin-owned bindings|passes thread parent ids|thread binding"`
- `node scripts/run-vitest.mjs src/auto-reply/reply/dispatch-from-config.test.ts -t "routes Discord thread plugin-owned bindings by raw thread id"`
- `pnpm build`
- `pnpm lint --threads=8`
- `CI=true FORCE_COLOR=0 pnpm lint --threads=8`
- `.agents/skills/autoreview/scripts/autoreview --mode local`
- GitHub: Real behavior proof, check-test-types, check-dependencies, check-prod-types, auto-reply dispatch shard, hooks shard, and extension package boundary passed on head 1e896d9835.

Known unrelated CI noise at merge: broad opengrep/test/lint CI failures are outside the touched Discord/session-binding surface and contradicted by focused local proof where applicable.

Co-authored-by: Hex <hex@openclaw.ai>
2026-05-31 17:03:55 +01:00
saju01
fbb776d92c feat(github-copilot): add Claude Opus 4.8 to default model catalog
Add Claude Opus 4.8 to the GitHub Copilot static model catalog and default model IDs.

Updates provider manifest metadata and regression coverage so fallback/default discovery includes claude-opus-4.8.

PR: #88547
Co-authored-by: saju01 <saju@coderedcorp.com>
2026-05-31 17:00:24 +01:00
Peter Steinberger
6f4ba7c80e ci: fix acp spawn defaults lint 2026-05-31 12:00:09 -04:00
Ho Lim
044f7f3790 fix: route iMessage DM media through attachment handoff (#87904)
* fix: route iMessage DM media through attachment handoff

* fix: close iMessage caption follow-up clients

* test: stabilize iMessage timeout recovery checks

* fix(imessage): keep attachment reply-cache identifiers aligned

* fix(imessage): preserve service for media handoff

* fix(imessage): prefer caption ids for placeholder attachments

* fix(imessage): preserve region fallback for media handles

* fix(imessage): retain chat id attachment cache scope

* fix(imessage): avoid premature caption echoes

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
2026-05-31 09:00:00 -07:00
Peter Steinberger
d07f508020 fix: retire webchat channel config 2026-05-31 11:58:54 -04:00
Peter Steinberger
e5097b3b09 fix(tlon): avoid bundling native skill packages 2026-05-31 16:58:46 +01:00
Jayesh Betala
29dd7847fd fix(terminal): clamp wide graphemes in narrow table cells
Clamps ANSI-aware terminal table cells before padding so width-2 graphemes cannot push borders out of alignment in width-1/narrow columns.

Fixes #88556.

Proof:
- node scripts/run-vitest.mjs run packages/terminal-core/src/ansi.test.ts packages/terminal-core/src/table.test.ts
- CI run 26717035619; check-dependencies red only for unrelated current-main deadcode issue ui/src/ui/browser-redact.ts, also red on main run 26717029674. checks-node-agentic-agents-core rerun failed in unrelated src/agents/bash-tools*.test.ts outside this PR diff.

Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com>
2026-05-31 16:54:47 +01:00
Alix-007
2870a28aa9 fix(memory-core): reclaim orphaned dreaming sessions
When dreaming narrative cleanup calls subagent.deleteSession() in the finally block and it throws, the store row can be left behind referencing a still-present transcript. The scrubber only pruned dreaming rows whose transcript was missing, so these orphans lingered in the recent sessions sidebar with no kind/status/endedAt and accumulated across restarts.

Reclaim a dreaming store row when its transcript is missing OR has aged past DREAMING_ORPHAN_MIN_AGE_MS, then leave the transcript unreferenced so the orphan-transcript pass archives it.

Fixes #88322

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:52:11 +01:00
Vincent Koc
9850ee65c9 test(doctor): cache default command in e2e 2026-05-31 17:50:38 +02:00
Peter Steinberger
d1c4c3344e ci: mark browser redactor as UI entry 2026-05-31 11:47:40 -04:00
Peter Steinberger
a3c6164a8d test: add ACP spawn defaults live Docker test 2026-05-31 16:46:20 +01:00
Franco Viotti
a71b121c69 fix(googlechat): preserve thread for message tool replies (#80996)
Use the Google Chat thread resource as the ambient message-tool reply target so replies stay in the inbound thread. Normalize the current Google Chat space target and let plugin threading adapters explicitly suppress the generic message-id fallback when a provider needs a thread resource instead of a message resource.

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Franco Viotti <franco-viotti@users.noreply.github.com>
2026-05-31 16:43:46 +01:00
Peter Steinberger
ed74fa692b test(ui): narrow vite resolve hook in config test 2026-05-31 16:42:13 +01:00
Peter Steinberger
210adf1d11 fix(agents): retry transient stale session locks
Follow-up to #88658. Retries transient stale session-lock acquire failures when diagnostics show the old stale report disappeared, was replaced by a fresh valid lock, or was replaced by a fresh payload-less lock still inside the mtime/orphan grace window.

Preserves typed `SessionWriteLockStaleError` diagnostics for still-present live OpenClaw-owned stale locks.

Proof: 53 focused session-write-lock tests passed locally and in the agents-core CI shard; `pnpm tsgo:test:src`, touched-file oxlint, `git diff --check`, and autoreview passed locally. CI run 26716843811 has unrelated failures in UI deadcode/types and bash-tools tests; session-write-lock tests passed in that run.

Refs #87217.
2026-05-31 16:41:37 +01:00
clawSean
51228aecd4 fix(sms): cover native proof follow-ups (#88601)
Remove the duplicate plain approve command from pairing replies so SMS/native pairing shows one copyable owner approval command in the fenced block.

Add regression coverage for the single approve-command occurrence, plus Twilio guarded-egress release coverage for non-2xx and malformed-success send responses.

Verification:
- pnpm exec oxfmt --check src/pairing/pairing-messages.ts src/pairing/pairing-messages.test.ts extensions/sms/src/twilio.test.ts
- node scripts/run-vitest.mjs src/pairing/pairing-messages.test.ts src/pairing/pairing-challenge.test.ts src/plugin-sdk/channel-pairing.test.ts
- node scripts/run-vitest.mjs extensions/sms/src/twilio.test.ts

Thanks @clawSean.
2026-05-31 16:41:24 +01:00
Peter Steinberger
63de51ab96 refactor(cron): clarify sqlite store internals 2026-05-31 16:38:49 +01:00
Sanjay Santhanam
e0e7bae612 fix(discord): handle PluralKit DM pairing ids
Fix Discord DM pairing for PluralKit senders by storing the pairing identity with the same `pk:<member-id>` form used at inbound lookup time. Also recognizes both canonical direct DM session keys and account-scoped direct DM session keys as DM approval sessions.

Focused proof: `node scripts/run-vitest.mjs extensions/discord/src/approval-native.test.ts extensions/discord/src/monitor/dm-command-auth.test.ts extensions/discord/src/monitor/dm-command-decision.test.ts extensions/discord/src/monitor/message-handler.preflight.test.ts` passed with 4 files and 82 tests.

Closes #86332

Co-authored-by: Sanjays2402 <51058514+Sanjays2402@users.noreply.github.com>
2026-05-31 16:35:48 +01:00
Peter Steinberger
b9dc3c3894 perf: trim tui startup and refresh work 2026-05-31 16:30:04 +01:00
Lawrence Tran
507c6fd5ca fix(slack): avoid forced threads for replyToMode off
Slack top-level channel mentions with replyToMode off now reply at the channel root instead of inheriting stale or auto-created thread targets.

Existing Slack thread replies and Slack assistant DM thread targets continue to preserve their thread target.

Thanks @lawrencetran.
2026-05-31 16:29:34 +01:00
Alexander Falk
e18099b8c3 fix(macos): prevent duplicate menu bar icons
Fix macOS menu bar status-item storms during rapid gateway connection churn by removing stale SwiftUI-vended status items before adopting replacements and debouncing transient control-channel states.

Surface: macOS menu bar app, `MenuBarExtra` status item ownership, `ControlChannel` UI-observed connection state.

Proof:
- `git diff --check origin/main...pr/82739`
- `swift test --package-path apps/macos --filter ControlChannelStateDebouncerTests`
- PR CI: preflight, security-fast, macos-node, macos-swift, dependency-guard, changed-path scan, real behavior proof, Socket checks

Co-authored-by: Alexander Falk <al@falk.us>
2026-05-31 16:18:37 +01:00
Vincent Koc
a52c4d101a perf(agents): avoid full setup registry for runtime aliases 2026-05-31 17:14:09 +02:00
Peter Steinberger
4ef141d525 fix(agents): prevent embedded runtime shadowing 2026-05-31 16:13:01 +01:00
Peter Steinberger
1955f42bfe fix(outbound): route source replies through configured channels 2026-05-31 16:12:52 +01:00
Peter Steinberger
cd3b467f3c refactor(cron): split tool and doctor repair helpers 2026-05-31 16:11:45 +01:00
Peter Steinberger
45ab822918 perf: reduce tui refresh work 2026-05-31 16:10:09 +01:00
Peter Steinberger
6b1b2ff20a feat: default exec shell snapshots 2026-05-31 16:09:43 +01:00
Peter Steinberger
89cdf164ca fix(ui): keep chat usable during session loading 2026-05-31 16:08:56 +01:00
Peter Steinberger
972d2b66d1 fix(cron): guard flat atMs canonicalization 2026-05-31 16:02:06 +01:00
Peter Steinberger
a84819a639 refactor(cron): keep runtime on canonical sqlite rows 2026-05-31 16:02:06 +01:00
Peter Steinberger
827ceb55d0 fix(codex): restore bounded recovery continuity
Restore bounded Codex native recovery continuity without replaying covered mirrored transcript history. Closes #88352. Closes #88354.
2026-05-31 15:55:32 +01:00
Peter Steinberger
7b78941ea5 refactor: clean up ACP package metadata and helpers (#88659)
* refactor: derive acp core package subpath maps

* refactor: split acp manager task and timeout helpers

* refactor: split acp translator presentation helpers

* fix: keep packaged acp core plugin aliases

* ci: split gateway control plane runtime shard
2026-05-31 15:53:14 +01:00
Chunyue Wang
a5d8f09fd4 fix(discord): ping mention-bearing final replies
Fixes #88360.

Route Discord live-preview final replies containing targeted user or role mentions through fresh message delivery instead of edit finalization, preserving mention alias rewriting and notification behavior. Plain, broadcast-only, and mixed targeted-plus-broadcast replies keep the existing preview edit path.

Proof: CI run 26708866609 green for relevant lanes; Real behavior proof run 26708866194 successful; local git diff --check and git merge-tree clean.
2026-05-31 15:52:59 +01:00
Peter Steinberger
8f941ea0ac fix(telegram): preserve usage footer for tool-only replies
Route implicit message_tool_only current-source sends through the internal source-reply sink for non-webchat transports, preserving the final reply payload path where usage decoration runs. Also keep reply payload metadata when appending usage text so transcript mirror text matches the delivered footer-bearing reply.

Recreated from PR #87425 because the fork branch is draft, dirty against main, and not maintainer-pushable.

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
2026-05-31 15:51:41 +01:00
Vincent Koc
b334e7ef29 fix(agents): avoid alias setup load for matching refs 2026-05-31 16:48:27 +02:00
Peter Steinberger
d5ac97652a chore(ui): translate thinking default label 2026-05-31 15:47:58 +01:00
Vincent Koc
4d135ae28b fix(agents): preserve runtime tools in lean mode (#88381)
fix(agents): preserve runtime tools in lean mode

Keep runtime-required tools, especially `message`, available when local-model lean filtering is enabled. This preserves `forceMessageTool`, `message_tool_only` source replies, explicit runtime allowlists, and schema projection without disabling lean filtering for ordinary denied tools.

Proof: focused Vitest passed 190 tests; `git diff --check origin/main...HEAD` passed; PR CI had no failing or pending checks.
2026-05-31 15:43:48 +01:00
xiaotian
f547ea7668 fix(messages): use best-effort for implicit tool-only source replies (#84232)
fix(messages): use best-effort for implicit tool-only source replies

Preserve durable required-send semantics for explicit non-current targets while allowing current-source `message_tool_only` replies to be delivered through best-effort outbound sends. This fixes Slack source replies that otherwise fail when the adapter has no `reconcileUnknownSend` hook.

Fixes #84078.
2026-05-31 15:41:30 +01:00
Peter Steinberger
66775c037e docs: raise bulk PR close threshold 2026-05-31 15:40:16 +01:00
Peter Steinberger
c389839d30 feat: add exec shell snapshot cache
Add an opt-in bash/zsh shell snapshot cache for host exec runs, consolidate shell helper ownership into src/agents/shell-utils.ts, document OPENCLAW_EXEC_SHELL_SNAPSHOT, and keep Windows config command execution on the bash resolver. Also removes a redundant Discord gateway close-code type branch that was blocking test type checks.
2026-05-31 15:39:53 +01:00
Peter Steinberger
50c651900e fix: use typed tui empty session defaults 2026-05-31 15:38:55 +01:00
Peter Steinberger
18dc6e5cd4 perf: speed up tui session refresh 2026-05-31 15:38:54 +01:00
Peter Steinberger
9a4b631a1d fix(ci): align agent thinking default surfaces 2026-05-31 15:38:32 +01:00
Peter Steinberger
832b6487e0 docs: require live batch issue verification 2026-05-31 15:37:36 +01:00
Peter Steinberger
d689893a6f ci(release): extend QA runtime parity timeout 2026-05-31 15:36:35 +01:00
Peter Steinberger
d1bec469af ci: stabilize Testbox changed checks 2026-05-31 15:34:23 +01:00
Peter Steinberger
7ca77124fe fix(agents): report stale session locks without cleanup
Report live-owned stale session locks as typed acquisition failures instead of auto-removing them, while preserving safe reclaim for dead/orphaned lock files. Propagate stale lock acquisition through embedded runner takeover handling, failover/cache/delivery classifiers, and QA retry detection.

Refs #87779
2026-05-31 15:28:54 +01:00
Peter Steinberger
fb7e21796d fix(gateway): reject stale lifecycle session updates
Fixes #88538. Carry the owning run sessionId through lifecycle events, skip stale persistence and sessions.changed projection when sessions.reset rotated the row, and register the persisted owning id across session-backed run paths. Also aligns per-agent subagent thinking typing with existing runtime/test usage.\n\nCo-authored-by: openperf <16864032@qq.com>
2026-05-31 15:27:01 +01:00
Peter Steinberger
88c99ddf5f docs(agents): require typed presentation actions 2026-05-31 15:19:45 +01:00
Peter Steinberger
1bfae9d458 fix(models): keep auth login out of main config
Store provider login profiles in auth-state, preserve configured auth order/profile constraints, and keep legacy credential/keyRef normalization durable. Fixes #88565.
2026-05-31 15:14:16 +01:00
Peter Steinberger
2b61d38a45 fix: guard stale lifecycle snapshots (#88583) 2026-05-31 15:08:36 +01:00
openperf
613f51a7aa fix(gateway): reject pre-reset run lifecycle events from clobbering rotated session
sessions.reset rotates a channel session to a fresh sessionId under the same
sessionKey, but an old in-flight run could still emit late start/end/error
lifecycle events. persistGatewaySessionLifecycleEvent resolved the row purely
by sessionKey, so those stale events overwrote the new row's status
(running/failed with hasActiveRun=false).

Stamp the owning run's sessionId onto lifecycle events in emitAgentEvent and
skip persistence when it differs from the current row's sessionId. The embedded
runner refreshes the run context's sessionId on every live-session rotation
(mid-run compaction), so a legitimately rotated run's terminal event still
matches the rotated row; only an external sessions.reset stays mismatched.
Matching and unknown-owner events are unaffected.

Fixes #88538
2026-05-31 15:08:36 +01:00
Peter Steinberger
ff22b1e9e6 fix: apply ACP spawn model defaults 2026-05-31 15:07:33 +01:00
Logan Ye
fdf6092494 fix(agents): accept disabled thinking params
Fixes #74374.

Normalizes params.thinking false, disabled, and none to the existing off state for agent and auto-reply model selection. Thanks @yelog.

Known proof gap: build-artifacts is failing in an unrelated plugin prerelease plan assertion that expects an old Docker stats helper string; targeted tests, diff check, autoreview, and all touched-path checks pass.
2026-05-31 15:07:18 +01:00
Jayesh Betala
f8f52592c5 fix(gateway): expose agent thinking defaults
Fixes #81760.

Exposes existing agent and model thinking defaults through agents.list, including protocol and Swift model support. Thanks @jbetala7.
2026-05-31 15:05:55 +01:00
Peter Steinberger
d99934aacd ci: use normal node_modules for Blacksmith Testbox 2026-05-31 15:04:49 +01:00
Steven
13d2800489 fix(agents): inherit subagent thinking defaults
Fixes #55790.

Adds tested subagent thinking precedence for explicit tool input, requester agent subagent defaults, global subagent defaults, and inherited caller thinking. Thanks @stevenepalmer.
2026-05-31 15:03:10 +01:00
Peter Steinberger
82a0ba8c4c fix(plugins): remove redundant proxy assertion 2026-05-31 15:02:44 +01:00
Peter Steinberger
4d69fc23d0 fix(codex): clear completed dynamic tool release blockers 2026-05-31 15:02:44 +01:00
Peter Steinberger
1e82263492 fix(codex): let async media coexist with terminal batches 2026-05-31 15:02:44 +01:00
Peter Steinberger
d99c824ac1 fix(plugins): delegate wrapped tool properties 2026-05-31 15:02:44 +01:00
Peter Steinberger
3ebbf9a0c1 fix(agents): keep async media starts nonterminal 2026-05-31 15:02:44 +01:00
Peter Steinberger
f62a22ce56 fix(plugins): preserve wrapped tool descriptors 2026-05-31 15:02:44 +01:00
Peter Steinberger
643633c1e5 fix(plugins): scope tool callbacks during materialization 2026-05-31 15:02:44 +01:00
Lellansin Huang
0dfcf73a57 fix(gateway): enforce OpenAI tool_choice contracts
Enforce OpenAI-compatible `tool_choice` contracts for Gateway HTTP Chat Completions and Responses client function tools.

- Add shared request normalization and post-run enforcement for required and pinned client function tool choices.
- Buffer streaming output until the tool-choice contract is satisfied, so failed runs do not leak partial assistant prose.
- Document the client-function-tool scope and add regression coverage for Chat/Responses success and failure cases.

Thanks @Lellansin for the contribution.

Proof: exact-head CI passed for `79fa0947360d307cf4ecffe713489cdf5db61093` in run `26714604449`; focused gateway tests passed locally.
2026-05-31 15:02:29 +01:00
Peter Steinberger
ec8cb8bcbf feat: add MCP code-mode namespace (#88636)
* feat: add MCP code-mode namespace

* fix: unblock mcp namespace ci gates
2026-05-31 15:02:19 +01:00
Andy Ye
44c65de17a fix(agents): avoid synthetic tool results during parallel races
Fixes the session transcript race where a newer assistant tool-call turn could force pending older tool calls to be written as synthetic missing-result entries while real parallel tool results were still in flight.

The guard no longer synthesizes at that racing boundary when synthetic repair is enabled, and transcript repair now moves late real results back beside their matching assistant tool-call turn before adding any placeholder. This keeps provider replay strict while preserving useful tool output.

Regression coverage: focused guard and transcript-repair tests for late parallel results.

Closes #88168.
Follow-up lock-lifetime report tracked in #88647.
Thanks @TurboTheTurtle for the fix and @jhartman00 for the report.

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-31 15:00:44 +01:00
Vincent Koc
0833c68b1b fix(e2e): show plugins docker sweep progress 2026-05-31 15:57:19 +02:00
Peter Steinberger
f2ace9ff4e docs: require gh comment drafts 2026-05-31 09:56:56 -04:00
Peter Steinberger
036acbd358 docs: require codex source citations 2026-05-31 09:55:07 -04:00
Peter Steinberger
95890fe150 fix(agents): release session lock on manual abort
Release the embedded attempt session lock on manual aborts through the same best-effort abort cleanup path used by timeout aborts.

Proof: focused Vitest for abort/session-lock cleanup, `pnpm check:test-types`, oxfmt, `git diff --check`, branch autoreview, and full PR CI on 56fa5420d6.

Fixes #88600
2026-05-31 14:53:42 +01:00
Peter Steinberger
a7075f3634 docs: clarify autoreview refactor follow-up 2026-05-31 14:52:45 +01:00
Chunyue Wang
582fea942b fix(agents): scope timeout cooldowns by model
Fixes #87462.

Timeout transport failures now record cooldowns against the attempted model when available. Model-scoped cooldown bypasses continue to respect profile-wide blocked/disabled windows, and timeout expiry selection stays per profile while rate-limit expiry keeps shared reset aggregation.

Verification:
- pnpm exec oxfmt --check --threads=1 src/agents/auth-profiles/usage-state.ts src/agents/auth-profiles/usage.test.ts src/agents/auth-profiles.getsoonestcooldownexpiry.test.ts src/agents/auth-profiles.markauthprofilefailure.test.ts src/agents/embedded-agent-runner/run.ts
- pnpm check:test-types
- pnpm test src/agents/auth-profiles.getsoonestcooldownexpiry.test.ts src/agents/auth-profiles/usage.test.ts src/agents/auth-profiles.markauthprofilefailure.test.ts src/agents/embedded-agent-runner/run.incomplete-turn.test.ts
- autoreview clean
- GitHub Actions green on PR head d64e2a4d2f
2026-05-31 14:51:20 +01:00
Peter Steinberger
d927e73609 test(discord): drive application id retry timer 2026-05-31 14:50:08 +01:00
Peter Steinberger
b36ed41559 docs: strengthen review dependency inspection rules 2026-05-31 09:49:03 -04:00
Peter Steinberger
7dea283756 refactor: expand acp core package (#88618)
* refactor: expand acp core package

* chore: drop acp core package symlink

* fix: keep acp core dependency graph stable

* fix: add acp core tsconfig subpaths

* fix: sync acp core boundary path artifacts

* fix: use kysely for cron run-log queries

* fix: resolve acp core subpaths in loaders
2026-05-31 14:48:57 +01:00
Vincent Koc
cc290050b4 fix(doctor): diagnose malformed provider catalogs
Move malformed static provider catalog diagnostics into `openclaw doctor` instead of adding fallback behavior to runtime projection.

Doctor now validates full provider registrations for malformed static catalog hooks, result containers, provider keys, model arrays, model iteration, model ids/names, invalid catalog order, and proxy/access errors. Runtime unified text provider catalog projection remains strict on the typed provider catalog contract.

Verification:
- `node scripts/run-vitest.mjs src/flows/doctor-core-checks.runtime.test.ts src/flows/doctor-core-checks.test.ts src/flows/doctor-health-contributions.test.ts src/flows/doctor-health-conversion-plan.test.ts src/plugin-sdk/provider-entry.test.ts`
- `node_modules/.bin/oxfmt --check src/plugins/provider-catalog-unified-text.ts src/flows/doctor-core-checks.ts src/flows/doctor-core-checks.test.ts src/flows/doctor-core-checks.runtime.ts src/flows/doctor-core-checks.runtime.test.ts src/flows/doctor-health-contributions.ts src/flows/doctor-health-contributions.test.ts src/flows/doctor-health-conversion-plan.ts src/flows/doctor-health-conversion-plan.test.ts`
- `node scripts/run-oxlint.mjs src/flows/doctor-core-checks.runtime.ts src/flows/doctor-core-checks.runtime.test.ts src/plugins/provider-catalog-unified-text.ts`
- `pnpm tsgo:test`
- `git diff --check origin/main...HEAD`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --prompt-file /tmp/provider-catalog-doctor-review-context.txt`
- GitHub PR checks green on head `876fdda5a352b0f15bfbe2abe9be43ebada7c596`

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-31 14:48:15 +01:00
Andy Ye
826b378452 fix(agents): normalize prefixed Anthropic model ids (#88587) 2026-05-31 14:47:40 +01:00
Peter Steinberger
0d17623f00 chore: bump OpenClaw version to 2026.5.31
Bumps OpenClaw release metadata to 2026.5.31 across package manifests, app version files, plugin metadata, changelog headings, and generated shrinkwraps.

Verification:
- pnpm plugins:sync:check
- pnpm ios:version:check
- pnpm deps:shrinkwrap:check
- git diff --check
- stale 2026.5.30/build-code scan across changed files
- autoreview clean: no accepted/actionable findings
- PR CI green for real gates: Checks, security scans, dependency guard, app lanes, real behavior proof

Known non-code workflow issue:
- label workflow failed because this PR hits GitHub's 100-label issue cap before the size-label step.
2026-05-31 14:46:17 +01:00
Soham Patankar
400be62f76 feat(codex): add portable Codex command pickers (#82224)
Refactor Codex slash-command pickers so the Codex plugin owns the native command tree and returns portable presentation buttons for channels to render. Telegram now maps portable slash-command buttons to `tgcmd:` native callbacks while preserving approval callback shortening/bypass behavior, and the old Telegram-specific Codex callback menu path is gone.

Verification:
- `node scripts/run-vitest.mjs extensions/codex/src/command-plugins-management.test.ts extensions/codex/src/commands.test.ts extensions/telegram/src/button-types.test.ts`
- `node scripts/run-vitest.mjs extensions/telegram/src/bot.test.ts extensions/telegram/src/button-types.test.ts extensions/telegram/src/bot-native-commands.test.ts extensions/telegram/src/shared.test.ts`
- `node scripts/run-vitest.mjs run --config test/vitest/vitest.media-understanding.config.ts --reporter=verbose`
- `pnpm check:test-types`
- `pnpm tsgo:prod`
- `pnpm lint --threads=8`
- `git diff --check`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- CI `26714121462`

Co-authored-by: Soham Patankar <102520430+yaanfpv@users.noreply.github.com>
2026-05-31 14:45:10 +01:00
Nao
5a0e67791f fix(tui): preserve pending local runs during session sync (#87959)
* fix(tui): preserve pending local runs during session sync

* fix(tui): guard optimistic run ownership

* fix(tui): consume early accepted run finals

* fix(tui): preserve deferred pending history reloads

---------

Co-authored-by: nao860226-rgb <nao860226-rgb@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-31 14:39:24 +01:00
Peter Steinberger
85beee613c docs: clarify inline code comments
Comment-only follow-up documenting reusable gateway, auth, proxy, device, Talk, session, and agent helper contracts.\n\nVerification: git diff --check plus targeted tests recorded in PR body.
2026-05-31 14:37:41 +01:00
yaoyi1222
75e0053cf9 fix(auto-reply): warn on substantive private message-tool finals
Warn operators when message_tool_only produces unusually substantive private final text without a delivered source reply. Keeps short/NO_REPLY silence quiet, avoids logging response bodies, and distinguishes unrelated side effects from source-reply delivery.
2026-05-31 14:35:58 +01:00
Sebastien Tardif
81b9da0bb0 fix(tui): use middle truncation for paths and commands in tool display (#88050)
* fix(tui): use middle truncation for paths and commands in tool display

Closes #87936

* fix(test): update channel-streaming test for middle truncation output

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* chore: retrigger CI (vitest env teardown flake)

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* fix(tui): redact tool details before middle truncation

Apply redactToolDetail() to command and generic string text before
middle truncation so credential-like suffixes are masked while full
flag/key context is still available. Previously, truncation could
remove the --flag prefix while preserving the raw secret at the tail,
causing redaction patterns to miss the value.

Add regression tests for sk- prefixed tokens in commands and ghp_
tokens in generic string details.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

---------

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
2026-05-31 14:35:55 +01:00
Mukunda Rao Katta
e452d16cea fix(webchat): suppress stale active session rows (#87962) 2026-05-31 14:35:50 +01:00
Sebastien Tardif
9a1b95c1e6 fix(tui): skip history reload when final event has displayable output (#88004)
* fix(tui): skip history reload when final event has displayable output

On external/gateway runs, handleChatEvent fires void loadHistory() on
every final event. loadHistory() does clearAll() + rebuild from server
data, but the server may not have persisted the just-finished message
yet, causing the rendered final message to vanish.

Add a hasDisplayableFinal option to maybeRefreshHistoryForRun that skips
the destructive reload when the final text is already rendered locally.
This mirrors the existing local-run guard. Compute finalText before the
reload decision so the guard has the information it needs.

Closes #87922

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* retrigger proof check

Signed-off-by: Seb Tardif <sebtardif@ncf.ca>

---------

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
Signed-off-by: Seb Tardif <sebtardif@ncf.ca>
2026-05-31 14:35:44 +01:00
Peter Steinberger
5dc4531fdf test(discord): isolate timer-sensitive request tests 2026-05-31 14:31:10 +01:00
Vincent Koc
9518d1f27c fix(auth): coerce persisted device auth tokens 2026-05-31 15:22:44 +02:00
Vincent Koc
fbde572491 fix(e2e): heartbeat resource-sampled docker lanes 2026-05-31 15:22:44 +02:00
Peter Steinberger
f24a138790 refactor: unify subagent handoffs into agent steering queue
Refactor the subagent completion handoff path into the generic agent steering queue, preserving legacy persisted handoff lease fields by normalizing them into steering lease fields on restore.

Also allowlists the split cron run-log SQLite boundary in the Kysely guardrail after rebasing onto current main.

Refs #88407.
2026-05-31 14:21:20 +01:00
Chunyue Wang
02c7b5b82f fix(tasks): reclaim ACP zombie runs blocking gateway restart (#88281)
* fix(tasks): reclaim ACP zombie runs blocking gateway restart (#88205)

hasBackingSession treated an ACP task as backed whenever its persisted
session-store entry existed, so a crashed mid-turn ACP run left a
status=running record that survived the crash and wedged gateway
restart/update forever.

Gate ACP backing on in-process live-turn liveness instead of entry
existence, behind the existing authoritative-process flag (generalized
from cron-only) so a standalone maintenance CLI with an empty live-turn
map stays conservative and never reclaims. The liveness signal lives in a
core-internal active-turns registry (mirroring cron active-jobs) so it
stays off the SDK-exported AcpSessionManager surface. It is marked once
before the backend loop and cleared when the task is marked terminal, so
a slow init or backend failover cleanup cannot let the sweep reclaim a
still-live turn.

* fix(tasks): preserve cron operator JSON diagnostic reason

Split the merged runtime_not_authoritative reason back into the existing cron_runtime_not_authoritative (shipped, consumed by openclaw tasks maintenance --json operator scripts) and a new acp_runtime_not_authoritative for the ACP branch. Strengthen the cron non-authoritative test to lock the reason string contract.

* fix(tasks): clear ACP turn liveness on retry failures

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 14:19:56 +01:00
Peter Steinberger
100dd79468 test(release): wait for bundled runtime commands 2026-05-31 14:09:27 +01:00
Chunyue Wang
318cae1500 fix(hooks): isolate slug-generator auth failures
Summary:
- route slug-generator embedded runs through lane-local auth profile failure handling
- add regression coverage for the run option
- repair current landing checks for media auth mocks, device auth parsing, transcript sanitizer harness typing, and lint

Verification:
- node scripts/run-vitest.mjs src/hooks/llm-slug-generator.test.ts src/agents/embedded-agent-runner/run/auth-profile-failure-policy.test.ts
- node scripts/run-vitest.mjs run --config test/vitest/vitest.media-understanding.config.ts --reporter=verbose
- pnpm check:test-types
- pnpm lint --threads=8
- git diff --check
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main

Fixes #71709.
Co-authored-by: openperf <16864032@qq.com>
2026-05-31 14:09:03 +01:00
Peter Steinberger
17c8602a9c docs: require issue summaries in agent replies 2026-05-31 14:07:08 +01:00
Peter Steinberger
3ca4e5f616 docs: clarify agent workflow rules 2026-05-31 14:06:22 +01:00
Peter Steinberger
7423e9cb66 refactor(openai): confine legacy codex repair to doctor
Confine retired OpenAI Codex identifiers to doctor repair and migration paths while keeping runtime OpenAI surfaces canonical.\n\nProof: focused Vitest; autoreview clean; AWS Crabbox check:changed run_3789cbe12413 (cbx_2c88b700810b) passed.
2026-05-31 14:03:17 +01:00
tynamite
2f7e6ec196 fix(auto-reply): honor per-model thinking params
Auto-reply now uses the existing per-model model params thinking value before falling back to the global thinkingDefault, matching gateway/shared model selection behavior.\n\nVerified with targeted auto-reply and agents Vitest coverage plus formatting and diff checks.\n\nThanks @tynamite for the fix.
2026-05-31 14:01:25 +01:00
Peter Steinberger
b222b5f6fa refactor(cron): keep legacy notify migration in doctor 2026-05-31 14:00:47 +01:00
Peter Steinberger
2fe019ccae fix(exec): allow predicate shell builtins in allowlist mode 2026-05-31 14:00:12 +01:00
Peter Steinberger
657a668d94 test(voice-call): drive Twilio stream failure timers 2026-05-31 13:59:48 +01:00
Peter Steinberger
c797f02ff7 fix(diagnostics): surface Bonjour state in support exports 2026-05-31 13:57:17 +01:00
Peter Steinberger
32c0279cec perf(cli): narrow gateway dispatch startup 2026-05-31 13:56:27 +01:00
Peter Steinberger
44512b5297 docs: tighten refactor storage policy 2026-05-31 13:51:43 +01:00
Peter Steinberger
f1fc204f5c docs: require PR review transparency 2026-05-31 08:50:47 -04:00
Peter Steinberger
c8f7e9102b docs: clarify runtime migration boundary 2026-05-31 13:42:59 +01:00
Peter Steinberger
cf315ddef6 fix(agents): preserve reasoning replay from model metadata
Preserve OpenAI-compatible replay reasoning when the selected custom or self-hosted model already has reasoning metadata enabled.

The transcript policy now treats existing model metadata as the replay contract instead of requiring a new provider config knob, and the OpenAI-compatible serializer preserves reasoning_content for those routes while keeping stock OpenAI, Gemma 4, and known non-replayable OpenRouter safeguards.

Fixes #88068.
Replaces #88071.
2026-05-31 13:41:44 +01:00
Peter Steinberger
7a22515972 test(release): harden beta validation gates 2026-05-31 13:39:48 +01:00
kinjitakabe
fee4e52f22 fix(exec): allow known safe shell builtins in allowlist mode
Treat pathless POSIX shell builtins (`:`, `cd`, `false`, `pwd`, `true`) as internally safe only during shell allowlist evaluation. This avoids approval prompts for chains like `cd /tmp && git status` when the executable segment is already allowlisted, without adding a `tools.exec.safeBuiltins` config knob.

Environment-mutating builtins (`export`, `unset`), code-evaluating builtins (`eval`, `source`, `.`), unknown commands, and direct argv execution remain approval-gated unless separately allowlisted.

Proof: `pnpm test src/infra/exec-safe-builtins.test.ts src/agents/bash-tools.exec.security-floor.test.ts -- --reporter=verbose`; `pnpm changed:lanes --json`; `pnpm check:no-conflict-markers`; `git diff --check origin/main...HEAD`. CI related failures were resolved on the final SHA; remaining `checks-node-core-runtime-media-ui` failure is unrelated to this PR.

Fixes #46056.
Thanks @kinjitakabe.

Co-authored-by: kevinkang-ai <273844887+kevinkang-ai@users.noreply.github.com>
2026-05-31 13:39:13 +01:00
Peter Steinberger
ca166a85d4 docs: explain per-agent model params 2026-05-31 13:38:17 +01:00
Peter Steinberger
e5c61383e5 refactor: move plugin state stores to sqlite (#88609) 2026-05-31 13:37:11 +01:00
Peter Steinberger
fd88f34a8f fix: preserve discord policy close narrowing 2026-05-31 13:28:53 +01:00
Peter Steinberger
1e54e908e2 fix: queue subagent completion handoffs (#88613) 2026-05-31 13:25:23 +01:00
Peter Steinberger
729712d194 docs(codex): clarify first-party plugin marketplaces 2026-05-31 13:22:00 +01:00
Peter Steinberger
97a97aded7 docs: tighten env surface policy 2026-05-31 13:21:12 +01:00
Peter Steinberger
2e254005a0 docs: tighten config surface policy 2026-05-31 13:14:53 +01:00
Peter Steinberger
703fae16a9 fix(devices): refresh paired device last-seen metadata
Refresh paired-device last-seen metadata on successful device-token auth, paired reconnect, and first silent auto-approved connect.

Centralize approved paired-device record construction so normal and bootstrap approvals preserve existing last-seen state unless the gateway passes explicit access metadata.

Fixes #81169.
Supersedes #81189.

Proof:
- node scripts/run-vitest.mjs src/infra/device-pairing.test.ts --reporter=verbose
- node scripts/run-vitest.mjs src/gateway/server.auth.control-ui.test.ts --reporter=verbose
- git diff --check
- pnpm exec oxfmt --check --threads=1 src/infra/device-pairing.ts src/infra/device-pairing.test.ts src/gateway/server/ws-connection/message-handler.ts src/gateway/server.auth.control-ui.suite.ts
- pnpm check:changed passed before final rebase; post-rebase rerun blocked before checks by local Crabbox 0.21.0 needing >=0.22.0
- autoreview clean: .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main

Known unrelated CI failure on latest origin/main/PR base: extensions/discord/src/monitor/gateway-plugin.ts TS2367 in check-prod-types/check-lint/check-test-types/extension-channel checks.

Co-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
2026-05-31 13:12:55 +01:00
clawsweeper[bot]
fdf8dddf0a fix(agents): classify expired thinking signatures (#88340)
Summary:
- The branch adds thinking-signature replay-invalid classification, retries matching terminal stream-error eve ... output, preserves static fallback model params, and updates related tests including a Copilot hook fixture.
- PR surface: Source +57, Tests +177. Total +234 across 6 files.
- Reproducibility: yes. for the classifier boundary: current main lacks a thinking-signature replay-invalid ma ... ort supplies the exact provider error payload. The time-dependent live expiry path was not reproduced here.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(agents): classify expired thinking signatures
- PR branch already contained follow-up commit before automerge: fix(agents): recover thinking signature stream errors
- PR branch already contained follow-up commit before automerge: fix(agents): recover expired thinking signatures
- PR branch already contained follow-up commit before automerge: fix(clawsweeper): address review for automerge-openclaw-openclaw-8807…

Validation:
- ClawSweeper review passed for head b65f2b8bda.
- Required merge gates passed before the squash merge.

Prepared head SHA: b65f2b8bda
Review: https://github.com/openclaw/openclaw/pull/88340#issuecomment-4582955790

Co-authored-by: Bryan Tegomoh <bryan.tegomoh@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-31 12:11:30 +00:00
FMLS
3a88142ddd fix(browser): document stable tab references (#88393)
Summary:
- The branch documents friendly browser tab references across docs, the browser skill, CLI help, and tool schema descriptions, and adds tests for target reference resolution and tab alias behavior.
- PR surface: Source +24, Tests +328, Docs +9. Total +361 across 21 files.
- Reproducibility: yes. for the documentation mismatch by source inspection: current main supports friendly ta ... schema/help surfaces still emphasize raw CDP target ids. Runtime behavior itself is not a new failing path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: refactor(browser): share tab reference CLI help

Validation:
- ClawSweeper review passed for head 118af80b0b.
- Required merge gates passed before the squash merge.

Prepared head SHA: 118af80b0b
Review: https://github.com/openclaw/openclaw/pull/88393#issuecomment-4583558133

Co-authored-by: FMLS <kfliuyang@gmail.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-05-31 12:09:50 +00:00
Peter Steinberger
94b1427fdf fix(discord): log gateway websocket close details 2026-05-31 13:03:23 +01:00
Peter Steinberger
f83886c12d chore(lint): trim remaining suppressions 2026-05-31 13:01:19 +01:00
Peter Steinberger
63c6252389 test(release): stabilize beta validation after rebase 2026-05-31 13:00:09 +01:00
Ayaan Zaidi
4de9b79d30 refactor(agents): simplify stale cli retry cleanup 2026-05-31 17:28:05 +05:30
brokemac79
afe9826fc1 Stabilize lint suppression guard in CI 2026-05-31 17:28:05 +05:30
brokemac79
0b02148656 Fix stale CLI retry CI contracts 2026-05-31 17:28:05 +05:30
brokemac79
e8c7c933f8 Retry stale CLI sessions in runner lifecycle 2026-05-31 17:28:05 +05:30
Peter Steinberger
00d17e9df7 refactor: make OpenAI Codex legacy doctor-only (#88605) 2026-05-31 12:58:01 +01:00
Vincent Koc
5976f14832 docs(skills): full rewrite of skills section with Mintlify components
Rewrites all skills documentation pages with rich Mintlify components
(Steps, CardGroup, AccordionGroup, ParamField, Note, Warning, Tip) and
code-verified accuracy throughout.

- tools/skills.md: CardGroup quick-nav, verified precedence table from
  workspace.ts, Security accordions, Steps for env injection, token
  impact formula, Related CardGroup
- tools/creating-skills.md: Steps walkthrough, gating accordion,
  propose-update command (was missing), Best practices Tip, ClawHub
  publish flow, Related CardGroup
- tools/skills-config.md: ParamField for every config key, agent
  allowlist section, Workshop config, sandbox Warning
- tools/slash-commands.md: CardGroup for 3 command types, command tables
  in AccordionGroup sections, ParamFields for all config keys, dedicated
  sections for /tools /model /config /mcp /debug /plugins /trace /btw
- prose.md: Steps for install, CardGroup quick-nav, AccordionGroup for
  state backends, runtime mapping table

docs.json: adds skill-workshop nav entry and redirects
(/skill-workshop, /tools/skills-workshop -> /tools/skill-workshop)
2026-05-31 12:57:16 +01:00
Peter Steinberger
242eab9d20 fix(media): use typed auth for no-auth media providers 2026-05-31 12:56:38 +01:00
WhatsSkiLL
f59113cfd3 fix(gateway): avoid restarts for auth cooldown reloads
Fixes #88443.

Cooldown-only edits under auth.cooldowns now hot reload the active runtime config instead of scheduling a gateway restart. This avoids dropping active gateway work while preserving restart-required behavior for gateway.auth.* credential changes.

Verification:
- pnpm test src/gateway/config-reload.test.ts -- --reporter=verbose
- env -u OPENCLAW_TESTBOX pnpm check:changed
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --prompt 'Review PR 88474 after rebase. Focus on whether auth.cooldowns hot reload correctly refreshes active runtime config without weakening gateway auth/token restart behavior. Treat no-op vs hot reload semantics as central.'
- GitHub CI, Real behavior proof, CodeQL, Dependency Guard, OpenGrep PR Diff, and Workflow Sanity passed on 51232ff66c.

Thanks @IWhatsskill.
2026-05-31 12:54:19 +01:00
Peter Steinberger
fde87f475f perf(cli): defer shell env for gateway dispatch 2026-05-31 12:42:35 +01:00
Vincent Koc
823c38a1f9 fix(e2e): keep plugin binding escape smoke focused 2026-05-31 13:37:41 +02:00
Sally O'Malley
1cb5a57631 fix: transient banner showing lastError leak into page headers (#88463)
Signed-off-by: sallyom <somalley@redhat.com>
2026-05-31 07:33:58 -04:00
Sally O'Malley
615f71a88f fix(gateway): guide dashboard auth after service repair (#88466)
Signed-off-by: sallyom <somalley@redhat.com>
2026-05-31 07:31:44 -04:00
Peter Steinberger
899dc5f248 fix(memory): retry transient embedding failures
Retry live query embeddings on transient provider transport failures and split eligible batch embedding socket failures after bounded retries.

Fixes #71784
Fixes #44166
Supersedes #44167

Co-authored-by: MrGeDiao <MrGeDiao@users.noreply.github.com>
2026-05-31 12:30:26 +01:00
stain lu
95b2f9c6f9 fix(boot): suppress fallback BOOT.md echoes
Suppress BOOT.md/internal-runtime-context echoes in fallback boot sends.

Wrap boot prompts as internal runtime context, track the active boot prompt during boot runs, and sanitize message-tool visible payloads before dispatch so fallback models cannot deliver copied BOOT.md instructions or leak them through raw-params errors. Preserves media/presentation sends that still contain non-text payload content after sanitization.

Fixes #53732.

Co-authored-by: stainlu <stainlu@newtype-ai.org>
2026-05-31 12:25:41 +01:00
sqsge
a76db8cff3 fix(media): allow explicit synthetic auth for media providers
Allow media understanding providers to opt into synthetic non-secret auth for local or self-hosted no-auth audio/video execution.

This preserves configured env/profile/literal provider credentials first, keeps explicit profile failures hard-fail, and leaves unmarked remote providers fail-closed.

Fixes #74644.
2026-05-31 12:20:50 +01:00
Peter Steinberger
9f5c981f9f perf: speed up chat hydration and add 3d workboard 2026-05-31 12:18:08 +01:00
Peter Steinberger
2bd07eead7 Refactor cron SQLite runtime paths (#88582)
* refactor: clean cron sqlite runtime paths

* fix: preserve legacy cron sqlite delivery migration

* fix: keep legacy cron notify fallback for invalid webhooks

* test: handle packaged lint suppression files

* fix: keep invalid cron notify migrations retryable

* test: fix ui timer lint
2026-05-31 12:14:48 +01:00
Peter Steinberger
3525a965ed test(release): stabilize beta validation lanes 2026-05-31 12:09:49 +01:00
WhatsSkiLL
22b8e1cf4f fix(plugins): scope startup metadata manifest reads
Limit plugin metadata snapshots to the channel, provider, and startup surfaces that need them, while preserving unscoped fallback for incomplete index data and provider runtime resolution.

Refs #70533.
Refs #84628.

Co-authored-by: IWhatsskill <IWhatsskill@users.noreply.github.com>
2026-05-31 11:58:56 +01:00
Peter Steinberger
1e08af453a fix(sms): add Twilio webhook diagnostics
* fix(sms): diagnose Twilio webhook setup

* test(sms): satisfy diagnostic lint gates

* fix(sms): redact recent probe participants

* docs(sms): refresh SecretRef credential matrix

* fix(sms): probe Messaging Service webhooks

* fix(sms): resolve env-backed SecretRefs
2026-05-31 11:44:39 +01:00
Vincent Koc
6d76acc258 fix(test): repair e2e standalone regressions 2026-05-31 12:42:17 +02:00
kinjitakabe
f7a1d3f3f6 fix(model-auth): resolve per-entry apiKey profile references
Fixes #67423.

Resolve provider-entry apiKey fields that intentionally reference model auth profiles through centralized binding logic, so runtime auth and status labeling agree. Preserve env-first precedence, SecretRef handling, provider/baseUrl compatibility checks, and model auth-mode guards.

Verification:
- node scripts/run-vitest.mjs src/agents/model-auth.profiles.test.ts src/agents/model-auth-label.test.ts
- PATH=/tmp/openclaw-corepack-shim.XXXXXX:$PATH CI=true pnpm check:changed
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- GitHub CI run 26710260760 and related CodeQL/proof checks on f55dec154d

Co-authored-by: kinjitakabe <273844887+kinjitakabe@users.noreply.github.com>
2026-05-31 11:39:55 +01:00
Peter Steinberger
7d8fdef995 ci(release): run npm preflight on larger runner 2026-05-31 11:37:04 +01:00
Peter Steinberger
9dc4c9ec2e fix: expose Feishu tools for named accounts 2026-05-31 11:36:48 +01:00
Peter Steinberger
77f1359612 refactor: extract media and ACP core packages (#88534)
* refactor: extract media and acp core packages

* refactor: remove relocated media and acp sources

* build: wire new core packages into dependency checks

* test: alias new core packages in vitest

* build: keep media sniffer runtime dependency

* docs: refresh plugin sdk api baseline

* fix: keep normalized proposal queries non-empty

* test: keep channel timer tests isolated

* fix: keep rebased plugin checks green

* fix: preserve sms numeric allowlist entries

* test: harden exec foreground timeout failure

* test: remove duplicate skill workshop assertion

* fix: remove channel config lint suppression

* test: refresh lint suppression allowlist
2026-05-31 11:30:33 +01:00
stain lu
4b1e5b7943 fix(cli): stabilize claude auth epochs on token rotation
Stabilizes Claude CLI reusable sessions when Claude token rotation causes transient token-shaped credential reads. Local Claude CLI OAuth and token credential encodings now share the same identity-only auth-epoch, while ref-backed token auth profiles ignore refreshed token material and plaintext token profiles remain epoch-sensitive on manual token replacement.

Fixes #74312.

Proof: focused local Vitest, autoreview, Testbox-through-Crabbox tbx_01ksyrcknbt743x32x6k1s95qw, and GitHub CI run 26709864094 all passed.

Co-authored-by: stainlu <stainlu@newtype-ai.org>
2026-05-31 11:19:42 +01:00
Ted Li
92b6af76d9 fix(reply): deliver plugin binding replies
Deliver plugin-owned bound-thread replies even when the source room is configured for `message_tool` visible replies. Normal agent final text still stays private unless the agent calls `message(action=send)`.

Document the distinction in the group/channel docs and root routing policy, and keep ambient room-event plus unauthorized text-slash suppression covered by regression tests.

Fixes #87721.
2026-05-31 11:17:45 +01:00
Peter Steinberger
53a9f13cf4 chore(lint): reduce lint suppressions 2026-05-31 11:17:16 +01:00
Firas Alswihry
b2f71db7bb feat(dreaming): add report-only shadow trial runner
Adds a report-only memory-core dreaming shadow-trial runner that writes inspectable artifacts without mutating durable memory. The public helper now stores default reports under daily directories with opaque content-hash filenames, so multiple same-day trials coexist without leaking candidate text into paths.

Verification:
- OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.extension-memory.config.ts extensions/memory-core/src/dreaming-shadow-trial.test.ts --reporter=verbose --maxWorkers=1
- git diff --check
- pnpm exec oxfmt --check extensions/memory-core/src/dreaming-shadow-trial.ts extensions/memory-core/src/dreaming-shadow-trial.test.ts
- pnpm tsgo:extensions
- autoreview clean: no accepted/actionable findings
- GitHub CI run 26709794635 passed
- Real behavior proof run 26709798698 passed
- Dependency Guard run 26709794113 passed

Co-authored-by: Firas Alswihry <itzfiras@gmail.com>
2026-05-31 11:16:33 +01:00
Peter Steinberger
6fb1f386c6 perf(cli): slim agent command registration 2026-05-31 11:14:26 +01:00
Peter Steinberger
ae4ab2a41f refactor(logging): share stuck recovery session refs 2026-05-31 11:10:06 +01:00
Soham Patankar
4f3d8a57dd fix(codex): accept first-party OpenAI plugin marketplaces
Allow Codex native plugin config to target first-party OpenAI marketplaces, including openai-curated, openai-bundled, and openai-primary-runtime.

Fixes #82216.
Thanks @yaanfpv for the contribution.

Verification:
- node scripts/run-vitest.mjs test/scripts/lint-suppressions.test.ts
- pnpm build:ci-artifacts
- OPENCLAW_VITEST_MAX_WORKERS=2 node scripts/run-vitest.mjs run --config test/vitest/vitest.full-core-support-boundary.config.ts test/scripts/lint-suppressions.test.ts
- node scripts/run-vitest.mjs extensions/codex/src/app-server/config.test.ts extensions/codex/src/app-server/plugin-activation.test.ts extensions/codex/src/app-server/session-binding.test.ts extensions/codex/src/migration/provider.test.ts extensions/sms/src/channel.test.ts extensions/sms/src/inbound.test.ts
- git diff --check
- ./.agents/skills/autoreview/scripts/autoreview --mode local
- GitHub PR CI on head 896640060b, including build-artifacts run 26709647050
2026-05-31 11:08:42 +01:00
Ayaan Zaidi
f454d6202f fix(agents): preserve explicit active run aborts 2026-05-31 15:31:48 +05:30
Ayaan Zaidi
1556e3c68c fix(agents): surface internal abort incomplete turns 2026-05-31 15:31:48 +05:30
Ayaan Zaidi
a4d3add6da fix(agents): classify internal aborts as non-deliverable 2026-05-31 15:31:48 +05:30
Feelw00
b4cdc33fc9 fix(logging): align diagnostic recovery dedup keys
Align diagnostic stuck-session recovery in-flight dedup with the runtime recovery key. The coordinator now dedups by logical session ref only, so a mid-flight generation bump cannot emit a phantom `session.recovery.requested` event that runtime recovery skips as already in flight.

Adds a regression test for the idle-queued stall path where a queued message bumps generation while recovery is pending.

Fixes #88010
2026-05-31 11:00:42 +01:00
Chinar Amrutkar
c2c20a0b0d fix(ui): pair sequential tool results by fallback order
Fixes #70746 by pairing nameless same-name tool results with the earliest unmatched Control UI tool card while preserving exact ID matches. Empty fallback results now count as consumed, so later results do not overwrite the first card.

Focused regression coverage covers sequential same-name calls and empty-result fallback pairing. Thanks @chinar-amrutkar.

Co-authored-by: Chinar Amrutkar <chinar.amrutkar@gmail.com>
2026-05-31 11:00:00 +01:00
Vincent Koc
a753e6bc86 fix(test): extend e2e vitest watchdog 2026-05-31 11:50:18 +02:00
tanshanshan
425a4ab2f2 chore(lint): enable object-shorthand (#81808)
* fix: narrow current-main core type guards

* fix: preserve query and test guard narrowing

* fix(copilot): align client options with sdk rename

* test(sms): type fetch mocks

* fix(sms): preserve numeric allowlist entries

* test(sms): preserve pairing send count assertion

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 10:46:10 +01:00
Peter Steinberger
724160b7eb docs: clarify package guard policy 2026-05-31 10:45:28 +01:00
Peter Steinberger
6699e7331a docs: document scoped mention patterns
## Summary

- Document scoped configured mention-pattern policy on the Groups page, including allow/deny mode semantics, supported conversation IDs, account-level precedence, and native-mention behavior.
- Add config UI help for `mentionPatterns.mode`, `allowIn`, and `denyIn` on Discord, Matrix, Slack, Telegram, and WhatsApp.
- Regenerate channel config/docs/plugin SDK metadata baselines for the new hint copy.

Refs #70864.

## Verification

- git diff --check
- pnpm format:docs:check
- pnpm docs:check-mdx
- pnpm docs:check-links
- pnpm config:channels:check
- pnpm config:docs:check
- pnpm plugin-sdk:api:check
- node scripts/run-vitest.mjs src/config/schema.hints.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode local

## Real behavior proof

Behavior addressed: Documentation and config UI metadata for scoped configured mention-pattern policy.
Real environment tested: Local OpenClaw checkout on macOS.
Exact steps or command run after this patch: The verification commands listed above.
Evidence after fix: Docs formatting, MDX, link audit, generated config/channel/API baselines, and config hint tests passed; autoreview reported no accepted/actionable findings.
Observed result after fix: The Groups page now explains how to scope `messages.groupChat.mentionPatterns` with `channels.<channel>.mentionPatterns`, and config metadata exposes field help for the supported channels.
What was not tested: Live Discord, Matrix, Slack, Telegram, or WhatsApp inbound messages; this PR is documentation/config metadata only and follows the already-landed runtime behavior from #70864.
2026-05-31 10:44:20 +01:00
Vincent Koc
b0625bdd1c fix(agents): strip malformed arg-value suffixes
Strip malformed terminal `</arg_value>>` suffixes from selected agent read/path and exec routing arguments before validation.

This keeps valid literal `</arg_value>` text intact, preserves payload fields such as write content and edit replacements, and prevents read/exec failures caused by malformed tool XML suffixes.

Fixes #48780.
Thanks @vincentkoc for the original fix.

Verification:
- `node scripts/run-vitest.mjs src/agents/agent-tools.params.test.ts src/agents/agent-tools.read.arg-value-suffix.test.ts src/agents/agent-tools.read.workspace-root-guard.test.ts src/agents/agent-tools.workspace-only-false.test.ts src/agents/bash-tools.exec.path.test.ts src/agents/bash-tools.exec-foreground-failures.test.ts`
- `node_modules/.bin/oxfmt --check src/agents/agent-tools.params.ts src/agents/agent-tools.params.test.ts src/agents/bash-tools.exec.path.test.ts`
- `node scripts/run-oxlint.mjs src/agents/agent-tools.params.ts src/agents/agent-tools.params.test.ts src/agents/bash-tools.exec.path.test.ts`
- `pnpm check:test-types`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- GitHub Actions green on PR head `f1d8026352`.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-31 10:44:12 +01:00
stain lu
4ca22b95bc test(plugins): cover Link agent wallet bundle shape (#75181)
* test(plugins): cover Link agent wallet bundle shape

* test(plugins): add bundle fixture helpers

* test(plugins): align Link manifest fixture expectation

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 10:43:12 +01:00
Peter Steinberger
3950605561 chore(lint): tighten lint exception coverage 2026-05-31 10:42:59 +01:00
Peter Steinberger
2c6a3f6b04 fix(gateway): make bare reset commands fast 2026-05-31 10:38:20 +01:00
Peter Steinberger
7bf6667d6c docs: update crabbox skill cache-volume guidance 2026-05-31 10:38:05 +01:00
David
778c4f90b9 fix(agents): route per-turn media task hints below the cache boundary (#87998)
* fix(agents): route media task hints below the system-prompt cache boundary

Per-turn image/video/music generation task hints were injected into the
static prependSystemContext slot, landing above the cache boundary inside the
cacheable prefix. The hints are present only on user/manual turns and vary
with active media tasks, so the cacheable prefix shifted turn-to-turn and
defeated Anthropic/OpenAI prompt caching (#85203).

Split the per-turn media hints out of the prepend resolver into
resolveAttemptMediaTaskSystemPromptAddition and route them below the boundary
via the existing prependSystemPromptAddition helper, matching how subagent and
context-engine system-prompt additions are already routed. The static plugin
prependSystemContext / appendSystemContext hook fields are unchanged and
remain in the cacheable prefix. Applied at both consumers (embedded agent
runner and CLI runner).

* fix(agents): keep media task hints below the cache boundary for hook systemPrompt overrides

A before_prompt_build hook that returns a full systemPrompt override replaces
the base prompt with marker-free text. Per-turn media-generation task hints
were then front-prepended into that marker-free prompt, which providers cache
as a single block, so the cached prefix still shifted turn-to-turn on the
override path (#85203).

Wrap the base with ensureSystemPromptCacheBoundary at both media-routing sites
(embedded agent runner and CLI runner) so a marker-free override gets an
appended boundary and the hint routes into the uncached suffix. The helper is
idempotent, so marker-bearing prompts are unchanged. The shared
prependSystemPromptAddition wrapper and the static prependSystemContext /
appendSystemContext hook fields are untouched.

* fix(agents): keep marker-free idle prompts cacheable below the boundary

A marker-free hook systemPrompt override only had the cache boundary
ensured on turns with an active media task. On idle turns the later
appendModelIdentitySystemPrompt landed above the absent boundary, so the
idle cached system prefix diverged from active turns and prompt caching
broke across active/idle transitions. Ensure the boundary regardless of
media state in both the embedded and CLI runners, and extend the
regression to cover the model-identity append across active->idle.

* fix(agents): scope cache-boundary ensure to the model-identity append

Ensuring the boundary unconditionally on media-idle turns appended a
boundary marker to empty raw/gateway system prompts (turning "" into a
marker-only prompt) and to prompts with nothing below the boundary.
Instead ensure the boundary only when a model identity line is actually
appended to a non-empty prompt, in both the embedded and CLI runners.
This still keeps the identity below the boundary for marker-free hook
systemPrompt overrides (the #85203 idle-cache regression) while leaving
empty and identity-less prompts untouched.

* test: refresh stale type and lint expectations

* test: stabilize CI timeout checks

* test: satisfy channel entry lint

* fix(agents): skip cache boundary for blank prompts

* fix(channels): keep draft flush timer referenced

* test(agents): tolerate failed exec timeout setup

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 10:35:53 +01:00
Peter Steinberger
75ea8b5094 docs: clarify message-tool-only visible replies 2026-05-31 10:35:20 +01:00
Patrick Star
9c1adf4e51 feat: scope group mention patterns by channel
Provider-scoped configured regex mention patterns for Discord, Matrix, Slack, Telegram, and WhatsApp.

Native platform mentions keep their existing behavior, and unsupported channels do not opt into the new regex policy path. The new policy supports per-channel allow/deny routing through mentionPatterns.mode with allowIn and denyIn so group auto-reply regexes can be limited without broad global blast radius.

Refs #70864.
Supersedes #87200.
Thanks @patrick-slimelab.
2026-05-31 10:34:56 +01:00
giming
f94512cd7f fix(xiaomi): support MiMo voicedesign TTS
Adds Xiaomi MiMo voicedesign TTS support by registering the v2.5 voicedesign model and omitting audio.voice for that model's prompt-driven voice design flow.

Also accepts generic TTS aliases modelId, speakerVoice, and speakerVoiceId for Xiaomi provider config and request overrides.

Fixes exec timeout classification so a process that exits after a missed timeout callback is still reported as timed out, using monotonic deadlines to avoid wall-clock skew.

Verification:
- node scripts/run-vitest.mjs extensions/xiaomi/speech-provider.test.ts
- node scripts/run-vitest.mjs src/process/supervisor/supervisor.test.ts
- node scripts/run-vitest.mjs src/agents/bash-tools.exec-foreground-failures.test.ts
- git diff --check
- autoreview --mode local
- live Xiaomi MiMo voicedesign call returned wav RIFF/WAVE output, 169004 bytes
- GitHub CI success on fb3018ef31: CI 26708919072, CodeQL Critical Quality 26708919082, CodeQL 26708919091, OpenGrep PR Diff 26708919089, Workflow Sanity 26708919083, Dependency Guard 26708918574, Real behavior proof 26708921767

Thanks @GimingRao.

Co-authored-by: Raoyu <2425198313@qq.com>
Co-authored-by: giming <53329020+GimingRao@users.noreply.github.com>
2026-05-31 10:34:51 +01:00
Coder
d9d5d97dbc fix(cron): accept sub-second --at datetimes resolved in a timezone (#88185)
getTimeZoneOffsetMs built localAsUtc via Date.UTC() without the millisecond
argument, so for a sub-second instant the computed timezone offset was wrong by
that fraction. That corrupts resolvedMs and fails the exact-millisecond
re-validation in matchesOffsetlessIsoDateTimeParts, so parseOffsetlessIsoDateTimeInTimeZone
returned null for valid fractional input.

User impact: openclaw cron --at "<ISO>.<ms>" --tz <zone> was silently rejected
even though the parser's regex explicitly accepts fractional seconds (\.\d+).

Pass parts.millisecond (carried from utcMs via getUTCMilliseconds) into Date.UTC
so the offset is exact. Add fractional-second regression rows.

Co-authored-by: coder999999999 <coder999999999@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 10:32:17 +01:00
Feelw00
056bc46a67 fix(infra): guard against overwriting corrupt target session store during migration (#88018)
* fix(infra): guard against overwriting corrupt target session store during migration

migrateLegacySessions reads the target agents/{id}/sessions/sessions.json
and merges it with the legacy sessions dir. When the target file is
corrupt, readSessionStoreJson5 swallows the parse error and returns
{store:{}, ok:false}, so the merge becomes legacy-only. The save gate
(legacyParsed.ok || targetParsed.ok) passes on legacyParsed.ok alone and
never checks targetParsed.ok, so the corrupt target is atomically
overwritten with the legacy-only store. Target-only session records (keys
with no legacy counterpart) are lost permanently and the corrupt file can
no longer be recovered by hand. Legacy corruption is already guarded
(warn + skip delete); target corruption was asymmetrically unprotected.

Skip the save (and the legacy delete) when the target store exists but is
unreadable, leaving the corrupt file and the legacy store both in place,
and push a warning mirroring the legacy-unreadable path. saveSessionStore
and readSessionStoreJson5 signatures are untouched.

AI-assisted: drafted with claude code (claude-opus-4-8).

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

* fix(infra): report direct-chat session migration only after target save commits

Addresses ClawSweeper review on #88018. The `Migrated latest direct-chat session`
result.changes entry was pushed before the targetReadable guard, so the
corrupt-target skip path (which intentionally does not save) still reported a
session migration in doctor/startup logs. Defer that report into the
save-committed block (keeping its existing position before `Merged sessions
store`) and assert its absence in the corrupt-target regression test.

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

* fix(infra): add explicit corrupt session recovery

* fix(infra): keep legacy sessions retryable

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 10:31:59 +01:00
Peter Steinberger
ed46e62bcc feat(workboard): add worker dispatch CLI
* feat(workboard): add worker dispatch CLI

* fix(workboard): avoid new unsafe assertions

* fix(workboard): keep remote dispatch failures remote
2026-05-31 10:31:56 +01:00
shawnduggan
1d55caa162 fix(memory): respect QMD status timeout
Respect the configured QMD status timeout during vector availability probes and skip checkpoint-style session transcript exports while preserving valid session IDs that merely contain checkpoint words.

Includes maintainer fixups for latest-main timer-dependent CI and SMS status/test drift.

Thanks @shawnduggan.

Verification:
- `mise exec node@24.13.0 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.agents-core.config.ts src/agents/bash-tools.exec-foreground-failures.test.ts --maxWorkers=1`
- `mise exec node@24.16.0 -- node scripts/run-vitest.mjs run src/channels/draft-stream-loop.test.ts --maxWorkers=1`
- `mise exec node@24.16.0 -- node scripts/run-vitest.mjs run extensions/sms/src/channel.test.ts extensions/sms/src/inbound.test.ts extensions/sms/src/twilio.test.ts extensions/sms/src/gateway.test.ts --maxWorkers=1`
- `mise exec node@24.16.0 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway-server.config.ts src/gateway/server.agent.gateway-server-agent-b.test.ts --maxWorkers=1`
- `mise exec node@24.16.0 -- node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.extensions.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions-test.tsbuildinfo`
- `mise exec node@24.16.0 -- node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test.tsbuildinfo`
- `mise exec node@24.16.0 -- node scripts/run-oxlint-shards.mjs --threads=8`
- `git diff --check`
- GitHub Actions: run `26708853296` and required checks passed on `0c97217a9de501cb861fee731d5c008781da056c`.
2026-05-31 10:29:45 +01:00
Andy Ye
17c2e95334 fix(ui): prefer Talk source-reply final text
Fix Control UI Talk consults so an empty final chat event no longer forces the no-text realtime tool result when a later source-reply or delivery-mirror final contains the answer displayed in the UI.

Also makes agent.wait use the chat-side terminal snapshot while a same-runId chat.send is active, so lifecycle completion cannot beat chat post-dispatch/source-reply delivery.

Adds regression coverage for delayed source replies, agent.wait failure/timeout handling, the wait-before-source-reply race, gateway wait ordering, and punctuation-only skill searches.

Fixes #85275.

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-31 10:29:19 +01:00
Evan Newman
a0d2febe6b fix(scripts): timeout crabbox wrapper sanity checks
Add bounded timeouts for Crabbox wrapper sanity probes so a stale or hung selected binary cannot block the wrapper indefinitely. The wrapper now maps timed-out sanity probes to a deterministic failure and keeps provider/help parsing behavior intact.

Also add regression coverage for a binary whose `--version` probe hangs while `run --help` still responds.

Co-authored-by: Evan Newman <evanjames010101@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 10:29:12 +01:00
Peter Steinberger
70c8abdca1 refactor(telegram): keep topic thread mapping plugin-local
* refactor(telegram): keep topic thread mapping plugin-local

* fix(telegram): preserve native topic ids for username targets
2026-05-31 10:25:54 +01:00
Peter Steinberger
9e2bd8b2f7 fix(memory): fail open when embedding recall stalls
Preserve custom OpenAI-compatible memory embedding provider ids from #81170.
Fixes #47884.
Fixes #49524.
Refs #56532.

Co-authored-by: adone0 <vladyslav.yavorskyi@outlook.com>
2026-05-31 10:21:17 +01:00
Gio Della-Libera
2d3fa4832f feat(doctor): expose UI freshness health findings
Expose UI freshness doctor findings through the structured health contribution path so lint JSON and dry-run repair output include stale UI asset guidance.

Keep legacy positional repair filtering stable while excluding health checks that already own their structured repair output. Maintainer fixups also avoid stale UI warnings when git history cannot prove changed sources, and make the foreground timeout regression test deterministic.

Verification:
- Local: `git diff --check origin/main...HEAD`
- Local: `node_modules/.bin/oxfmt --check --threads=1 src/agents/bash-tools.exec-foreground-failures.test.ts src/commands/doctor-ui.test.ts src/commands/doctor-ui.ts src/flows/doctor-core-checks.ts src/flows/doctor-health-contributions.test.ts src/flows/doctor-health-contributions.ts src/flows/doctor-health-conversion-plan.ts`
- Local: `node_modules/.bin/oxlint --tsconfig config/tsconfig/oxlint.core.json src/agents/bash-tools.exec-foreground-failures.test.ts src/commands/doctor-ui.test.ts src/commands/doctor-ui.ts src/flows/doctor-core-checks.ts src/flows/doctor-health-contributions.test.ts src/flows/doctor-health-contributions.ts src/flows/doctor-health-conversion-plan.ts`
- Local: `node scripts/run-vitest.mjs src/commands/doctor-ui.test.ts src/flows/doctor-health-contributions.test.ts src/commands/doctor-lint.test.ts src/agents/bash-tools.exec-foreground-failures.test.ts --reporter=dot --pool=forks --testTimeout=30000 --hookTimeout=30000`
- Local: `GOMAXPROCS=4 node scripts/run-tsgo.mjs -p tsconfig.core.json --noEmit --incremental false --pretty false`
- Local: `GOMAXPROCS=4 node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json --noEmit --incremental false --pretty false`
- Local: `GOMAXPROCS=4 node scripts/run-tsgo.mjs -p tsconfig.extensions.json --noEmit --incremental false --pretty false`
- Autoreview: `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- GitHub Actions: CI `26708647282`, Real behavior proof `26708646476`, CodeQL `26708647258`, CodeQL Critical Quality `26708647230`, OpenGrep PR Diff `26708647214`, Workflow Sanity `26708647232`, Dependency Guard `26708646489`, ClawSweeper Dispatch `26708646475`, Labeler `26708646480`

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
2026-05-31 10:20:12 +01:00
Peter Steinberger
7606e1dd3d docs: expand MCP operator guide 2026-05-31 10:12:44 +01:00
github-actions[bot]
ef7854abbc chore(ui): refresh fa control ui locale 2026-05-31 09:12:28 +00:00
github-actions[bot]
53e063962d chore(ui): refresh nl control ui locale 2026-05-31 09:12:16 +00:00
github-actions[bot]
cef86c1748 chore(ui): refresh vi control ui locale 2026-05-31 09:11:52 +00:00
github-actions[bot]
ee39aa84b2 chore(ui): refresh pl control ui locale 2026-05-31 09:11:49 +00:00
github-actions[bot]
e93e2a0f18 chore(ui): refresh th control ui locale 2026-05-31 09:11:42 +00:00
github-actions[bot]
fce45a2178 chore(ui): refresh id control ui locale 2026-05-31 09:11:33 +00:00
github-actions[bot]
2a58d92655 chore(ui): refresh uk control ui locale 2026-05-31 09:10:59 +00:00
github-actions[bot]
b335018c3c chore(ui): refresh tr control ui locale 2026-05-31 09:10:54 +00:00
github-actions[bot]
80294a4f6b chore(ui): refresh ar control ui locale 2026-05-31 09:10:48 +00:00
github-actions[bot]
20ab73e7d4 chore(ui): refresh it control ui locale 2026-05-31 09:10:43 +00:00
github-actions[bot]
a041e393c1 chore(ui): refresh fr control ui locale 2026-05-31 09:10:15 +00:00
github-actions[bot]
2e0d191725 chore(ui): refresh ko control ui locale 2026-05-31 09:10:13 +00:00
github-actions[bot]
ec949a856e chore(ui): refresh ja-JP control ui locale 2026-05-31 09:10:03 +00:00
github-actions[bot]
0b9193c0b7 chore(ui): refresh es control ui locale 2026-05-31 09:09:55 +00:00
github-actions[bot]
aa56f592bb chore(ui): refresh pt-BR control ui locale 2026-05-31 09:09:24 +00:00
github-actions[bot]
10b4057c36 chore(ui): refresh zh-TW control ui locale 2026-05-31 09:09:22 +00:00
github-actions[bot]
ecef6ae626 chore(ui): refresh zh-CN control ui locale 2026-05-31 09:09:18 +00:00
github-actions[bot]
f456114b12 chore(ui): refresh de control ui locale 2026-05-31 09:09:13 +00:00
Peter Steinberger
617c658498 feat: improve MCP operator controls (#88536)
* feat: improve MCP operator controls

* test: stabilize draft stream loop background errors

* fix: prune empty MCP server enablement stubs

* fix: ignore disabled MCP overrides in doctor

* fix: keep MCP doctor saved-config warnings

* fix: redact malformed MCP URLs in UI

* fix: harden MCP UI command actions

* fix: allow MCP logout after auth removal
2026-05-31 10:06:55 +01:00
Peter Steinberger
3258338ec8 chore(lint): clean sms lint fallout 2026-05-31 10:04:48 +01:00
Peter Steinberger
3a4943ef87 fix(ci): repair sms channel checks 2026-05-31 05:02:18 -04:00
Vincent Koc
84b025eb62 fix(e2e): make plugin sweep wrappers executable 2026-05-31 10:42:50 +02:00
Jason O'Neal
a776de25e8 fix(auto-reply): redact secrets in config show output (#88496)
* fix(auto-reply): redact config show secrets

* fix(auto-reply): use schema redaction for config show

* fix(auto-reply): redact config set acknowledgements

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 09:36:24 +01:00
Peter Steinberger
b4aaca3365 fix(ci): repair copilot sdk drift 2026-05-31 04:34:54 -04:00
Peter Steinberger
f5eca3f84c chore(lint): enable object and reassignment rules 2026-05-31 09:32:52 +01:00
Peter Steinberger
ea11b8ad3d docs: expand SMS channel setup guide 2026-05-31 09:31:00 +01:00
Peter Steinberger
d4f78c9339 ci: harden Crabbox Testbox runs 2026-05-31 09:29:56 +01:00
Zee Zheng
15ae2deb30 fix(webchat): preserve refresh-visible history and composer state (#83992)
WebChat now stores/restores composer draft and queued sends across refresh, scoped by gateway/session/agent. It skips in-flight/steered sends, restores after agent scope hydration, waits for fresh idle session proof before draining restored sends, and backfills visible chat history when the raw tail contains silent/context entries.

Refs #83344

Co-authored-by: Zee Zheng <zheng.zuo0@gmail.com>
2026-05-31 09:23:58 +01:00
Vincent Koc
e6ce83487c fix(check): restore core typecheck 2026-05-31 10:23:33 +02:00
Peter Steinberger
3513e8bfd9 feat: add Twilio SMS channel
Add a bundled SMS channel backed by Twilio inbound webhooks and outbound text delivery.

Includes signed webhook validation, pairing/allowlist access, Messaging Service sender support, chunked plain-text SMS delivery, default target support, docs, config metadata, labeler updates, and focused SMS coverage.

Verification:
- pnpm exec tsgo -p extensions/sms/tsconfig.json --noEmit
- OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=/tmp/openclaw-vitest-sms-land-fix2 node scripts/run-vitest.mjs extensions/sms/src/phone.test.ts extensions/sms/src/accounts.test.ts extensions/sms/src/twilio.test.ts extensions/sms/src/inbound.test.ts extensions/sms/src/gateway.test.ts extensions/sms/src/channel.test.ts extensions/sms/src/send.test.ts extensions/sms/src/webhook.test.ts --reporter=verbose
- pnpm config:channels:check
- pnpm plugins:inventory:check
- git diff --check
- .agents/skills/autoreview/scripts/autoreview --mode local
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
2026-05-31 09:22:31 +01:00
Andy Ye
0f1767a26a fix(telegram): support media message edits
Fixes #86161.

Route Telegram media-message edits through the Telegram caption/reply-markup APIs instead of always calling `editMessageText`. Button-only edits now update reply markup, explicit captions use `editMessageCaption`, and text edits can fall back to caption edits when Telegram reports the message has no editable text.

Also documents the edit behavior, adds regression coverage, tightens timer-spy cleanup for the affected agents test lane, and removes a stale loader helper from the current base that broke core typecheck.

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-31 09:22:20 +01:00
Peter Steinberger
63a95930ca chore: remove stale unsafe assertion suppressions 2026-05-31 09:11:28 +01:00
Peter Steinberger
b81adc6202 fix(ci): keep unsafe assertion lint disabled 2026-05-31 04:09:48 -04:00
Peter Steinberger
0ac725278d Revert "fix(ci): annotate unsafe boundary casts"
This reverts commit 8a40f90f62.
2026-05-31 04:09:48 -04:00
Peter Steinberger
4471335d26 Revert "fix(ci): clean core unsafe assertions"
This reverts commit 88203c9b10.
2026-05-31 04:09:48 -04:00
Peter Steinberger
b78dd6a9ca test: remove channel test isolation hack
Remove isolate: true from the channel Vitest config and fix the leaking fake-timer/mock tests so the lane runs under the shared non-isolated runner. Verified with focused scoped-config/channel tests, the full channel Vitest config, git diff --check, and branch-mode autoreview.
2026-05-31 09:09:10 +01:00
Frank Yang
15c1511817 fix(agents): clear stale compaction bindings 2026-05-31 13:38:52 +05:30
Frank Yang
5e1e029d91 fix(agents): skip below-target CLI compaction failures 2026-05-31 13:38:52 +05:30
Peter Steinberger
48ccc50282 chore: update dependencies 2026-05-31 09:07:53 +01:00
Shakker
5a8bb1a7d2 docs: add Skill Workshop guide 2026-05-31 09:05:03 +01:00
Peter Steinberger
e1ad5f5170 docs: remove divider comments (#88115) 2026-05-31 09:03:18 +01:00
Peter Steinberger
88203c9b10 fix(ci): clean core unsafe assertions 2026-05-31 03:58:58 -04:00
Steven
b48c72cd19 fix(discord): deliver same-session channel replies
Deliver same-session channel replies directly while preserving stale-reply guards.

The fix bypasses the announce decider only when the requester and target are the same source channel, carries reply baselines into fire-and-forget follow-up delivery, and keeps history reads best-effort so timeout-zero sends still dispatch. It also includes focused regression coverage for delayed same-session replies, stale snapshots, retry timer caps, and the current strict-null/package-boundary blockers fixed while preparing the PR.
2026-05-31 08:52:01 +01:00
Peter Steinberger
8a40f90f62 fix(ci): annotate unsafe boundary casts 2026-05-31 03:51:18 -04:00
Peter Steinberger
ae651e7210 docs: add permission modes page 2026-05-31 08:47:02 +01:00
Peter Steinberger
4d95ae39d4 fix(ci): repair extension type drift 2026-05-31 03:40:32 -04:00
Peter Steinberger
e6782254e4 perf: avoid blocking gateway bind on control ui build 2026-05-31 08:36:30 +01:00
Peter Steinberger
59694e86d9 chore(lint): enable structured clone rules 2026-05-31 08:34:28 +01:00
Ayaan Zaidi
87664ed096 Fix iMessage startup watch replay (#88406)
* fix(imessage): start watch after existing local rows

* test(imessage): cover stale startup watch rows

* fix(imessage): keep watch startup tolerant of db probes

* fix(imessage): watermark default local watch startup

* fix(imessage): tolerate missing sqlite startup probe

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 08:33:22 +01:00
Peter Steinberger
1fd9fe2b33 fix(ci): isolate timer-sensitive tests 2026-05-31 03:30:37 -04:00
Peter Steinberger
122ae5db9e perf: lazy-load agent reply payload formatter 2026-05-31 08:29:32 +01:00
Peter Steinberger
930b371a2f refactor(telegram): persist plugin state in sqlite
Move Telegram plugin-local state from JSON sidecars into plugin-state SQLite. Keep legacy JSON handling in startup and doctor migration plans, with runtime state now reading and writing SQLite directly. Stabilize the channel Vitest lane by cleaning up typing timers and isolating that lane.
2026-05-31 08:28:53 +01:00
Peter Steinberger
b9fe0894a6 chore(lint): enable additional cleanup rules 2026-05-31 08:16:11 +01:00
Peter Steinberger
444562b3de chore: remove stale dead code 2026-05-31 03:04:25 -04:00
Peter Steinberger
ce547bfd44 fix(ci): stabilize ui paste and telegram types 2026-05-31 02:56:46 -04:00
Peter Steinberger
d4d7fdbc59 fix(ci): satisfy strict nullish guards 2026-05-31 02:50:24 -04:00
mochiexists
096bd13962 build(OpenClawKit): make ElevenLabsKit optional behind Talk trait
Adds a default-enabled SwiftPM Talk trait for OpenClawKit so chat-only consumers can opt out with traits: [] and avoid resolving ElevenLabsKit. Default traits preserve existing talk/TTS API and bundled app behavior; macOS CI now verifies the trait-off dependency graph and build.

Verification:
- CI at 85f00ebc04 passed macos-swift and Real behavior proof.
- Local Swift 6.3.2: trait-off dependency graph omitted ElevenLabsKit; full swift build with default traits disabled built through OpenClawChatUI; default dependency graph still included ElevenLabsKit; trait-off OpenClawKit target build passed.
- merge-tree against latest origin/main 4eba3e5d7d was clean.
- Current main already fails plugin-SDK declaration gates in unrelated TS files; reproduced locally with node scripts/run-tsgo.mjs -p tsconfig.plugin-sdk.dts.json --declaration true.

Thanks @mochiexists.

Co-authored-by: mochiexists <259077624+mochiexists@users.noreply.github.com>
Co-authored-by: atlascodesai <76924051+atlascodesai@users.noreply.github.com>
2026-05-31 07:40:35 +01:00
Peter Steinberger
4eba3e5d7d chore(lint): enable more readability rules 2026-05-31 07:38:33 +01:00
Peter Steinberger
9c3cf35e08 fix(ci): stop channel timers holding vitest open 2026-05-31 02:33:48 -04:00
Peter Steinberger
deb7bc6539 chore(lint): enable readability lint rules 2026-05-31 07:17:57 +01:00
Peter Steinberger
0211a3aa9f fix(ci): restore main validation 2026-05-31 02:13:10 -04:00
Peter Steinberger
ade6e7769b perf: prewarm gateway runtime plugins 2026-05-31 07:09:42 +01:00
guanbear
f1cb9f2f6a fix(slack): keep DM thread turns out of active steering
Keep Slack direct-message sessions stable while tracking routed Slack thread ids on active reply operations. Different top-level Slack DM threads from the same sender no longer steer into or block each other, while ordinary same-thread follow-ups and non-Slack direct-message behavior keep their existing semantics.

Verification:
- `git diff --check origin/main...FETCH_HEAD`
- `/Users/steipete/Projects/agent-scripts/skills/autoreview/scripts/autoreview --mode branch --base origin/main --output /tmp/pr85904-autoreview.txt --json-output /tmp/pr85904-autoreview.json`
- GitHub CI green for head `6703e166545bcb96c1a50de93a42446212cca9a7`, including Real behavior proof and auto-reply reply routing/dispatch shards.

Co-authored-by: guanbear <123guan@gmail.com>
2026-05-31 07:05:50 +01:00
Ted Li
667393be8f fix(commands): make /skill load workspace skills
Fixes #88056.

Reload workspace skill commands for `/skill <name>` when directive resolution supplied only an empty placeholder list, so the generic skill wrapper can invoke the same command-visible skills as direct slash commands.

Keep stale-message cutoff and empty-config channel suppression ahead of skill discovery and tool dispatch so suppressed `/skill` messages cannot trigger side-effecting skill tools.

Co-authored-by: Ted Li <tl2493@columbia.edu>
2026-05-31 07:04:59 +01:00
brokemac79
72c61bc123 fix(telegram): align DM topic session routing
Align Telegram proactive DM-topic outbound session routing with inbound reply routing.

The Telegram plugin now uses the chat-scoped DM-topic suffix for direct-topic outbound sessions, so cron/proactive sends and replies reuse the same session. Delivery metadata is kept as the numeric Telegram topic id so visible sends still target the correct private topic.

Refs #80212.
Thanks @brokemac79.

Verification:
- PR head d904115e4c
- GitHub CI/checks green on PR head; Real behavior proof passed; OpenGrep passed; CodeQL neutral/pass
- git diff --check origin/main...pr/88421 -- extensions/telegram/src/channel.ts extensions/telegram/src/session-route.test.ts
- git merge-tree $(git merge-base origin/main pr/88421) origin/main pr/88421
2026-05-31 06:54:51 +01:00
Peter Steinberger
b372af6b81 test: keep timeout clamp checks under one second 2026-05-31 06:51:35 +01:00
Peter Steinberger
04b68e8fa4 fix(shared): restore number coercion barrel 2026-05-31 06:51:34 +01:00
Peter Steinberger
9f99acf12d test: restore marketplace cleanup coverage 2026-05-31 06:51:34 +01:00
Peter Steinberger
23dac6c263 test: keep vitest cases under one second 2026-05-31 06:51:34 +01:00
Peter Steinberger
8a4679026c fix: clarify generated media reply prompts (#88458)
* fix: clarify generated media reply prompts

* fix: hide media compat aliases from message schema

* fix: hide message media url aliases

* test: refresh media prompt snapshots
2026-05-31 06:45:57 +01:00
Peter Steinberger
0303f3a8f0 fix(qa): clamp transport wait intervals 2026-05-31 01:37:46 -04:00
Peter Steinberger
bb680a845b fix(onboard): clamp gateway reachability polling 2026-05-31 01:37:46 -04:00
Peter Steinberger
5de0d873ed fix(qa): clamp gateway restart polling 2026-05-31 01:37:46 -04:00
Peter Steinberger
83597b7f95 fix(qa): clamp cron run poll intervals 2026-05-31 01:37:46 -04:00
Peter Steinberger
edd8aa2f4e fix(agents): clamp embedded run drain polling 2026-05-31 01:37:46 -04:00
Peter Steinberger
fab8d29d21 fix(feishu): clamp sequential queue timeouts 2026-05-31 01:37:46 -04:00
Peter Steinberger
7595d52e56 fix(auth): bound profile usage window expiries 2026-05-31 01:37:46 -04:00
Peter Steinberger
11c050d0d0 fix(agents): clamp session suspension TTLs 2026-05-31 01:37:46 -04:00
Peter Steinberger
764321d3d3 fix(channels): clamp draft stream throttles 2026-05-31 01:37:46 -04:00
Peter Steinberger
54a27f4e57 fix(gateway): clamp auth limiter durations 2026-05-31 01:37:46 -04:00
Peter Steinberger
84061c1f8e fix(memory): clamp batch timeout minutes 2026-05-31 01:37:46 -04:00
Peter Steinberger
5c38c0c76d fix(memory): clamp sync interval timers 2026-05-31 01:37:46 -04:00
Peter Steinberger
db94eac5c0 fix(auto-reply): clamp typing timers 2026-05-31 01:37:46 -04:00
Peter Steinberger
92f1d90e0f fix(channels): clamp typing timers 2026-05-31 01:37:46 -04:00
Peter Steinberger
a1d7a7536a fix(gateway): clamp auth limiter prune intervals 2026-05-31 01:37:46 -04:00
Peter Steinberger
287f531de6 fix(sqlite): clamp WAL checkpoint intervals 2026-05-31 01:37:46 -04:00
Peter Steinberger
d06e1b2c71 fix(gateway): bound health monitor intervals 2026-05-31 01:37:46 -04:00
Peter Steinberger
5b0036ffde fix(nostr): bound seen tracker capacity 2026-05-31 01:37:46 -04:00
Peter Steinberger
dca53afd53 fix(openai): convert realtime secret expiry 2026-05-31 01:37:46 -04:00
Peter Steinberger
604339ebf9 fix(clickclack): normalize reconnect intervals 2026-05-31 01:37:46 -04:00
Peter Steinberger
74c5548c0d fix(github-copilot): chunk device polling waits 2026-05-31 01:37:46 -04:00
Peter Steinberger
33c44626d2 fix(memory): bound embedding batch poll intervals 2026-05-31 01:37:46 -04:00
Peter Steinberger
68b5371fca fix(discord): bound transport activity timestamps 2026-05-31 01:37:46 -04:00
Peter Steinberger
9417b9cea8 fix(discord): bound ACP activity timestamps 2026-05-31 01:37:46 -04:00
Peter Steinberger
0c97922e23 fix(codex): bound synthesized hook start times 2026-05-31 01:37:46 -04:00
Peter Steinberger
2bdb2e8e02 fix(gateway-client): clamp readiness intervals 2026-05-31 01:37:46 -04:00
Peter Steinberger
984951f55c fix(gateway-client): clamp tick watchdog intervals 2026-05-31 01:37:46 -04:00
Peter Steinberger
59f96078b2 fix(sessions): bound lifecycle timestamps 2026-05-31 01:37:45 -04:00
Peter Steinberger
f0c0181b10 fix(sandbox): prune invalid registry timestamps 2026-05-31 01:37:45 -04:00
Peter Steinberger
84dec338e7 fix(sandbox): bound fs bridge mtimes 2026-05-31 01:37:45 -04:00
Peter Steinberger
66a4410095 fix(memory): bound session export timestamps 2026-05-31 01:37:45 -04:00
Peter Steinberger
b26f89213e fix(usage): bound minimax usage epochs 2026-05-31 01:37:45 -04:00
Peter Steinberger
ae4ddece91 fix(agents): bound model scan created timestamps 2026-05-31 01:37:45 -04:00
Peter Steinberger
9161db534e fix(zalouser): bound inbound timestamp normalization 2026-05-31 01:37:45 -04:00
Peter Steinberger
cae98c1daf fix(gateway): centralize plugin approval timeout bounds 2026-05-31 01:37:45 -04:00
Peter Steinberger
3d0dc15904 fix(codex): bound native hook cleanup grace 2026-05-31 01:37:45 -04:00
Peter Steinberger
f71df664c9 fix(synology-chat): bound rate limit windows 2026-05-31 01:37:45 -04:00
Peter Steinberger
4ca218246e fix(diffs): cap artifact ttl normalization 2026-05-31 01:37:45 -04:00
Peter Steinberger
11b5968534 fix(channels): bound thread binding lifecycle durations 2026-05-31 01:37:45 -04:00
Peter Steinberger
5e139e32dc fix(agents): centralize subagent timeout math 2026-05-31 01:37:45 -04:00
Peter Steinberger
620acafb15 fix(browser): bound tab cleanup timer config 2026-05-31 01:37:45 -04:00
Peter Steinberger
8247f824b9 fix(google-meet): bound timer config values 2026-05-31 01:37:45 -04:00
Peter Steinberger
c767b37e3b fix(discord): bound identify limiter clock skew 2026-05-31 01:37:45 -04:00
Peter Steinberger
21478cab93 fix(agents): clamp abortable sleep helper 2026-05-31 01:37:45 -04:00
Peter Steinberger
dbddf4093f fix(utils): clamp shared sleep timers 2026-05-31 01:37:45 -04:00
Peter Steinberger
1cf264c468 fix(infra): clamp abortable sleep timers 2026-05-31 01:37:45 -04:00
Peter Steinberger
9d866d8b2a fix(cli): clamp port wait timers 2026-05-31 01:37:45 -04:00
Peter Steinberger
0e3cc2e5ad fix(cli): clamp cron wait poll timers 2026-05-31 01:37:45 -04:00
Peter Steinberger
1670b970ee fix(gateway): clamp lock poll timers 2026-05-31 01:37:45 -04:00
Peter Steinberger
37fbc8cd8f fix(memory): clamp remote batch timers 2026-05-31 01:37:45 -04:00
Peter Steinberger
7eeea30d8c fix(minimax): clamp oauth poll interval 2026-05-31 01:37:45 -04:00
Peter Steinberger
cdab5fc16a fix(infra): clamp provider usage timeout 2026-05-31 01:37:45 -04:00
Peter Steinberger
aa42905354 fix(agents): clamp idle flush timeout 2026-05-31 01:37:45 -04:00
Peter Steinberger
fbee4d56c4 fix(web): clamp shared tool timeout 2026-05-31 01:37:45 -04:00
Peter Steinberger
ed59533574 fix(feishu): clamp abortable delay timers 2026-05-31 01:37:45 -04:00
Peter Steinberger
1f92a3e351 fix(mattermost): clamp probe timeout 2026-05-31 01:37:45 -04:00
Peter Steinberger
ac4ebc053a fix(cli): clamp progress delay timers 2026-05-31 01:37:45 -04:00
Peter Steinberger
9b2146775f fix(browser): clamp client action timeouts 2026-05-31 01:37:45 -04:00
Peter Steinberger
bc38a929aa fix(plugins): clamp hook timeouts 2026-05-31 01:37:44 -04:00
Peter Steinberger
8e12c6ea1f fix(plugin-sdk): clamp oauth callback timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
65167c9637 fix(tlon): clamp sse reconnect delays 2026-05-31 01:37:44 -04:00
Peter Steinberger
87eae7f811 fix(memory): clamp lancedb recall timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
b4e331fe81 fix(health): clamp probe timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
c8d458d13d fix(outbound): clamp message gateway timeouts 2026-05-31 01:37:44 -04:00
Peter Steinberger
2cbfb910f2 fix(commands): clamp gateway agent timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
1c1f42a74a fix(secrets): clamp provider timeouts 2026-05-31 01:37:44 -04:00
Peter Steinberger
3734ed6402 fix(agents): clamp compaction worker timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
5e11c85c0a fix(codex): clamp app-server timer config 2026-05-31 01:37:44 -04:00
Peter Steinberger
4135771adf fix(codex): clamp app-server watch timers 2026-05-31 01:37:44 -04:00
Peter Steinberger
58db38e088 fix(voice-call): clamp telephony tts timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
e500f401ea fix(voice-call): clamp pre-start stream timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
1ff52b2786 fix(voice-call): clamp continue poll timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
2b3f09659c fix(qa): clamp cli child timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
0d391bacf7 fix(qa): clamp bus wait timers 2026-05-31 01:37:44 -04:00
Peter Steinberger
85c8e7f89f fix(qa): clamp browser runtime timeouts 2026-05-31 01:37:44 -04:00
Peter Steinberger
1987d364b5 fix(qa): clamp web runtime timeouts 2026-05-31 01:37:44 -04:00
Peter Steinberger
971cb2d4bd fix(discord): clamp rest scheduler delay 2026-05-31 01:37:44 -04:00
Peter Steinberger
c66b21662d fix(discord): clamp rest request timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
6e318684c1 fix(discord): clamp component wait timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
89022023a3 fix(talk): clamp forced consult delay 2026-05-31 01:37:44 -04:00
Peter Steinberger
10e8426aa5 fix(hooks): clamp fire-and-forget timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
79d3083eb6 fix(agents): clamp catalog browse timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
95e2427189 fix(provider): clamp poll wait timers 2026-05-31 01:37:44 -04:00
Peter Steinberger
29a67f4d11 fix(media): clamp saved response idle timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
2d2ab6d480 fix(media): clamp response idle timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
cb79864cb9 fix(tts): clamp summarization timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
0530596eef fix(codex): clamp turn collector timeout 2026-05-31 01:37:44 -04:00
Peter Steinberger
417022864b fix(codex): clamp media turn timeout 2026-05-31 01:37:43 -04:00
Peter Steinberger
2833d4b347 fix(agents): clamp embedded run wait timers 2026-05-31 01:37:43 -04:00
Peter Steinberger
490a155f15 fix(auto-reply): clamp reply idle wait timers 2026-05-31 01:37:43 -04:00
Peter Steinberger
5f7217db2c fix(gateway): bound exec approval timers 2026-05-31 01:37:43 -04:00
Peter Steinberger
de1c4f8aec fix(infra): clamp restart deferral poll 2026-05-31 01:37:43 -04:00
Peter Steinberger
3a31b34151 fix(infra): clamp heartbeat wake delay 2026-05-31 01:37:43 -04:00
Peter Steinberger
8ef5b5ddba fix(auto-reply): bound session lifecycle expiries 2026-05-31 01:37:43 -04:00
Peter Steinberger
0adf3220b8 fix(auto-reply): bound auth label expiries 2026-05-31 01:37:43 -04:00
Peter Steinberger
edb2c498d5 fix(auth): bound profile unusable windows 2026-05-31 01:37:43 -04:00
Peter Steinberger
00831cf8ff fix(gateway): bound model auth expiry rollups 2026-05-31 01:37:43 -04:00
Peter Steinberger
b0ca5d7407 fix(auth): adopt main oauth over invalid local expiry 2026-05-31 01:37:43 -04:00
Peter Steinberger
c9311ef0a9 fix(auth): bound stored oauth replacement expiry 2026-05-31 01:37:43 -04:00
Peter Steinberger
1a4d2f7cca fix(auth): bound inherited oauth expiry 2026-05-31 01:37:43 -04:00
Peter Steinberger
9d31cbbd6a refactor(cron): split service timer helpers
Split cron timer helper concerns into focused service modules for agent watchdog cleanup, execution error text, failure alerts, task-run ledger handling, and wake dispatch.

Verification: focused cron Vitest, oxfmt check, git diff check, autoreview clean, and GitHub PR checks green.
2026-05-31 06:31:27 +01:00
Peter Steinberger
bbc4bee7a2 fix(heartbeat): advance stale scheduler deferrals
Fix stale heartbeat scheduler deferrals so disabled/non-retry skips and flood deferrals advance the due slot instead of rearming a 0 ms timer loop.

Fixes #79380.
Supersedes #79418.

Proof:
- pnpm test src/infra/heartbeat-runner.scheduler.test.ts -- --reporter=verbose
- pnpm check:changed via Testbox tbx_01ksxfavykc7qyve4ysnxg3smh
- autoreview clean
- GitHub CI green for 213003a854, including Real behavior proof
2026-05-31 06:26:44 +01:00
Vincent Koc
ef9e9bf6b9 fix(build): preserve fresh startup metadata across rebuilds 2026-05-31 07:16:13 +02:00
Peter Steinberger
51dee73a5d perf: cache log timestamp formatters 2026-05-31 06:09:22 +01:00
Peter Steinberger
b858d418aa fix(ui): localize tool error card label 2026-05-31 06:04:55 +01:00
Vincent Koc
e1a9817141 fix(e2e): preflight openai chat tools auth 2026-05-31 05:20:33 +02:00
Peter Steinberger
4dad7bd93b fix(release): tolerate npm README metadata lag 2026-05-31 02:49:06 +01:00
Vincent Koc
26913e60a4 fix(codex): preserve public OpenAI app-server provider 2026-05-31 03:39:03 +02:00
Shakker
5c5711f061 fix: keep tool card actions inline 2026-05-31 02:11:53 +01:00
Shakker
1e3542bbe7 fix: label collapsed tool cards by tool 2026-05-31 02:07:52 +01:00
Shakker
9a00d74044 fix: share skill workshop prompt with codex 2026-05-31 01:53:01 +01:00
Shakker
e9d01320d7 fix: isolate dev source plugin aliases 2026-05-31 01:47:11 +01:00
Shakker
ae800e160d fix: prefer source plugins in dev runs 2026-05-31 01:47:11 +01:00
3816 changed files with 131208 additions and 39167 deletions

View File

@@ -223,6 +223,21 @@ Read the JSON summary and the Testbox line. Useful fields:
- Actions run URL/id from the Testbox output
- `exitCode`
Use provider-backed cache volumes only for rebuildable caches, not secrets or
checkout state. On Blacksmith, Crabbox forwards them as sticky disks:
```sh
node scripts/crabbox-wrapper.mjs run \
--provider blacksmith-testbox \
--cache-volume pnpm-store=openclaw-node24-pnpm-lock:/tmp/openclaw-pnpm-store \
--timing-json \
-- \
corepack pnpm check:changed
```
The selected provider must advertise cache-volume support. If not, omit
`--cache-volume` and rely on kept-lease caches.
`blacksmith testbox list` may hide hydrating or ready boxes. Use:
```sh
@@ -590,7 +605,8 @@ Crabbox Blacksmith backend delegates setup to:
The hydration workflow owns checkout, Node/pnpm setup, dependency install,
secrets, ready marker, and keepalive. Crabbox owns dispatch, sync, SSH command
execution, timing, logs/results, and cleanup.
execution, timing, logs/results, cleanup, and cache-volume requests. Blacksmith
implements cache volumes as sticky disks.
Minimal Blacksmith-backed Crabbox run, from repo root:
@@ -685,6 +701,7 @@ crabbox events <run_id> --json
crabbox logs <run_id>
crabbox results <run_id>
crabbox cache stats --id <id-or-slug>
crabbox cache volumes
crabbox ssh --id <id-or-slug>
blacksmith testbox list
```

View File

@@ -187,11 +187,37 @@ gh pr view <number> --json additions,deletions,changedFiles \
## Read beyond the diff
- Review the surrounding code path, not just changed lines. Open the caller, callee, data contracts, adjacent tests, and owner module.
- Before any verdict, read enough code to fill this map: changed surface, runtime entry point, owner boundary, one caller, one callee, sibling implementations sharing the invariant, adjacent tests, current `main` behavior, and shipped/dependency/Codex contracts when relevant.
- For large-codebase PRs, sample enough related files to understand the runtime boundary before deciding. Default to more code reading when the change touches agents, gateway, plugins, auth, sessions, process, config, or provider/runtime seams.
- Compare the PR against current `origin/main` behavior. Check whether recent main already changed the same surface.
- Dependency-backed behavior: MUST read upstream docs/source/types before judging API use, defaults, output shapes, errors, timeouts, memory behavior, or compatibility. Do not assume dependency contracts from memory or PR text.
- Judge solution quality, not only correctness. Ask whether the PR is the clean owner-boundary fix or a wart/workaround that should be replaced by a small refactor, moved seam, contract change, or deletion of duplicate logic.
- Mention the main files read when the verdict depends on code-path evidence.
- If the user challenges the verdict or asks whether the idea is really good, resume code reading first. Do not defend, soften, or reverse the verdict until the missing caller/callee/sibling/dependency path is checked.
## Best-fix review loop
Every PR review must explicitly answer: "Is this the best fix, or only a plausible fix?"
Before verdict:
1. Reconstruct the bug, feature need, or behavior claim from issue/PR/proof.
2. Trace current behavior from entry point to failure or decision point.
3. Read touched files, callers, callees, owner modules, adjacent tests, and relevant docs.
4. Read sibling surfaces that should share the invariant or could be broken by a one-sided fix.
5. Compare against current `origin/main` and shipped behavior when regression/compat matters.
6. Inspect upstream dependency/Codex source or docs for dependency-backed behavior.
7. Identify at least one alternative fix location or shape, then reject it with evidence.
8. If any required path above is uninspected, keep reading or mark `Remaining uncertainty`; do not call the PR best, blocked, proof-sufficient, or merge-ready.
Review output must include:
- `Best-fix verdict:` best / acceptable mitigation / wrong layer / too narrow / too broad.
- `Alternatives considered:` 1-3 concrete alternatives and why rejected.
- `Code read:` compact list of main files/contracts checked.
- `Remaining uncertainty:` what was not proven.
If the best-fix answer is only "maybe", keep reading or state the missing evidence. Do not call proof sufficient until the best-fix judgment is explicit.
## Enforce the bug-fix evidence bar

View File

@@ -2,7 +2,7 @@
// Secret scanning alert handler for OpenClaw maintainers.
// Usage: node secret-scanning.mjs <command> [options]
import { execFileSync, spawnSync } from "node:child_process";
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
@@ -39,7 +39,9 @@ function gh(args, { json = true, allowFailure = false } = {}) {
stderr: proc.stderr,
};
}
if (!json) return proc.stdout;
if (!json) {
return proc.stdout;
}
try {
return JSON.parse(proc.stdout);
} catch {
@@ -70,7 +72,9 @@ export function loadBodyRedactionResult(locationType, resultFile) {
if (!resultFile) {
fail("Body notifications require a redaction result file from redact-body-if-needed");
}
if (!fs.existsSync(resultFile)) fail(`File not found: ${resultFile}`);
if (!fs.existsSync(resultFile)) {
fail(`File not found: ${resultFile}`);
}
const result = JSON.parse(fs.readFileSync(resultFile, "utf8"));
if (typeof result.notify_required !== "boolean") {
@@ -182,10 +186,11 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
failOnGraphQLFailure(gql, `Failed to fetch discussion #${discussionNumber}`);
const discussion = gql?.data?.repository?.discussion;
if (!discussion)
if (!discussion) {
fail(
`Discussion #${discussionNumber} not found — it may have been deleted. The alert cannot be processed via this skill.`,
);
}
discussionId = discussion.id;
@@ -205,15 +210,18 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
`Failed to fetch replies for discussion comment ${topLevelComment.id}`,
);
const replies = replyPage?.data?.node?.replies;
if (!replies)
if (!replies) {
fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);
}
reply = findDiscussionCommentNode(replies.nodes, discussionCommentDbId);
hasMoreReplies = replies.pageInfo.hasNextPage;
replyCursor = replies.pageInfo.endCursor;
}
if (reply) return { discussionId, comment: reply };
if (reply) {
return { discussionId, comment: reply };
}
}
hasNextPage = discussion.comments.pageInfo.hasNextPage;
@@ -241,7 +249,9 @@ function createDiscussionComment(discussionNodeId, body, replyToNodeId) {
* Fetch alert metadata + locations. Never exposes .secret.
*/
function cmdFetchAlert(alertNumber) {
if (!alertNumber) fail("Usage: fetch-alert <number>");
if (!alertNumber) {
fail("Usage: fetch-alert <number>");
}
const alert = gh(["api", `repos/${REPO}/secret-scanning/alerts/${alertNumber}?hide_secret=true`]);
@@ -280,17 +290,23 @@ function cmdFetchAlert(alertNumber) {
* Saves full body to a temp file. Prints metadata + file path to stdout.
*/
function cmdFetchContent(locationJson) {
if (!locationJson) fail("Usage: fetch-content '<location-json>'");
if (!locationJson) {
fail("Usage: fetch-content '<location-json>'");
}
const location = JSON.parse(locationJson);
const type = location.type;
const details = location.details;
if (type === "discussion_comment") {
const commentUrl = details.discussion_comment_url;
if (!commentUrl) fail("No discussion_comment_url in location details");
if (!commentUrl) {
fail("No discussion_comment_url in location details");
}
const urlMatch = commentUrl.match(/discussions\/(\d+)#discussioncomment-(\d+)/);
if (!urlMatch) fail(`Cannot parse discussion comment URL: ${commentUrl}`);
if (!urlMatch) {
fail(`Cannot parse discussion comment URL: ${commentUrl}`);
}
const discussionNumber = urlMatch[1];
const discussionCommentDbId = urlMatch[2];
@@ -298,10 +314,11 @@ function cmdFetchContent(locationJson) {
discussionNumber,
discussionCommentDbId,
);
if (!comment)
if (!comment) {
fail(
`Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`,
);
}
const bodyFile = tmpFile("body.md");
fs.writeFileSync(bodyFile, comment.body || "");
@@ -334,7 +351,9 @@ function cmdFetchContent(locationJson) {
details.issue_comment_url ||
details.pull_request_comment_url ||
details.pull_request_review_comment_url;
if (!commentUrl) fail(`No comment URL in location details`);
if (!commentUrl) {
fail(`No comment URL in location details`);
}
const comment = gh(["api", commentUrl]);
const bodyFile = tmpFile("body.md");
@@ -378,7 +397,9 @@ function cmdFetchContent(locationJson) {
);
} else if (type === "issue_body") {
const issueUrl = details.issue_body_url || details.issue_url;
if (!issueUrl) fail("No issue URL in location details");
if (!issueUrl) {
fail("No issue URL in location details");
}
const issue = gh(["api", issueUrl]);
const bodyFile = tmpFile("body.md");
@@ -414,7 +435,9 @@ function cmdFetchContent(locationJson) {
);
} else if (type === "pull_request_body") {
const prUrl = details.pull_request_body_url || details.pull_request_url;
if (!prUrl) fail("No PR URL in location details");
if (!prUrl) {
fail("No PR URL in location details");
}
const pr = gh(["api", prUrl]);
const bodyFile = tmpFile("body.md");
@@ -490,7 +513,9 @@ function cmdRedactBody(kind, number, bodyFile) {
if (!kind || !number || !bodyFile) {
fail("Usage: redact-body <issue|pr> <number> <redacted-body-file>");
}
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
if (!fs.existsSync(bodyFile)) {
fail(`File not found: ${bodyFile}`);
}
const endpoint =
kind === "pr" ? `repos/${REPO}/pulls/${number}` : `repos/${REPO}/issues/${number}`;
@@ -509,8 +534,12 @@ function cmdRedactBodyIfNeeded(kind, number, currentBodyFile, redactedBodyFile,
"Usage: redact-body-if-needed <issue|pr> <number> <current-body-file> <redacted-body-file> <result-file>",
);
}
if (!fs.existsSync(currentBodyFile)) fail(`File not found: ${currentBodyFile}`);
if (!fs.existsSync(redactedBodyFile)) fail(`File not found: ${redactedBodyFile}`);
if (!fs.existsSync(currentBodyFile)) {
fail(`File not found: ${currentBodyFile}`);
}
if (!fs.existsSync(redactedBodyFile)) {
fail(`File not found: ${redactedBodyFile}`);
}
const currentBody = fs.readFileSync(currentBodyFile, "utf8");
const redactedBody = fs.readFileSync(redactedBodyFile, "utf8");
@@ -541,7 +570,9 @@ function cmdRedactBodyIfNeeded(kind, number, currentBodyFile, redactedBodyFile,
* Delete a comment (and all its edit history).
*/
function cmdDeleteComment(commentId) {
if (!commentId) fail("Usage: delete-comment <comment-id>");
if (!commentId) {
fail("Usage: delete-comment <comment-id>");
}
gh(["api", `repos/${REPO}/issues/comments/${commentId}`, "-X", "DELETE"], { json: false });
console.log(JSON.stringify({ ok: true, deleted_comment_id: Number(commentId) }));
}
@@ -551,7 +582,9 @@ function cmdDeleteComment(commentId) {
* Delete a discussion comment via GraphQL (and all its edit history).
*/
function cmdDeleteDiscussionComment(nodeId) {
if (!nodeId) fail("Usage: delete-discussion-comment <node-id>");
if (!nodeId) {
fail("Usage: delete-discussion-comment <node-id>");
}
const result = ghGraphQL(
`mutation { deleteDiscussionComment(input: { id: "${nodeId}" }) { comment { id } } }`,
);
@@ -566,9 +599,12 @@ function cmdDeleteDiscussionComment(nodeId) {
* Create a new discussion comment via GraphQL.
*/
function cmdRecreateDiscussionComment(discussionNodeId, bodyFile, replyToNodeId) {
if (!discussionNodeId || !bodyFile)
if (!discussionNodeId || !bodyFile) {
fail("Usage: recreate-discussion-comment <discussion-node-id> <body-file> [reply-to-node-id]");
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
}
if (!fs.existsSync(bodyFile)) {
fail(`File not found: ${bodyFile}`);
}
const body = fs.readFileSync(bodyFile, "utf8");
const newComment = createDiscussionComment(discussionNodeId, body, replyToNodeId);
@@ -586,8 +622,12 @@ function cmdRecreateDiscussionComment(discussionNodeId, bodyFile, replyToNodeId)
* Create a new comment from a file.
*/
function cmdRecreateComment(issueNumber, bodyFile) {
if (!issueNumber || !bodyFile) fail("Usage: recreate-comment <issue-number> <body-file>");
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
if (!issueNumber || !bodyFile) {
fail("Usage: recreate-comment <issue-number> <body-file>");
}
if (!fs.existsSync(bodyFile)) {
fail(`File not found: ${bodyFile}`);
}
const result = gh([
"api",
@@ -715,7 +755,9 @@ function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) {
* Close a secret scanning alert.
*/
function cmdResolve(alertNumber, resolution, comment) {
if (!alertNumber) fail("Usage: resolve <alert-number> [resolution] [comment]");
if (!alertNumber) {
fail("Usage: resolve <alert-number> [resolution] [comment]");
}
const res = resolution || "revoked";
const resComment = comment || "Content redacted and author notified to rotate credentials.";
@@ -773,8 +815,12 @@ function cmdListOpen() {
* Print a formatted summary table from a JSON results file.
*/
function cmdSummary(jsonFile) {
if (!jsonFile) fail("Usage: summary <json-file>");
if (!fs.existsSync(jsonFile)) fail(`File not found: ${jsonFile}`);
if (!jsonFile) {
fail("Usage: summary <json-file>");
}
if (!fs.existsSync(jsonFile)) {
fail(`File not found: ${jsonFile}`);
}
const results = JSON.parse(fs.readFileSync(jsonFile, "utf8"));
const lines = [];

View File

@@ -19,7 +19,7 @@ or validating a change without wasting hours.
Prove the touched surface first. Do not reflexively run the whole suite.
1. Inspect the diff and classify the touched surface:
- normal source checkout, source change: `pnpm changed:lanes --json`, then `pnpm check:changed`
- normal source checkout, source change: `pnpm changed:lanes --json`, then `pnpm check:changed` (delegates to Crabbox/Testbox)
- normal source checkout, tests only: `pnpm test:changed`
- normal source checkout, one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
- Codex worktree or linked/sparse checkout, one/few explicit files: `node scripts/run-vitest.mjs <path-or-filter>`
@@ -27,7 +27,7 @@ Prove the touched surface first. Do not reflexively run the whole suite.
use the Crabbox wrapper with the provider that matches the proof surface.
For maintainer heavy `pnpm` gates, that is usually delegated Blacksmith
Testbox through Crabbox, e.g. `node scripts/crabbox-wrapper.mjs run
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
--provider blacksmith-testbox ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed`. For direct AWS
Crabbox proof, omit `--provider` and let `.crabbox.yaml` choose AWS.
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
@@ -66,7 +66,7 @@ scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
```bash
pnpm changed:lanes --json
pnpm check:changed # changed typecheck/lint/guards; no Vitest
pnpm check:changed # Crabbox/Testbox changed typecheck/lint/guards; no Vitest
pnpm test:changed # cheap smart changed Vitest targets
pnpm verify # full check, then full Vitest
OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed

View File

@@ -4,11 +4,11 @@ profile: openclaw-check
provider: azure
class: standard
capacity:
market: spot
market: on-demand
strategy: most-available
# Fail closed instead of silently falling back to on-demand while the
# Azure-backed billing account is the default runner path.
fallback: spot-only
# The Azure-backed billing account carries the OpenClaw runner credits; use
# explicit on-demand capacity instead of low-priority spot, whose regional
# quota is too small for broad maintainer proof or parallel Crabbox lanes.
hints: true
actions:
workflow: .github/workflows/crabbox-hydrate.yml
@@ -28,11 +28,30 @@ blacksmith:
workflow: .github/workflows/ci-check-testbox.yml
job: check
ref: main
cache:
pnpm: true
npm: true
git: true
volumes:
- name: pnpm
key: openclaw-linux-node24-pnpm
path: /var/cache/crabbox/pnpm
sizeGB: 80
required: false
- name: npm
key: openclaw-linux-node24-npm
path: /var/cache/crabbox/npm
sizeGB: 40
required: false
aws:
# AWS-specific overrides still pin direct `--provider aws` runs without
# leaking AWS region names into the Azure default capacity fallback list.
region: eu-west-1
rootGB: 400
azure:
# The OpenClaw Azure subscription is reliable in eastus2; eastus rejects the
# same SKUs and can stall provisioning.
location: eastus2
sync:
delete: true
checksum: false
@@ -52,4 +71,64 @@ env:
- OPENCLAW_*
ssh:
user: crabbox
port: "2222"
# Azure coordinator leases expose SSH on 22. The run wrapper can fall back
# from 2222, but `crabbox job run` hydrates via the configured port directly.
port: "22"
jobs:
prewarm:
provider: azure
target: linux
class: standard
type: Standard_D4ads_v6
market: on-demand
idleTimeout: 90m
hydrate:
actions: true
waitTimeout: 20m
actions:
workflow: .github/workflows/crabbox-hydrate.yml
job: hydrate
ref: main
noSync: true
shell: true
command: "true"
stop: never
changed:
provider: azure
target: linux
class: standard
type: Standard_D4ads_v6
market: on-demand
idleTimeout: 90m
hydrate:
actions: true
waitTimeout: 20m
actions:
workflow: .github/workflows/crabbox-hydrate.yml
job: hydrate
ref: main
shell: true
command: |
set -euo pipefail
if ! git status --short >/dev/null 2>&1; then
rm -rf .git
git init -q
git add -A
if ! git diff --cached --quiet; then
git -c user.name=OpenClaw -c user.email=ci@openclaw.local commit -q --no-gpg-sign -m remote-check-tree
fi
fi
env CI=1 corepack pnpm check --timed
stop: always
testbox-changed:
provider: blacksmith-testbox
target: linux
idleTimeout: 90m
hydrate:
actions: false
actions:
workflow: .github/workflows/ci-check-testbox.yml
job: check
ref: main
command: env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 CI=1 corepack pnpm check:changed
stop: always

View File

@@ -128,6 +128,7 @@ runs:
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
mkdir -p "$PNPM_CONFIG_MODULES_DIR"
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
export NODE_PATH="$PNPM_CONFIG_MODULES_DIR${NODE_PATH:+:$NODE_PATH}"
fi
pnpm "${install_args[@]}" || pnpm "${install_args[@]}"
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then

5
.github/labeler.yml vendored
View File

@@ -132,6 +132,11 @@
- any-glob-to-any-file:
- "extensions/slack/**"
- "docs/channels/slack.md"
"channel: sms":
- changed-files:
- any-glob-to-any-file:
- "extensions/sms/**"
- "docs/channels/sms.md"
"channel: synology-chat":
- changed-files:
- any-glob-to-any-file:

View File

@@ -15,6 +15,8 @@ permissions:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
jobs:
check:
@@ -137,3 +139,139 @@ jobs:
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
check-arm:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
permissions:
contents: read
name: "check-arm"
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
timeout-minutes: 120
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
with:
testbox_id: ${{ inputs.testbox_id }}
- name: Verify ARM runner
shell: bash
run: |
set -euo pipefail
runner_arch="$(uname -m)"
echo "check-arm runner architecture: ${runner_arch}"
case "$runner_arch" in
aarch64 | arm64)
;;
*)
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
exit 1
;;
esac
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
if [[ -z "$CHECKOUT_TOKEN" ]]; then
echo "checkout token is missing" >&2
exit 1
fi
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git config --global --add safe.directory "$workdir"
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
echo "checkout attempt ${attempt}/5 succeeded"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Prepare Testbox shell
shell: bash
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=10s 30s git \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"
PNPM
sudo chmod 0755 /usr/local/bin/pnpm
- name: Hydrate Testbox provider env helper
shell: bash
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -1202,6 +1202,9 @@ jobs:
- check_name: check-guards
task: guards
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-shrinkwrap
task: shrinkwrap
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-prod-types
task: prod-types
runner: blacksmith-4vcpu-ubuntu-2404
@@ -1277,7 +1280,6 @@ jobs:
pnpm tool-display:check
pnpm check:host-env-policy:swift
pnpm dup:check:coverage
pnpm deps:shrinkwrap:check
pnpm deps:patches:check
pnpm lint:webhook:no-low-level-body-read
pnpm lint:auth:no-pairing-store-group
@@ -1286,6 +1288,9 @@ jobs:
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:plugin-sdk:strict-smoke
;;
shrinkwrap)
pnpm deps:shrinkwrap:check
;;
prod-types)
pnpm tsgo:prod
;;
@@ -1404,7 +1409,7 @@ jobs:
packages/plugin-sdk/dist
extensions/*/dist/.boundary-tsc.tsbuildinfo
extensions/*/dist/.boundary-tsc.stamp
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/plugins/types.ts', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-extension-package-boundary-v1-
@@ -1972,6 +1977,21 @@ jobs:
done
exit 1
- name: OpenClawKit Talk-trait opt-out (no ElevenLabsKit when default traits disabled)
run: |
set -euo pipefail
# Guard: chat-only consumers build OpenClawKit with the Talk trait
# disabled and must NOT link ElevenLabsKit. Assert that future sources
# under OpenClawKit cannot silently reintroduce an unconditional
# ElevenLabsKit dependency while the manifest still looks correct.
deps="$(swift package --package-path apps/shared/OpenClawKit show-dependencies --disable-default-traits)"
echo "$deps"
if grep -qi 'elevenlabs' <<<"$deps"; then
echo "::error::ElevenLabsKit resolved with the Talk trait disabled; keep it gated behind the Talk trait."
exit 1
fi
swift build --package-path apps/shared/OpenClawKit --target OpenClawKit --disable-default-traits
- name: Swift test
run: |
set -euo pipefail

View File

@@ -123,6 +123,7 @@ jobs:
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
mkdir -p "$PNPM_CONFIG_MODULES_DIR"
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
export NODE_PATH="$PNPM_CONFIG_MODULES_DIR${NODE_PATH:+:$NODE_PATH}"
fi
pnpm "${install_args[@]}" || pnpm "${install_args[@]}"
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then

View File

@@ -51,7 +51,8 @@ jobs:
# so this source workflow can stay focused on OIDC publish only.
preflight_openclaw_npm:
if: ${{ inputs.preflight_only }}
runs-on: ubuntu-latest
# Preflight builds the full release package before publish; ubuntu-latest can OOM in tsdown.
runs-on: blacksmith-16vcpu-ubuntu-2404
permissions:
contents: read
steps:
@@ -256,7 +257,8 @@ jobs:
return -1;
}
for (let start = input.indexOf("["); start !== -1; start = input.indexOf("[", start + 1)) {
for (const match of input.matchAll(/\[/g)) {
const start = match.index;
const end = arrayEndFrom(start);
if (end === -1) {
continue;

View File

@@ -903,7 +903,7 @@ jobs:
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
continue-on-error: true
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
timeout-minutes: 45
permissions:
contents: read
env:
@@ -1075,7 +1075,7 @@ jobs:
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_matrix_enabled == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read

View File

@@ -43,4 +43,4 @@ jobs:
published_upgrade_survivor_baselines: ${{ inputs.baselines }}
published_upgrade_survivor_scenarios: ${{ inputs.scenarios }}
telegram_mode: none
secrets: inherit
secrets: inherit # zizmor: ignore[secrets-inherit] Maintainer-dispatched package acceptance lane intentionally forwards its declared live-test secret matrix.

View File

@@ -61,12 +61,14 @@ jobs:
submodules: false
- name: Probe native Windows
env:
TARGET_REF: ${{ inputs.target_ref || github.ref }}
run: |
$ErrorActionPreference = "Stop"
Write-Host "runner=$env:RUNNER_NAME"
Write-Host "machine=$env:COMPUTERNAME"
Write-Host "workspace=$env:GITHUB_WORKSPACE"
Write-Host "target_ref=${{ inputs.target_ref || github.ref }}"
Write-Host "target_ref=$env:TARGET_REF"
Write-Host ("os=" + [System.Environment]::OSVersion.VersionString)
Write-Host ("arch=" + [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)
Write-Host ("powershell=" + $PSVersionTable.PSVersion.ToString())

View File

@@ -84,6 +84,65 @@ jobs:
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Prepare trusted workflow audit configs
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
trusted_zizmor_config="$RUNNER_TEMP/zizmor-base.yml"
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+${BASE_SHA}:refs/remotes/origin/security-base" ||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
fi
if git cat-file -e "${BASE_SHA}:.pre-commit-config.yaml" 2>/dev/null; then
git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config"
elif git show "refs/remotes/origin/${BASE_REF}:.pre-commit-config.yaml" \
> "$trusted_config" 2>/dev/null; then
echo "Base SHA ${BASE_SHA} does not expose .pre-commit-config.yaml; using origin/${BASE_REF} instead."
else
echo "::error title=trusted pre-commit config unavailable::Could not read .pre-commit-config.yaml from ${BASE_SHA} or origin/${BASE_REF}."
exit 1
fi
if git cat-file -e "${BASE_SHA}:.github/zizmor.yml" 2>/dev/null; then
git show "${BASE_SHA}:.github/zizmor.yml" > "$trusted_zizmor_config"
elif git show "refs/remotes/origin/${BASE_REF}:.github/zizmor.yml" \
> "$trusted_zizmor_config" 2>/dev/null; then
echo "Base SHA ${BASE_SHA} does not expose .github/zizmor.yml; using origin/${BASE_REF} instead."
else
echo "::error title=trusted zizmor config unavailable::Could not read .github/zizmor.yml from ${BASE_SHA} or origin/${BASE_REF}."
exit 1
fi
python3 - "$trusted_config" "$trusted_zizmor_config" <<'PY'
from pathlib import Path
import sys
config_path = Path(sys.argv[1])
zizmor_config_path = sys.argv[2]
text = config_path.read_text()
if ".github/zizmor.yml" not in text:
raise SystemExit("trusted pre-commit config does not reference .github/zizmor.yml")
config_path.write_text(text.replace(".github/zizmor.yml", zizmor_config_path))
PY
echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"
- name: Install pre-commit
run: python -m pip install --disable-pip-version-check pre-commit==4.2.0
- name: Install actionlint
shell: bash
run: |
@@ -103,6 +162,15 @@ jobs:
- name: Lint workflows
run: actionlint
- name: Audit all workflows with zizmor
shell: bash
run: |
set -euo pipefail
mapfile -t workflow_files < <(
find .github/workflows -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) | sort
)
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
- name: Disallow direct inputs interpolation in composite run blocks
run: python3 scripts/check-composite-action-input-interpolation.py

1
.gitignore vendored
View File

@@ -42,6 +42,7 @@ apps/macos-mlx-tts/.build/
apps/shared/MoltbotKit/.build/
apps/shared/OpenClawKit/.build/
apps/shared/*/.build/
packages/*/dist/
apps/shared/OpenClawKit/Package.resolved
**/ModuleCache/
bin/

View File

@@ -20,34 +20,46 @@
"eslint/no-multi-str": "error",
"eslint/no-new": "error",
"eslint/no-object-constructor": "error",
"eslint/no-param-reassign": "error",
"eslint/no-proto": "error",
"eslint/no-promise-executor-return": "error",
"eslint/no-regex-spaces": "error",
"eslint/no-return-assign": "error",
"eslint/no-sequences": "error",
"eslint/no-self-compare": "error",
"eslint/no-shadow": "off",
"eslint/no-shadow": "error",
"eslint/no-implicit-coercion": "error",
"eslint/no-var": "error",
"eslint/no-useless-call": "error",
"eslint/no-useless-computed-key": "error",
"eslint/no-useless-concat": "error",
"eslint/no-useless-constructor": "error",
"eslint/no-unused-vars": "off",
"eslint/no-useless-rename": "error",
"eslint/no-useless-return": "error",
"eslint/no-useless-assignment": "error",
"eslint/no-unused-vars": "error",
"eslint/no-warning-comments": "error",
"eslint/no-unmodified-loop-condition": "error",
"eslint/no-new-wrappers": "error",
"eslint/no-else-return": "error",
"eslint/no-lonely-if": "error",
"eslint/no-case-declarations": "error",
"eslint/default-case-last": "error",
"eslint/default-param-last": "error",
"eslint/prefer-exponentiation-operator": "error",
"eslint/prefer-const": "error",
"eslint/prefer-numeric-literals": "error",
"eslint/prefer-object-has-own": "error",
"eslint/object-shorthand": "error",
"eslint/prefer-rest-params": "error",
"eslint/prefer-spread": "error",
"eslint/radix": "error",
"eslint/unicode-bom": "error",
"eslint/yoda": "error",
"import/no-absolute-path": "error",
"import/first": "error",
"import/no-empty-named-blocks": "error",
"import/no-duplicates": "error",
"import/no-self-import": "error",
"node/no-exports-assign": "error",
"eslint-plugin-unicorn/prefer-set-size": "error",
@@ -66,7 +78,10 @@
"typescript/no-empty-object-type": ["error", { "allowInterfaces": "with-single-extends" }],
"typescript/no-explicit-any": "error",
"typescript/no-extraneous-class": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-meaningless-void-operator": "error",
"typescript/no-misused-promises": "error",
"typescript/no-inferrable-types": "error",
"typescript/no-non-null-asserted-nullish-coalescing": "error",
"typescript/no-unnecessary-qualifier": "error",
"typescript/no-unnecessary-type-assertion": "error",
@@ -86,6 +101,7 @@
"typescript/prefer-namespace-keyword": "error",
"typescript/prefer-return-this-type": "error",
"typescript/prefer-find": "error",
"typescript/prefer-for-of": "error",
"typescript/prefer-function-type": "error",
"typescript/prefer-includes": "error",
"typescript/prefer-reduce-type-parameter": "error",
@@ -106,14 +122,17 @@
"unicorn/no-new-buffer": "error",
"unicorn/no-thenable": "error",
"unicorn/no-typeof-undefined": "error",
"unicorn/no-unreadable-array-destructuring": "error",
"unicorn/no-unnecessary-array-flat-depth": "error",
"unicorn/no-unnecessary-array-splice-count": "error",
"unicorn/no-unnecessary-slice-end": "error",
"unicorn/no-useless-error-capture-stack-trace": "error",
"unicorn/no-useless-promise-resolve-reject": "error",
"unicorn/no-zero-fractions": "error",
"unicorn/prefer-date-now": "error",
"unicorn/prefer-dom-node-text-content": "error",
"unicorn/prefer-keyboard-event-key": "error",
"unicorn/prefer-array-flat": "error",
"unicorn/prefer-array-some": "error",
"unicorn/prefer-math-min-max": "error",
"unicorn/prefer-node-protocol": "error",
@@ -123,6 +142,8 @@
"unicorn/prefer-prototype-methods": "error",
"unicorn/prefer-regexp-test": "error",
"unicorn/prefer-set-size": "error",
"unicorn/prefer-set-has": "error",
"unicorn/prefer-structured-clone": "error",
"unicorn/prefer-string-starts-ends-with": "error",
"unicorn/prefer-string-slice": "error",
"unicorn/require-array-join-separator": "error",
@@ -183,6 +204,7 @@
"docs/_layouts/",
"extensions/diffs/assets/viewer-runtime.js",
"extensions/diffs-language-pack/assets/viewer-runtime.js",
"extensions/canvas/src/host/a2ui/a2ui.bundle.js",
"node_modules/",
"patches/",
"pnpm-lock.yaml",
@@ -199,13 +221,6 @@
"**/node_modules/**"
],
"overrides": [
{
"files": ["src/security/**"],
"rules": {
"eslint/no-warning-comments": "off",
"oxc/no-map-spread": "off"
}
},
{
"files": [
"**/*.test.ts",
@@ -217,9 +232,7 @@
"**/*test-support.ts"
],
"rules": {
"typescript/no-explicit-any": "off",
"typescript/unbound-method": "off",
"eslint/no-unsafe-optional-chaining": "off"
"typescript/no-explicit-any": "off"
}
}
]

View File

@@ -9,7 +9,11 @@ Skills own workflows; root owns hard policy and routing.
- Replies: repo-root refs only: `extensions/telegram/src/index.ts:80`. No absolute paths, no `~/`.
- Docs/user-visible work: `pnpm docs:list`, then read relevant docs only.
- Fix/triage answers need source, tests, current/shipped behavior, and dependency contract proof.
- Reviews/answers: high confidence required. Default to exhaustive relevant codebase search/read, including owners, callers, siblings, tests, docs, and upstream/dependency contracts before verdict. Diff-only review is insufficient.
- Review default: read the whole changed function/module plus callers, callees, sibling implementations, adjacent tests, scoped docs, and dependency/Codex contracts before saying `good`, `bad`, `best fix`, `proof sufficient`, or posting a comment. If challenged, keep reading first; do not defend the earlier verdict until the missing path is checked.
- Dependency-touching work: direct dependency inspection is mandatory when feasible; do not rely on assumptions, wrappers, or memory. Most dependencies are OSS, so read their source/docs/types. Codex-related work has a hard gate: the acting agent must personally inspect sibling `../codex` source for the exact protocol/runtime behavior before any verdict, comment, approval, merge recommendation, code change, or `proof sufficient` claim. If missing, clone `https://github.com/openai/codex.git` there first. Subagent reports, PR text, OpenClaw wrappers, generated schemas, memory, and prior bot reviews do not satisfy this gate. No direct `../codex` check means no Codex verdict. Cite Codex files/lines checked in final/review/comment.
- Dependency-backed behavior: read upstream docs/source/types first. No API/default/error/timing guesses.
- External API work: live test required. Google/search for additional proof. Prefer official docs/source/types; cite current proof. No memory-only API claims.
- Live-verify when feasible. Never print secrets.
- Missing deps: `pnpm install`, retry once, then report first actionable error.
- CODEOWNERS: maint/refactor/tests ok. Larger behavior/product/security/ownership: owner ask/review.
@@ -26,6 +30,8 @@ Skills own workflows; root owns hard policy and routing.
- Plugin APIs, provider routing, auth/session state, persisted preferences, config loading, config/default additions, migrations, setup, startup checks, and fallback behavior are compatibility/upgrade-sensitive. Treat config breaks, new config/default surfaces, removed fallbacks, fail-closed changes, stricter validation, or new operator action as merge risk even with green CI when they can affect existing users, upgrades, provider/plugin behavior, or maintainer operations.
- For PRs that add, remove, or change config/default surfaces with possible compatibility, upgrade, provider/plugin, operator, setup, startup, or fallback impact, ClawSweeper review should emit a `reviewMetrics` entry when practical. The metric should name the count and direction of the changes, such as added, changed, or removed config/default surfaces, and explain why the metric matters before merge. When the metric indicates concrete merge risk, also surface the concern in `risks`, use `mergeRiskLabels` when the risk matches the label rubric, make `bestSolution` name the desired pre-merge state, and ensure `labelJustifications` explain the specific reason rather than restating the label.
- Review whole decision surfaces, not only the touched runtime, provider, channel, harness, plugin seam, or context path. Check sibling Codex/Pi-style runtimes, provider/model routing, channel delivery, gateway/protocol, plugin SDK, and context-management paths when relevant.
- Every PR review must explicitly ask whether the PR is the best fix, not merely a plausible fix. Verdicts need a best-fix judgment backed by enough code reading to compare owner boundaries, callers, siblings, tests, docs, current `main`, shipped behavior when relevant, and dependency/Codex contracts when involved.
- Before a PR verdict, build a small evidence map: changed surface, entry point, owner boundary, at least one caller and callee, sibling surfaces that share the invariant, existing tests, and current `main` behavior. If any cell is missing, say the gap instead of concluding.
- One-sided fixes need sibling-surface proof, an explanation for why siblings are unaffected, or explicit follow-up work.
- Changelog findings: see Docs / Changelog.
- Public ClawSweeper comments prefer `https://docs.openclaw.ai/...` when a public docs page exists; structured evidence still cites repo files, lines, SHAs.
@@ -57,12 +63,25 @@ Skills own workflows; root owns hard policy and routing.
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
- Runtime reads canonical config only. No silent compat for old/malformed config keys. If a config change invalidates existing files, add a matching `openclaw doctor --fix` migration. Core/auth config repairs live in core doctor; plugin-owned config repairs live in that plugin's doctor contract (`legacyConfigRules` / `normalizeCompatibilityConfig`).
- OpenAI Codex is folded into `openai`. No new/live `openai-codex` provider/plugin/auth/model routes; treat them as legacy input only. Runtime/setup/auth/catalog use `openai` + `openai/*`; doctor/migrations repair stale `openai-codex/*` profiles/metadata.
- Config/env surface bar is high; `openclaw.json` and environment variables are already large. Before adding a config option or env var, first prove existing product behavior, provider selection, defaults, or doctor migration cannot solve it. Prefer removing or consolidating config/env options when touching these surfaces. Core supports only the latest config shape; `openclaw doctor --fix` migrates older shipped shapes into the current one.
- CLI setup flows are public API when external docs, installers, or integrations can copy them. Changes to `openclaw onboard`, `openclaw configure`, their documented flags, non-interactive behavior, or generated config shape are compatibility-sensitive API contract changes; prefer additive flags/aliases, deprecation windows, and backward-preserving migrations over breaking existing snippets.
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
- Fix observed local failures with generic product rules; do not hardcode names, ids, log phrases, or user examples in prod code unless they are an explicit contract.
- Tests may use observed examples, but prod literals need a short contract reason.
- Compatibility is opt-in. "Shipped" means reachable from a release Git tag; main/GitHub/PR/unreleased code is not shipped.
- Refactor default: one canonical path. Delete the old path unless user explicitly wants compat or the shipped public contract is obvious and cited.
- Core runtime consumes only current canonical shapes/config/data. Legacy or retired shapes normalize only in doctor/migration code before runtime; no runtime shims, aliases, or fallback readers.
- State/storage migrations are database-first. Runtime reads/writes the canonical store only. Old file stores, sidecars, aliases, and fallback readers belong in `openclaw doctor --fix` migration code only, never steady-state runtime.
- Storage default: SQLite only. Do not add JSON/JSONL/TXT/sidecar files for OpenClaw-owned runtime state, caches, queues, registries, indexes, cursors, checkpoints, or plugin scratch data.
- SQLite runtime access uses Kysely helpers, not raw SQL statement strings, except schema DDL, migrations, low-level DB bootstrap, or narrowly justified SQLite primitives.
- Use the shared state DB (`state/openclaw.sqlite`) for global runtime state and plugin KV data. Use the per-agent DB (`agents/<agentId>/agent/openclaw-agent.sqlite`) for agent-scoped state/cache. Use a dedicated SQLite DB only when schema, volume, or lifecycle clearly does not fit those stores.
- Legacy state/cache files are migration debt. When touching code that reads/writes them, prefer moving the data into SQLite or calling out the refactor follow-up; do not add parallel file paths.
- File storage must be a named product artifact: import/export, user attachment, log, backup, or external tool contract. If it is app state or cache, it belongs in SQLite.
- Before adding any path under state dirs, choose one: shared state DB, plugin KV, agent DB, or dedicated SQLite schema. If none fits, design the SQLite owner/schema first.
- Cache/transient state gets no compat migration unless a shipped user contract is cited. Prefer delete/drop/rebuild over import. If old state can be lost without user-visible data loss, remove the old path entirely.
- Persistent user state gets one migration owner. Doctor migrates, verifies, and then runtime assumes the new shape. No dual-write, read-through fallback, lazy import, or "if SQLite fails use JSON" branches.
- Fallback is a product decision, not an implementation convenience. Before adding one, name the shipped contract, failure mode, removal plan, and why doctor cannot solve it. Otherwise delete it.
- Keep old behavior only for an explicit public API/config/plugin SDK/data contract, tagged upgrade path, security/migration boundary, dependency contract, or observed prod state.
- If unsure, ask before preserving compat. Do not keep aliases, shims, fallback stacks, stale names, or obsolete tests just in case.
- Tests alone do not make internals contracts. If compat stays, name the contract and migration/removal plan in code, test, or PR.
@@ -72,6 +91,9 @@ Skills own workflows; root owns hard policy and routing.
- Plugin SDK exception: shipped external API gets new API first plus named compat/deprecation, small tests/docs if useful, removal plan.
- Migrate internal/bundled callers to modern API in the same change. Do not let internal compat become permanent architecture.
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
- Message/channel plugins stay transport-only. They render portable presentation/actions, enforce transport limits, and map native callback envelopes. They do not own product command trees, plugin/provider policy, or feature-specific menus.
- Portable command UI must use typed presentation actions, not raw string inference. Do not make channels guess that `value` starting with `/` means a native command; core/owner plugins declare command actions, channels map them when supported.
- Raw callback data is transport/private. Approval, command, URL, web-app, and select actions must stay distinguishable before channel encoding so transport adapters do not special-case product strings.
- Agent run terminal state: normalize/merge via `src/agents/agent-run-terminal-outcome.ts`; do not rederive timeout/cancel precedence in projections.
- Hot paths should carry prepared facts forward: provider id, model ref, channel id, target, capability family, attachment class. Do not rediscover with broad plugin/provider/channel/capability loaders.
- Do not fix repeated request-time discovery with scattered caches. Move the canonical fact earlier; reuse prepared runtime objects; delete duplicate lookup branches.
@@ -95,8 +117,8 @@ Skills own workflows; root owns hard policy and routing.
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
- If raw Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch mode and will not exit on its own.
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
- Checks in a normal source checkout: `pnpm check:changed`; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... --shell -- "pnpm check:changed"` so pnpm runs inside Testbox, not locally.
- Checks in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox, not locally.
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).
@@ -106,12 +128,13 @@ Skills own workflows; root owns hard policy and routing.
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
- Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely.
- Visual proof: use Crabbox, set up like a user, then screenshot-verify. No harness/bypass/shortcut unless explicitly asked.
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
- One/few files local. If a local command fans out, stop and move broad proof to Crabbox/Testbox.
- Before handoff/push: prove touched surface. Before landing to `main`: issue proof plus appropriate full/broad proof unless scope is clearly narrow.
- Pre-land/pre-commit code changes: use `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already done, trivial/docs-only, or user opts out.
- Pre-land/pre-commit code changes: mandatory fresh `$autoreview` until no accepted/actionable findings remain. Do not land code on CI, ClawSweeper, prior review comments, or your own manual review alone unless user explicitly opts out or scope is truly trivial/docs-only. If findings want refactor, refactor; no ugly fixes.
- If proof is blocked, say exactly what is missing and why.
- Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof.
- Docs/changelog-only and CI/workflow metadata-only: `git diff --check` plus relevant docs/workflow sanity; escalate only if scripts/config/generated/package/runtime behavior changed.
@@ -120,27 +143,35 @@ Skills own workflows; root owns hard policy and routing.
## GitHub / PRs
- Use `$openclaw-pr-maintainer` immediately for maintainer-side OpenClaw issue/PR review, triage, duplicates, labels, comments, close, land, or evidence. Contributor PR creation/refresh follows the requested contributor workflow; linked refs alone do not require maintainer archive tooling.
- Pasted GitHub issue/PR: first `git status -sb`; if dirty, yell; then `git push` + `git pull --ff-only`.
- Issue/PR start: `git status -sb`; if clean, `git pull --ff-only`; if dirty, yell before pull/rebase.
- PR refs: `gh pr view/diff` or `gh api`, not web search. Prefer `gitcrawl` for maintainer discovery; missing/stale `gitcrawl` falls through to live `gh`, not contributor setup. Verify live with `gh` before mutation.
- Bare issue/PR URL/number means review/report in chat. Suggest comment/close/merge when appropriate; mutate only when asked.
- No unsolicited PR comments/reviews/labels/retitles/rebases/fixups/landing. Exception: close/duplicate action that needs a reason comment after explicit close/sweep/landing request.
- Bare issue/PR URL/number: inspect live and take the efficient maintainer path; switch branches/refs when useful.
- No unsolicited PR labels/retitles/rebases/fixups/landing. Comments/reviews ok only for reviewable findings, pre-merge proof, or close/duplicate reason after explicit close/sweep/landing request.
- Maintainer decision closes the cluster: if deciding reported behavior/proposed fix is not planned, comment+close all directly associated open issues/PRs unless explicitly told to keep one open. Associated means linked PRs/issues, duplicates, companion workaround PRs, and the canonical issue for the rejected behavior.
- Do not leave associated issues open for hypothetical future repros. Close with rationale; ask for a new issue or reopen only if concrete new evidence appears. Close comment states: decision, why, supported alternative, and what evidence would change the decision.
- Issue/PR work: search strong related issues/PRs before final; close proven dupes/fixed siblings. If none close, suggest one next related follow-up.
- PR superseded by `main`: if code proof shows `main` already has same-or-better behavior, comment canonical commit/PR + focused proof, then close. Bar high: inspect PR diff, current code/tests, linked issue, caller/sibling path. If unsure, leave open.
- Issue/PR numbers need a short summary every time; assume the reader has not opened or read them.
- Before presenting a batch of issues/PRs, use smart subagents to verify live state and current `main`; omit closed/fixed items, and comment+close items already fixed on `main` when maintainer action is authorized.
- PR review answer: bug/behavior, URL(s), affected surface, provenance for regressions when traceable, best-fix judgment, evidence from code/tests/CI/current or shipped behavior.
- PR reviewable findings: post them on the PR, not chat-only, so author sees actionable feedback.
- Issue/PR final answer: last line is the full GitHub URL.
- PR verification: before merge, post exact local commands, CI/Testbox run IDs, before/after proof when used, and known proof gaps.
- PR verification: before merge, post land-ready work done, exact local commands, CI/Testbox run IDs, before/after proof when used, and known proof gaps.
- Issue fixed on `main` with proof: comment proof + commit/PR, then close.
- After landing or requested close/sweep: search duplicates; comment proof + canonical commit/PR/release before closing.
- After landing/ship final: include 2-5 sentence recap of what landed: behavior change, key files/surface, proof run, issue/PR state. Do not answer with only status/links.
- `ship` that fixes an issue: after push, comment proof + commit link, then close the issue.
- Public GH comments: show draft in chat first unless user explicitly asked to post/comment/reply/close/merge/land. After work starts and changes/proof exist, post the review/proof/commit comment.
- Representing user: if user already has a comment/thread for the point, update/reply there when possible; avoid duplicate PR/issue comments.
- No surprise GH writes: chat must mention every posted/updated public comment with URL.
- GH comments with backticks, `$`, or shell snippets: use heredoc/body file, not inline double-quoted `--body`.
- PR create: real body required. Include Summary + Verification; mention refs, behavior, and proof.
- PR create/refresh: keep PR branches takeover-ready. Use a branch maintainers can push to, or for fork PRs ensure `maintainer_can_modify` / GitHub's `Allow edits by maintainers` is enabled unless explicitly told otherwise or GitHub's Actions/secrets warning makes that unsafe.
- GitHub issue/PR create: read `$agent-transcript`; ask about sanitized transcript logs when available.
- Real behavior proof section is parsed. Use exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
- Contributor PRs: parsed `Real behavior proof` uses exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`.
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
- Maintainers: may skip/ignore `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
- OpenClaw write-access maintainers may skip `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
- `/landpr`: use `~/.codex/prompts/landpr.md`; do not idle on `auto-response` or `check-docs`.
## Code
@@ -156,12 +187,17 @@ Skills own workflows; root owns hard policy and routing.
- Use named intermediates only for domain meaning or readability; avoid temp-variable soup.
- Code size matters. Prefer small clear code; maintainability includes not growing LOC without payoff.
- Refactors should delete about as much local complexity as they add. If LOC grows, the new ownership/API needs to clearly pay for it.
- Refactors should reduce non-test LOC unless they remove a larger architectural cost. Treat positive prod LOC as a smell. Before closeout, run `git diff --numstat`; if non-test LOC grew, trim or explicitly justify why fewer paths now exist.
- Prefer deleting branches, modes, adapters, and tests over preserving them. A refactor that adds a second path has probably failed unless the old path is a cited shipped contract.
- New helpers/files must pay rent immediately: fewer call paths, fewer concepts, or less repeated logic. No helpers for one-off compat, naming translation, or speculative resilience.
- Before adding helpers/files, check whether existing code can absorb the behavior with less new surface.
- Keep APIs narrow: export only current caller needs; keep types/helpers local by default.
- Return the smallest useful shape. Avoid broad result objects, flags, metadata unless callers use them.
- Avoid adapter layers that only rename fields. Move real responsibility or leave code local.
- Inline simple one-use objects/spreads when clearer. Extract only when it removes duplication or hard logic.
- Tests prove behavior/regressions, not every internal branch.
- Tests are welcome, but review them before landing for duplication and value. Delete useless tests, such as assertions for behavior or paths just removed.
- Tests protect canonical behavior and migration boundaries, not obsolete internals. Delete tests for removed fallback paths instead of updating them.
- For non-trivial refactors, check `git diff --numstat` before closeout. If LOC grew, trim or explain why.
- Prefer existing narrow helpers over repeated casts/guards. Add local helpers when 2+ nearby call sites share real boundary logic.
- Prefer ctor parameter properties for injected deps/config. Do not ban them for erasable-syntax purity.
@@ -191,7 +227,7 @@ Skills own workflows; root owns hard policy and routing.
- Use `$technical-documentation` for docs writing/review. Docs change with behavior/API.
- Codex harness upgrade (`extensions/codex/package.json` `@openai/codex`): refresh `docs/plugins/codex-harness.md` model snapshot from the new harness `model/list`.
- Docs final answers: include relevant full `https://docs.openclaw.ai/...` URL(s). If issue/PR work too, GitHub URL last.
- `CHANGELOG.md`: release-owned. Do not edit for normal PRs, direct `main` fixes, or `ship it`; only explicit release/changelog generation may rewrite it. Do not ask contributors/agents for changelog edits.
- `CHANGELOG.md`: release-only. Do not edit for normal PRs, direct `main` fixes, or `ship it`; release generation owns it. Do not ask contributors/agents for changelog edits.
- User-facing `fix`/`feat`/`perf`: put release-note context in PR body, squash message, or direct commit: behavior, surface, issue/PR refs, credited human author/reporter.
- Release generation: derive `CHANGELOG.md` from merged PRs + all direct `main` commits. Entries: active `### Changes`/`### Fixes`, single-line, thank credited humans; never thank bots/forbidden handles: `@openclaw`, `@clawsweeper`, `@codex`, `@steipete`.
@@ -199,18 +235,19 @@ Skills own workflows; root owns hard policy and routing.
- Commit via `scripts/committer "<msg>" <file...>`; stage intended files only.
- Commits: conventional-ish, concise, grouped.
- No manual stash/autostash unless explicit. No branch/worktree changes unless requested.
- No manual stash/autostash unless explicit. Branch switches ok when useful; no new worktrees unless requested.
- `main`: no merge commits; rebase on latest `origin/main` before push. After one green run plus clean rebase sanity, do not chase moving `main` with repeated full gates.
- User says `commit`: your changes only. `commit all`: all changes in grouped chunks. `push`: may `git pull --rebase` first.
- User says `ship it`: commit intended changes, pull --rebase, push.
- Do not delete/rename unexpected files; ask if blocking, else ignore.
- Bulk PR close/reopen >5: ask with count/scope.
- Bulk PR close/reopen >50: ask with count/scope.
## Security / Release
- Never commit real phone numbers, videos, credentials, live config.
- Secrets: channel/provider creds in `~/.openclaw/credentials/`; model auth profiles in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
- Dependency patches/overrides/vendor changes need explicit approval. `pnpm-workspace.yaml` patched dependencies use exact versions only.
- Release/package guards: no hard-coded retired-package denylists; use generic artifact/dependency checks or fix build source.
- Lockfiles/shrinkwrap are security surface: review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, `package-lock.json`; root/plugin npm packages ship shrinkwrap, not package-lock.
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
- Releases/publish/version bumps need explicit approval. Use `$release-openclaw-maintainer`.
@@ -228,8 +265,10 @@ Skills own workflows; root owns hard policy and routing.
- Version bump surfaces live in `$release-openclaw-maintainer`.
- Parallels: `$openclaw-parallels-smoke`; Discord roundtrip: `$parallels-discord-roundtrip`.
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
- Before sharing WebVNC links, use Crabbox screenshot first; verify real app/path works and target UI is not broken.
- ClawSweeper ops: `$clawsweeper`. Deployed hook sessions may post one concise `#clawsweeper` note only when surprising/actionable/risky; if using message tool, reply exactly `NO_REPLY`.
- Generated-media completions wake the requester agent first. Requester visible-reply config decides final text vs message tool; direct media send is fallback/recovery only.
- `message_tool_only`: normal agent final visible reply = current-source `message(action=send)` only. No `NO_REPLY` prompt/contract; no message call = no source reply. Plugin-owned bound-thread reply = plugin return value; no message tool needed. Never auto-publish private final.
- Memory wiki prompt digest stays tiny; prefer `wiki_search` / `wiki_get`; verify contact data before use; source-class provenance for generated people facts.
- Rebrand/migration/config warnings: run `openclaw doctor`.
- Never edit `node_modules`.

View File

@@ -2,7 +2,7 @@
Docs: https://docs.openclaw.ai
## 2026.5.30
## 2026.5.31
### Highlights
@@ -10,15 +10,17 @@ Docs: https://docs.openclaw.ai
- Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)
- Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.
- Skills, session metadata, gateway runtime state, plugin metadata, and store writes do less repeated work on hot paths while keeping config and dispatch behavior stable.
- Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.
- Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)
- Release, CI, Docker, E2E, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, and status polling so failures report bounded proof instead of stalling.
### Changes
- Skills: let the `skill_research` agent tool apply, reject, and quarantine explicit Skill Workshop proposals through the guarded proposal lifecycle. Thanks @shakkernerd.
- Skills: let Skill Workshop proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.
- Skills: let pending Skill Workshop proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.
- Skills: add Skill Workshop proposals with pending `PROPOSAL.md` drafts, CLI/Gateway review actions, rollback metadata, and the `skill_research` agent tool. Thanks @shakkernerd.
- Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery. Thanks @shakkernerd.
- Skills: let the `skill_workshop` agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.
- Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.
- Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.
- Skills: add Skill Workshop with pending proposals, CLI/Gateway review actions, rollback metadata, and the `skill_workshop` agent tool. Thanks @shakkernerd.
- Plugins: externalize Tokenjuice as the official `@openclaw/tokenjuice` plugin with npm and ClawHub publish metadata.
- Plugins: externalize the GitHub Copilot agent runtime as the official `@openclaw/copilot` plugin with npm and ClawHub publish metadata.
- iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)
@@ -30,8 +32,16 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.
- Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.
- Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.
- Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when `skill_workshop` is available. Thanks @shakkernerd.
- Agents/auth: write auth profiles atomically, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state.
- Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill `apiKey` SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.
- CLI: avoid live catalog validation during `openclaw agents add`, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
- Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
@@ -47,7 +57,7 @@ Docs: https://docs.openclaw.ai
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
- CI/Crabbox: keep default runner capacity spot-only and provider-neutral so OpenClaw remote validation does not silently fall back to on-demand leases or stale AWS region hints.
- CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.
- CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.

View File

@@ -9,9 +9,9 @@
# Build stages use full bookworm; the runtime image is always bookworm-slim.
ARG OPENCLAW_EXTENSIONS=""
ARG OPENCLAW_BUNDLED_PLUGIN_DIR=extensions
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63"
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf"
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf"
# Keep in sync with .github/actions/setup-node-env/action.yml bun-version.
# To update: docker buildx imagetools inspect oven/bun:<version> and use the manifest-list digest.
ARG OPENCLAW_BUN_IMAGE="oven/bun:1.3.13@sha256:87416c977a612a204eb54ab9f3927023c2a3c971f4f345a01da08ea6262ae30e"

View File

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

View File

@@ -2,10 +2,18 @@ package ai.openclaw.app
import android.content.Intent
/** Android Assistant entry point used by manifest-declared app actions. */
const val actionAskOpenClaw = "ai.openclaw.app.action.ASK_OPENCLAW"
/** Debug action that opens the Voice tab directly for Android E2E automation. */
const val actionOpenVoiceE2e = "ai.openclaw.app.debug.OPEN_VOICE_E2E"
/** Intent extra that carries an optional assistant prompt for app actions. */
const val extraAssistantPrompt = "prompt"
/**
* Top-level home destinations that external actions may request.
*/
enum class HomeDestination {
Connect,
Chat,
@@ -14,20 +22,30 @@ enum class HomeDestination {
Settings,
}
/**
* Normalized launch request from Android Assistant or explicit app actions.
*/
data class AssistantLaunchRequest(
val source: String,
val prompt: String?,
val autoSend: Boolean,
)
/**
* Parses app-owned navigation actions that should open a specific home tab.
*/
fun parseHomeDestinationIntent(intent: Intent?): HomeDestination? {
val action = intent?.action ?: return null
return when {
// Debug-only shortcut keeps E2E navigation out of release builds.
BuildConfig.DEBUG && action == actionOpenVoiceE2e -> HomeDestination.Voice
else -> null
}
}
/**
* Parse external assistant entry points without starting any UI side effects.
*/
fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? {
val action = intent?.action ?: return null
return when (action) {

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app
/** Camera HUD state categories shown over the Android UI during capture. */
enum class CameraHudKind {
Photo,
Recording,
@@ -7,6 +8,7 @@ enum class CameraHudKind {
Error,
}
/** One-shot camera HUD message keyed by token so repeated text still replays. */
data class CameraHudState(
val token: Long,
val kind: CameraHudKind,

View File

@@ -5,6 +5,7 @@ import android.os.Build
import android.provider.Settings
object DeviceNames {
/** Prefers the user-visible Android device name, then falls back to manufacturer/model text. */
fun bestDefaultNodeName(context: Context): String {
val deviceName =
runCatching {
@@ -15,6 +16,8 @@ object DeviceNames {
if (deviceName.isNotEmpty()) return deviceName
// Manufacturer/model are best-effort platform fields; keep the final
// fallback stable so stored default names do not become blank.
val model =
listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() })
.joinToString(" ")

View File

@@ -1,5 +1,8 @@
package ai.openclaw.app
/**
* Persisted location capture mode advertised to the gateway.
*/
enum class LocationMode(
val rawValue: String,
) {
@@ -8,8 +11,10 @@ enum class LocationMode(
;
companion object {
/** Parses persisted location mode text while migrating old always-on configs to while-using. */
fun fromRawValue(raw: String?): LocationMode {
val normalized = raw?.trim()?.lowercase()
// Older configs used "always"; Android node currently exposes while-using location only.
if (normalized == "always") return WhileUsing
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
}

View File

@@ -15,6 +15,9 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
/**
* Main Android activity that owns Compose UI attachment and runtime UI wiring.
*/
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
private lateinit var permissionRequester: PermissionRequester
@@ -43,6 +46,7 @@ class MainActivity : ComponentActivity() {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.runtimeInitialized.collect { ready ->
if (!ready || didAttachRuntimeUi) return@collect
// Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready.
viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
didAttachRuntimeUi = true
if (!didStartNodeService) {
@@ -78,6 +82,9 @@ class MainActivity : ComponentActivity() {
handleAssistantIntent(intent)
}
/**
* Routes assistant/app-action intents into ViewModel state without recreating the activity.
*/
private fun handleAssistantIntent(intent: android.content.Intent?) {
parseHomeDestinationIntent(intent)?.let { destination ->
viewModel.requestHomeDestination(destination)

View File

@@ -22,6 +22,9 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
/**
* UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModel(
app: Application,
@@ -39,6 +42,9 @@ class MainViewModel(
private val _pendingAssistantAutoSend = MutableStateFlow<String?>(null)
val pendingAssistantAutoSend: StateFlow<String?> = _pendingAssistantAutoSend
/**
* Lazily starts NodeRuntime and preserves the current foreground bit across startup.
*/
private fun ensureRuntime(): NodeRuntime {
runtimeRef.value?.let { return it }
val runtime = nodeApp.ensureRuntime()
@@ -47,6 +53,9 @@ class MainViewModel(
return runtime
}
/**
* Adapts a runtime StateFlow to a stable ViewModel StateFlow before runtime startup.
*/
private fun <T> runtimeState(
initial: T,
selector: (NodeRuntime) -> StateFlow<T>,
@@ -185,6 +194,9 @@ class MainViewModel(
val sms: SmsManager
get() = ensureRuntime().sms
/**
* Attaches Activity-owned permission and lifecycle seams after runtime initialization.
*/
fun attachRuntimeUi(
owner: LifecycleOwner,
permissionRequester: PermissionRequester,
@@ -195,6 +207,9 @@ class MainViewModel(
runtime.sms.attachPermissionRequester(permissionRequester)
}
/**
* Starts runtime on foreground entry only after onboarding has completed.
*/
fun setForeground(value: Boolean) {
foreground = value
val runtime =
@@ -254,10 +269,12 @@ class MainViewModel(
prefs.setGatewayPassword(value)
}
/** Clears setup credentials through the runtime so active gateway sessions drop stale auth state. */
fun resetGatewaySetupAuth() {
ensureRuntime().resetGatewaySetupAuth()
}
/** Marks onboarding complete and starts the runtime before UI observes connected-state flows. */
fun setOnboardingCompleted(value: Boolean) {
if (value) {
ensureRuntime()
@@ -265,6 +282,7 @@ class MainViewModel(
prefs.setOnboardingCompleted(value)
}
/** Re-enters gateway setup after disconnecting and clearing one-time setup credentials. */
fun pairNewGateway() {
runtimeRef.value?.disconnect()
resetGatewaySetupAuth()
@@ -272,6 +290,7 @@ class MainViewModel(
prefs.setOnboardingCompleted(false)
}
/** Acknowledges the one-shot request that opens onboarding at the gateway setup step. */
fun clearGatewaySetupStartRequest() {
_startOnboardingAtGatewaySetup.value = false
}
@@ -315,6 +334,7 @@ class MainViewModel(
ensureRuntime().setVoiceScreenActive(active)
}
/** Routes assistant intents into chat, either as a draft or queued auto-send prompt. */
fun handleAssistantLaunch(request: AssistantLaunchRequest) {
_requestedHomeDestination.value = HomeDestination.Chat
if (request.autoSend) {

View File

@@ -3,11 +3,17 @@ package ai.openclaw.app
import android.app.Application
import android.os.StrictMode
/**
* Android Application singleton that owns process-wide secure prefs and lazy NodeRuntime startup.
*/
class NodeApp : Application() {
val prefs: SecurePrefs by lazy { SecurePrefs(this) }
@Volatile private var runtimeInstance: NodeRuntime? = null
/**
* Returns the single NodeRuntime for this process, creating it on first use.
*/
fun ensureRuntime(): NodeRuntime {
runtimeInstance?.let { return it }
return synchronized(this) {
@@ -15,6 +21,9 @@ class NodeApp : Application() {
}
}
/**
* Reads the runtime without forcing startup, used by lifecycle probes and services.
*/
fun peekRuntime(): NodeRuntime? = runtimeInstance
override fun onCreate() {

View File

@@ -19,6 +19,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
/** Foreground service that keeps the Android node connection and voice capture visible to the OS. */
class NodeForegroundService : Service() {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var notificationJob: Job? = null
@@ -36,6 +37,8 @@ class NodeForegroundService : Service() {
stopSelf()
return
}
// Split connection and capture flows before combining so notification text
// can update without restarting runtime-owned connection work.
notificationJob =
scope.launch {
combine(
@@ -181,6 +184,7 @@ class NodeForegroundService : Service() {
private fun startForegroundWithTypes(notification: Notification) {
val serviceTypes = foregroundServiceTypesForVoiceMode(voiceCaptureMode)
if (didStartForeground) {
// Re-issue startForeground when Talk mode toggles so Android sees the microphone service type.
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes)
return
}
@@ -196,16 +200,19 @@ class NodeForegroundService : Service() {
private const val ACTION_SET_VOICE_CAPTURE_MODE = "ai.openclaw.app.action.SET_VOICE_CAPTURE_MODE"
private const val EXTRA_VOICE_CAPTURE_MODE = "ai.openclaw.app.extra.VOICE_CAPTURE_MODE"
/** Starts the persistent node foreground service from UI lifecycle code. */
fun start(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java)
context.startForegroundService(intent)
}
/** Requests disconnect through the service action path so notification actions and UI share behavior. */
fun stop(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
context.startService(intent)
}
/** Updates Android's foreground-service type before voice capture mode changes require microphone access. */
fun setVoiceCaptureMode(
context: Context,
mode: VoiceCaptureMode,
@@ -215,6 +222,7 @@ class NodeForegroundService : Service() {
.setAction(ACTION_SET_VOICE_CAPTURE_MODE)
.putExtra(EXTRA_VOICE_CAPTURE_MODE, mode.name)
if (mode == VoiceCaptureMode.TalkMode) {
// Microphone foreground service type must be declared before Talk capture starts.
ContextCompat.startForegroundService(context, intent)
} else {
context.startService(intent)
@@ -223,6 +231,9 @@ class NodeForegroundService : Service() {
}
}
/**
* Foreground-service type mask required by Android for the current voice capture mode.
*/
internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int {
val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
return if (mode == VoiceCaptureMode.TalkMode) {
@@ -232,6 +243,9 @@ internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int {
}
}
/**
* Compact notification suffix for voice state; kept pure for service-notification tests.
*/
internal fun voiceNotificationSuffix(
mode: VoiceCaptureMode,
manualMicEnabled: Boolean,
@@ -260,6 +274,7 @@ private fun String?.toVoiceCaptureMode(): VoiceCaptureMode =
it.name == this
} ?: VoiceCaptureMode.Off
/** Connection fields that drive foreground notification title/body text. */
private data class VoiceNotificationBase(
val status: String,
val server: String?,
@@ -267,6 +282,7 @@ private data class VoiceNotificationBase(
val mode: VoiceCaptureMode,
)
/** Voice capture fields that affect foreground-service type and suffix. */
private data class VoiceNotificationCapture(
val micEnabled: Boolean,
val micListening: Boolean,
@@ -274,6 +290,7 @@ private data class VoiceNotificationCapture(
val talkSpeaking: Boolean,
)
/** Aggregated notification state from runtime flows. */
private data class VoiceNotificationState(
val base: VoiceNotificationBase,
val capture: VoiceNotificationCapture,

View File

@@ -75,11 +75,17 @@ import kotlinx.serialization.json.buildJsonObject
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
/**
* Process runtime that owns gateway sessions, node command handlers, capture managers, and UI-facing state.
*/
class NodeRuntime(
context: Context,
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
private val tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult = ::probeGatewayTlsFingerprint,
) {
/**
* Authentication material supplied by setup/manual connect flows before gateway session routing.
*/
data class GatewayConnectAuth(
val token: String?,
val bootstrapToken: String?,
@@ -251,6 +257,9 @@ class NodeRuntime(
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
)
/**
* Pending TLS trust decision when a gateway certificate is new or has changed.
*/
data class GatewayTrustPrompt(
val endpoint: GatewayEndpoint,
val fingerprintSha256: String,
@@ -282,6 +291,9 @@ class NodeRuntime(
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
private val connectAttemptSeq = AtomicLong(0)
/**
* Builds the node-owned session key from stable device identity plus optional active agent.
*/
private fun resolveNodeMainSessionKey(agentId: String? = null): String {
val deviceId = identityStore.loadOrCreate().deviceId
return buildNodeMainSessionKey(deviceId, agentId)
@@ -841,6 +853,7 @@ class NodeRuntime(
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
/** Clears setup credentials plus paired device tokens for both Android gateway roles. */
fun resetGatewaySetupAuth() {
prefs.clearGatewaySetupAuth()
val deviceId = identityStore.loadOrCreate().deviceId
@@ -848,6 +861,7 @@ class NodeRuntime(
deviceAuthStore.clearToken(deviceId, "operator")
}
/** Persists onboarding state; callers decide whether runtime startup is needed first. */
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
@@ -917,6 +931,7 @@ class NodeRuntime(
updateHomeCanvasState()
}
/** Updates foreground state and triggers reconnect/presence behavior on app visibility changes. */
fun setForeground(value: Boolean) {
_isForeground.value = value
if (value) {
@@ -1006,6 +1021,8 @@ class NodeRuntime(
if (didAutoConnect) return
if (_isConnected.value) return
val endpoint = resolvePreferredGatewayEndpoint() ?: return
// Only attempt the stored preferred gateway once per runtime lifetime; users
// can still reconnect explicitly from the UI after a failed auto attempt.
didAutoConnect = true
connect(endpoint)
}

View File

@@ -3,6 +3,7 @@ package ai.openclaw.app
import java.time.Instant
import java.time.ZoneId
/** Package-filter mode used before notification events are forwarded to the gateway. */
enum class NotificationPackageFilterMode(
val rawValue: String,
) {
@@ -11,10 +12,12 @@ enum class NotificationPackageFilterMode(
;
companion object {
/** Parses persisted filter mode text, defaulting to blocklist for safer forwarding. */
fun fromRawValue(raw: String?): NotificationPackageFilterMode = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Blocklist
}
}
/** Runtime policy used before forwarding notification events to a node session. */
internal data class NotificationForwardingPolicy(
val enabled: Boolean,
val mode: NotificationPackageFilterMode,
@@ -26,6 +29,7 @@ internal data class NotificationForwardingPolicy(
val sessionKey: String?,
)
/** Applies the operator-configured package allow/block list after trimming input. */
internal fun NotificationForwardingPolicy.allowsPackage(packageName: String): Boolean {
val normalized = packageName.trim()
if (normalized.isEmpty()) {
@@ -37,6 +41,7 @@ internal fun NotificationForwardingPolicy.allowsPackage(packageName: String): Bo
}
}
/** Returns true for both same-day and overnight quiet-hour windows. */
internal fun NotificationForwardingPolicy.isWithinQuietHours(
nowEpochMs: Long,
zoneId: ZoneId = ZoneId.systemDefault(),
@@ -64,12 +69,14 @@ internal fun NotificationForwardingPolicy.isWithinQuietHours(
private val localHourMinuteRegex = Regex("""^([01]\d|2[0-3]):([0-5]\d)$""")
/** Normalizes persisted or user-entered local times to strict HH:mm form. */
internal fun normalizeLocalHourMinute(raw: String): String? {
val trimmed = raw.trim()
val match = localHourMinuteRegex.matchEntire(trimmed) ?: return null
return "${match.groupValues[1]}:${match.groupValues[2]}"
}
/** Converts strict local HH:mm text to minutes since midnight for window checks. */
internal fun parseLocalHourMinute(raw: String): Int? {
val normalized = normalizeLocalHourMinute(raw) ?: return null
val parts = normalized.split(':')
@@ -78,11 +85,13 @@ internal fun parseLocalHourMinute(raw: String): Int? {
return hour * 60 + minute
}
/** Fixed-window limiter that bounds notification bursts per wall-clock minute. */
internal class NotificationBurstLimiter {
private val lock = Any()
private var windowStartMs: Long = -1L
private var eventsInWindow: Int = 0
/** Returns true when the current minute bucket still has forwarding capacity. */
fun allow(
nowEpochMs: Long,
maxEventsPerMinute: Int,
@@ -90,6 +99,8 @@ internal class NotificationBurstLimiter {
if (maxEventsPerMinute <= 0) {
return false
}
// Align all callers to the same minute bucket so concurrent notifications
// share the quota even when they arrive with slightly different timestamps.
val currentWindow = nowEpochMs - (nowEpochMs % 60_000L)
synchronized(lock) {
if (currentWindow != windowStartMs) {

View File

@@ -26,6 +26,9 @@ import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
/**
* Serializes Android runtime-permission prompts behind coroutine-friendly request calls.
*/
class PermissionRequester internal constructor(
private val activity: ComponentActivity,
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
@@ -50,8 +53,12 @@ class PermissionRequester internal constructor(
private val mutex = Mutex()
private val requestSlotsLock = Any()
private val mainHandler = Handler(Looper.getMainLooper())
// ActivityResult launchers cannot be registered after start; pre-register a small pool for nested UI flows.
private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) }
/**
* Request missing Android runtime permissions and return the final grant state for every requested permission.
*/
suspend fun requestIfMissing(
permissions: List<String>,
timeoutMs: Long = 20_000,
@@ -93,6 +100,7 @@ class PermissionRequester internal constructor(
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (err: TimeoutCancellationException) {
// Late ActivityResult callbacks are ignored by completePermissionRequest.
request.timedOut = true
throw err
}
@@ -130,6 +138,7 @@ class PermissionRequester internal constructor(
private fun reservePermissionRequestSlot(request: PendingPermissionRequest): PermissionRequestSlot =
synchronized(requestSlotsLock) {
// The outer mutex serializes normal callers; this guard catches accidental concurrent launchers in tests.
val slot = launchers.firstOrNull { it.request == null } ?: error("permission request launcher busy")
slot.request = request
slot
@@ -145,6 +154,7 @@ class PermissionRequester internal constructor(
slot.request = null
}
} ?: return
// Timed-out requests have already resumed callers with failure; ignore any late platform callback.
if (request.timedOut) return
request.deferred.complete(result)
}
@@ -186,6 +196,7 @@ class PermissionRequester internal constructor(
val actualObserver =
LifecycleEventObserver { _, event ->
if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver
// Do not resume a destroyed Activity with a positive result.
finish(false)
}
observer = actualObserver

View File

@@ -15,6 +15,9 @@ import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import java.util.UUID
/**
* Reactive settings facade for Android node preferences and encrypted gateway credentials.
*/
class SecurePrefs(
context: Context,
private val securePrefsOverride: SharedPreferences? = null,
@@ -42,9 +45,11 @@ class SecurePrefs(
private val appContext = context.applicationContext
private val json = Json { ignoreUnknownKeys = true }
// Non-secret UI/runtime preferences stay readable for migration and backup behavior.
private val plainPrefs: SharedPreferences =
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
// Gateway credentials and arbitrary secret strings are isolated behind EncryptedSharedPreferences.
private val masterKey by lazy {
MasterKey
.Builder(appContext)
@@ -253,6 +258,7 @@ class SecurePrefs(
val configuredPackages = loadNotificationForwardingPackages()
val normalizedAppPackage = appPackageName.trim()
// Always block OpenClaw's own notifications in blocklist mode to prevent forwarding loops.
val defaultBlockedPackages =
if (normalizedAppPackage.isNotEmpty()) setOf(normalizedAppPackage) else emptySet()
@@ -311,6 +317,7 @@ class SecurePrefs(
.toSet()
.toList()
.sorted()
// Persist deterministic JSON so settings diffs and state restoration are stable.
val encoded = JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
plainPrefs.edit { putString(notificationsForwardingPackagesKey, encoded) }
_notificationForwardingPackages.value = sanitized.toSet()
@@ -355,6 +362,7 @@ class SecurePrefs(
_notificationForwardingSessionKey.value = normalized
}
/** Loads manual or instance-scoped gateway token material from encrypted preferences. */
fun loadGatewayToken(): String? {
val manual =
_gatewayToken.value.trim().ifEmpty {
@@ -363,16 +371,19 @@ class SecurePrefs(
stored
}
if (manual.isNotEmpty()) return manual
// Per-instance tokens keep reused Android installs from sharing stale gateway auth.
val key = "gateway.token.${_instanceId.value}"
val stored = securePrefs.getString(key, null)?.trim()
return stored?.takeIf { it.isNotEmpty() }
}
/** Saves the paired gateway token under the current Android instance id. */
fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}"
securePrefs.edit { putString(key, token.trim()) }
}
/** Loads the bootstrap token used during gateway setup and device-token handoff. */
fun loadGatewayBootstrapToken(): String? {
val key = "gateway.bootstrapToken.${_instanceId.value}"
val stored =
@@ -404,9 +415,11 @@ class SecurePrefs(
securePrefs.edit { putString(key, password.trim()) }
}
/** Clears manual/setup credentials without removing persisted role-specific device tokens. */
fun clearGatewaySetupAuth() {
val instanceId = _instanceId.value
securePrefs.edit {
// Clear both current manual credentials and instance-scoped setup credentials after pairing/reset.
remove("gateway.manual.token")
remove("gateway.token.$instanceId")
remove("gateway.bootstrapToken.$instanceId")
@@ -416,11 +429,13 @@ class SecurePrefs(
_gatewayBootstrapToken.value = ""
}
/** Loads the pinned gateway TLS fingerprint for a discovered/manual stable endpoint id. */
fun loadGatewayTlsFingerprint(stableId: String): String? {
val key = "gateway.tls.$stableId"
return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
}
/** Persists the gateway TLS fingerprint captured through TOFU or explicit trust. */
fun saveGatewayTlsFingerprint(
stableId: String,
fingerprint: String,
@@ -457,6 +472,7 @@ class SecurePrefs(
private fun loadOrCreateInstanceId(): String {
val existing = plainPrefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing
// Instance id is not secret; it scopes local credentials and survives display-name changes.
val fresh = UUID.randomUUID().toString()
plainPrefs.edit { putString("node.instanceId", fresh) }
return fresh
@@ -466,6 +482,7 @@ class SecurePrefs(
val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty()
if (existing.isNotEmpty() && existing != "Android Node") return existing
// Replace the historical generic name with a device-specific default once.
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
val resolved = candidate.ifEmpty { "Android Node" }
@@ -473,6 +490,7 @@ class SecurePrefs(
return resolved
}
/** Persists sanitized voice wake triggers and updates the reactive settings flow. */
fun setWakeWords(words: List<String>) {
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
val encoded =
@@ -521,7 +539,7 @@ class SecurePrefs(
val raw = plainPrefs.getString(voiceWakeModeKey, null)
val resolved = VoiceWakeMode.fromRawValue(raw)
// Default ON (foreground) when unset.
// Default ON (foreground) when unset, but keep "always" opt-in through explicit settings.
if (raw.isNullOrBlank()) {
plainPrefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
}
@@ -533,6 +551,7 @@ class SecurePrefs(
val raw = plainPrefs.getString(locationModeKey, "off")
val resolved = LocationMode.fromRawValue(raw)
if (raw?.trim()?.lowercase() == "always") {
// Migrate old "always" configs to the current while-using contract.
plainPrefs.edit { putString(locationModeKey, resolved.rawValue) }
}
return resolved

View File

@@ -1,10 +1,12 @@
package ai.openclaw.app
/** Normalizes blank gateway session keys to the legacy main session alias. */
internal fun normalizeMainKey(raw: String?): String {
val trimmed = raw?.trim()
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
}
/** Accepts only gateway session keys that can represent the main chat stream. */
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return false
@@ -12,6 +14,7 @@ internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
return trimmed.startsWith("agent:")
}
/** Extracts the agent id from canonical agent-scoped main session keys. */
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()
if (!trimmed.startsWith("agent:")) return null
@@ -22,6 +25,7 @@ internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
.ifEmpty { null }
}
/** Builds the node session key shape consumed by gateway chat and presence APIs. */
internal fun buildNodeMainSessionKey(
deviceId: String,
agentId: String?,

View File

@@ -1,5 +1,8 @@
package ai.openclaw.app
/**
* Persisted voice capture mode that controls foreground-service microphone requirements.
*/
enum class VoiceCaptureMode {
Off,
ManualMic,

View File

@@ -1,5 +1,8 @@
package ai.openclaw.app
/**
* Persisted wake-word mode; raw values are stored in secure preferences.
*/
enum class VoiceWakeMode(
val rawValue: String,
) {
@@ -9,6 +12,9 @@ enum class VoiceWakeMode(
;
companion object {
/**
* Invalid stored values fall back to foreground wake so hands-free behavior stays opt-in.
*/
fun fromRawValue(raw: String?): VoiceWakeMode = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground
}
}

View File

@@ -1,11 +1,16 @@
package ai.openclaw.app
/**
* Wake-word parsing limits and sanitizers shared by settings and voice runtime paths.
*/
object WakeWords {
const val maxWords: Int = 32
const val maxWordLength: Int = 64
/** Splits comma-separated user input into non-empty wake-word entries. */
fun parseCommaSeparated(input: String): List<String> = input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
/** Returns null when edited text normalizes to the current wake-word list. */
fun parseIfChanged(
input: String,
current: List<String>,
@@ -14,6 +19,7 @@ object WakeWords {
return if (parsed == current) null else parsed
}
/** Applies persisted-list bounds and falls back to defaults when all entries are empty. */
fun sanitize(
words: List<String>,
defaults: List<String>,

View File

@@ -61,12 +61,15 @@ class ChatController(
private val pendingRuns = mutableSetOf<String>()
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
// Preserve sent messages locally until chat.history includes the gateway-confirmed copy.
private val optimisticMessagesByRunId = LinkedHashMap<String, ChatMessage>()
private val pendingRunTimeoutMs = 120_000L
// Drops stale history responses after session switches or refresh races.
private val historyLoadGeneration = AtomicLong(0)
private var lastHealthPollAtMs: Long? = null
/** Clears transient chat state when the operator gateway session disconnects. */
fun onDisconnected(message: String) {
_healthOk.value = false
_errorText.value = null
@@ -78,6 +81,7 @@ class ChatController(
_sessionId.value = null
}
/** Loads a chat session, normalizing "main" to the current gateway-provided main session key. */
fun load(sessionKey: String) {
val key = normalizeRequestedSessionKey(sessionKey)
val generation = beginHistoryLoad(key, clearMessages = key != _sessionKey.value)
@@ -86,6 +90,7 @@ class ChatController(
}
}
/** Rebinds chat to a new canonical main session key after gateway hello/agent changes. */
fun applyMainSessionKey(mainSessionKey: String) {
val trimmed = mainSessionKey.trim()
if (trimmed.isEmpty()) return
@@ -108,6 +113,7 @@ class ChatController(
}
}
/** Refreshes current chat history and session list without clearing optimistic messages first. */
fun refresh() {
val key = normalizeRequestedSessionKey(_sessionKey.value)
val generation = beginHistoryLoad(key, clearMessages = false)
@@ -120,12 +126,14 @@ class ChatController(
scope.launch { fetchSessions(limit = limit) }
}
/** Persists the normalized thinking level used for subsequent chat sends. */
fun setThinkingLevel(thinkingLevel: String) {
val normalized = normalizeThinking(thinkingLevel)
if (normalized == _thinkingLevel.value) return
_thinkingLevel.value = normalized
}
/** Switches to another gateway chat session and starts a fresh history load. */
fun switchSession(sessionKey: String) {
val key = normalizeRequestedSessionKey(sessionKey)
if (key.isEmpty()) return
@@ -163,6 +171,7 @@ class ChatController(
return key
}
/** Queues a chat send without waiting for gateway acceptance. */
fun sendMessage(
message: String,
thinkingLevel: String,
@@ -177,6 +186,7 @@ class ChatController(
}
}
/** Sends a chat message and returns once the gateway accepts or rejects the request. */
suspend fun sendMessageAwaitAcceptance(
message: String,
thinkingLevel: String,
@@ -194,7 +204,7 @@ class ChatController(
val sessionKey = _sessionKey.value
val thinking = normalizeThinking(thinkingLevel)
// Optimistic user message.
// Optimistic user message keeps the composer responsive while chat.send and history refresh complete.
val userContent =
buildList {
add(ChatMessageContent(type = "text", text = text))
@@ -257,6 +267,7 @@ class ChatController(
val res = session.request("chat.send", params.toString())
val actualRunId = parseRunId(res) ?: runId
if (actualRunId != runId) {
// Gateway may return a canonical run id; move all pending bookkeeping to that id.
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
clearPendingRun(runId)
armPendingRunTimeout(actualRunId)
@@ -274,6 +285,7 @@ class ChatController(
}
}
/** Sends best-effort abort requests for every currently pending gateway run. */
fun abort() {
val runIds =
synchronized(pendingRuns) {
@@ -296,6 +308,7 @@ class ChatController(
}
}
/** Applies gateway chat/agent stream events to local transcript and pending-run state. */
fun handleGatewayEvent(
event: String,
payloadJson: String?,
@@ -396,7 +409,7 @@ class ChatController(
val state = payload["state"].asStringOrNull()
when (state) {
"delta" -> {
// Only show streaming text for runs we initiated
// Only show streaming text for runs we initiated in this controller.
if (!isPending) return
val text = parseAssistantDeltaText(payload)
if (!text.isNullOrEmpty()) {
@@ -637,6 +650,9 @@ internal fun isCurrentHistoryLoad(
activeGeneration: Long,
): Boolean = requestedSessionKey == currentSessionKey && requestGeneration == activeGeneration
/**
* Convert gateway chat content parts into Android UI content parts.
*/
internal fun parseChatMessageContent(el: JsonElement): ChatMessageContent? {
val obj = el.asObjectOrNull() ?: return null
return when (obj["type"].asStringOrNull() ?: "text") {
@@ -663,6 +679,9 @@ internal data class MainSessionState(
val appliedMainSessionKey: String,
)
/**
* Rewrite only the active "main" alias when the gateway publishes a new canonical main session key.
*/
internal fun applyMainSessionKey(
currentSessionKey: String,
appliedMainSessionKey: String,
@@ -680,6 +699,9 @@ internal fun applyMainSessionKey(
)
}
/**
* Keep Compose item identity stable across history refreshes by matching existing messages to incoming copies.
*/
internal fun reconcileMessageIds(
previous: List<ChatMessage>,
incoming: List<ChatMessage>,
@@ -729,6 +751,9 @@ internal fun mergeOptimisticMessages(
return (incoming + missingOptimistic).sortedWith(compareBy<ChatMessage> { it.timestampMs ?: Long.MAX_VALUE }.thenBy { it.id })
}
/**
* Message identity used only for refresh reconciliation; it avoids exposing gateway ids as UI keys.
*/
internal fun messageIdentityKey(message: ChatMessage): String? {
val contentKey = messageContentIdentityKey(message) ?: return null
val timestamp = message.timestampMs?.toString().orEmpty()

View File

@@ -1,5 +1,8 @@
package ai.openclaw.app.chat
/**
* Chat transcript item as delivered by gateway chat history and live chat events.
*/
data class ChatMessage(
val id: String,
val role: String,
@@ -7,6 +10,9 @@ data class ChatMessage(
val timestampMs: Long?,
)
/**
* One content part in a chat message; binary parts carry base64 plus their MIME metadata.
*/
data class ChatMessageContent(
val type: String = "text",
val text: String? = null,
@@ -15,6 +21,9 @@ data class ChatMessageContent(
val base64: String? = null,
)
/**
* Tool call placeholder shown while a gateway run is still streaming.
*/
data class ChatPendingToolCall(
val toolCallId: String,
val name: String,
@@ -23,12 +32,18 @@ data class ChatPendingToolCall(
val isError: Boolean? = null,
)
/**
* Stable session selector row; [key] is the gateway session key used in chat requests.
*/
data class ChatSessionEntry(
val key: String,
val updatedAtMs: Long?,
val displayName: String? = null,
)
/**
* Snapshot of one chat session, including optional thinking level selected on the gateway.
*/
data class ChatHistory(
val sessionKey: String,
val sessionId: String?,
@@ -36,6 +51,9 @@ data class ChatHistory(
val messages: List<ChatMessage>,
)
/**
* User-selected attachment payload sent to the gateway as inline base64.
*/
data class OutgoingAttachment(
val type: String,
val mimeType: String,

View File

@@ -1,6 +1,10 @@
package ai.openclaw.app.gateway
/**
* Decoder for Bonjour DNS-SD service names returned with decimal byte escapes.
*/
object BonjourEscapes {
/** Decodes Bonjour DNS-SD decimal escapes while preserving ordinary UTF-8. */
fun decode(input: String): String {
if (input.isEmpty()) return input
@@ -15,6 +19,7 @@ object BonjourEscapes {
val value =
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
if (value in 0..255) {
// Bonjour escape bytes are decimal octets, not Unicode code points.
bytes.add(value.toByte())
i += 4
continue

View File

@@ -1,6 +1,10 @@
package ai.openclaw.app.gateway
/**
* Canonical device-auth payload builder shared with gateway verification rules.
*/
internal object DeviceAuthPayload {
/** Builds the canonical v3 auth string signed by device registration flows. */
fun buildV3(
deviceId: String,
clientId: String,
@@ -32,6 +36,7 @@ internal object DeviceAuthPayload {
).joinToString("|")
}
/** Normalizes signed metadata fields without locale-sensitive lowercasing. */
internal fun normalizeMetadataField(value: String?): String {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) {

View File

@@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/** Stored gateway device-token material scoped by device id and role. */
data class DeviceAuthEntry(
val token: String,
val role: String,
@@ -18,17 +19,21 @@ private data class PersistedDeviceAuthMetadata(
val updatedAtMs: Long = 0L,
)
/** Persistence interface used by gateway pairing/session code for role tokens. */
interface DeviceAuthTokenStore {
/** Loads the stored token plus metadata for one device/role pair. */
fun loadEntry(
deviceId: String,
role: String,
): DeviceAuthEntry?
/** Loads only the bearer token when callers do not need scope metadata. */
fun loadToken(
deviceId: String,
role: String,
): String? = loadEntry(deviceId, role)?.token
/** Persists a role token and deterministic scope metadata under normalized keys. */
fun saveToken(
deviceId: String,
role: String,
@@ -36,12 +41,14 @@ interface DeviceAuthTokenStore {
scopes: List<String> = emptyList(),
)
/** Removes both token and metadata for the normalized device/role pair. */
fun clearToken(
deviceId: String,
role: String,
)
}
/** SecurePrefs-backed implementation of Android gateway device-token storage. */
class DeviceAuthStore(
private val prefs: SecurePrefs,
) : DeviceAuthTokenStore {
@@ -103,6 +110,8 @@ class DeviceAuthStore(
): String {
val normalizedDevice = normalizeDeviceId(deviceId)
val normalizedRole = normalizeRole(role)
// Keep key normalization shared with metadata keys so token and metadata
// are added/removed as one logical auth entry.
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
}
@@ -115,14 +124,19 @@ class DeviceAuthStore(
return "gateway.deviceTokenMeta.$normalizedDevice.$normalizedRole"
}
/** Normalizes device ids before they become encrypted preference key segments. */
private fun normalizeDeviceId(deviceId: String): String = deviceId.trim().lowercase()
/** Normalizes role names so node/operator token slots are stable across callers. */
private fun normalizeRole(role: String): String = role.trim().lowercase()
/** Stores scopes in deterministic order for display and restart comparisons. */
private fun normalizeScopes(scopes: List<String>): List<String> =
scopes
.map { it.trim() }
.filter { it.isNotEmpty() }
// Persist deterministic scope lists because they are displayed and may be
// compared across process restarts.
.distinct()
.sorted()
}

View File

@@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json
import java.io.File
import java.security.MessageDigest
/** Persistent Ed25519 identity used to register this Android node with gateways. */
@Serializable
data class DeviceIdentity(
val deviceId: String,
@@ -15,6 +16,7 @@ data class DeviceIdentity(
val createdAtMs: Long,
)
/** Owns device identity generation, persistence, and auth payload signatures. */
class DeviceIdentityStore(
context: Context,
) {
@@ -23,6 +25,7 @@ class DeviceIdentityStore(
@Volatile private var cachedIdentity: DeviceIdentity? = null
/** Loads the persisted identity or creates one, repairing old device-id drift. */
@Synchronized
fun loadOrCreate(): DeviceIdentity {
cachedIdentity?.let { return it }
@@ -44,12 +47,13 @@ class DeviceIdentityStore(
return fresh
}
/** Signs gateway connect payload text with the persisted Ed25519 private key. */
fun signPayload(
payload: String,
identity: DeviceIdentity,
): String? =
try {
// Use BC lightweight API directly JCA provider registration is broken by R8
// Use BC lightweight API directly; R8 can break JCA provider registration.
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
val pkInfo =
org.bouncycastle.asn1.pkcs.PrivateKeyInfo
@@ -74,6 +78,7 @@ class DeviceIdentityStore(
null
}
/** Verifies a signature against the persisted public key for debug diagnostics. */
fun verifySelfSignature(
payload: String,
signatureBase64Url: String,
@@ -97,12 +102,16 @@ class DeviceIdentityStore(
false
}
/** Decodes gateway URL-safe base64 signatures, accepting unpadded input. */
private fun base64UrlDecode(input: String): ByteArray {
val normalized = input.replace('-', '+').replace('_', '/')
// Android Base64 expects padded input; gateway signatures are URL-safe
// unpadded strings.
val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4)
return Base64.decode(padded, Base64.DEFAULT)
}
/** Returns the public key in the gateway's unpadded URL-safe base64 format. */
fun publicKeyBase64Url(identity: DeviceIdentity): String? =
try {
val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
@@ -142,7 +151,7 @@ class DeviceIdentityStore(
}
private fun generate(): DeviceIdentity {
// Use BC lightweight API directly to avoid JCA provider issues with R8
// Use BC lightweight API directly to avoid JCA provider issues with R8.
val kpGen =
org.bouncycastle.crypto.generators
.Ed25519KeyPairGenerator()
@@ -155,7 +164,8 @@ class DeviceIdentityStore(
val privKey = kp.private as org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
val rawPublic = pubKey.encoded // 32 bytes
val deviceId = sha256Hex(rawPublic)
// Encode private key as PKCS8 for storage
// Store private key as PKCS8 so signPayload can parse the same persisted
// shape after app restarts and upgrades.
val privKeyInfo =
org.bouncycastle.crypto.util.PrivateKeyInfoFactory
.createPrivateKeyInfo(privKey)
@@ -168,6 +178,7 @@ class DeviceIdentityStore(
)
}
/** Re-derives the stable device id from the raw Ed25519 public key bytes. */
private fun deriveDeviceId(publicKeyRawBase64: String): String? =
try {
val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT)

View File

@@ -49,6 +49,9 @@ import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways.
*/
class GatewayDiscovery(
context: Context,
private val scope: CoroutineScope,
@@ -63,9 +66,11 @@ class GatewayDiscovery(
private val localById = ConcurrentHashMap<String, GatewayEndpoint>()
private val unicastById = ConcurrentHashMap<String, GatewayEndpoint>()
private val _gateways = MutableStateFlow<List<GatewayEndpoint>>(emptyList())
/** Current discovered gateway list, merged from local DNS-SD and optional wide-area DNS-SD. */
val gateways: StateFlow<List<GatewayEndpoint>> = _gateways.asStateFlow()
private val _statusText = MutableStateFlow("Searching…")
/** Short diagnostic text shown by connect UI while discovery is running. */
val statusText: StateFlow<String> = _statusText.asStateFlow()
private var unicastJob: Job? = null
@@ -130,6 +135,8 @@ class GatewayDiscovery(
val cm = connectivity ?: return
cm.activeNetwork?.let(availableNetworks::add)
try {
// Track all networks so wide-area DNS can prefer VPN/split-DNS answers
// even when Android's active network is not the VPN.
cm.registerNetworkCallback(NetworkRequest.Builder().build(), networkCallback)
} catch (_: Throwable) {
// ignore (best-effort)
@@ -168,6 +175,7 @@ class GatewayDiscovery(
private fun resolve(serviceInfo: NsdServiceInfo) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// Android 14+ streams service updates; older releases require one-shot resolve calls.
resolveWithServiceInfoCallback(serviceInfo)
} else {
resolveLegacy(serviceInfo)
@@ -255,6 +263,7 @@ class GatewayDiscovery(
val tlsEnabled = txtBool(resolved, "gatewayTls")
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
val id = stableId(serviceName, "local.")
// Local NSD gives the socket host/port; TXT ports are retained as gateway metadata only.
localById[id] =
GatewayEndpoint(
stableId = id,
@@ -288,6 +297,7 @@ class GatewayDiscovery(
private fun publish() {
_gateways.value =
// Merge local and wide-area results deterministically for stable UI selection.
(localById.values + unicastById.values).sortedBy { it.name.lowercase() }
_statusText.value = buildStatusText()
}
@@ -369,6 +379,7 @@ class GatewayDiscovery(
?: resolveHostUnicast(targetFqdn)
?: continue
// Wide-area DNS-SD may put TXT in additional records; fall back to a direct TXT query.
val txtFromPtr =
recordsByName(ptrMsg, Section.ADDITIONAL)[keyName(instanceFqdn)]
.orEmpty()
@@ -454,6 +465,7 @@ class GatewayDiscovery(
val system = queryViaSystemDns(query)
if (records(system, Section.ANSWER).any { it.type == type }) return system
// Android's DnsResolver can miss split-DNS answers; retry with dnsjava against network DNS servers.
val direct = createDirectResolver() ?: return system
return try {
val msg = direct.send(query)
@@ -548,6 +560,7 @@ class GatewayDiscovery(
val candidateNetworks =
buildList {
// Put VPN DNS first so Tailscale split-horizon names win over public DNS.
trackedNetworks(cm)
.firstOrNull { n ->
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.gateway
/** Resolved gateway address and optional metadata discovered from Bonjour/manual entry. */
data class GatewayEndpoint(
val stableId: String,
val name: String,
@@ -13,6 +14,7 @@ data class GatewayEndpoint(
val tlsFingerprintSha256: String? = null,
) {
companion object {
/** Builds a stable manual endpoint key that survives display-name changes. */
fun manual(
host: String,
port: Int,

View File

@@ -4,6 +4,7 @@ import android.os.Build
import java.net.InetAddress
import java.util.Locale
/** Returns true only for loopback hosts safe to treat as local gateway origins. */
internal fun isLoopbackGatewayHost(
rawHost: String?,
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
@@ -18,9 +19,12 @@ internal fun isLoopbackGatewayHost(
host = host.dropLast(1)
}
val zoneIndex = host.indexOf('%')
// Scoped IPv6 literals are not stable origin identifiers; reject them for
// loopback trust instead of guessing which interface the zone names.
if (zoneIndex >= 0) return false
if (host.isEmpty()) return false
if (host == "localhost") return true
// Android emulator maps host loopback through this bridge alias.
if (allowEmulatorBridgeAlias && host == "10.0.2.2") return true
parseIpv4Address(host)?.let { ipv4 ->
@@ -44,6 +48,7 @@ internal fun isLoopbackGatewayHost(
return isMappedIpv4 && address[12] == 127.toByte()
}
/** Allows cleartext only for loopback and private/link-local network ranges. */
internal fun isLocalCleartextGatewayHost(
rawHost: String?,
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
@@ -59,6 +64,8 @@ internal fun isLocalCleartextGatewayHost(
}
val zoneIndex = host.indexOf('%')
if (zoneIndex >= 0) {
// Link-local cleartext policy is about the address range; strip the
// interface zone before InetAddress parsing rejects otherwise valid hosts.
host = host.substring(0, zoneIndex)
}
if (host.isEmpty()) return false
@@ -107,6 +114,7 @@ private fun isAndroidEmulatorRuntime(): Boolean {
product.contains("simulator")
}
/** Parses strict dotted-quad IPv4, rejecting shorthand and out-of-range octets. */
private fun parseIpv4Address(host: String): ByteArray? {
val parts = host.split('.')
if (parts.size != 4) return null
@@ -119,4 +127,5 @@ private fun parseIpv4Address(host: String): ByteArray? {
return bytes
}
/** Cheap prefilter before handing potential IPv6 literals to InetAddress. */
private fun isIpv6LiteralChar(char: Char): Boolean = char in '0'..'9' || char in 'a'..'f' || char == ':' || char == '.'

View File

@@ -1,4 +1,7 @@
package ai.openclaw.app.gateway
/** Gateway protocol version emitted by Android node clients. */
const val GATEWAY_PROTOCOL_VERSION = 4
/** Oldest gateway protocol version this Android client can speak safely. */
const val GATEWAY_MIN_PROTOCOL_VERSION = 4

View File

@@ -33,6 +33,9 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/**
* Identity advertised during gateway connect; these fields become the device row users approve.
*/
data class GatewayClientInfo(
val id: String,
val displayName: String?,
@@ -44,6 +47,9 @@ data class GatewayClientInfo(
val modelIdentifier: String?,
)
/**
* Role, scopes, commands, and permission snapshot sent with the connect frame.
*/
data class GatewayConnectOptions(
val role: String,
val scopes: List<String>,
@@ -62,6 +68,9 @@ private enum class GatewayConnectAuthSource {
NONE,
}
/**
* Structured auth failure guidance from the gateway, preserved for reconnect and UI decisions.
*/
data class GatewayConnectErrorDetails(
val code: String?,
val canRetryWithDeviceToken: Boolean,
@@ -70,6 +79,9 @@ data class GatewayConnectErrorDetails(
val reason: String? = null,
)
/**
* Server hello fields cached by the Android runtime after a successful connect.
*/
data class GatewayHelloSummary(
val serverName: String?,
val remoteAddress: String?,
@@ -99,6 +111,9 @@ private class GatewayConnectFailure(
val gatewayError: GatewaySession.ErrorShape,
) : IllegalStateException(gatewayError.message)
/**
* WebSocket RPC session that maintains gateway connection lifecycle, auth, events, and node invokes.
*/
class GatewaySession(
private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore,
@@ -114,6 +129,9 @@ class GatewaySession(
private const val CONNECT_RPC_TIMEOUT_MS = 12_000L
}
/**
* Gateway node.invoke request routed to Android command handlers.
*/
data class InvokeRequest(
val id: String,
val nodeId: String,
@@ -143,6 +161,9 @@ class GatewaySession(
val details: GatewayConnectErrorDetails? = null,
)
/**
* Structured RPC result used by callers that need error codes without exceptions.
*/
data class RpcResult(
val ok: Boolean,
val payloadJson: String?,
@@ -174,12 +195,15 @@ class GatewaySession(
@Volatile private var currentConnection: Connection? = null
// One reconnect can retry a shared-token mismatch by pairing the shared token with the stored device token.
@Volatile private var pendingDeviceTokenRetry = false
// Keep the mismatch retry single-shot so an invalid stored token cannot create an auth loop.
@Volatile private var deviceTokenRetryBudgetUsed = false
@Volatile private var reconnectPausedForAuthFailure = false
/** Starts or replaces the desired gateway connection and launches the reconnect loop. */
fun connect(
endpoint: GatewayEndpoint,
token: String?,
@@ -202,6 +226,7 @@ class GatewaySession(
connectionToClose?.closeQuietly()
}
/** Clears desired connection state, closes the socket, and stops reconnect attempts. */
fun disconnect() {
val jobToCancel: Job?
val connectionToClose: Connection?
@@ -225,6 +250,7 @@ class GatewaySession(
}
}
/** Forces the current socket closed so the loop reconnects to the current desired endpoint. */
fun reconnect() {
reconnectPausedForAuthFailure = false
currentConnection?.closeQuietly()
@@ -232,6 +258,7 @@ class GatewaySession(
fun currentCanvasHostUrl(): String? = pluginSurfaceUrls["canvas"]
/** Refreshes the canvas plugin surface URL and caches the normalized Android-reachable URL. */
suspend fun refreshCanvasHostUrl(timeoutMs: Long = 8_000): String? {
val refreshed =
refreshPluginSurfaceUrl(
@@ -247,6 +274,7 @@ class GatewaySession(
fun currentMainSessionKey(): String? = mainSessionKey
/** Sends a best-effort node.event and returns false instead of throwing on failure. */
suspend fun sendNodeEvent(
event: String,
payloadJson: String?,
@@ -287,6 +315,7 @@ class GatewaySession(
}
}
/** Sends node.event and preserves the gateway RPC error shape for callers that need diagnostics. */
suspend fun sendNodeEventDetailed(
event: String,
payloadJson: String?,
@@ -319,9 +348,11 @@ class GatewaySession(
): JsonObject =
buildJsonObject {
put("event", JsonPrimitive(event))
// Gateway node events carry payloadJSON as a string for compatibility with non-JSON payload producers.
put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
}
/** Sends an RPC request and throws a code-prefixed exception when the gateway returns an error. */
suspend fun request(
method: String,
paramsJson: String?,
@@ -333,6 +364,7 @@ class GatewaySession(
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
}
/** Sends an RPC request and returns the structured success/error payload. */
suspend fun requestDetailed(
method: String,
paramsJson: String?,
@@ -349,6 +381,7 @@ class GatewaySession(
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
}
/** Sends an RPC request frame and reports errors asynchronously through [onError]. */
suspend fun sendRequestFrame(
method: String,
paramsJson: String?,
@@ -705,6 +738,7 @@ class GatewaySession(
persistIssuedDeviceToken(authSource, deviceId, authRole, deviceToken, authScopes)
}
if (shouldPersistBootstrapHandoffTokens(authSource)) {
// Bootstrap connects can mint role-specific device tokens; store only locally trusted handoffs.
authObj
?.get("deviceTokens")
.asArrayOrNull()
@@ -725,6 +759,7 @@ class GatewaySession(
val rawPluginSurfaceUrls = obj["pluginSurfaceUrls"].asObjectOrNull()
val normalizedPluginSurfaceUrls =
rawPluginSurfaceUrls?.mapNotNull { (surface, value) ->
// Canvas URLs may be loopback gateway metadata; normalize them to the reachable Android endpoint.
normalizeCanvasHostUrl(value.asStringOrNull(), endpoint, isTlsConnection = tls != null)
?.let { normalized -> surface to normalized }
} ?: emptyList()
@@ -797,6 +832,7 @@ class GatewaySession(
val connectScopes = resolveConnectScopes(selectedAuth)
val signedAtMs = System.currentTimeMillis()
// V3 signatures bind the auth token, nonce, role, and scopes so replayed connect frames fail.
val payload =
DeviceAuthPayload.buildV3(
deviceId = identity.deviceId,
@@ -966,6 +1002,7 @@ class GatewaySession(
if (parsedPayload != null) {
put("payload", parsedPayload)
} else if (result.payloadJson != null) {
// Preserve malformed/non-object payloads as payloadJSON so the gateway can report handler output.
put("payloadJSON", JsonPrimitive(result.payloadJson))
}
result.error?.let { err ->
@@ -1189,6 +1226,7 @@ class GatewaySession(
if (!isTrustedDeviceRetryEndpoint(endpoint, tls)) return false
val detailCode = error.details?.code
val recommendedNextStep = error.details?.recommendedNextStep
// New gateways set canRetryWithDeviceToken; older builds expose equivalent string codes.
return error.details?.canRetryWithDeviceToken == true ||
recommendedNextStep == "retry_with_device_token" ||
detailCode == "AUTH_TOKEN_MISMATCH"
@@ -1213,10 +1251,13 @@ class GatewaySession(
tls: GatewayTlsParams?,
): Boolean {
if (isLocalCleartextGatewayHost(endpoint.host)) return true
// Retrying a stored device token alongside a shared token is only safe for
// remote gateways when an existing TLS pin already identifies the endpoint.
return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true
}
}
/** Decides whether auth failures should stop reconnect churn until the user changes credentials. */
internal fun shouldPauseGatewayReconnectAfterAuthFailure(
error: GatewaySession.ErrorShape,
hasBootstrapToken: Boolean,
@@ -1249,6 +1290,7 @@ internal fun shouldPauseGatewayReconnectAfterAuthFailure(
else -> false
}
/** Builds the gateway WebSocket URL from endpoint authority and TLS policy. */
internal fun buildGatewayWebSocketUrl(
host: String,
port: Int,
@@ -1258,6 +1300,7 @@ internal fun buildGatewayWebSocketUrl(
return "$scheme://${formatGatewayAuthority(host, port)}"
}
/** Formats host/port for gateway URLs, including IPv6 bracket wrapping. */
internal fun formatGatewayAuthority(
host: String,
port: Int,
@@ -1308,6 +1351,7 @@ private fun parseJsonOrNull(payload: String): JsonElement? {
}
}
/** Keeps invoke-result ack waits inside the gateway-supported timeout window. */
internal fun resolveInvokeResultAckTimeoutMs(invokeTimeoutMs: Long?): Long {
val normalized = invokeTimeoutMs?.takeIf { it > 0L } ?: 15_000L
return normalized.coerceIn(15_000L, 120_000L)

View File

@@ -25,6 +25,7 @@ import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/** TLS pinning inputs for a discovered or manually configured gateway endpoint. */
data class GatewayTlsParams(
val required: Boolean,
val expectedFingerprint: String?,
@@ -32,22 +33,26 @@ data class GatewayTlsParams(
val stableId: String,
)
/** SSL primitives installed into OkHttp when a gateway needs TLS pinning/TOFU. */
data class GatewayTlsConfig(
val sslSocketFactory: SSLSocketFactory,
val trustManager: X509TrustManager,
val hostnameVerifier: HostnameVerifier,
)
/** Distinguishes non-TLS endpoints from unreachable endpoints during probing. */
enum class GatewayTlsProbeFailure {
TLS_UNAVAILABLE,
ENDPOINT_UNREACHABLE,
}
/** Result of probing a gateway TLS endpoint for first-use fingerprint capture. */
data class GatewayTlsProbeResult(
val fingerprintSha256: String? = null,
val failure: GatewayTlsProbeFailure? = null,
)
/** Builds a TLS config that supports pinned fingerprints and trust-on-first-use. */
fun buildGatewayTlsConfig(
params: GatewayTlsParams?,
onStore: ((String) -> Unit)? = null,
@@ -82,6 +87,9 @@ fun buildGatewayTlsConfig(
return
}
if (params.allowTOFU) {
// Store only after the TLS stack presents a concrete server cert; the
// caller persists the fingerprint against the endpoint's stable id,
// and later connects must come back through the pinned branch above.
onStore?.invoke(fingerprint)
return
}
@@ -107,6 +115,7 @@ fun buildGatewayTlsConfig(
)
}
/** Connects with a probe trust manager that captures the presented cert hash. */
suspend fun probeGatewayTlsFingerprint(
host: String,
port: Int,
@@ -132,6 +141,7 @@ suspend fun probeGatewayTlsFingerprint(
) {
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
fingerprintRef.set(sha256Hex(chain[0].encoded))
// Abort validation after capture; the probe is not deciding trust.
throw CertificateException("gateway TLS probe captured fingerprint")
}
@@ -154,7 +164,8 @@ suspend fun probeGatewayTlsFingerprint(
socket.sslParameters = params
}
} catch (_: Throwable) {
// ignore
// SNI is only a probe hint. IP literals and odd Bonjour names should
// still be probed instead of failing before the TLS handshake.
}
socket.startHandshake()
@@ -203,6 +214,7 @@ private fun sha256Hex(data: ByteArray): String {
return out.toString()
}
/** Normalizes user-visible fingerprint text to lowercase bare SHA-256 hex. */
fun normalizeGatewayTlsFingerprint(raw: String): String {
val stripped =
raw

View File

@@ -5,10 +5,15 @@ data class ParsedInvokeError(
val message: String,
val hadExplicitCode: Boolean,
) {
/** Gateway-facing form expected by UI and retry copy. */
val prefixedMessage: String
get() = "$code: $message"
}
/**
* Parses gateway invoke errors encoded as CODE: message while preserving legacy
* plain-text errors as UNAVAILABLE.
*/
fun parseInvokeErrorMessage(raw: String): ParsedInvokeError {
val trimmed = raw.trim()
if (trimmed.isEmpty()) {
@@ -30,6 +35,7 @@ fun parseInvokeErrorMessage(raw: String): ParsedInvokeError {
return ParsedInvokeError(code = "UNAVAILABLE", message = trimmed, hadExplicitCode = false)
}
/** Extracts an invoke error from a throwable without exposing blank messages. */
fun parseInvokeErrorFromThrowable(
err: Throwable,
fallbackMessage: String = "error",

View File

@@ -6,6 +6,9 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Android bridge for applying gateway A2UI messages to the canvas WebView.
*/
class A2UIHandler(
private val canvas: CanvasController,
private val json: Json,
@@ -21,6 +24,7 @@ class A2UIHandler(
fun resolveA2uiHostUrl(): String? {
val nodeRaw = getNodeCanvasHostUrl()?.trim().orEmpty()
val operatorRaw = getOperatorCanvasHostUrl()?.trim().orEmpty()
// Prefer node-advertised canvas host; operator URL is a fallback for older hello payloads.
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
if (raw.isBlank()) return null
val base = raw.trimEnd('/')
@@ -36,6 +40,7 @@ class A2UIHandler(
}
canvas.navigate(a2uiUrl)
// A2UI host bootstraps asynchronously after navigation; poll briefly before failing the command.
repeat(50) {
try {
val ready = canvas.eval(a2uiReadyCheckJS)
@@ -65,6 +70,7 @@ class A2UIHandler(
if (command == "canvas.a2ui.pushJSONL" || (!hasMessagesArray && jsonlField.isNotBlank())) {
val jsonl = jsonlField
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
// JSONL keeps large A2UI streams model-friendly while still validating each message.
val messages =
jsonl
.lineSequence()
@@ -98,6 +104,7 @@ class A2UIHandler(
lineNumber: Int,
) {
if (msg.containsKey("createSurface")) {
// Android scaffold currently implements A2UI v0.8, not the v0.9 createSurface shape.
throw IllegalArgumentException(
"A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.",
)

View File

@@ -20,12 +20,18 @@ import java.util.TimeZone
private const val DEFAULT_CALENDAR_LIMIT = 50
/**
* Parsed calendar.events request; times are epoch millis for CalendarContract queries.
*/
internal data class CalendarEventsRequest(
val startMs: Long,
val endMs: Long,
val limit: Int,
)
/**
* Parsed calendar.add request before resolving the target Android calendar.
*/
internal data class CalendarAddRequest(
val title: String,
val startMs: Long,
@@ -37,6 +43,9 @@ internal data class CalendarAddRequest(
val calendarTitle: String?,
)
/**
* Normalized calendar event returned through gateway calendar commands.
*/
internal data class CalendarEventRecord(
val identifier: String,
val title: String,
@@ -47,6 +56,9 @@ internal data class CalendarEventRecord(
val calendarTitle: String?,
)
/**
* Injectable CalendarProvider facade for command tests and Android runtime access.
*/
internal interface CalendarDataSource {
fun hasReadPermission(context: Context): Boolean
@@ -78,6 +90,7 @@ private object SystemCalendarDataSource : CalendarDataSource {
): List<CalendarEventRecord> {
val resolver = context.contentResolver
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
// Instances expands recurring events inside the requested time window.
ContentUris.appendId(builder, request.startMs)
ContentUris.appendId(builder, request.endMs)
val projection =
@@ -155,10 +168,12 @@ private object SystemCalendarDataSource : CalendarDataSource {
calendarTitle: String?,
): Long {
if (calendarId != null) {
// Explicit id wins over title/default selection and must already exist.
if (calendarExists(resolver, calendarId)) return calendarId
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar id $calendarId")
}
if (!calendarTitle.isNullOrEmpty()) {
// Title lookup is exact to avoid adding events to a similarly named calendar.
findCalendarByTitle(resolver, calendarTitle)?.let { return it }
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar named $calendarTitle")
}
@@ -209,6 +224,7 @@ private object SystemCalendarDataSource : CalendarDataSource {
projection,
"${CalendarContract.Calendars.VISIBLE}=1",
null,
// Prefer Android's primary visible calendar, then lowest id for deterministic fallback.
"${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
@@ -342,6 +358,7 @@ class CalendarHandler private constructor(
if (paramsJson.isNullOrBlank()) {
val start = Instant.now()
val end = start.plus(7, ChronoUnit.DAYS)
// Default calendar read is a one-week window, not the full calendar store.
return CalendarEventsRequest(startMs = start.toEpochMilli(), endMs = end.toEpochMilli(), limit = DEFAULT_CALENDAR_LIMIT)
}
val params =
@@ -354,6 +371,7 @@ class CalendarHandler private constructor(
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
val resolvedStart = start ?: Instant.now()
val resolvedEnd = end ?: resolvedStart.plus(7, ChronoUnit.DAYS)
// Keep model-driven calendar reads bounded.
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALENDAR_LIMIT).coerceIn(1, 500)
return CalendarEventsRequest(
startMs = resolvedStart.toEpochMilli(),
@@ -390,6 +408,7 @@ class CalendarHandler private constructor(
private fun parseISO(raw: String?): Instant? {
val value = raw?.trim().orEmpty()
if (value.isEmpty()) return null
// Gateway calendar payloads use UTC ISO-8601 instants for unambiguous Android storage.
return try {
Instant.parse(value)
} catch (_: Throwable) {

View File

@@ -41,19 +41,25 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.math.roundToInt
/**
* CameraX-backed capture service used by gateway camera commands.
*/
class CameraCaptureManager(
private val context: Context,
) {
/** Base64 JSON response for camera.snap after resize and JPEG budget enforcement. */
data class Payload(
val payloadJson: String,
)
/** Temporary MP4 response for camera.clip before CameraHandler validates invoke size. */
data class FilePayload(
val file: File,
val durationMs: Long,
val hasAudio: Boolean,
)
/** Camera device metadata exposed through camera.list. */
data class CameraDeviceInfo(
val id: String,
val name: String,
@@ -65,14 +71,19 @@ class CameraCaptureManager(
@Volatile private var permissionRequester: PermissionRequester? = null
/** Supplies the foreground Activity lifecycle required by CameraX use-case binding. */
fun attachLifecycleOwner(owner: LifecycleOwner) {
// CameraX binds use cases to an Activity lifecycle; background services cannot capture alone.
lifecycleOwner = owner
}
/** Supplies the Activity-owned permission launcher used by camera and microphone commands. */
fun attachPermissionRequester(requester: PermissionRequester) {
// Permission prompts must be launched by the Activity that owns the ActivityResult registry.
permissionRequester = requester
}
/** Lists CameraX devices with stable Camera2 ids where available. */
suspend fun listDevices(): List<CameraDeviceInfo> =
withContext(Dispatchers.Main) {
val provider = context.cameraProvider()
@@ -107,6 +118,7 @@ class CameraCaptureManager(
}
}
/** Captures one still image and returns a gateway-sized JPEG payload. */
suspend fun snap(paramsJson: String?): Payload =
withContext(Dispatchers.Main) {
ensureCameraPermission()
@@ -122,6 +134,7 @@ class CameraCaptureManager(
val selector = resolveCameraSelector(provider, facing, deviceId)
provider.unbindAll()
// Bind only the still capture use case; CameraX owns camera open/close through the lifecycle owner.
provider.bindToLifecycle(owner, selector, capture)
val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor(), context.cacheDir)
@@ -179,6 +192,7 @@ class CameraCaptureManager(
}
}
/** Records a short MP4 clip into a temporary cache file for the caller to encode/delete. */
@SuppressLint("MissingPermission")
suspend fun clip(paramsJson: String?): FilePayload =
withContext(Dispatchers.Main) {
@@ -303,6 +317,7 @@ class CameraCaptureManager(
orientation: Int,
): Bitmap {
val matrix = Matrix()
// CameraX JPEG bytes keep sensor orientation in EXIF; normalize before resizing/encoding.
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
@@ -365,6 +380,7 @@ class CameraCaptureManager(
}
return CameraSelector
.Builder()
// CameraX selectors are filters over CameraInfo; pin by Camera2 id for stable device selection.
.addCameraFilter { infos -> infos.filter { cameraIdOrNull(it) == deviceId } }
.build()
}
@@ -419,7 +435,9 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider =
)
}
/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */
/**
* Returns JPEG bytes plus EXIF orientation so callers can normalize the decoded bitmap.
*/
private suspend fun ImageCapture.takeJpegWithExif(
executor: Executor,
tempDir: File,

View File

@@ -16,8 +16,14 @@ import kotlinx.serialization.json.put
internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
/**
* Raw MP4 size guard before base64 encoding the clip into a node.invoke response.
*/
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean = rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
/**
* Gateway camera command adapter that adds HUD feedback and payload-size enforcement.
*/
class CameraHandler(
private val appContext: Context,
private val camera: CameraCaptureManager,
@@ -26,6 +32,7 @@ class CameraHandler(
private val triggerCameraFlash: () -> Unit,
private val invokeErrorFromThrowable: (err: Throwable) -> Pair<String, String>,
) {
/** Handles camera.list by exposing CameraX devices through gateway metadata. */
suspend fun handleList(_paramsJson: String?): GatewaySession.InvokeResult =
try {
val devices = camera.listDevices()
@@ -53,6 +60,7 @@ class CameraHandler(
GatewaySession.InvokeResult.error(code = code, message = message)
}
/** Handles camera.snap with HUD progress, flash feedback, and normalized invoke errors. */
suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult {
val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
@@ -92,6 +100,7 @@ class CameraHandler(
}
}
/** Handles camera.clip and keeps external audio capture paused while camera audio is active. */
suspend fun handleClip(paramsJson: String?): GatewaySession.InvokeResult {
val clipLogFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
@@ -124,6 +133,7 @@ class CameraHandler(
val rawBytes = filePayload.file.length()
if (!isCameraClipWithinPayloadLimit(rawBytes)) {
clipLog("payload too large: bytes=$rawBytes max=$CAMERA_CLIP_MAX_RAW_BYTES")
// Delete oversized clips before returning so cache files do not accumulate after failed invokes.
withContext(Dispatchers.IO) { filePayload.file.delete() }
showCameraHud("Clip too large", CameraHudKind.Error, 2400)
return GatewaySession.InvokeResult.error(
@@ -152,6 +162,7 @@ class CameraHandler(
clipLog("stack: ${err.stackTraceToString().take(2000)}")
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera clip failed")
} finally {
// Prevent talk/transcription capture from competing with camera audio after every exit path.
if (includeAudio) externalAudioCaptureActive.value = false
}
}

View File

@@ -2,9 +2,14 @@ package ai.openclaw.app.node
import java.net.URI
/**
* Trust helper for WebView-originated canvas/A2UI actions.
*/
object CanvasActionTrust {
/** Local canvas scaffold is the only trusted file URL. */
const val scaffoldAssetUrl: String = "file:///android_asset/CanvasScaffold/scaffold.html"
/** Accepts local scaffold or exact remote A2UI URLs advertised by the gateway. */
fun isTrustedCanvasActionUrl(
rawUrl: String?,
trustedA2uiUrls: List<String>,
@@ -28,11 +33,14 @@ object CanvasActionTrust {
candidateUri: URI,
trustedUrl: String,
): Boolean {
// Gateway-advertised URLs are capabilities. Treat malformed entries as
// absent instead of broadening trust to same-origin or prefix matches.
val trustedUri = parseUri(trustedUrl) ?: return false
val normalizedTrusted = normalizeTrustedRemoteA2uiUri(trustedUri) ?: return false
return candidateUri == normalizedTrusted
}
/** Normalizes only the URL parts allowed to vary across trusted remote A2UI URLs. */
private fun normalizeTrustedRemoteA2uiUri(uri: URI): URI? {
// Keep Android trust normalization aligned with iOS ScreenController:
// exact remote URL match, scheme/host normalized, fragment ignored.
@@ -52,6 +60,7 @@ object CanvasActionTrust {
}
}
/** Parses untrusted WebView/gateway URL text without throwing into UI event handlers. */
private fun parseUri(raw: String): URI? =
try {
URI(raw)

View File

@@ -23,6 +23,9 @@ import org.json.JSONObject
import java.io.ByteArrayOutputStream
import kotlin.coroutines.resume
/**
* Owns the Android WebView canvas surface used by canvas and A2UI commands.
*/
class CanvasController {
enum class SnapshotFormat(
val rawValue: String,
@@ -60,19 +63,23 @@ class CanvasController {
return scale(maxWidth, scaledHeight)
}
/** Attaches the active WebView and replays state that may have arrived before the view existed. */
fun attach(webView: WebView) {
this.webView = webView
// Replay persisted state because WebView attachment can happen after gateway events arrive.
reload()
applyDebugStatus()
applyHomeCanvasState()
}
/** Detaches only the currently attached WebView instance. */
fun detach(webView: WebView) {
if (this.webView === webView) {
this.webView = null
}
}
/** Navigates the canvas to a remote URL or back to the bundled scaffold for blank/root input. */
fun navigate(url: String) {
val trimmed = url.trim()
this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed
@@ -113,6 +120,7 @@ class CanvasController {
if (Looper.myLooper() == Looper.getMainLooper()) {
block(wv)
} else {
// WebView APIs must run on the main thread.
wv.post { block(wv) }
}
}
@@ -178,6 +186,7 @@ class CanvasController {
}
}
/** Evaluates JavaScript against the attached WebView on the main thread. */
suspend fun eval(javaScript: String): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
@@ -206,6 +215,7 @@ class CanvasController {
}
}
/** Captures the WebView as PNG/JPEG base64 with optional width and quality bounds. */
suspend fun snapshotBase64(
format: SnapshotFormat,
quality: Double?,
@@ -246,17 +256,22 @@ class CanvasController {
}
companion object {
/**
* Parsed canvas.snapshot options used by invoke dispatch.
*/
data class SnapshotParams(
val format: SnapshotFormat,
val quality: Double?,
val maxWidth: Int?,
)
/** Parses canvas.navigate params and returns blank when the payload is missing or invalid. */
fun parseNavigateUrl(paramsJson: String?): String {
val obj = parseParamsObject(paramsJson) ?: return ""
return obj.string("url").trim()
}
/** Parses non-blank JavaScript from canvas.eval params. */
fun parseEvalJs(paramsJson: String?): String? {
val obj = parseParamsObject(paramsJson) ?: return null
val js = obj.string("javaScript").trim()
@@ -286,9 +301,11 @@ class CanvasController {
if (!obj.containsKey("quality")) return null
val q = obj.double("quality") ?: Double.NaN
if (!q.isFinite()) return null
// Keep JPEG quality inside encoder-safe bounds; PNG ignores it.
return q.coerceIn(0.1, 1.0)
}
/** Parses canvas.snapshot params using JPEG defaults and encoder-safe bounds. */
fun parseSnapshotParams(paramsJson: String?): SnapshotParams =
SnapshotParams(
format = parseSnapshotFormat(paramsJson),

View File

@@ -12,6 +12,9 @@ import ai.openclaw.app.gateway.isLocalCleartextGatewayHost
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import android.os.Build
/**
* Builds gateway connect metadata from current Android permissions, settings, and device identity.
*/
class ConnectionManager(
private val prefs: SecurePrefs,
private val cameraEnabled: () -> Boolean,
@@ -28,6 +31,9 @@ class ConnectionManager(
private val manualTls: () -> Boolean,
) {
companion object {
/**
* Decide whether a discovered/manual endpoint must use pinned TLS or can stay local cleartext.
*/
internal fun resolveTlsParamsForEndpoint(
endpoint: GatewayEndpoint,
storedFingerprint: String?,
@@ -44,6 +50,7 @@ class ConnectionManager(
}
if (isManual) {
// Manual remote hosts default to TLS; only local manual hosts may honor the cleartext toggle.
if (!manualTlsEnabled && cleartextAllowedHost) return null
if (!stored.isNullOrBlank()) {
return GatewayTlsParams(
@@ -83,6 +90,7 @@ class ConnectionManager(
}
if (!cleartextAllowedHost) {
// Non-loopback discovered hosts require TLS even without TXT hints.
return GatewayTlsParams(
required = true,
expectedFingerprint = null,
@@ -110,10 +118,15 @@ class ConnectionManager(
debugBuild = BuildConfig.DEBUG,
)
/** Builds the gateway-advertised node.invoke command list from current permission and feature state. */
fun buildInvokeCommands(): List<String> = InvokeCommandRegistry.advertisedCommands(runtimeFlags())
/** Builds the gateway-advertised capability list from current permission and feature state. */
fun buildCapabilities(): List<String> = InvokeCommandRegistry.advertisedCapabilities(runtimeFlags())
/**
* Debug Android builds advertise a dev version so gateway logs do not look like release clients.
*/
fun resolvedVersionName(): String {
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
@@ -123,12 +136,16 @@ class ConnectionManager(
}
}
/** Human-readable Android device model used in gateway client metadata. */
fun resolveModelIdentifier(): String? =
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { null }
/**
* User-Agent used for gateway telemetry and troubleshooting.
*/
fun buildUserAgent(): String {
val version = resolvedVersionName()
val release =
@@ -139,6 +156,7 @@ class ConnectionManager(
return "OpenClawAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})"
}
/** Client identity block shared by node and operator gateway sessions. */
fun buildClientInfo(
clientId: String,
clientMode: String,
@@ -154,6 +172,7 @@ class ConnectionManager(
modelIdentifier = resolveModelIdentifier(),
)
/** Connect options for the Android node session that exposes phone capabilities. */
fun buildNodeConnectOptions(): GatewayConnectOptions =
GatewayConnectOptions(
role = "node",
@@ -165,6 +184,7 @@ class ConnectionManager(
userAgent = buildUserAgent(),
)
/** Connect options for the Android operator session that drives approvals and UI actions. */
fun buildOperatorConnectOptions(): GatewayConnectOptions =
GatewayConnectOptions(
role = "operator",
@@ -181,6 +201,7 @@ class ConnectionManager(
userAgent = buildUserAgent(),
)
/** Resolves persisted TLS pin policy for a concrete gateway endpoint. */
fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
return resolveTlsParamsForEndpoint(endpoint, storedFingerprint = stored, manualTlsEnabled = manualTls())

View File

@@ -17,6 +17,9 @@ import kotlinx.serialization.json.put
private const val DEFAULT_CONTACTS_LIMIT = 25
/**
* Normalized Android contact row returned through the contacts commands.
*/
internal data class ContactRecord(
val identifier: String,
val displayName: String,
@@ -27,11 +30,17 @@ internal data class ContactRecord(
val emails: List<String>,
)
/**
* Parsed contacts.search request with bounded result count.
*/
internal data class ContactsSearchRequest(
val query: String?,
val limit: Int,
)
/**
* Parsed contacts.add request before ContentProviderOperation batching.
*/
internal data class ContactsAddRequest(
val givenName: String?,
val familyName: String?,
@@ -41,6 +50,9 @@ internal data class ContactsAddRequest(
val emails: List<String>,
)
/**
* Injectable ContactsProvider facade for command tests and Android runtime access.
*/
internal interface ContactsDataSource {
fun hasReadPermission(context: Context): Boolean
@@ -82,6 +94,7 @@ private object SystemContactsDataSource : ContactsDataSource {
selection = null
selectionArgs = null
} else {
// Escape wildcard characters so user text remains a substring search, not a LIKE pattern.
selection = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} LIKE ? ESCAPE '\\'"
selectionArgs = arrayOf("%${escapeLikePattern(request.query)}%")
}
@@ -119,6 +132,7 @@ private object SystemContactsDataSource : ContactsDataSource {
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
.build()
// Subsequent Data rows use back-reference 0 to attach to the RawContact inserted above.
if (!request.givenName.isNullOrEmpty() || !request.familyName.isNullOrEmpty() || !request.displayName.isNullOrEmpty()) {
operations +=
ContentProviderOperation
@@ -168,6 +182,7 @@ private object SystemContactsDataSource : ContactsDataSource {
rawContactUri.lastPathSegment?.toLongOrNull()
?: throw IllegalStateException("contact insert failed")
val contactId =
// Android returns the RawContact id; resolve the aggregate Contact id used by search APIs.
resolveContactIdForRawContact(resolver, rawContactId)
?: throw IllegalStateException("contact insert failed")
return loadContactRecord(
@@ -330,12 +345,16 @@ private object SystemContactsDataSource : ContactsDataSource {
}
}
/**
* Handles contacts.search and contacts.add gateway commands through Android ContactsProvider.
*/
class ContactsHandler private constructor(
private val appContext: Context,
private val dataSource: ContactsDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemContactsDataSource)
/** Searches contacts by optional display-name substring with bounded result count. */
fun handleContactsSearch(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasReadPermission(appContext)) {
return GatewaySession.InvokeResult.error(
@@ -369,6 +388,7 @@ class ContactsHandler private constructor(
}
}
/** Adds a local contact after validating that at least one user-visible field is present. */
fun handleContactsAdd(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasWritePermission(appContext)) {
return GatewaySession.InvokeResult.error(
@@ -418,6 +438,7 @@ class ContactsHandler private constructor(
null
} ?: return null
val query = (params["query"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }
// Keep gateway-driven searches bounded even if the model asks for a large contact dump.
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CONTACTS_LIMIT).coerceIn(1, 200)
return ContactsSearchRequest(query = query, limit = limit)
}
@@ -435,6 +456,7 @@ class ContactsHandler private constructor(
organizationName = (params["organizationName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
displayName = (params["displayName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
phoneNumbers = stringArray(params["phoneNumbers"] as? JsonArray),
// Store emails case-normalized so repeated model calls do not create casing-only duplicates.
emails = stringArray(params["emails"] as? JsonArray).map { it.lowercase() },
)
}
@@ -458,6 +480,7 @@ class ContactsHandler private constructor(
}
companion object {
/** Creates a handler with an injected contacts source for parser and payload tests. */
internal fun forTesting(
appContext: Context,
dataSource: ContactsDataSource,

View File

@@ -8,15 +8,21 @@ import kotlinx.serialization.json.JsonPrimitive
private const val LOGCAT_PATH = "/system/bin/logcat"
/**
* Debug-only node.invoke commands for Android cryptography and log diagnostics.
*/
class DebugHandler(
private val appContext: Context,
private val identityStore: DeviceIdentityStore,
) {
/**
* Runs an Ed25519 self-test and returns redacted diagnostics for debug builds.
*/
fun handleEd25519(): GatewaySession.InvokeResult {
if (!BuildConfig.DEBUG) {
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds")
}
// Self-test Ed25519 signing and return diagnostic info
// Self-test Ed25519 signing without returning full private/public key material.
try {
val identity = identityStore.loadOrCreate()
val testPayload = "test|${identity.deviceId}|${System.currentTimeMillis()}"
@@ -25,15 +31,14 @@ class DebugHandler(
results.add("publicKeyRawBase64: ${identity.publicKeyRawBase64.take(20)}...")
results.add("privateKeyPkcs8Base64: ${identity.privateKeyPkcs8Base64.take(20)}...")
// Test publicKeyBase64Url
// Public-key URL encoding must match the gateway device-auth payload contract.
val pubKeyUrl = identityStore.publicKeyBase64Url(identity)
results.add("publicKeyBase64Url: ${pubKeyUrl ?: "NULL (FAILED)"}")
// Test signing
// Sign/verify through DeviceIdentityStore to catch provider and key-format failures together.
val signature = identityStore.signPayload(testPayload, identity)
results.add("signPayload: ${if (signature != null) "${signature.take(20)}... (OK)" else "NULL (FAILED)"}")
// Test self-verify
if (signature != null) {
val verifyOk = identityStore.verifySelfSignature(testPayload, signature, identity)
results.add("verifySelfSignature: $verifyOk")
@@ -74,6 +79,9 @@ class DebugHandler(
}
}
/**
* Returns a filtered logcat snapshot plus CameraX debug log for debug builds.
*/
fun handleLogs(): GatewaySession.InvokeResult {
if (!BuildConfig.DEBUG) {
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds")
@@ -81,7 +89,7 @@ class DebugHandler(
val pid = android.os.Process.myPid()
val rt = Runtime.getRuntime()
val info = "v6 pid=$pid thread=${Thread.currentThread().name} free=${rt.freeMemory() / 1024}K total=${rt.totalMemory() / 1024}K max=${rt.maxMemory() / 1024}K uptime=${android.os.SystemClock.elapsedRealtime() / 1000}s sdk=${android.os.Build.VERSION.SDK_INT} device=${android.os.Build.MODEL}\n"
// Run logcat on current dispatcher thread (no withContext) with file redirect
// Capture only this process and redirect through a temp file to avoid blocking on pipe backpressure.
val logResult =
try {
val tmpFile = java.io.File(appContext.cacheDir, "debug_logs.txt")
@@ -123,6 +131,7 @@ class DebugHandler(
if (line.isBlank()) continue
if (spamPatterns.any { line.contains(it) }) continue
if (sb.length + line.length > 16000) {
// Keep debug.invoke responses small enough for the gateway WebSocket frame budget.
sb.append("\n(truncated)")
break
}
@@ -133,7 +142,7 @@ class DebugHandler(
} catch (e: Throwable) {
"(logcat error: ${e::class.java.simpleName}: ${e.message})"
}
// Also include camera debug log if it exists
// Camera capture writes a separate debug file because CameraX failures often happen off logcat's hot path.
val camLogFile = java.io.File(appContext.cacheDir, "camera_debug.log")
val camLog =
if (camLogFile.exists() && camLogFile.length() > 0) {

View File

@@ -24,6 +24,9 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.Locale
/**
* Gateway device command adapter for Android status, info, permission, and health snapshots.
*/
class DeviceHandler(
private val appContext: Context,
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
@@ -31,6 +34,9 @@ class DeviceHandler(
private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
) {
companion object {
/**
* SMS is available only when the feature flag, telephony hardware, and at least one SMS permission align.
*/
internal fun hasAnySmsCapability(
smsEnabled: Boolean,
telephonyAvailable: Boolean,
@@ -38,6 +44,9 @@ class DeviceHandler(
smsReadGranted: Boolean,
): Boolean = smsEnabled && telephonyAvailable && (smsSendGranted || smsReadGranted)
/**
* Prompt only when Android can grant a missing SMS permission that this build can use.
*/
internal fun isSmsPromptable(
smsEnabled: Boolean,
telephonyAvailable: Boolean,
@@ -53,12 +62,16 @@ class DeviceHandler(
val temperatureC: Double?,
)
/** Returns battery, storage, network, and uptime state for device.status. */
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(statusPayloadJson())
/** Returns stable Android hardware, OS, app, and locale metadata for device.info. */
fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(infoPayloadJson())
/** Returns permission and promptability state for Android capabilities exposed to the gateway. */
fun handleDevicePermissions(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(permissionsPayloadJson())
/** Returns coarse device health for memory, power, thermal, battery, and security patch state. */
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(healthPayloadJson())
private fun statusPayloadJson(): String {
@@ -71,6 +84,7 @@ class DeviceHandler(
val connectivity = appContext.getSystemService(ConnectivityManager::class.java)
val activeNetwork = connectivity?.activeNetwork
val caps = activeNetwork?.let { connectivity.getNetworkCapabilities(it) }
// elapsedRealtime is monotonic device uptime, not wall-clock time.
val uptimeSeconds = SystemClock.elapsedRealtime() / 1_000.0
return buildJsonObject {
@@ -154,6 +168,7 @@ class DeviceHandler(
if (!photosEnabled) {
false
} else if (Build.VERSION.SDK_INT >= 33) {
// Android 13 split media permissions; earlier versions use external storage.
hasPermission(Manifest.permission.READ_MEDIA_IMAGES)
} else {
hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
@@ -161,6 +176,7 @@ class DeviceHandler(
val motionGranted = hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)
val notificationsGranted =
if (Build.VERSION.SDK_INT >= 33) {
// POST_NOTIFICATIONS exists only on Android 13+.
hasPermission(Manifest.permission.POST_NOTIFICATIONS)
} else {
true
@@ -295,6 +311,7 @@ class DeviceHandler(
if (currentNowUa == null || currentNowUa == Long.MIN_VALUE) {
null
} else {
// BatteryManager reports microamps; expose milliamps in the gateway payload.
currentNowUa.toDouble() / 1_000.0
}
@@ -349,6 +366,7 @@ class DeviceHandler(
}
private fun readBatterySnapshot(): BatterySnapshot {
// ACTION_BATTERY_CHANGED is sticky; registerReceiver(null, ...) reads the last system snapshot.
val intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val status =
intent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN)
@@ -410,6 +428,7 @@ class DeviceHandler(
if (caps == null) return "unsatisfied"
return when {
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) -> "satisfied"
// Internet without validation mirrors iOS "requiresConnection" for captive or unproven networks.
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -> "requiresConnection"
else -> "unsatisfied"
}
@@ -436,6 +455,7 @@ class DeviceHandler(
if (totalBytes <= 0L) return if (lowMemory) "critical" else "unknown"
if (lowMemory) return "critical"
val freeRatio = availableBytes.toDouble() / totalBytes.toDouble()
// Thresholds intentionally mirror coarse OS health labels instead of exact memory pressure.
return when {
freeRatio <= 0.05 -> "critical"
freeRatio <= 0.15 -> "high"

View File

@@ -21,11 +21,18 @@ import kotlinx.serialization.json.put
private const val MAX_NOTIFICATION_TEXT_CHARS = 512
private const val NOTIFICATIONS_CHANGED_EVENT = "notifications.changed"
/**
* Trims notification text and caps payload size before it enters gateway-visible state.
*/
internal fun sanitizeNotificationText(value: CharSequence?): String? {
val normalized = value?.toString()?.trim().orEmpty()
// Notification extras can include long previews; cap before sending over node events.
return normalized.take(MAX_NOTIFICATION_TEXT_CHARS).ifEmpty { null }
}
/**
* Stable notification snapshot entry exposed through the Android notifications command.
*/
data class DeviceNotificationEntry(
val key: String,
val packageName: String,
@@ -53,24 +60,36 @@ internal fun DeviceNotificationEntry.toJsonObject(): JsonObject =
channelId?.let { put("channelId", JsonPrimitive(it)) }
}
/**
* Listener state exposed to the gateway, including whether Android has connected the service.
*/
data class DeviceNotificationSnapshot(
val enabled: Boolean,
val connected: Boolean,
val notifications: List<DeviceNotificationEntry>,
)
/**
* Gateway-supported notification actions mapped to Android listener operations.
*/
enum class NotificationActionKind {
Open,
Dismiss,
Reply,
}
/**
* Gateway action request; [key] must match Android's StatusBarNotification key.
*/
data class NotificationActionRequest(
val key: String,
val kind: NotificationActionKind,
val replyText: String? = null,
)
/**
* Normalized notification action result returned through node.invoke.
*/
data class NotificationActionResult(
val ok: Boolean,
val code: String? = null,
@@ -79,6 +98,9 @@ data class NotificationActionResult(
internal fun actionRequiresClearableNotification(kind: NotificationActionKind): Boolean = kind == NotificationActionKind.Dismiss
/**
* Process-local cache of active notifications mirrored from Android listener callbacks.
*/
private object DeviceNotificationStore {
private val lock = Any()
private var connected = false
@@ -109,6 +131,7 @@ private object DeviceNotificationStore {
synchronized(lock) {
connected = value
if (!value) {
// Android invalidates activeNotifications when the listener disconnects.
byKey.clear()
}
}
@@ -127,6 +150,9 @@ private object DeviceNotificationStore {
}
}
/**
* Android notification listener that mirrors notification state and executes gateway actions.
*/
class DeviceNotificationListenerService : NotificationListenerService() {
private val securePrefs by lazy { SecurePrefs(applicationContext) }
private val forwardingLimiter = NotificationBurstLimiter()
@@ -226,6 +252,7 @@ class DeviceNotificationListenerService : NotificationListenerService() {
if (policy.isWithinQuietHours(nowEpochMs = nowEpochMs)) {
return null
}
// Apply burst limits after package/quiet-hour filters so blocked notifications do not consume quota.
if (!forwardingLimiter.allow(nowEpochMs, policy.maxEventsPerMinute)) {
return null
}
@@ -288,6 +315,7 @@ class DeviceNotificationListenerService : NotificationListenerService() {
private fun serviceComponent(context: Context): ComponentName = ComponentName(context, DeviceNotificationListenerService::class.java)
/** Installs the node event sink used to emit filtered notification change events. */
fun setNodeEventSink(sink: ((event: String, payloadJson: String?) -> Unit)?) {
nodeEventSink = sink
}
@@ -299,6 +327,7 @@ class DeviceNotificationListenerService : NotificationListenerService() {
val hasNew = prefs.contains(recentPackagesPref)
val legacy = prefs.getString(legacyRecentPackagesPref, null)?.trim().orEmpty()
if (!hasNew && legacy.isNotEmpty()) {
// Keep recent package suggestions across the preference-key rename.
prefs.edit {
putString(recentPackagesPref, legacy)
remove(legacyRecentPackagesPref)
@@ -308,6 +337,7 @@ class DeviceNotificationListenerService : NotificationListenerService() {
}
}
/** Returns recent third-party packages seen by the listener for settings suggestions. */
fun recentPackages(context: Context): List<String> {
migrateLegacyRecentPackagesIfNeeded(context)
val prefs = recentPackagesPrefs(context)
@@ -319,22 +349,26 @@ class DeviceNotificationListenerService : NotificationListenerService() {
.distinct()
}
/** Checks whether Android has granted listener access to this service component. */
fun isAccessEnabled(context: Context): Boolean {
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
}
/** Reads the current mirrored notification snapshot without forcing service startup. */
fun snapshot(
context: Context,
enabled: Boolean = isAccessEnabled(context),
): DeviceNotificationSnapshot = DeviceNotificationStore.snapshot(enabled = enabled)
/** Asks Android to rebind the listener after settings grant access but callbacks have not arrived. */
fun requestServiceRebind(context: Context) {
runCatching {
NotificationListenerService.requestRebind(serviceComponent(context))
}
}
/** Executes an open, dismiss, or reply action through the active listener instance. */
fun executeAction(
context: Context,
request: NotificationActionRequest,
@@ -376,6 +410,7 @@ class DeviceNotificationListenerService : NotificationListenerService() {
.map { it.trim() }
.filter { it.isNotEmpty() && it != normalized }
.take(recentPackagesLimit - 1)
// Most recent package first keeps settings suggestions useful without storing notification content.
val updated = listOf(normalized) + existing
prefs.edit { putString(recentPackagesPref, updated.joinToString(",")) }
}
@@ -449,6 +484,7 @@ class DeviceNotificationListenerService : NotificationListenerService() {
val action =
sbn.notification.actions
?.firstOrNull { candidate ->
// Android reply actions are identified by RemoteInput, not by a stable action title.
candidate.actionIntent != null && !candidate.remoteInputs.isNullOrEmpty()
}
?: return NotificationActionResult(

View File

@@ -9,6 +9,9 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
/**
* Handles gateway-originated events that need to update local Android preferences.
*/
class GatewayEventHandler(
private val scope: CoroutineScope,
private val prefs: SecurePrefs,
@@ -19,12 +22,14 @@ class GatewayEventHandler(
private var suppressWakeWordsSync = false
private var wakeWordsSyncJob: Job? = null
/** Applies gateway wake words locally without echoing the same change back to the gateway. */
fun applyWakeWordsFromGateway(words: List<String>) {
suppressWakeWordsSync = true
prefs.setWakeWords(words)
suppressWakeWordsSync = false
}
/** Debounces local wake-word edits before sending voicewake.set to the operator session. */
fun scheduleWakeWordsSyncIfNeeded() {
if (suppressWakeWordsSync) return
if (!isConnected()) return
@@ -44,6 +49,7 @@ class GatewayEventHandler(
}
}
/** Loads gateway wake words on connect so Android settings show server truth. */
suspend fun refreshWakeWordsFromGateway() {
if (!isConnected()) return
try {
@@ -57,6 +63,7 @@ class GatewayEventHandler(
}
}
/** Applies voicewake.changed event payloads emitted by the gateway. */
fun handleVoiceWakeChangedEvent(payloadJson: String?) {
if (payloadJson.isNullOrBlank()) return
try {

View File

@@ -16,6 +16,7 @@ import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
import ai.openclaw.app.protocol.OpenClawTalkCommand
/** Runtime feature flags used to decide which node tools are advertised. */
data class NodeRuntimeFlags(
val cameraEnabled: Boolean,
val locationEnabled: Boolean,
@@ -30,6 +31,7 @@ data class NodeRuntimeFlags(
val debugBuild: Boolean,
)
/** Per-command availability gates checked before advertising invoke methods. */
enum class InvokeCommandAvailability {
Always,
CameraEnabled,
@@ -44,6 +46,7 @@ enum class InvokeCommandAvailability {
DebugBuild,
}
/** Per-capability availability gates for the node capabilities manifest. */
enum class NodeCapabilityAvailability {
Always,
CameraEnabled,
@@ -55,11 +58,13 @@ enum class NodeCapabilityAvailability {
MotionAvailable,
}
/** Capability entry reported to the gateway when its availability gate passes. */
data class NodeCapabilitySpec(
val name: String,
val availability: NodeCapabilityAvailability = NodeCapabilityAvailability.Always,
)
/** Invoke method entry advertised to gateway plus foreground routing metadata. */
data class InvokeCommandSpec(
val name: String,
val requiresForeground: Boolean = false,
@@ -67,6 +72,7 @@ data class InvokeCommandSpec(
)
object InvokeCommandRegistry {
/** Capabilities mirror gateway protocol ids and are filtered by device state. */
val capabilityManifest: List<NodeCapabilitySpec> =
listOf(
NodeCapabilitySpec(name = OpenClawCapability.Canvas.rawValue),
@@ -106,6 +112,7 @@ object InvokeCommandRegistry {
),
)
/** Complete Android node command catalog before runtime availability filtering. */
val all: List<InvokeCommandSpec> =
listOf(
InvokeCommandSpec(
@@ -240,8 +247,10 @@ object InvokeCommandRegistry {
private val byNameInternal: Map<String, InvokeCommandSpec> = all.associateBy { it.name }
/** Finds the command metadata used by dispatch and advertised-method builders. */
fun find(command: String): InvokeCommandSpec? = byNameInternal[command]
/** Returns gateway capability ids the current Android device can actually serve. */
fun advertisedCapabilities(flags: NodeRuntimeFlags): List<String> =
capabilityManifest
.filter { spec ->
@@ -257,6 +266,7 @@ object InvokeCommandRegistry {
}
}.map { it.name }
/** Returns gateway invoke method ids available under current permissions/build flags. */
fun advertisedCommands(flags: NodeRuntimeFlags): List<String> =
all
.filter { spec ->

View File

@@ -15,12 +15,16 @@ import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
import ai.openclaw.app.protocol.OpenClawTalkCommand
/** Runtime state for SMS search, split so permission prompts are not reported as hard unavailability. */
internal enum class SmsSearchAvailabilityReason {
Available,
PermissionRequired,
Unavailable,
}
/**
* Distinguish permanent SMS search unavailability from permission-gated search.
*/
internal fun classifySmsSearchAvailability(
readSmsAvailable: Boolean,
smsFeatureEnabled: Boolean,
@@ -53,6 +57,9 @@ internal fun smsSearchAvailabilityError(
)
}
/**
* Gateway node.invoke command router for Android-owned capabilities.
*/
class InvokeDispatcher(
private val canvas: CanvasController,
private val cameraHandler: CameraHandler,
@@ -85,6 +92,7 @@ class InvokeDispatcher(
private val motionActivityAvailable: () -> Boolean,
private val motionPedometerAvailable: () -> Boolean,
) {
/** Dispatches one gateway node.invoke command after foreground and availability gates pass. */
suspend fun handleInvoke(
command: String,
paramsJson: String?,
@@ -96,6 +104,7 @@ class InvokeDispatcher(
message = "INVALID_REQUEST: unknown command",
)
if (spec.requiresForeground && !isForeground()) {
// Canvas, camera, and screen-backed commands need an active Activity/WebView surface.
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
@@ -103,6 +112,7 @@ class InvokeDispatcher(
}
availabilityError(spec.availability)?.let { return it }
// Command strings come from OpenClawProtocolConstants; the registry above owns advertised availability.
return when (command) {
// Canvas commands
OpenClawCanvasCommand.Present.rawValue -> {
@@ -239,6 +249,7 @@ class InvokeDispatcher(
)
val readyOnFirstCheck = a2uiHandler.ensureA2uiReady(a2uiUrl)
if (!readyOnFirstCheck) {
// Gateway canvas host metadata can lag reconnects; refresh once before failing the command.
refreshCanvasHostUrl()
a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: a2uiUrl
if (!a2uiHandler.ensureA2uiReady(a2uiUrl)) {
@@ -255,6 +266,7 @@ class InvokeDispatcher(
try {
block()
} catch (_: Throwable) {
// WebView calls throw when the Activity is backgrounded between the foreground check and execution.
GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
@@ -312,6 +324,7 @@ class InvokeDispatcher(
InvokeCommandAvailability.ReadSmsAvailable,
InvokeCommandAvailability.RequestableSmsSearchAvailable,
->
// SMS search may still be advertised as promptable; runtime invoke fails only on permanent unavailability.
smsSearchAvailabilityError(
readSmsAvailable = readSmsAvailable(),
smsFeatureEnabled = smsFeatureEnabled(),
@@ -347,12 +360,19 @@ class InvokeDispatcher(
}
}
/**
* Talk-mode command adapter implemented by the voice subsystem.
*/
interface TalkHandler {
/** Starts a push-to-talk capture session and keeps it open until stop or cancel. */
suspend fun handlePttStart(paramsJson: String?): GatewaySession.InvokeResult
/** Finishes the active push-to-talk capture and submits recognized speech. */
suspend fun handlePttStop(paramsJson: String?): GatewaySession.InvokeResult
/** Aborts the active push-to-talk capture without submitting speech. */
suspend fun handlePttCancel(paramsJson: String?): GatewaySession.InvokeResult
/** Runs a bounded one-shot push-to-talk capture. */
suspend fun handlePttOnce(paramsJson: String?): GatewaySession.InvokeResult
}

View File

@@ -4,6 +4,9 @@ import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* Result of a JPEG compression attempt after quality and scale reductions.
*/
internal data class JpegSizeLimiterResult(
val bytes: ByteArray,
val width: Int,
@@ -11,7 +14,11 @@ internal data class JpegSizeLimiterResult(
val quality: Int,
)
/**
* Utility that searches quality/scale combinations until a JPEG fits a byte budget.
*/
internal object JpegSizeLimiter {
/** Compresses with the caller-provided encoder, reducing quality before image dimensions. */
fun compressToLimit(
initialWidth: Int,
initialHeight: Int,

View File

@@ -14,6 +14,9 @@ import kotlinx.coroutines.withTimeout
import java.time.Instant
import java.time.format.DateTimeFormatter
/**
* Android LocationManager-backed capture used by gateway location commands.
*/
class LocationCaptureManager(
private val context: Context,
) {
@@ -35,6 +38,7 @@ class LocationCaptureManager(
throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled")
}
// Prefer a recent cached fix before waking GPS/network providers.
val cached = bestLastKnown(manager, desiredProviders, maxAgeMs)
val location =
cached ?: requestCurrent(manager, desiredProviders, timeoutMs)
@@ -81,6 +85,7 @@ class LocationCaptureManager(
val candidates =
providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
val freshest = candidates.maxByOrNull { it.time } ?: return null
// maxAgeMs is a caller contract; stale cached fixes force a live provider request.
if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null
return freshest
}
@@ -102,6 +107,7 @@ class LocationCaptureManager(
val resolved =
providers.firstOrNull { manager.isProviderEnabled(it) }
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
// getCurrentLocation can return null; the handler maps timeout/null fixes to gateway error shapes.
val location =
withTimeout(timeoutMs.coerceAtLeast(1)) {
suspendCancellableCoroutine<Location?> { cont ->

View File

@@ -10,6 +10,9 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
/**
* Injectable location facade for command tests and Android runtime access.
*/
internal interface LocationDataSource {
fun hasFinePermission(context: Context): Boolean
@@ -69,11 +72,14 @@ class LocationHandler private constructor(
locationPreciseEnabled = locationPreciseEnabled,
)
/** Reports whether precise GPS-backed location can be requested from Android. */
fun hasFineLocationPermission(): Boolean = dataSource.hasFinePermission(appContext)
/** Reports whether network/coarse location can be requested from Android. */
fun hasCoarseLocationPermission(): Boolean = dataSource.hasCoarsePermission(appContext)
companion object {
/** Creates a handler with injected location state for permission and payload tests. */
internal fun forTesting(
appContext: Context,
dataSource: LocationDataSource,
@@ -90,8 +96,10 @@ class LocationHandler private constructor(
)
}
/** Handles location.get with foreground, permission, and user precision gates applied. */
suspend fun handleLocationGet(paramsJson: String?): GatewaySession.InvokeResult {
if (!isForeground()) {
// Android foreground restrictions and user expectation keep live location tied to the visible app.
return GatewaySession.InvokeResult.error(
code = "LOCATION_BACKGROUND_UNAVAILABLE",
message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open",
@@ -105,6 +113,8 @@ class LocationHandler private constructor(
}
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
val preciseEnabled = locationPreciseEnabled()
// Gateway requests are advisory; Android permission and user settings decide
// whether precise capture is actually allowed for this invocation.
val accuracy =
when (desiredAccuracy) {
"precise" -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced"
@@ -113,6 +123,7 @@ class LocationHandler private constructor(
}
val providers =
when (accuracy) {
// Provider order is part of the accuracy policy: GPS first for precise, network first otherwise.
"precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
"coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
@@ -151,6 +162,7 @@ class LocationHandler private constructor(
val timeoutMs =
(root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L)
?: 10_000L
// desiredAccuracy is advisory; invalid values fall through to the default policy.
val desiredAccuracy =
(root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase()
return Triple(maxAgeMs, timeoutMs, desiredAccuracy)

View File

@@ -25,17 +25,20 @@ import kotlin.math.sqrt
private const val ACCELEROMETER_SAMPLE_TARGET = 20
private const val ACCELEROMETER_SAMPLE_TIMEOUT_MS = 6_000L
/** Gateway request for motion.activity after parsing and limit bounds. */
internal data class MotionActivityRequest(
val startISO: String?,
val endISO: String?,
val limit: Int,
)
/** Gateway request for motion.pedometer. */
internal data class MotionPedometerRequest(
val startISO: String?,
val endISO: String?,
)
/** Motion activity sample returned in gateway-compatible boolean flags. */
internal data class MotionActivityRecord(
val startISO: String,
val endISO: String,
@@ -48,6 +51,7 @@ internal data class MotionActivityRecord(
val isUnknown: Boolean,
)
/** Pedometer sample returned from Android's cumulative step counter. */
internal data class PedometerRecord(
val startISO: String,
val endISO: String,
@@ -57,6 +61,7 @@ internal data class PedometerRecord(
val floorsDescended: Int?,
)
/** Motion data seam for Android sensors and tests. */
internal interface MotionDataSource {
fun isActivityAvailable(context: Context): Boolean
@@ -97,6 +102,8 @@ private object SystemMotionDataSource : MotionDataSource {
request: MotionActivityRequest,
): MotionActivityRecord {
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
// Android does not expose historical activity samples here; fail with a
// stable gateway code instead of pretending the range is empty.
throw IllegalArgumentException("MOTION_RANGE_UNAVAILABLE: historical activity range not supported on Android")
}
val sensorManager =
@@ -130,6 +137,7 @@ private object SystemMotionDataSource : MotionDataSource {
request: MotionPedometerRequest,
): PedometerRecord {
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
// TYPE_STEP_COUNTER is cumulative since boot, not a historical query API.
throw IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: historical pedometer range not supported on Android")
}
val sensorManager =
@@ -216,6 +224,8 @@ private object SystemMotionDataSource : MotionDataSource {
sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble())
count += 1
if (count >= ACCELEROMETER_SAMPLE_TARGET) {
// Average gravity-adjusted magnitude across a short window so
// one noisy sensor event cannot decide the activity label.
val result =
AccelerometerSample(
samples = count,
@@ -260,12 +270,14 @@ private object SystemMotionDataSource : MotionDataSource {
}
}
/** Handles Android motion-related node.invoke commands backed by live sensors. */
class MotionHandler private constructor(
private val appContext: Context,
private val dataSource: MotionDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemMotionDataSource)
/** Classifies a short accelerometer sample into the gateway activity shape. */
suspend fun handleMotionActivity(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasPermission(appContext)) {
return GatewaySession.InvokeResult.error(
@@ -313,6 +325,7 @@ class MotionHandler private constructor(
}
}
/** Returns the current boot-scoped Android step-counter reading. */
suspend fun handleMotionPedometer(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasPermission(appContext)) {
return GatewaySession.InvokeResult.error(
@@ -350,8 +363,10 @@ class MotionHandler private constructor(
fun isAvailable(): Boolean = dataSource.isAvailable(appContext)
/** Returns true when live accelerometer classification can be sampled. */
fun isActivityAvailable(): Boolean = dataSource.isActivityAvailable(appContext)
/** Returns true when Android exposes a cumulative step-counter sensor. */
fun isPedometerAvailable(): Boolean = dataSource.isPedometerAvailable(appContext)
private fun parseActivityRequest(paramsJson: String?): MotionActivityRequest? {
@@ -364,6 +379,8 @@ class MotionHandler private constructor(
} catch (_: Throwable) {
null
} ?: return null
// Keep the accepted gateway parameter even though Android can only return
// one live classification sample for now.
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 200).coerceIn(1, 1000)
return MotionActivityRequest(
startISO = (params["startISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
@@ -389,8 +406,10 @@ class MotionHandler private constructor(
}
companion object {
/** Static capability probe used before a MotionHandler instance is needed. */
fun isMotionCapabilityAvailable(context: Context): Boolean = SystemMotionDataSource.isAvailable(context)
/** Creates a handler with an injected sensor source for parser and payload tests. */
internal fun forTesting(
appContext: Context,
dataSource: MotionDataSource,

View File

@@ -6,10 +6,16 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
internal object NodePresenceAliveBeacon {
/** Gateway event emitted by Android when background execution confirms liveness. */
const val EVENT_NAME: String = "node.presence.alive"
/** Avoids spamming presence when multiple background triggers fire together. */
const val MIN_SUCCESS_INTERVAL_MS: Long = 10 * 60 * 1000
private const val MAX_RESPONSE_JSON_CHARS: Int = 16 * 1024
/**
* Source of the liveness event, serialized as gateway-stable wire values.
*/
enum class Trigger(
val rawValue: String,
) {
@@ -21,6 +27,9 @@ internal object NodePresenceAliveBeacon {
Connect("connect"),
}
/**
* Minimal gateway response fields used to decide whether a liveness event was accepted.
*/
data class ResponsePayload(
val ok: Boolean?,
val event: String?,
@@ -30,6 +39,7 @@ internal object NodePresenceAliveBeacon {
private val json = Json { ignoreUnknownKeys = true }
/** Skips sends after a recent successful presence update. */
fun shouldSkipRecentSuccess(
nowMs: Long,
lastSuccessAtMs: Long?,
@@ -41,6 +51,7 @@ internal object NodePresenceAliveBeacon {
return elapsed >= 0 && elapsed < minIntervalMs
}
/** Human-readable Android version label included in presence payloads. */
fun androidPlatformLabel(): String {
val release =
Build.VERSION.RELEASE
@@ -50,6 +61,7 @@ internal object NodePresenceAliveBeacon {
return "Android $release (SDK ${Build.VERSION.SDK_INT})"
}
/** Builds the compact JSON payload consumed by gateway node-presence handlers. */
fun makePayloadJson(
trigger: Trigger,
sentAtMs: Long,
@@ -71,8 +83,11 @@ internal object NodePresenceAliveBeacon {
pushTransport?.trim()?.takeIf { it.isNotEmpty() }?.let { put("pushTransport", JsonPrimitive(it)) }
}.toString()
/** Parses the gateway response while rejecting empty, oversized, or malformed payloads. */
fun decodeResponse(payloadJson: String?): ResponsePayload? {
val raw = payloadJson?.trim()?.takeIf { it.isNotEmpty() } ?: return null
// Bound log/IPC responses before JSON parsing to avoid memory spikes from
// malformed gateway replies.
if (raw.length > MAX_RESPONSE_JSON_CHARS) return null
val obj =
try {
@@ -88,6 +103,7 @@ internal object NodePresenceAliveBeacon {
)
}
/** Sanitizes gateway response reasons before writing them into Android logs. */
fun sanitizeReasonForLog(raw: String?): String {
val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: "unsupported"
return value

View File

@@ -8,8 +8,10 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
/** Default canvas seam color used when gateway/user params omit a hex color. */
const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
/** Small tuple used by Android node handlers that need four return values. */
data class Quad<A, B, C, D>(
val first: A,
val second: B,
@@ -17,6 +19,7 @@ data class Quad<A, B, C, D>(
val fourth: D,
)
/** Escapes a Kotlin string into a JSON string literal without building a JsonElement. */
fun String.toJsonString(): String {
val escaped =
this
@@ -29,6 +32,7 @@ fun String.toJsonString(): String {
fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
/** Parses invoke params into a JSON object, returning null for absent/malformed input. */
fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
if (paramsJson.isNullOrBlank()) return null
return try {
@@ -38,26 +42,31 @@ fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
}
}
/** Reads a primitive field from invoke params without accepting arrays/objects. */
fun readJsonPrimitive(
params: JsonObject?,
key: String,
): JsonPrimitive? = params?.get(key) as? JsonPrimitive
/** Parses an optional integer invoke param. */
fun parseJsonInt(
params: JsonObject?,
key: String,
): Int? = readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull()
/** Parses an optional decimal invoke param. */
fun parseJsonDouble(
params: JsonObject?,
key: String,
): Double? = readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull()
/** Parses an optional string invoke param. */
fun parseJsonString(
params: JsonObject?,
key: String,
): String? = readJsonPrimitive(params, key)?.contentOrNull
/** Parses strict true/false flags from string-like JSON primitives. */
fun parseJsonBooleanFlag(
params: JsonObject?,
key: String,
@@ -70,6 +79,7 @@ fun parseJsonBooleanFlag(
}
}
/** Converts JSON null to Kotlin null while preserving primitive text content. */
fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null
@@ -77,6 +87,7 @@ fun JsonElement?.asStringOrNull(): String? =
else -> null
}
/** Parses #RRGGBB or RRGGBB into opaque ARGB. */
fun parseHexColorArgb(raw: String?): Long? {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return null
@@ -86,15 +97,18 @@ fun parseHexColorArgb(raw: String?): Long? {
return 0xFF000000L or rgb
}
/** Converts gateway invocation throwables into protocol code/message pairs. */
fun invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = "UNAVAILABLE: error")
val message = if (parsed.hadExplicitCode) parsed.prefixedMessage else parsed.message
return parsed.code to message
}
/** Normalizes user/session keys while preserving main as the canonical session id. */
fun normalizeMainKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()
return if (trimmed.isEmpty()) null else trimmed
}
/** Returns true only for the canonical main-session key understood by gateway UI. */
fun isCanonicalMainSessionKey(key: String): Boolean = key == "main"

View File

@@ -10,6 +10,9 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
/**
* Injectable notification listener facade so command parsing can be tested without Android service state.
*/
internal interface NotificationsStateProvider {
fun readSnapshot(context: Context): DeviceNotificationSnapshot
@@ -22,6 +25,7 @@ internal interface NotificationsStateProvider {
}
private object SystemNotificationsStateProvider : NotificationsStateProvider {
/** Reads listener state through Android APIs and returns a disabled snapshot when access is missing. */
override fun readSnapshot(context: Context): DeviceNotificationSnapshot {
val enabled = DeviceNotificationListenerService.isAccessEnabled(context)
if (!enabled) {
@@ -34,27 +38,32 @@ private object SystemNotificationsStateProvider : NotificationsStateProvider {
return DeviceNotificationListenerService.snapshot(context, enabled = true)
}
/** Requests a platform listener rebind after access has been granted. */
override fun requestServiceRebind(context: Context) {
DeviceNotificationListenerService.requestServiceRebind(context)
}
/** Delegates actions to the active listener service instance. */
override fun executeAction(
context: Context,
request: NotificationActionRequest,
): NotificationActionResult = DeviceNotificationListenerService.executeAction(context, request)
}
/** Handles notification listing and actions via the Android listener service. */
class NotificationsHandler private constructor(
private val appContext: Context,
private val stateProvider: NotificationsStateProvider,
) {
constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider)
/** Lists the current listener snapshot after nudging Android to reconnect if needed. */
suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult {
val snapshot = readSnapshotWithRebind()
return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot))
}
/** Executes an action against a notification key from the current listener snapshot. */
suspend fun handleNotificationsActions(paramsJson: String?): GatewaySession.InvokeResult {
readSnapshotWithRebind()
@@ -76,6 +85,8 @@ class NotificationsHandler private constructor(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: action required (open|dismiss|reply)",
)
// Keep accepted action names aligned with the cross-platform notification
// command contract rather than Android-specific PendingIntent labels.
val action =
when (actionRaw) {
"open" -> NotificationActionKind.Open
@@ -123,6 +134,7 @@ class NotificationsHandler private constructor(
private fun readSnapshotWithRebind(): DeviceNotificationSnapshot {
val snapshot = stateProvider.readSnapshot(appContext)
if (snapshot.enabled && !snapshot.connected) {
// Access can be granted while Android has not rebound the listener yet.
stateProvider.requestServiceRebind(appContext)
}
return snapshot

View File

@@ -29,12 +29,14 @@ private const val DEFAULT_PHOTOS_QUALITY = 0.85
private const val MAX_TOTAL_BASE64_CHARS = 340 * 1024
private const val MAX_PER_PHOTO_BASE64_CHARS = 300 * 1024
/** Request shape for photos.latest after defaults and bounds are applied. */
internal data class PhotosLatestRequest(
val limit: Int,
val maxWidth: Int,
val quality: Double,
)
/** Encoded photo payload returned to the gateway. */
internal data class EncodedPhotoPayload(
val format: String,
val base64: String,
@@ -43,6 +45,7 @@ internal data class EncodedPhotoPayload(
val createdAt: String?,
)
/** Photo access seam for Android MediaStore and tests. */
internal interface PhotosDataSource {
fun hasPermission(context: Context): Boolean
@@ -53,6 +56,7 @@ internal interface PhotosDataSource {
}
private object SystemPhotosDataSource : PhotosDataSource {
/** Checks the API-specific image read permission used by MediaStore image access. */
override fun hasPermission(context: Context): Boolean {
val permission =
if (Build.VERSION.SDK_INT >= 33) {
@@ -77,6 +81,8 @@ private object SystemPhotosDataSource : PhotosDataSource {
if (remainingBudget <= 0) break
val bitmap = decodeScaledBitmap(resolver, row.uri, request.maxWidth) ?: continue
try {
// Enforce both per-photo and total payload budgets before returning
// base64 data through the gateway invoke response.
val encoded = encodeJpegUnderBudget(bitmap, request.quality, MAX_PER_PHOTO_BASE64_CHARS)
if (encoded == null) continue
if (encoded.base64.length > remainingBudget) break
@@ -172,6 +178,8 @@ private object SystemPhotosDataSource : PhotosDataSource {
} ?: return null
if (decoded.width <= maxWidth) return decoded
// Decode sampling is power-of-two only; finish with exact scaling when the
// sampled bitmap is still wider than the requested max width.
val targetHeight = max(1, ((decoded.height.toDouble() * maxWidth) / decoded.width).roundToInt())
return try {
decoded.scale(maxWidth, targetHeight, true)
@@ -215,6 +223,7 @@ private object SystemPhotosDataSource : PhotosDataSource {
)
}
if (jpegQuality > 35) {
// Try quality reduction before resizing so small images keep detail.
jpegQuality = max(25, jpegQuality - 15)
return@repeat
}
@@ -232,12 +241,14 @@ private object SystemPhotosDataSource : PhotosDataSource {
}
}
/** Handles photos.latest by querying MediaStore and returning bounded JPEG payloads. */
class PhotosHandler private constructor(
private val appContext: Context,
private val dataSource: PhotosDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemPhotosDataSource)
/** Returns the newest accessible photos as gateway-sized base64 JPEGs. */
fun handlePhotosLatest(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasPermission(appContext)) {
return GatewaySession.InvokeResult.error(
@@ -300,6 +311,7 @@ class PhotosHandler private constructor(
val maxWidthRaw = (params["maxWidth"] as? JsonPrimitive)?.content?.toIntOrNull()
val qualityRaw = (params["quality"] as? JsonPrimitive)?.content?.toDoubleOrNull()
// Clamp model-supplied values to protect memory and response-size limits.
val limit = (limitRaw ?: DEFAULT_PHOTOS_LIMIT).coerceIn(1, 20)
val maxWidth = (maxWidthRaw ?: DEFAULT_PHOTOS_MAX_WIDTH).coerceIn(240, 4096)
val quality = (qualityRaw ?: DEFAULT_PHOTOS_QUALITY).coerceIn(0.1, 1.0)
@@ -307,6 +319,7 @@ class PhotosHandler private constructor(
}
companion object {
/** Creates a handler with an injected photo source for parser and payload tests. */
internal fun forTesting(
appContext: Context,
dataSource: PhotosDataSource,

View File

@@ -17,6 +17,7 @@ import kotlinx.serialization.json.contentOrNull
private const val NOTIFICATION_CHANNEL_BASE_ID = "openclaw.system.notify"
/** Parsed payload for system.notify invocations. */
internal data class SystemNotifyRequest(
val title: String,
val body: String,
@@ -24,6 +25,7 @@ internal data class SystemNotifyRequest(
val priority: String?,
)
/** Notification posting seam used by production Android and unit tests. */
internal interface SystemNotificationPoster {
fun isAuthorized(): Boolean
@@ -33,6 +35,7 @@ internal interface SystemNotificationPoster {
private class AndroidSystemNotificationPoster(
private val appContext: Context,
) : SystemNotificationPoster {
/** Checks both Android 13 runtime permission and app-level notification enablement. */
override fun isAuthorized(): Boolean {
if (Build.VERSION.SDK_INT >= 33) {
val granted =
@@ -43,6 +46,7 @@ private class AndroidSystemNotificationPoster(
return NotificationManagerCompat.from(appContext).areNotificationsEnabled()
}
/** Posts through a priority-specific channel so Android's immutable channel importance is respected. */
override fun post(request: SystemNotifyRequest) {
val channelId = ensureChannel(request.priority)
val silent = isSilentSound(request.sound)
@@ -69,6 +73,8 @@ private class AndroidSystemNotificationPoster(
private fun ensureChannel(priority: String?): String {
val normalizedPriority = priority.orEmpty().trim().lowercase()
// Android channel importance is immutable after creation, so priority maps
// to stable channel ids instead of mutating one shared channel.
val (suffix, importance, name) =
when (normalizedPriority) {
"passive" -> Triple("passive", NotificationManager.IMPORTANCE_LOW, "OpenClaw Passive")
@@ -97,11 +103,13 @@ private class AndroidSystemNotificationPoster(
}
}
/** Handles system-level node.invoke commands implemented by Android services. */
class SystemHandler private constructor(
private val poster: SystemNotificationPoster,
) {
constructor(appContext: Context) : this(poster = AndroidSystemNotificationPoster(appContext))
/** Posts an Android notification from the gateway system.notify command. */
fun handleSystemNotify(paramsJson: String?): GatewaySession.InvokeResult {
val params =
parseNotifyRequest(paramsJson)
@@ -139,6 +147,8 @@ class SystemHandler private constructor(
private fun parseNotifyRequest(paramsJson: String?): SystemNotifyRequest? {
val params = parseParamsObject(paramsJson) ?: return null
// title/body are required by the gateway contract; optional fields only
// influence Android channel/silence behavior.
val rawTitle =
(params["title"] as? JsonPrimitive)
?.contentOrNull
@@ -167,6 +177,7 @@ class SystemHandler private constructor(
}
companion object {
/** Creates a handler with a fake poster for parser and authorization tests. */
internal fun forTesting(poster: SystemNotificationPoster): SystemHandler = SystemHandler(poster)
}
}

View File

@@ -4,6 +4,7 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
object OpenClawCanvasA2UIAction {
/** Reads the agent-facing action name from either the modern name field or legacy action field. */
fun extractActionName(userAction: JsonObject): String? {
val name =
(userAction["name"] as? JsonPrimitive)
@@ -19,6 +20,7 @@ object OpenClawCanvasA2UIAction {
return action.ifEmpty { null }
}
/** Normalizes prompt tag values so the compact CANVAS_A2UI envelope stays parser-friendly. */
fun sanitizeTagValue(value: String): String {
val trimmed = value.trim().ifEmpty { "-" }
val normalized = trimmed.replace(" ", "_")
@@ -35,6 +37,7 @@ object OpenClawCanvasA2UIAction {
return out.toString()
}
/** Formats the compact text envelope sent to the agent when a canvas UI action fires. */
fun formatAgentMessage(
actionName: String,
sessionKey: String,
@@ -57,6 +60,7 @@ object OpenClawCanvasA2UIAction {
).joinToString(separator = " ")
}
/** Builds JS that reports an agent action result back to the canvas runtime. */
fun jsDispatchA2UIActionStatus(
actionId: String,
ok: Boolean,

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.protocol
/** Capability ids advertised by the Android node to the OpenClaw gateway. */
enum class OpenClawCapability(
val rawValue: String,
) {
@@ -19,6 +20,7 @@ enum class OpenClawCapability(
CallLog("callLog"),
}
/** Canvas command ids mirrored from the gateway tool namespace. */
enum class OpenClawCanvasCommand(
val rawValue: String,
) {
@@ -34,6 +36,7 @@ enum class OpenClawCanvasCommand(
}
}
/** Streaming canvas commands sent from agents back into the Android UI. */
enum class OpenClawCanvasA2UICommand(
val rawValue: String,
) {
@@ -47,6 +50,7 @@ enum class OpenClawCanvasA2UICommand(
}
}
/** Camera command ids accepted by the Android node. */
enum class OpenClawCameraCommand(
val rawValue: String,
) {
@@ -60,6 +64,7 @@ enum class OpenClawCameraCommand(
}
}
/** SMS command ids accepted by the Android node. */
enum class OpenClawSmsCommand(
val rawValue: String,
) {
@@ -72,6 +77,7 @@ enum class OpenClawSmsCommand(
}
}
/** Push-to-talk command ids accepted by the Android node. */
enum class OpenClawTalkCommand(
val rawValue: String,
) {
@@ -86,6 +92,7 @@ enum class OpenClawTalkCommand(
}
}
/** Location command ids accepted by the Android node. */
enum class OpenClawLocationCommand(
val rawValue: String,
) {
@@ -97,6 +104,7 @@ enum class OpenClawLocationCommand(
}
}
/** Device status and metadata command ids accepted by the Android node. */
enum class OpenClawDeviceCommand(
val rawValue: String,
) {
@@ -111,6 +119,7 @@ enum class OpenClawDeviceCommand(
}
}
/** Notification command ids accepted by the Android node. */
enum class OpenClawNotificationsCommand(
val rawValue: String,
) {
@@ -123,6 +132,7 @@ enum class OpenClawNotificationsCommand(
}
}
/** System command ids accepted by the Android node. */
enum class OpenClawSystemCommand(
val rawValue: String,
) {
@@ -134,6 +144,7 @@ enum class OpenClawSystemCommand(
}
}
/** Photos command ids accepted by the Android node. */
enum class OpenClawPhotosCommand(
val rawValue: String,
) {
@@ -145,6 +156,7 @@ enum class OpenClawPhotosCommand(
}
}
/** Contacts command ids accepted by the Android node. */
enum class OpenClawContactsCommand(
val rawValue: String,
) {
@@ -157,6 +169,7 @@ enum class OpenClawContactsCommand(
}
}
/** Calendar command ids accepted by the Android node. */
enum class OpenClawCalendarCommand(
val rawValue: String,
) {
@@ -169,6 +182,7 @@ enum class OpenClawCalendarCommand(
}
}
/** Motion sensor command ids accepted by the Android node. */
enum class OpenClawMotionCommand(
val rawValue: String,
) {
@@ -181,6 +195,7 @@ enum class OpenClawMotionCommand(
}
}
/** Call-log command ids accepted by the Android node. */
enum class OpenClawCallLogCommand(
val rawValue: String,
) {

View File

@@ -31,6 +31,7 @@ private data class ToolDisplayConfig(
val tools: Map<String, ToolDisplaySpec>? = null,
)
/** Compact UI summary for a running or pending tool call. */
data class ToolDisplaySummary(
val name: String,
val emoji: String,
@@ -39,6 +40,7 @@ data class ToolDisplaySummary(
val verb: String?,
val detail: String?,
) {
/** Optional second-line detail assembled from the action verb and best argument preview. */
val detailLine: String?
get() {
val parts = mutableListOf<String>()
@@ -47,10 +49,12 @@ data class ToolDisplaySummary(
return if (parts.isEmpty()) null else parts.joinToString(" · ")
}
/** Single-line fallback for compact tool rows that do not render detail separately. */
val summaryLine: String
get() = if (detailLine != null) "$emoji $label: $detailLine" else "$emoji $label"
}
/** Resolves tool-call names and args into user-facing Android display text. */
object ToolDisplayRegistry {
private const val CONFIG_ASSET = "tool-display.json"
@@ -58,6 +62,7 @@ object ToolDisplayRegistry {
@Volatile private var cachedConfig: ToolDisplayConfig? = null
/** Resolves a raw tool call into stable, bounded UI text for pending-tool surfaces. */
fun resolve(
context: Context,
name: String?,
@@ -86,6 +91,8 @@ object ToolDisplayRegistry {
detail = pathDetail(args)
}
// Action-specific detail keys win over tool defaults so commands like
// read/write can surface the most useful argument for that action.
val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList()
if (detail == null) {
detail = firstValue(args, detailKeys)
@@ -122,6 +129,8 @@ object ToolDisplayRegistry {
cachedConfig = decoded
decoded
} catch (_: Throwable) {
// The chat UI should still render pending tools if the asset is absent or
// malformed in debug builds.
val fallback = ToolDisplayConfig()
cachedConfig = fallback
fallback

View File

@@ -14,6 +14,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.delay
/** Full-screen white flash keyed by camera capture tokens. */
@Composable
fun CameraFlashOverlay(
token: Long,
@@ -29,6 +30,8 @@ private fun CameraFlash(token: Long) {
var alpha by remember { mutableFloatStateOf(0f) }
LaunchedEffect(token) {
if (token == 0L) return@LaunchedEffect
// Token changes replay the animation even when consecutive captures use
// the same HUD message.
alpha = 0.85f
delay(110)
alpha = 0f

View File

@@ -26,6 +26,7 @@ import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import java.util.concurrent.atomic.AtomicReference
/** Hosts the gateway canvas WebView and attaches it to the runtime canvas controller. */
@SuppressLint("SetJavaScriptEnabled")
@Suppress("DEPRECATION")
@Composable
@@ -151,6 +152,9 @@ fun CanvasScreen(
}
}
// The listener accepts any WebView origin at registration time because
// gateway A2UI URLs are dynamic; CanvasActionTrust validates the live URL
// before forwarding each message.
val bridge =
CanvasA2UIActionBridge(
isTrustedPage = { viewModel.isTrustedCanvasActionUrl(currentPageUrlRef.get()) },
@@ -184,6 +188,7 @@ fun CanvasScreen(
)
}
/** Filters WebView postMessage payloads before they enter the A2UI action handler. */
internal class CanvasA2UIActionBridge(
private val isTrustedPage: () -> Boolean,
private val onMessage: (String) -> Unit,

View File

@@ -31,6 +31,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/** Settings detail surface for live canvas status, refresh, and embedded preview. */
@Composable
internal fun CanvasSettingsScreen(
viewModel: MainViewModel,
@@ -47,6 +48,8 @@ internal fun CanvasSettingsScreen(
LaunchedEffect(isConnected) {
if (isConnected) {
// Refresh once when the gateway comes online so the settings preview is
// populated before the user manually asks for a rehydrate.
viewModel.refreshHomeCanvasOverviewIfConnected()
}
}

View File

@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** Settings screen for gateway channel readiness and account status. */
@Composable
internal fun ChannelsSettingsScreen(
viewModel: MainViewModel,
@@ -71,6 +72,8 @@ internal fun ChannelsSettingsScreen(
}
}
if (summary.partial || summary.warnings.isNotEmpty()) {
// Partial channel scans still include useful rows; surface the warning
// without hiding successful channel status.
ClawPanel {
Text(text = channelsWarningText(summary), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
@@ -156,4 +159,5 @@ private fun channelBadge(label: String): String =
.joinToString("")
.ifBlank { "C" }
/** Chooses the first gateway warning or a generic partial-scan message. */
private fun channelsWarningText(summary: GatewayChannelsSummary): String = summary.warnings.firstOrNull()?.takeIf { it.isNotBlank() } ?: "Some channel status checks did not complete."

View File

@@ -4,6 +4,7 @@ import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.chat.ChatSheetContent
import androidx.compose.runtime.Composable
/** Keeps the public shell entry point stable while chat internals live under ui.chat. */
@Composable
fun ChatSheet(viewModel: MainViewModel) {
ChatSheetContent(viewModel = viewModel)

View File

@@ -50,6 +50,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/** Full-screen command palette for navigation and recent-session search. */
@Composable
internal fun CommandPalette(
viewModel: MainViewModel,
@@ -158,6 +159,7 @@ private data class CommandItem(
val icon: ImageVector,
val onClick: () -> Unit,
) {
/** Matches palette queries against both action title and explanatory subtitle. */
fun matches(query: String): Boolean = query.isEmpty() || title.lowercase().contains(query) || subtitle.lowercase().contains(query)
}
@@ -295,6 +297,7 @@ private fun CommandSectionLabel(title: String) {
}
}
/** Builds provider quick-action metadata from current gateway/catalog state. */
private fun providerCommandSubtitle(
isConnected: Boolean,
providers: List<GatewayModelProviderSummary>,
@@ -307,8 +310,10 @@ private fun providerCommandSubtitle(
return "Configure model access"
}
/** Falls back to the canonical main-session label when gateway display names are blank. */
private fun commandSessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"
/** Formats command-palette session timestamps for compact rows. */
private fun commandRelativeTime(updatedAtMs: Long): String {
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
val minutes = deltaMs / 60_000L

View File

@@ -61,6 +61,7 @@ private enum class ConnectInputMode {
Manual,
}
/** Gateway connection screen for setup-code and manual endpoint pairing. */
@Composable
fun ConnectTabScreen(viewModel: MainViewModel) {
val context = LocalContext.current
@@ -291,6 +292,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
validationText = null
if (inputMode == ConnectInputMode.SetupCode) {
// Setup-code auth should replace old bootstrap/shared credentials;
// manual reconnects keep existing typed credentials.
viewModel.resetGatewaySetupAuth()
}
viewModel.setManualEnabled(true)

View File

@@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/** Settings screen for gateway dreaming state and recent dream diary entries. */
@Composable
internal fun DreamingSettingsScreen(
viewModel: MainViewModel,
@@ -187,6 +188,7 @@ private fun DreamDiaryRow(entry: GatewayDreamDiaryEntry) {
}
}
/** Formats the next dreaming cycle as a compact relative label. */
private fun formatDreamingNextRun(nextRunAtMs: Long?): String {
val next = nextRunAtMs ?: return "Not scheduled"
val deltaMinutes = ((next - System.currentTimeMillis()) / 60_000L).coerceAtLeast(0L)

View File

@@ -10,6 +10,7 @@ import java.net.URI
import java.util.Base64
import java.util.Locale
/** Parsed endpoint fields after URL validation and cleartext-safety checks. */
internal data class GatewayEndpointConfig(
val host: String,
val port: Int,
@@ -17,6 +18,7 @@ internal data class GatewayEndpointConfig(
val displayUrl: String,
)
/** Decoded setup-code payload; only one credential family is expected to be populated. */
internal data class GatewaySetupCode(
val url: String,
val bootstrapToken: String?,
@@ -24,6 +26,7 @@ internal data class GatewaySetupCode(
val password: String?,
)
/** Final gateway connection fields selected from setup-code or manual UI input. */
internal data class GatewayConnectConfig(
val host: String,
val port: Int,
@@ -33,22 +36,26 @@ internal data class GatewayConnectConfig(
val password: String,
)
/** Validation reason used by setup, QR, and manual endpoint copy. */
internal enum class GatewayEndpointValidationError {
INVALID_URL,
INSECURE_REMOTE_URL,
}
/** User input source used to choose endpoint-validation wording. */
internal enum class GatewayEndpointInputSource {
SETUP_CODE,
MANUAL,
QR_SCAN,
}
/** Endpoint parse result that preserves the reason when no usable config exists. */
internal data class GatewayEndpointParseResult(
val config: GatewayEndpointConfig? = null,
val error: GatewayEndpointValidationError? = null,
)
/** QR scan result that separates a usable setup code from validation copy. */
internal data class GatewayScannedSetupCodeResult(
val setupCode: String? = null,
val error: GatewayEndpointValidationError? = null,
@@ -60,6 +67,7 @@ private const val remoteGatewaySecurityRule =
private const val remoteGatewaySecurityFix =
"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access."
/** Resolves setup-code or manual UI fields into a connection config. */
internal fun resolveGatewayConnectConfig(
useSetupCode: Boolean,
setupCode: String,
@@ -77,6 +85,8 @@ internal fun resolveGatewayConnectConfig(
val setup = decodeGatewaySetupCode(setupCode) ?: return null
val parsed = parseGatewayEndpointResult(setup.url).config ?: return null
val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty()
// Bootstrap setup codes intentionally suppress stale shared credentials;
// the bootstrap token owns the first authenticated pairing exchange.
val sharedToken =
when {
!setup.token.isNullOrBlank() -> setup.token.trim()
@@ -121,8 +131,10 @@ internal fun resolveGatewayConnectConfig(
)
}
/** Parses an endpoint string and returns only the valid connection config. */
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? = parseGatewayEndpointResult(rawInput).config
/** Parses and validates gateway endpoint input with user-facing error reasons. */
internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseResult {
val raw = rawInput.trim()
if (raw.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
@@ -166,6 +178,7 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
)
}
/** Decodes base64url setup-code payloads produced by gateway onboarding. */
internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
val trimmed = rawInput.trim()
if (trimmed.isEmpty()) return null
@@ -193,8 +206,10 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
}
}
/** Extracts a setup code from QR scanner text when the embedded endpoint is valid. */
internal fun resolveScannedSetupCode(rawInput: String): String? = resolveScannedSetupCodeResult(rawInput).setupCode
/** Resolves QR scanner text to setup-code or validation error for UI copy. */
internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult {
val setupCode =
resolveSetupCodeCandidate(rawInput)
@@ -209,6 +224,7 @@ internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetu
return GatewayScannedSetupCodeResult(setupCode = setupCode)
}
/** Converts endpoint validation errors into setup-source-specific UI copy. */
internal fun gatewayEndpointValidationMessage(
error: GatewayEndpointValidationError,
source: GatewayEndpointInputSource,
@@ -231,6 +247,7 @@ internal fun gatewayEndpointValidationMessage(
}
}
/** Builds a URL from manual host/port/tls fields for shared endpoint parsing. */
internal fun composeGatewayManualUrl(
hostInput: String,
portInput: String,

View File

@@ -7,6 +7,7 @@ import android.content.Context
import android.os.Build
import android.widget.Toast
/** App version label shared by diagnostics and gateway-facing Android metadata. */
internal fun openClawAndroidVersionLabel(): String {
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
@@ -16,18 +17,22 @@ internal fun openClawAndroidVersionLabel(): String {
}
}
/** Normalizes blank gateway status text for display and diagnostics copy. */
internal fun gatewayStatusForDisplay(statusText: String): String = statusText.trim().ifEmpty { "Offline" }
/** Returns true when the status has enough signal to show diagnostics affordances. */
internal fun gatewayStatusHasDiagnostics(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
return lower != "offline" && !lower.contains("connecting")
}
/** Detects pairing/approval status text so UI can offer pairing-specific actions. */
internal fun gatewayStatusLooksLikePairing(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
return lower.contains("pair") || lower.contains("approve")
}
/** Builds the copyable support prompt with device, endpoint, and exact status context. */
internal fun buildGatewayDiagnosticsReport(
screen: String,
gatewayAddress: String,
@@ -67,6 +72,7 @@ internal fun buildGatewayDiagnosticsReport(
""".trimIndent()
}
/** Copies the diagnostics report to Android clipboard and shows a short confirmation toast. */
internal fun copyGatewayDiagnosticsReport(
context: Context,
screen: String,

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.delay
internal const val PAIRING_INITIAL_AUTO_RETRY_MS = 1_500L
internal const val PAIRING_AUTO_RETRY_MS = 4_000L
/** Retries pairing-only gateway refreshes while the screen is visible and started. */
@Composable
internal fun PairingAutoRetryEffect(
enabled: Boolean,
@@ -41,6 +42,8 @@ internal fun PairingAutoRetryEffect(
if (!enabled || !lifecycleStarted) {
return@LaunchedEffect
}
// Give the gateway a short settling window before the first retry so an
// approval response is not immediately chased by a redundant reconnect.
delay(PAIRING_INITIAL_AUTO_RETRY_MS)
while (true) {
onRetry()

View File

@@ -27,6 +27,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/** Settings health screen for gateway/node status and recent gateway logs. */
@Composable
internal fun HealthLogsSettingsScreen(
viewModel: MainViewModel,
@@ -45,6 +46,8 @@ internal fun HealthLogsSettingsScreen(
LaunchedEffect(isConnected) {
if (isConnected) {
// Load logs when the gateway becomes available; manual refresh covers
// later updates so this screen does not poll.
viewModel.refreshHealthLogs()
}
}
@@ -202,6 +205,8 @@ private fun GatewayLogRow(entry: GatewayLogEntry) {
private fun compactLogTime(value: String?): String {
val raw = value?.trim().orEmpty()
if (raw.isEmpty()) return "--:--"
// Gateway log timestamps may be ISO strings or already-compact fragments;
// keep only the HH:mm portion when present.
val time =
raw
.substringAfter('T', raw)

View File

@@ -11,10 +11,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// ---------------------------------------------------------------------------
// MobileColors semantic color tokens with light + dark variants
// ---------------------------------------------------------------------------
internal data class MobileColors(
val surface: Color,
val surfaceStrong: Color,
@@ -101,6 +97,8 @@ internal fun darkMobileColors() =
chipBorderError = Color(0xFF3E1E1E),
)
// Defaulting to light tokens keeps previews/tests usable when a screen forgets to
// provide the app theme; production roots override this composition local.
internal val LocalMobileColors = staticCompositionLocalOf { lightMobileColors() }
internal object MobileColorsAccessor {
@@ -108,12 +106,8 @@ internal object MobileColorsAccessor {
@Composable get() = LocalMobileColors.current
}
// ---------------------------------------------------------------------------
// Backward-compatible top-level accessors (composable getters)
// ---------------------------------------------------------------------------
// These allow existing call sites to keep using `mobileSurface`, `mobileText`, etc.
// without converting every file at once. Each resolves to the themed value.
// Keep these accessors while screens migrate to `MobileColorsAccessor.current`.
// Each getter must stay composable so callers always read the active theme.
internal val mobileSurface: Color @Composable get() = LocalMobileColors.current.surface
internal val mobileSurfaceStrong: Color @Composable get() = LocalMobileColors.current.surfaceStrong
internal val mobileCardSurface: Color @Composable get() = LocalMobileColors.current.cardSurface
@@ -136,7 +130,8 @@ internal val mobileCodeText: Color @Composable get() = LocalMobileColors.current
internal val mobileCodeBorder: Color @Composable get() = LocalMobileColors.current.codeBorder
internal val mobileCodeAccent: Color @Composable get() = LocalMobileColors.current.codeAccent
// Background gradient light fades white→gray, dark fades near-black→dark-gray
// Build the page backdrop from semantic surfaces so light/dark palettes keep
// their contrast relationship without duplicating raw color stops.
internal val mobileBackgroundGradient: Brush
@Composable get() {
val colors = LocalMobileColors.current
@@ -149,10 +144,6 @@ internal val mobileBackgroundGradient: Brush
)
}
// ---------------------------------------------------------------------------
// Typography tokens (theme-independent)
// ---------------------------------------------------------------------------
internal val mobileFontFamily =
FontFamily(
Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal),

View File

@@ -29,6 +29,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** Settings screen for gateway nodes, paired devices, and pending pairing requests. */
@Composable
internal fun NodesDevicesSettingsScreen(
viewModel: MainViewModel,
@@ -41,6 +42,8 @@ internal fun NodesDevicesSettingsScreen(
LaunchedEffect(isConnected) {
if (isConnected) {
// Refresh once on connection; user-triggered refresh handles later changes
// so device admin state is not polled from Compose.
viewModel.refreshNodesDevices()
}
}
@@ -195,6 +198,7 @@ private fun DeviceListRow(
)
}
/** True when the gateway returned no node or device rows to render. */
private fun GatewayNodesDevicesSummary.isEmpty(): Boolean = nodes.isEmpty() && pendingDevices.isEmpty() && pairedDevices.isEmpty()
private fun nodeSubtitle(node: GatewayNodeSummary): String {

View File

@@ -113,6 +113,7 @@ private enum class OnboardingStep {
private const val GATEWAY_CONNECT_SETTLING_MS = 2_500L
/** First-run Android onboarding flow for gateway pairing and permission setup. */
@Composable
fun OnboardingFlow(
viewModel: MainViewModel,
@@ -273,6 +274,8 @@ fun OnboardingFlow(
setupError = null
attemptedConnect = true
connectAttemptStartedAtMs = SystemClock.elapsedRealtime()
// Setup-code pairing replaces any stale shared credentials before
// the bootstrap token is stored for the first authenticated connect.
viewModel.resetGatewaySetupAuth()
viewModel.setManualEnabled(true)
viewModel.setManualHost(config.host)
@@ -905,6 +908,7 @@ internal enum class GatewayRecoveryUiState(
),
}
/** Derives recovery screen state from gateway/node readiness and transient status text. */
internal fun gatewayRecoveryUiState(
ready: Boolean,
statusText: String,
@@ -918,6 +922,7 @@ internal fun gatewayRecoveryUiState(
else -> GatewayRecoveryUiState.Failed
}
/** Detects gateway-approved states where the Android node is still coming online. */
internal fun gatewayStatusLooksLikePartialConnect(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
return lower.contains("operator offline") || lower.contains("node offline")
@@ -932,6 +937,7 @@ private data class GatewayConfig(
val password: String,
)
/** Resolves setup-code or manual fields into the gateway config used for first connect. */
private fun resolveGatewayConfig(
setupCode: String,
manualHost: String,
@@ -944,6 +950,8 @@ private fun resolveGatewayConfig(
if (setup != null) {
val endpoint = parseGatewayEndpointResult(setup.url).config ?: return null
val bootstrapToken = setup.bootstrapToken?.trim().orEmpty()
// Bootstrap setup codes own first-pairing auth; fall back to typed token or
// password only for non-bootstrap setup payloads.
return GatewayConfig(
host = endpoint.host,
port = endpoint.port,
@@ -974,6 +982,7 @@ private fun resolveGatewayConfig(
)
}
/** Selects the recovery detail line from endpoint metadata and transient gateway status. */
private fun recoveryGatewayDetail(
ready: Boolean,
remoteAddress: String?,
@@ -991,6 +1000,7 @@ private fun recoveryGatewayDetail(
"Gateway unreachable"
}
/** Copies the onboarding recovery snapshot for support without including credentials. */
private fun copyGatewayDiagnostic(
context: Context,
statusText: String,
@@ -1011,6 +1021,7 @@ private fun copyGatewayDiagnostic(
Toast.makeText(context, "Diagnostic copied", Toast.LENGTH_SHORT).show()
}
/** One permission row plus launcher callback for onboarding's final setup step. */
private data class PermissionRowModel(
val title: String,
val subtitle: String,
@@ -1019,16 +1030,19 @@ private data class PermissionRowModel(
val onClick: () -> Unit,
)
/** Permission screen model plus a commit hook that persists granted feature toggles. */
private class PermissionState(
val rows: List<PermissionRowModel>,
val applyToViewModel: () -> Unit,
)
/** Onboarding can finish only after gateway and node channels are both ready. */
internal fun canFinishOnboarding(
isConnected: Boolean,
isNodeConnected: Boolean,
): Boolean = isConnected && isNodeConnected
/** Builds permission rows and applies granted feature toggles after onboarding. */
@Composable
private fun rememberPermissionState(
context: Context,
@@ -1170,6 +1184,7 @@ private fun hasPermission(
permission: String,
): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
/** Returns true when Android exposes any motion sensor that can back node motion commands. */
private fun hasMotionCapabilities(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||

View File

@@ -13,6 +13,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
/**
* App theme wrapper that installs dynamic Material colors and legacy mobile color tokens.
*/
@Composable
fun OpenClawTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
@@ -35,6 +38,9 @@ fun OpenClawTheme(content: @Composable () -> Unit) {
}
}
/**
* Overlay background token tuned for panels floating over the mobile canvas.
*/
@Composable
fun overlayContainerColor(): Color {
val scheme = MaterialTheme.colorScheme
@@ -44,5 +50,8 @@ fun overlayContainerColor(): Color {
return if (isDark) base else base.copy(alpha = 0.88f)
}
/**
* Overlay icon token kept next to overlayContainerColor for callers outside the design package.
*/
@Composable
fun overlayIconColor(): Color = MaterialTheme.colorScheme.onSurfaceVariant

View File

@@ -68,6 +68,7 @@ private enum class StatusVisual {
Offline,
}
/** Legacy tab scaffold used by the mobile post-onboarding experience. */
@Composable
fun PostOnboardingTabs(
viewModel: MainViewModel,
@@ -150,6 +151,8 @@ fun PostOnboardingTabs(
.background(mobileBackgroundGradient),
) {
if (chatTabStarted) {
// Keep chat mounted after first use so session state and scroll position
// survive tab switches.
Box(
modifier =
Modifier
@@ -162,6 +165,8 @@ fun PostOnboardingTabs(
}
if (screenTabStarted) {
// Canvas can be expensive to initialize; keep it mounted once visited
// and hide it by alpha/z-order instead of destroying the view tree.
ScreenTabScreen(
viewModel = viewModel,
visible = activeTab == HomeTab.Screen,
@@ -184,6 +189,7 @@ fun PostOnboardingTabs(
}
}
/** Screen tab wrapper that refreshes canvas data once per gateway connection. */
@Composable
private fun ScreenTabScreen(
viewModel: MainViewModel,
@@ -205,6 +211,7 @@ private fun ScreenTabScreen(
}
}
/** Top status chip derived from gateway connection text. */
@Composable
private fun TopStatusBar(
statusText: String,
@@ -295,6 +302,7 @@ private fun TopStatusBar(
}
}
/** Bottom navigation for the legacy tab scaffold. */
@Composable
private fun BottomTabBar(
activeTab: HomeTab,

View File

@@ -55,6 +55,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** Android providers/models browser backed by the gateway catalog. */
@Composable
internal fun ProvidersModelsScreen(
viewModel: MainViewModel,
@@ -190,6 +191,7 @@ private data class ProviderRow(
val modelCount: Int,
)
/** Combines auth-provider readiness rows with catalog-only providers. */
private fun providerRows(
providers: List<GatewayModelProviderSummary>,
models: List<GatewayModelSummary>,
@@ -206,6 +208,8 @@ private fun providerRows(
modelCount = modelCounts[provider.id] ?: 0,
)
}
// Static/catalog-only providers may expose models without a matching auth
// provider row; keep them visible as ready providers.
val missingAuthRows =
modelCounts.keys
.filter { provider -> authRows.none { it.id == provider } }
@@ -245,6 +249,7 @@ private fun providerSetupSubtitle(
else -> "Add provider credentials on your Gateway"
}
/** Normalizes gateway provider status strings into a ready/not-ready boolean. */
internal fun modelProviderReady(status: String): Boolean {
val normalized = status.trim().lowercase()
return normalized == "ok" ||
@@ -254,6 +259,7 @@ internal fun modelProviderReady(status: String): Boolean {
normalized == "static"
}
/** Groups models by provider using the same display priority as provider rows. */
private fun sortedModelGroups(models: List<GatewayModelSummary>): List<Pair<String, List<GatewayModelSummary>>> =
models
.groupBy { it.provider }
@@ -483,6 +489,7 @@ private fun ModelRow(model: GatewayModelSummary) {
}
}
/** Derives compact capability chips for model catalog rows. */
private fun modelCapabilityLabels(model: GatewayModelSummary): List<String> =
buildList {
if (model.supportsReasoning) add("Reasoning")

View File

@@ -7,6 +7,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
/** Chooses onboarding or the authenticated app shell from persisted app state. */
@Composable
fun RootScreen(viewModel: MainViewModel) {
val onboardingCompleted by viewModel.onboardingCompleted.collectAsState()

View File

@@ -52,6 +52,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** Session browser for recent and currently-live chat sessions. */
@Composable
internal fun SessionsScreen(
viewModel: MainViewModel,
@@ -81,6 +82,8 @@ internal fun SessionsScreen(
LaunchedEffect(isConnected) {
if (isConnected) {
// Sessions are cheap to refresh on entry; subsequent sorting/filtering is
// local to avoid re-querying while the user explores the list.
viewModel.refreshChatSessions(limit = 200)
}
}
@@ -309,18 +312,21 @@ private enum class SessionFilter {
Live,
}
/** Empty-state title selected by the active session browser filter. */
private fun emptySessionTitle(filter: SessionFilter): String =
when (filter) {
SessionFilter.Recent -> "No sessions yet"
SessionFilter.Live -> "No live session"
}
/** Empty-state body selected by the active session browser filter. */
private fun emptySessionBody(filter: SessionFilter): String =
when (filter) {
SessionFilter.Recent -> "Start a new conversation and it will show up here."
SessionFilter.Live -> "Open Chat to start or resume the current session."
}
/** Formats session timestamps for compact mobile metadata. */
private fun relativeSessionTime(updatedAtMs: Long): String {
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
val minutes = deltaMs / 60_000L
@@ -331,4 +337,5 @@ private fun relativeSessionTime(updatedAtMs: Long): String {
return "${hours / 24}d"
}
/** Falls back to the canonical main-session label when gateway display names are blank. */
private fun displaySessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"

View File

@@ -94,6 +94,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
/**
* Detail routes reachable from the Android settings home surface.
*/
internal enum class SettingsRoute {
Home,
Profile,
@@ -115,6 +118,9 @@ internal enum class SettingsRoute {
About,
}
/**
* Dispatches a selected settings route to its detail screen without changing navigation ownership.
*/
@Composable
internal fun SettingsDetailScreen(
viewModel: MainViewModel,
@@ -784,6 +790,7 @@ private fun AppearanceSettingsScreen(onBack: () -> Unit) {
}
}
/** Converts raw gateway connection text into stable settings metric labels. */
private fun gatewayStatusLabel(
statusText: String,
isConnected: Boolean,
@@ -861,6 +868,7 @@ private fun AboutStatusRow(
}
}
/** Chooses about-screen copy based on whether the gateway advertises an update. */
private fun aboutUpdateText(latestVersion: String?): String =
if (latestVersion == null) {
"OpenClaw turns this phone into a clean mobile command surface for sessions, voice, providers, and Gateway."
@@ -868,6 +876,9 @@ private fun aboutUpdateText(latestVersion: String?): String =
"A Gateway update is available. Run the update from the Web UI or CLI when you are ready."
}
/**
* Shared settings detail shell with back navigation, title, subtitle, and section content.
*/
@Composable
internal fun SettingsDetailFrame(
title: String,
@@ -900,6 +911,9 @@ internal fun SettingsDetailFrame(
}
}
/**
* Toggle row model reused by settings sections that render simple on/off controls.
*/
private data class SettingsToggleRow(
val title: String,
val subtitle: String,
@@ -908,6 +922,9 @@ private data class SettingsToggleRow(
val onCheckedChange: (Boolean) -> Unit,
)
/**
* Compact metric row model for connected gateway summaries.
*/
internal data class SettingsMetric(
val title: String,
val value: String,
@@ -989,6 +1006,9 @@ private fun AgentListRow(
)
}
/**
* Chooses a display name for the configured default agent, falling back to any available agent.
*/
private fun defaultAgentName(
agents: List<GatewayAgentSummary>,
defaultAgentId: String?,
@@ -998,6 +1018,9 @@ private fun defaultAgentName(
return agent?.name?.takeIf { it.isNotBlank() } ?: agent?.id ?: "None"
}
/**
* Builds a short stable badge from agent emoji/name/id for dense lists.
*/
private fun agentBadge(agent: GatewayAgentSummary): String {
agent.emoji
?.trim()
@@ -1013,6 +1036,9 @@ private fun agentBadge(agent: GatewayAgentSummary): String {
.ifBlank { "A" }
}
/**
* Normalizes tool-call names into readable approval action labels.
*/
private fun approvalActionName(name: String): String {
val cleaned =
name
@@ -1027,6 +1053,7 @@ private fun approvalActionName(name: String): String {
.ifBlank { "Action Request" }
}
/** Builds approval row age/error copy without exposing raw tool arguments. */
private fun approvalSubtitle(
toolCall: ChatPendingToolCall,
hasIssue: Boolean,
@@ -1037,8 +1064,10 @@ private fun approvalSubtitle(
return if (minutes < 1) "Waiting for review" else "Waiting ${minutes}m"
}
/** Builds the dense cron-job subtitle from schedule, next wake, and prompt preview. */
private fun cronJobSubtitle(job: GatewayCronJobSummary): String = "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}"
/** Summarizes a provider plan and most-used quota window for usage rows. */
private fun usageProviderSubtitle(provider: GatewayUsageProviderSummary): String {
provider.error?.let { return it }
val window = provider.windows.maxByOrNull { it.usedPercent }
@@ -1046,6 +1075,9 @@ private fun usageProviderSubtitle(provider: GatewayUsageProviderSummary): String
return listOfNotNull(provider.plan, quota).joinToString(" · ").ifBlank { "No limits reported" }
}
/**
* Converts usage timestamps into short relative labels for metric panels.
*/
private fun formatUsageUpdated(updatedAtMs: Long?): String {
val updated = updatedAtMs ?: return "Never"
val deltaMs = (System.currentTimeMillis() - updated).coerceAtLeast(0L)
@@ -1059,6 +1091,7 @@ private fun formatUsageUpdated(updatedAtMs: Long?): String {
}
}
/** Converts gateway cron status text into the short row badge label. */
private fun cronJobStatusText(job: GatewayCronJobSummary): String {
if (!job.enabled) return "Off"
return when (job.lastRunStatus?.lowercase()) {
@@ -1069,6 +1102,7 @@ private fun cronJobStatusText(job: GatewayCronJobSummary): String {
}
}
/** Maps gateway cron status text to app status colors. */
private fun cronJobStatus(job: GatewayCronJobSummary): ClawStatus {
if (!job.enabled) return ClawStatus.Neutral
return when (job.lastRunStatus?.lowercase()) {
@@ -1078,6 +1112,9 @@ private fun cronJobStatus(job: GatewayCronJobSummary): ClawStatus {
}
}
/**
* Converts cron wake times into short relative labels for scheduled-work rows.
*/
private fun formatCronWake(timeMs: Long?): String {
val target = timeMs ?: return "None"
val deltaMs = target - System.currentTimeMillis()
@@ -1123,6 +1160,9 @@ private fun SettingsToggleListRow(row: SettingsToggleRow) {
}
}
/**
* Reusable metric panel for settings screens with compact title/value rows.
*/
@Composable
internal fun SettingsMetricPanel(rows: List<SettingsMetric>) {
ClawPanel(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp)) {
@@ -1159,11 +1199,15 @@ private fun SettingsIconMark(icon: ImageVector) {
}
}
/**
* Checks an exact Android runtime permission for settings enablement.
*/
private fun hasPermission(
context: Context,
permission: String,
): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
/** Returns true when either fine or coarse location is available to settings callers. */
private fun hasLocationPermission(context: Context): Boolean =
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)

View File

@@ -72,6 +72,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
/** Mobile settings surface for device permissions, forwarding, location, and app preferences. */
@Composable
fun SettingsSheet(viewModel: MainViewModel) {
val context = LocalContext.current
@@ -131,6 +132,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
val quietHoursCanEnable = notificationForwardingEnabled && quietHoursDraftValid
// Compare stored values against normalized drafts so equivalent HH:mm input
// does not keep the save button enabled.
val quietHoursDraftDirty =
notificationForwardingQuietStart != (normalizedQuietStartDraft ?: notificationQuietStartDraft.trim()) ||
notificationForwardingQuietEnd != (normalizedQuietEndDraft ?: notificationQuietEndDraft.trim())
@@ -344,6 +347,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
// Permission and role screens live outside Compose; refresh all derived
// toggles whenever Android returns to this settings surface.
micPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
@@ -1217,12 +1222,14 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
/** App entry shown in the notification-forwarding package picker. */
data class InstalledApp(
val label: String,
val packageName: String,
val isSystemApp: Boolean,
)
/** Reads launcher, recent-notification, and configured packages for the picker. */
private fun queryInstalledApps(
context: Context,
configuredPackages: Set<String>,
@@ -1273,6 +1280,7 @@ private fun queryInstalledApps(
.toList()
}
/** Merges package sources while excluding OpenClaw from its own forwarding filter. */
internal fun resolveNotificationCandidatePackages(
launcherPackages: Set<String>,
recentPackages: List<String>,
@@ -1290,6 +1298,7 @@ internal fun resolveNotificationCandidatePackages(
.toSet()
}
/** Shared Material text-field colors for the legacy mobile settings sheet. */
@Composable
private fun settingsTextFieldColors() =
OutlinedTextFieldDefaults.colors(
@@ -1302,6 +1311,7 @@ private fun settingsTextFieldColors() =
cursorColor = mobileAccent,
)
/** Applies the legacy mobile card border/background used by settings rows. */
@Composable
private fun Modifier.settingsRowModifier() =
this
@@ -1309,6 +1319,7 @@ private fun Modifier.settingsRowModifier() =
.border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp))
.background(mobileCardSurface, RoundedCornerShape(14.dp))
/** Primary button colors for the legacy mobile settings sheet. */
@Composable
private fun settingsPrimaryButtonColors() =
ButtonDefaults.buttonColors(
@@ -1318,6 +1329,7 @@ private fun settingsPrimaryButtonColors() =
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
/** Destructive button colors for permission and capability settings actions. */
@Composable
private fun settingsDangerButtonColors() =
ButtonDefaults.buttonColors(
@@ -1327,6 +1339,7 @@ private fun settingsDangerButtonColors() =
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
/** Opens this app's Android settings page for permissions that require system UI. */
private fun openAppSettings(context: Context) {
val intent =
Intent(
@@ -1336,6 +1349,7 @@ private fun openAppSettings(context: Context) {
context.startActivity(intent)
}
/** Opens notification-listener settings, falling back to app settings if the intent is unavailable. */
private fun openNotificationListenerSettings(context: Context) {
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
runCatching {
@@ -1345,14 +1359,17 @@ private fun openNotificationListenerSettings(context: Context) {
}
}
/** Android 13+ notification permission check; earlier versions grant posting at install time. */
private fun hasNotificationsPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < 33) return true
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
}
/** Mirrors the notification listener service access check for UI enablement. */
private fun isNotificationListenerEnabled(context: Context): Boolean = DeviceNotificationListenerService.isAccessEnabled(context)
/** Checks whether the device exposes motion sensors needed by motion-related capabilities. */
private fun hasMotionCapabilities(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||

View File

@@ -86,6 +86,7 @@ private enum class Tab(
ProvidersModels(key = "providers-models", label = "Providers"),
}
/** Main post-onboarding shell that owns top-level Android navigation state. */
@Composable
fun ShellScreen(
viewModel: MainViewModel,
@@ -101,6 +102,8 @@ fun ShellScreen(
LaunchedEffect(requestedHomeDestination) {
val destination = requestedHomeDestination ?: return@LaunchedEffect
// HomeDestination is a one-shot command from launch intents and settings
// actions; consume it after translating to local shell state.
activeTab =
when (destination) {
HomeDestination.Connect -> Tab.Overview
@@ -232,6 +235,8 @@ fun ShellScreen(
}
pendingTrust?.let { prompt ->
// Gateway certificate trust is modal across the shell so navigation
// cannot hide a changed TLS identity prompt.
GatewayTrustDialog(
prompt = prompt,
onAccept = viewModel::acceptGatewayTrustPrompt,
@@ -242,6 +247,7 @@ fun ShellScreen(
}
}
/** Modal trust decision for first-seen or changed gateway TLS fingerprints. */
@Composable
private fun GatewayTrustDialog(
prompt: NodeRuntime.GatewayTrustPrompt,
@@ -421,6 +427,7 @@ private data class ModuleRow(
val settingsRoute: SettingsRoute? = null,
)
/** Floating overview shortcut that keeps chat one tap away from module lists. */
@Composable
private fun OverviewChatButton(
onClick: () -> Unit,
@@ -561,6 +568,7 @@ private data class RecentSessionListItem(
val metadata: String,
)
/** Recent sessions panel that preserves the session key behind display labels. */
@Composable
private fun RecentSessionList(
rows: List<RecentSessionListItem>,
@@ -780,6 +788,7 @@ private fun approvalsSummary(count: Int): String =
private fun approvalsStatus(count: Int): Boolean? = if (count > 0) true else null
/** Summarizes scheduled gateway jobs for overview and settings rows. */
private fun cronJobsSummary(count: Int): String =
when (count) {
0 -> "No scheduled jobs"
@@ -787,6 +796,7 @@ private fun cronJobsSummary(count: Int): String =
else -> "$count scheduled"
}
/** Summarizes provider usage buckets without exposing detailed billing data. */
private fun usageSummaryText(count: Int): String =
when (count) {
0 -> "No provider usage"
@@ -794,11 +804,13 @@ private fun usageSummaryText(count: Int): String =
else -> "$count providers"
}
/** Reports how many gateway skills are enabled, eligible, and dependency-complete. */
private fun skillsSummaryText(skills: List<GatewaySkillSummary>): String {
val ready = skills.count { !it.disabled && it.eligible && it.missingCount == 0 }
return if (skills.isEmpty()) "No skills" else "$ready/${skills.size} ready"
}
/** Converts gateway skill health into a tri-state settings status dot. */
private fun skillsStatus(skills: List<GatewaySkillSummary>): Boolean? =
when {
skills.isEmpty() -> null
@@ -806,6 +818,7 @@ private fun skillsStatus(skills: List<GatewaySkillSummary>): Boolean? =
else -> true
}
/** Prioritizes pending pairings over online counts for compact node/device summaries. */
private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String {
val online = summary.nodes.count { it.connected }
val devices = summary.pairedDevices.size
@@ -817,6 +830,7 @@ private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String
}
}
/** Maps node/device state to a settings status dot, treating pending pairings as attention-needed. */
private fun nodesDevicesStatus(summary: GatewayNodesDevicesSummary): Boolean? =
when {
summary.pendingDevices.isNotEmpty() -> false
@@ -825,6 +839,7 @@ private fun nodesDevicesStatus(summary: GatewayNodesDevicesSummary): Boolean? =
else -> null
}
/** Summarizes channel connection state, surfacing errors before connected counts. */
private fun channelsSummaryText(summary: GatewayChannelsSummary): String {
val connected = summary.channels.count { it.connected }
return when {
@@ -834,6 +849,7 @@ private fun channelsSummaryText(summary: GatewayChannelsSummary): String {
}
}
/** Maps channel health to the settings status dot shown in the shell. */
private fun channelsStatus(summary: GatewayChannelsSummary): Boolean? =
when {
summary.channels.any { it.error != null } -> false
@@ -842,6 +858,7 @@ private fun channelsStatus(summary: GatewayChannelsSummary): Boolean? =
else -> null
}
/** Summarizes dreaming memory health before enabled/off state. */
private fun dreamingSummaryText(summary: GatewayDreamingSummary): String =
when {
!summary.storeHealthy || !summary.phaseSignalHealthy -> "Needs attention"
@@ -849,6 +866,7 @@ private fun dreamingSummaryText(summary: GatewayDreamingSummary): String =
else -> "Off"
}
/** Maps dreaming store/phase health and enabled state to a settings status dot. */
private fun dreamingStatus(summary: GatewayDreamingSummary): Boolean? =
when {
!summary.storeHealthy || !summary.phaseSignalHealthy -> false

View File

@@ -24,6 +24,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** Settings screen for gateway skills and their readiness state. */
@Composable
internal fun SkillsSettingsScreen(
viewModel: MainViewModel,

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