Compare commits

..

315 Commits

Author SHA1 Message Date
Vincent Koc
806e796cc2 fix(install): keep llama cpp runtime required 2026-06-23 13:43:24 +08:00
Patrick Erichsen
f66e83154b docs: update ClawHub skill route references
Update OpenClaw ClawHub docs and user-facing copy for canonical owner-qualified skill routes.\n\nEvidence:\n- pnpm docs:list\n- pnpm test src/plugins/clawhub.test.ts src/cli/plugins-cli.install.test.ts src/gateway/server-methods/skills.clawhub.test.ts ui/src/ui/views/skills.test.ts\n- pnpm exec oxfmt --check --threads=1 docs/clawhub/cli.md docs/clawhub/publishing.md docs/cli/skills.md docs/help/faq.md docs/start/showcase.md docs/tools/creating-skills.md docs/tools/skills.md src/gateway/server-methods/skills.clawhub.test.ts src/plugins/clawhub.test.ts src/plugins/clawhub.ts ui/src/ui/views/skills.test.ts\n- git diff --check\n- exact-head hosted CI passed for 8530374388d8a73235b2ac8444b95a4a4c7d0f1c\n\nNote: repo-native scripts/pr prepare-run was attempted; local broad pnpm test was stopped after unrelated existing failures in agent/media/provider shards, while hosted exact-head CI and targeted ClawHub route/copy validation were green.
2026-06-22 22:27:57 -07:00
Vincent Koc
1479078a25 fix(ci): require iOS Periphery evidence artifact 2026-06-23 13:17:42 +08:00
Patrick Erichsen
0a97f73402 feat: add bundled plugin icon manifest URLs (#95845) 2026-06-22 22:14:18 -07:00
Vincent Koc
7668a72843 fix(qa): allow evidence-free maturity input checks 2026-06-23 13:05:20 +08:00
joshavant
10d850b39c chore: make ios testflight upload path canonical 2026-06-23 00:01:20 -05:00
joshavant
d4f666874f feat: harden ios app store push release mode 2026-06-23 00:01:20 -05:00
Dallin Romney
606706492f ci: fail qa profile evidence on qa failures (#95971) 2026-06-22 22:00:30 -07:00
Vincent Koc
cc1b3a8550 fix(install): skip llama cpp native build by default 2026-06-23 12:58:41 +08:00
Dallin Romney
438f208a76 perf(qa-lab): speed up unified QA suites (#95944)
* perf(qa-lab): speed up smoke ci suite

* fix(qa-lab): satisfy suite scheduler lint

* fix(qa-lab): settle unified partitions before retry

* fix(qa-lab): preserve isolated suite safeguards

* refactor(qa-lab): make suite isolation explicit

* fix(qa-lab): preserve channel-driver suite serialization

* fix(qa-lab): narrow flow-only isolation metadata
2026-06-22 21:55:54 -07:00
Jason O'Neal
b8f1961aae fix(model-fallback): classify Codex usage-limit payloads (#95400)
* fix(model-fallback): classify Codex usage-limit payloads

* test: add real behavior proof for Codex usage-limit fallback

Adds a permanent real behavior proof test that exercises the production
classifyEmbeddedAgentRunResultForModelFallback() classifier with the exact
Codex subscription usage-limit error text.

Covers:
- Primary path: isError payload with usage-limit text -> rate_limit fallback
- Non-error payload: same text as normal assistant output -> no fallback
- Visible output already delivered -> no fallback
- Cross-provider: same text via openrouter -> rate_limit fallback

* fix(fallback-classifier): guard on finalAssistantVisibleText delivery evidence

When finalAssistantVisibleText contains real visible output (non-empty,
non-silent-reply), the agent already delivered a response to the user.
The classifier must not trigger model fallback in that case, because the
user already has their answer and rotating models would only burn quota
without improving the outcome.

Adds a guard in classifyEmbeddedAgentRunResultForModelFallback() that
checks finalAssistantVisibleText after committed outbound delivery
evidence and before the hook_block check. Uses the existing
isSilentReplyPayloadText() helper to avoid suppressing NO_REPLY and
similar intentional silent tokens.

This fixes the already-delivered-output test case in the Codex
usage-limit real behavior proof test.

* fix(test): use toEqual for cross-provider proof test type safety

The ModelFallbackResultClassification union includes { error: unknown },
so accessing .reason/.code after not.toBeNull() fails type checking.
Use toEqual with the full expected object instead, matching the pattern
used in result-fallback-classifier.test.ts.

* fix(model-fallback): refresh usage-limit fallback

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-23 00:55:17 -04:00
Vincent Koc
495a4f9b8e test(qa): accept verified live fanout completions 2026-06-23 06:46:40 +02:00
Vincent Koc
381cec0051 fix(ci): require live proof evidence artifacts
Require live Mantis and Telegram proof artifact uploads to fail when evidence is missing and guard the workflow invariant.
2026-06-23 12:43:09 +08:00
Joe Pahuchi
b27ac78d4d fix(plugins): make empty-allowlist actionable for new users (#78105)
* fix(plugins): make empty-allowlist warning actionable for first-time users

* fix(plugins): make empty-allowlist warnings actionable

* fix(plugins): make empty-allowlist warnings actionable

* fix(plugins): make empty-allowlist actionable for new users

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-23 04:41:40 +00:00
Dallin Romney
d3dc7aaa87 docs: update maturity scorecard (#95933)
* docs: update maturity scorecard

* docs: rerender maturity scorecard from all evidence
2026-06-22 21:37:03 -07:00
Shakker
1f1cb5f2cb test: contain bundle mcp home env 2026-06-23 05:33:12 +01:00
ooiuuii
2ea0e8807a fix(cli): show working commands for pinned plugin drift (#95541)
Merged via squash.

Prepared head SHA: d41b9b5b25
Co-authored-by: ooiuuii <169449607+ooiuuii@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 12:32:22 +08:00
Vincent Koc
77d0deedf2 improve: speed up provider tool-call streaming (#95957)
Merged via squash.

Prepared head SHA: d8f510757b
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 12:28:24 +08:00
Shakker
e253568d52 fix: scope bundle command home env 2026-06-23 05:24:48 +01:00
Stellar鱼
a64e270ae7 fix(agents): infer runtime provider from qualified model ids (#91724)
Merged via squash.

Prepared head SHA: 9b544a23d7
Co-authored-by: yu-xin-c <175149126+yu-xin-c@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 12:23:52 +08:00
Andy Ye
33b23214d9 Fix memory-wiki bridge self-import loop (#95666)
Merged via squash.

Prepared head SHA: 0f74629547
Co-authored-by: TurboTheTurtle <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 12:21:57 +08:00
Shakker
667e5bf67e test: isolate plugin update home env 2026-06-23 05:16:21 +01:00
Vincent Koc
c8ca44739a fix(qa): exclude archived categories from all profile 2026-06-23 12:08:33 +08:00
Vincent Koc
cfff6b2ac6 fix(ci): require QA live evidence artifacts
Require QA live artifact uploads to fail when evidence is missing and guard the workflow invariant.
2026-06-23 12:07:48 +08:00
Vincent Koc
81f0e93881 docs(copilot): refresh harness parity notes 2026-06-23 12:07:30 +08:00
Vincent Koc
035cfa1470 fix(apps): remove stale native A2UI assets 2026-06-23 12:05:41 +08:00
Vincent Koc
68a1e00b73 fix(agents): retry silent subagent completion handoffs 2026-06-23 06:04:16 +02:00
Vincent Koc
54b2243de3 test(qa): tighten release profile scenario waits 2026-06-23 06:04:16 +02:00
兰之
bd479958c0 feat(plugin-sdk): add extensible channel identity hook context (#91903)
Merged via squash.

Prepared head SHA: 90f51eafd5
Co-authored-by: lanzhi-lee <36190508+lanzhi-lee@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 11:56:49 +08:00
Dallin Romney
4460fa78c3 feat(qa): add "all" taxonomy profile (#95947)
* qa: add all maturity profile

* test: update qa coverage profile expectations
2026-06-22 20:49:08 -07:00
Vincent Koc
ca0eb62c87 fix(ci): finalize Windows Testbox after setup failures
Ensure the Windows Testbox workflow runs its lifecycle loop after setup failures and guard the shared Testbox finalization invariant.
2026-06-23 11:47:10 +08:00
Gio Della-Libera
67ee0dee00 Doctor: expose extra gateway service findings (#84340)
Merged via squash.

Prepared head SHA: f0bda85907
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-22 20:45:19 -07:00
Marcus Castro
02387e747d fix(whatsapp): resolve approval reactions across jid drift (#95935) 2026-06-23 00:44:50 -03:00
Vincent Koc
ad3b2f4b88 fix(agents): align OpenRouter model scan body cap 2026-06-23 11:28:29 +08:00
Alix-007
91b0567e89 fix(agents): bound Google prompt cache response reads (#95417)
The Google embedded-agent prompt-cache helpers parsed cachedContents
metadata with an unbounded `await response.json()` in both
createGooglePromptCache and updateGooglePromptCacheTtl. A buggy or
hostile Generative Language endpoint returning a 200 with a large or
never-ending body (especially with no Content-Length) would be fully
buffered into memory before parsing, with the existing
cancelUnreadResponseBody guard firing too late (json() already drained
the body).

Route both reads through the shared streaming byte-cap reader
(readResponseWithLimit) under a 1 MiB cap, cancelling the stream on
overflow instead of buffering it, then JSON.parse the bounded buffer.
This is the symmetric Google-endpoint counterpart to the Anthropic
error-stream and gateway pricing-catalog bounds.

Adds regressions that stream an oversized no-Content-Length body through
the real create and TTL-refresh paths and assert the body is cancelled.
2026-06-22 23:26:37 -04:00
Vincent Koc
f80d9b6eae fix(ci): finalize testbox sessions after setup failures
Ensure Testbox wrapper workflows finalize backend sessions even when setup fails, align the check timeout fallback with the documented 120-minute default, and guard the workflow invariants.
2026-06-23 11:26:30 +08:00
joshavant
f2b8668a54 feat: add ios push relay diagnostics 2026-06-22 22:17:04 -05:00
Vincent Koc
a9024741c2 test(qa): pin live artifact scenario contracts 2026-06-23 05:13:35 +02:00
Vincent Koc
d1b268f7f7 fix(qa): normalize completed wait envelopes 2026-06-23 05:13:35 +02:00
Alix-007
06ca1235ef fix(agents): bound OpenRouter model-scan catalog success body (#95418)
The OpenRouter /models catalog read in fetchOpenRouterModels hardened only
the error/early-return path (dbd5689 cancels the body when res.bodyUsed is
false), but the success branch still buffered the whole body with an
unbounded `await res.json()`. The response is a provider-controlled,
runtime-fetched body, so a faulty or hostile provider can stream an
effectively unbounded JSON document and exhaust process memory before the
parse completes; the finally-cancel is a no-op once .json() has drained.

Read the success body through the canonical byte-cap reader
(readResponseWithLimit) under a 4 MiB ceiling before JSON.parse, cancelling
the stream on overflow and bounding idle stalls with the call's existing
timeout. This is the symmetric success-path counterpart to the bounded-stream
hardening landed in #95103 (pricing catalog) and #95108 (Anthropic error
streams), reusing the same helper rather than a new abstraction.
2026-06-22 23:10:15 -04:00
Alix-007
3da4280caf fix(agents): bound OpenRouter model catalog response reads (#95420)
* fix(agents): bound OpenRouter model catalog response reads

The runtime OpenRouter model-capability detector fetched the full
/models catalog with an unbounded `await response.json()`, so a
compromised or misbehaving endpoint could stream an arbitrarily large
body and force the process to buffer the whole payload before parsing.

Read the body through the shared bounded reader instead, capping it at
16 MiB (matching the sibling pricing-cache endpoint hardened in #95103)
and cancelling the stream on overflow. This mirrors the symmetric
bound-stream fixes in #95103 and #95108.

Adds coverage that an oversized streamed catalog is cancelled instead of
buffered and that an under-cap chunked body still reassembles, parses,
and round-trips through the SQLite cache on a fresh import.

* fix(agents): avoid OpenRouter refetch after capped catalog miss

---------

Co-authored-by: sallyom <somalley@redhat.com>
2026-06-22 23:00:17 -04:00
mikasa
aa0bdb901f fix #95489: [Bug]: claude-cli out-of-credits error bypasses model fallback chain — error text delivered as final response (#95508)
* fix(agents): fallback on generic cli failure text

* fix(agents): guard generic cli failure payload visibility

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

* fix(agents): use exported generic failure text

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-22 22:55:35 -04:00
Vincent Koc
c48dd3cdd1 fix(ci): align maturity score source with taxonomy 2026-06-23 10:46:07 +08:00
Vincent Koc
ace3fe969b fix(ci): honor reusable QA evidence failure policy
Make QA Profile Evidence failure handling explicit for direct and reusable callers. Direct manual runs still fail on non-zero QA profiles by default, while maturity scorecard reusable calls can collect failed QA evidence for parent rendering. Verified with actionlint, diff check, Testbox changed gate, PR CI, and CodeQL.
2026-06-23 10:44:12 +08:00
Dallin Romney
b71ddbf1b4 ci: simplify maturity scorecard QA evidence inputs (#95898)
* ci: simplify maturity scorecard evidence inputs

* ci: keep maturity renderer defaults runnable

* ci: validate maturity evidence source

* ci: split maturity scorecard codex agent

* ci: remove codex copy from maturity evidence workflow

* ci: narrow maturity evidence workflow secrets
2026-06-22 19:24:43 -07:00
Sean Sun
1d013c219b plugins: clarify allowlist warning when entries don't match discovered ids (#68389)
* plugins: clarify allowlist warning when entries don't match discovered ids

When plugins.allow contains entries that do not match any discovered
plugin id (for example a channel id like feishu instead of the real
plugin id openclaw-lark), stop emitting the misleading "plugins.allow
is empty" warning. Emit a specific mismatch warning that lists the
unknown allow entries alongside the discovered plugin ids and points
users at the plugin id rather than a channel id or npm package name.

Refs #68352

* plugins: treat bundled plugin ids as valid allow entries

Codex P2 on #68389: warnWhenAllowlistIsOpen computed allowHasMatch
against the auto-discoverable (workspace + global) subset only, so
a legitimate bundled-only allowlist like plugins.allow=['telegram']
would trip the new mismatch warning whenever any non-bundled plugin
happened to be discoverable alongside it.

Compare allow entries to every discovered plugin id (bundled +
workspace + global) for both the short-circuit and the unmatched-
entries computation. The warning text stays scoped to non-bundled
auto-discoverable plugins; we just stop flagging bundled ids as
'does not match any discovered plugin ids'. Add a regression test
that covers the bundled-only allowlist + non-bundled workspace
plugin combination.

Refs #68352

* chore: drop release-owned CHANGELOG entry (AGENTS.md: changelog is release-generated)

* plugins: clarify allowlist warning when entries do not match plugin ids

---------

Co-authored-by: Sean Sun <lyfuci11@gmail.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-23 02:20:17 +00:00
Vincent Koc
33206ee583 fix(ci): use available Android SDK platform
Restores Android CI to a publicly installable SDK platform and keeps Gradle compileSdk aligned with the workflow install/cache key. Rolls back the API-37-only AndroidX core slice until Android 37 is available to hosted CI, while preserving the unrelated Kotlin dependency bump.

Verification:
- Google SDK repository index check: android-36 exists; android-37/android-37.0 do not.
- git diff --check
- Testbox changed gate: tbx_01kvs3r1bc925pxya94zey23c8
- PR CI: 68 successful, 12 skipped, 0 failing, 0 pending; Android build/play and both Android unit-test lanes passed.
2026-06-23 10:18:28 +08:00
wangjieweb3-design
a84d3b6853 docs: document local avatar file size limit (#78884)
* docs: document local avatar file size limit

* docs: update docs/gateway/config-agents.md

* docs: document local avatar file size limit

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-23 02:17:04 +00:00
Vincent Koc
19627c7dd9 fix(memory): improve node:sqlite unavailable guidance (#95916)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Song Luo <133665654+rrrrrredy@users.noreply.github.com>
2026-06-23 02:12:47 +00:00
Vincent Koc
abd8a46b0a improve: reduce hot-path linear scans and redundant I/O (#95697)
Merged via squash.

Prepared head SHA: 67f2678a34
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 10:11:18 +08:00
Marcus Castro
ce391dc382 fix(whatsapp): preserve durable reply target (#95914) 2026-06-22 23:05:35 -03:00
Vincent Koc
2205f50016 test(qa): satisfy history reply lint 2026-06-23 04:01:11 +02:00
Vincent Koc
7fc4bbc0bc fix(agents): wake active parents for subagent completions 2026-06-23 04:01:11 +02:00
Vincent Koc
d716dfd532 test(qa): wait for live history replies in flow scenarios 2026-06-23 04:01:11 +02:00
Vincent Koc
5822e8074d test(qa): accept completed agent wait status 2026-06-23 04:01:11 +02:00
Dallin Romney
27711b500c ci: add maturity scorecard renderer (#94272) (#95901)
* ci: add maturity scorecard renderer

* ci: render qa scorecard evidence

* ci: type maturity docs renderer

* ci: tighten maturity artifact inputs

* ci: move maturity renderer under qa scripts

* ci: share maturity score schema

* ci: centralize maturity taxonomy validation

* ci: move maturity scores under qa

* ci: remove docs maturity score source

* docs: simplify maturity scorecard output

* docs: commit generated maturity scorecard

* docs: group maturity pages

* docs: simplify maturity scorecard dates

* docs: promote maturity nav tab

* docs: clean up maturity pages

* docs: remove maturity outline page

* docs: filter maturity taxonomy doc links

* docs: simplify maturity taxonomy tables

* docs: keep artifact taxonomy links

* docs: simplify lts scorecard display

* docs: clarify maturity score definitions

* docs: derive maturity coverage from evidence

* docs: hide maturity scorecard until evidence

* docs: remove placeholder maturity pages

* docs: keep maturity scores out of pr

* ci: open maturity scorecard docs pr
2026-06-22 18:55:06 -07:00
Vincent Koc
1252378018 fix(installer): unblock Windows source installs 2026-06-23 09:48:43 +08:00
Vincent Koc
def4b51485 fix(qa): gate smoke profile scenarios by channel driver 2026-06-23 09:34:52 +08:00
ly-wang19
d84a8b1506 fix(discord): reserve closing-fence space on fence-closing lines (#95661)
`chunkDiscordText` reserved closing-fence space from the post-line fence state
(`nextOpenFence`), but a flush during a line's segment loop appends the closing
fence based on the still-open `openFence`, which is only advanced after the
line. On a line that closes a fence yet carries trailing text, `reserveChars`
was 0 while `flush()` still appended a `` ``` ``, producing a chunk of
`maxChars + 4` (e.g. 2004 > 2000) that Discord rejects with HTTP 400.

Reserve against `nextOpenFence ?? openFence` so whichever fence a flush can
close is accounted for, keeping a fence-closing line's chunk within `maxChars`.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:28:14 +00:00
Vincent Koc
1658fb6c14 fix(ci): restore QA workflow gates (#95890) 2026-06-23 09:26:35 +08:00
Mehraz Morshed
dd9706e902 Fix: plural agreement in VISION.md File (#78715)
Change "optional capability" to "optional capabilities" to better match the plural "plugins" in the same sentence.

No functional changes. Documentation only.
2026-06-23 01:23:35 +00:00
Wynne668
9fa14ff61a fix(control-ui): exclude disabled cron jobs from failed count (#95723)
Overview surfaces counted any job whose lastRunStatus was "error" as a
current failure, so an intentionally disabled job that previously failed
kept inflating the top-level "failed cron" badge and attention callout.

Add a shared isCronJobActiveFailure predicate that gates the error status
on enabled, matching the adjacent overdue filter, and use it in both the
overview card and the attention items list. Historical status stays
visible in detail views via resolveCronJobLastRunStatus.
2026-06-23 01:13:19 +00:00
joshavant
760f86453e feat: wire ios push sandbox tooling 2026-06-22 21:07:57 -04:00
joshavant
e08ef9f893 feat: add ios push relay sandbox profiles 2026-06-22 21:07:57 -04:00
joshavant
14b912261b feat: support sandbox relay apns registrations 2026-06-22 21:07:57 -04:00
Vincent Koc
9b9b058ebf refactor(android): share health status rows 2026-06-23 08:58:54 +08:00
Vincent Koc
1b7c1c2eb7 refactor(gateway): share doctor memory target resolution 2026-06-23 08:54:40 +08:00
Vincent Koc
026123dc76 refactor(android): share plain icon button component 2026-06-23 08:49:55 +08:00
Vincent Koc
2920dc3282 refactor(openai): share completion stop reason mapping 2026-06-23 08:45:12 +08:00
Voscko
ea56b135c8 feat(android): add settings detail panels (#95148)
* feat(android): add settings detail panels

* fix(android): strip escaped ansi log codes
2026-06-23 00:40:24 +00:00
Vincent Koc
32494c7ace refactor(agents): share session truncation warnings 2026-06-23 08:39:34 +08:00
Vincent Koc
43f2b61f3b test(qa): keep image generation fixture on mock lane 2026-06-23 02:35:02 +02:00
Yuval Dinodia
0ec12df245 fix(memory-wiki): preserve human notes block on source re-ingest (#95614)
* fix(memory-wiki): preserve human notes block on source re-ingest

Re-ingesting an existing source regenerated the page with an empty
wrote inside the human-managed markers. This broke the documented
contract that human note blocks are preserved, and diverged from the
synthesis and chatgpt-import writers that already preserve the block.

When a source page already exists, read it and re-inject its human Notes
block before writing. The block is located by scanning past the fenced
the content, then taking the first human start marker and the last end
marker, so the whole Notes block is preserved verbatim even when the
source content or the note text contains the markers or Markdown
headings. The same preservation is applied to writeImportedSourcePage so
the bridge and unsafe-local source-update writers keep notes too. New
page creation is unchanged.

Adds regressions for plain re-ingest, marker text in source content,
marker text inside the note, a heading inside the note, and an imported
source page update.

* fix(memory-wiki): preserve notes on CRLF source pages
2026-06-23 00:33:45 +00:00
Alix-007
2592f8a51a fix(agents): bound provider JSON response reads (#95218) 2026-06-23 00:33:38 +00:00
Dallin Romney
fee8ab4764 ci: generalize QA profile evidence workflow (#95880)
* ci: generalize qa profile evidence workflow

* ci: keep qa evidence workflow usable on qa failures
2026-06-22 17:33:02 -07:00
Vincent Koc
b60f63150f refactor(exec): share policy layer merging 2026-06-23 08:27:23 +08:00
youngting520
391e492f56 fix(cli): resolve trajectory export stores consistently (#95570) 2026-06-23 00:22:36 +00:00
Vincent Koc
086c629556 test(qa): scope provider-sensitive flow fixtures 2026-06-23 02:17:20 +02:00
Vincent Koc
d96ac02dc6 refactor(plugins): share public artifact candidate loading 2026-06-23 08:11:56 +08:00
Vincent Koc
c51661f1bf refactor(secrets): share env var candidate deduplication 2026-06-23 08:04:35 +08:00
Vincent Koc
2f8ad67a5e refactor(media): share local source path resolution 2026-06-23 08:01:35 +08:00
Colin Johnson
e39249100e fix: route Android exec approvals to in-app inbox (#95593)
* fix: route Android exec approvals to in-app inbox

* fix: read nested Android exec approval commands
2026-06-22 19:00:16 -05:00
Vincent Koc
befe04f465 test(qa): accept Sonnet max thinking support 2026-06-23 01:57:43 +02:00
Colin Johnson
5e342c774d improve: refresh Android overview control surface (#95557)
* improve android overview control surface

* fix android lint gates

* fix android voice e2e debug broadcast

* harden android voice e2e receiver

* fix(android): clarify Talk entry copy

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-06-22 18:57:33 -05:00
Vincent Koc
321e58c030 refactor(files): share nested ignore rule loading 2026-06-23 07:56:52 +08:00
ANIRUDDHA ADAK
82316c2f45 test: make qqbot symlinked media helper test robust on Windows
Gate the QQ Bot symlink-media helper regression test on actual file-symlink capability, so environments that cannot create file symlinks skip that specific test while capable hosts still run it.

Validation:
- Windows Vitest proof in the PR body: `extensions/qqbot/src/engine/utils/file-utils.test.ts` passed with 4 tests passed and 1 symlink test skipped when file symlinks were unavailable.
- Current CI is clean at `cb7d5a162e24f7ec5be6985e97b2b74ae45b20f9`, including the refreshed Real behavior proof run `27992101343`.

Co-authored-by: Aniruddha Adak <aniruddhaadak80@users.noreply.github.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-23 07:54:53 +08:00
Vincent Koc
a70dae40b7 refactor(media): share duplicate guard action results 2026-06-23 07:50:31 +08:00
Colin Johnson
3675c01410 fix: /status is too verbose for pinned model sessions (#95797)
* fix: compact status model override output

* fix: align compact status override wording
2026-06-22 18:49:58 -05:00
Vincent Koc
cc32f277fe refactor(models): centralize model key normalization 2026-06-23 07:45:50 +08:00
Vincent Koc
a409df6f9c refactor(models): reuse shared model key helper 2026-06-23 07:40:18 +08:00
Vincent Koc
264b37e9d2 test(qa): avoid redacted config cleanup patch 2026-06-23 01:39:39 +02:00
Vincent Koc
3f7ef1be37 refactor(cli): share precomputed help parsing 2026-06-23 07:35:59 +08:00
Vincent Koc
330fc9f7b9 refactor(cli): share gateway startup tracing 2026-06-23 07:26:53 +08:00
SannidhyaSah
3c06770a82 Simplify color mode button labels (#95837)
Merged via squash.

Prepared head SHA: 3da7299026
Co-authored-by: SannidhyaSah <186946675+SannidhyaSah@users.noreply.github.com>
Co-authored-by: hannesrudolph <49103247+hannesrudolph@users.noreply.github.com>
Reviewed-by: @hannesrudolph
2026-06-22 17:21:09 -06:00
xiayu
fc15c58715 fix(memory-core): report active dreaming phases in status (#93113)
* fix(memory-core): report active dreaming phases in status

* fix(memory-core): repair active dreaming status phases

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-23 07:16:14 +08:00
Vincent Koc
2ce4a7483a fix(ci): use workflow revision for proof checks
Checkout the trusted workflow revision for the Real behavior proof gate so old PR events with stale base SHAs can still run the current checker scripts.

Proof:
- `tbx_01kvrrqq6tnwee3r41p22sy0qk`: touched-file format check passed.
- `tbx_01kvrrqq6tnwee3r41p22sy0qk`: `corepack pnpm test:serial test/scripts/ci-workflow-guards.test.ts` passed.
- `tbx_01kvrrqq6tnwee3r41p22sy0qk`: `corepack pnpm check:changed` passed for tooling.
- PR CI passed with no failing or pending checks.
2026-06-23 07:11:23 +08:00
Vincent Koc
fac091b39d fix(installer): detect native Windows ARM64 hosts 2026-06-23 07:00:59 +08:00
Dallin Romney
89de454f82 ci: add manual release qa evidence workflow (#95876) 2026-06-22 15:48:59 -07:00
Dallin Romney
de9c94cbbb feat(qa): forward shared suite flags to multipass runner (#91506) 2026-06-22 15:48:05 -07:00
Bek
5e915e1f89 fix(agents): keep cron cloud idle watchdog enabled (#94445)
* fix(agents): keep cron cloud idle watchdog enabled

* docs: align cron idle timeout guidance
2026-06-23 06:47:19 +08:00
Vincent Koc
dcb6b0dd6f fix(ci): restore macOS and Windows QA gates
Restores Azure native Windows hydrated node_modules bootstrap, fixes the macOS settings SwiftFormat drift, and stabilizes lifecycle process-group CI proof.

Proof:
- `tbx_01kvrpr5kfc58wdnakx2zkc4k6`: `corepack pnpm test:serial test/scripts/plugin-lifecycle-measure.test.ts` passed.
- `tbx_01kvrpvcrmsxgyb886pa127qq3`: `OPENCLAW_TESTBOX=1 ... corepack pnpm check:changed` passed.
- `tbx_01kvrpzpafmp27tyb4tg9yvwvz`: touched-file `format:check` passed.
- PR CI `27988226071` passed, including `macos-node`, `macos-swift`, and `checks-node-compact-small-whole-2`.
2026-06-23 06:38:27 +08:00
Vincent Koc
961130c707 refactor(e2e): remove stale upgrade survivor setup 2026-06-23 06:27:49 +08:00
cornna
ef62076789 fix(agents): resolve webchat current session status
* fix(agents): resolve webchat current session status

* fix(agents): resolve webchat current session status

---------

Co-authored-by: Cornna <96944678+ymylive@users.noreply.github.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-23 06:26:49 +08:00
Dallin Romney
a1c2454b08 ci: move tui pty into node ci shard (#95872) 2026-06-22 15:25:20 -07:00
Dallin Romney
63b13ea837 feat(qa): crabline channel driver (#91502)
* feat(qa): add crabline channel driver seam

* feat: run crabline channel driver smoke

* chore: keep crabline qa dependency dev-only

* refactor(qa): keep crabline driver details opaque

* chore(qa): pin crabline to merged driver API

* feat(qa): drive channel driver from profiles

* fix(qa): declare crabline runtime peer

* feat(qa): resolve crabline channel from scenarios

* feat(qa): treat unsupported profile channels as coverage gaps

* Revert "feat(qa): treat unsupported profile channels as coverage gaps"

This reverts commit 65a9701655.

* fix(qa): adapt crabline driver to chat sdk cli

* refactor(qa): pass channel driver metadata directly

* chore(qa): update crabline provider pin

* chore(qa): default channel scenarios to driver

* chore: repair qa dependency lockfile

* chore: allow native qa dependency builds

* fix(qa): satisfy crabline driver lint

* fix(qa): satisfy crabline ci gates

* Use crabline transport for smoke QA profile

* fix(qa): keep crabline driver opt-in

* fix(qa): reuse crabline telegram driver token

* fix(qa): route smoke profile through crabline

* fix(qa): run full smoke profile lane

* fix(qa): remove smoke scenario workflow filter

* fix: stabilize crabline smoke qa profile

* fix: pin crabline qa dependency

* test: keep crabline smoke credential-free

* fix: skip visible reasoning lane for crabline smoke

* fix: unblock crabline qa ci

* Update crabline dependency

* Pin crabline to merged main

* Use Crabline fake provider servers
2026-06-22 15:24:59 -07:00
Vincent Koc
c0b6183b7b refactor(e2e): remove orphaned fixture manifest helper 2026-06-23 06:13:51 +08:00
Vincent Koc
0edd84f910 refactor(pr): remove unused path predicates 2026-06-23 06:12:16 +08:00
Vincent Koc
ea9065bc68 fix(installer): skip llama postinstall in Windows source installs 2026-06-23 06:08:48 +08:00
Vincent Koc
adc4d9fe02 refactor(install): remove stale shell helpers 2026-06-23 06:07:15 +08:00
ly-wang19
75af913ba6 feat(gateway-cli): scope usage-cost by agent (#94483)
* feat(gateway-cli): scope usage-cost by agent

The `gateway usage-cost` CLI only sent `{ days }` to the `usage.cost` RPC, so
callers could not break cost down per agent or aggregate across all agents the
way the Control UI can. Add `--agent <id>` (forwards `agentId`, scoping to one
agent) and `--all-agents` (forwards `agentScope: "all"`, aggregating every
agent). The two are mutually exclusive because the gateway honors `agentScope`
only when no `agentId` is set; passing both now errors instead of silently
dropping `--all-agents`. No flag keeps the existing default-agent behavior.

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

* feat(gateway-cli): scope usage-cost by agent

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-23 06:05:33 +08:00
Efe Büken
739e6cbbf8 fix(minimax): request hex TTS output explicitly
* fix(minimax): request hex TTS output explicitly

* fix(minimax): request hex TTS output explicitly

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-23 06:03:55 +08:00
Vincent Koc
8357260081 refactor(parallels): remove unused macOS exec wrapper 2026-06-23 06:00:26 +08:00
Vincent Koc
aeedfceb28 refactor(e2e): remove unused shell wrappers 2026-06-23 05:56:31 +08:00
Vincent Koc
75b9e761b7 refactor(onboard): remove obsolete interactive helpers 2026-06-23 05:50:22 +08:00
Vincent Koc
1cdc28605d refactor(parallels): remove orphaned package shell helpers 2026-06-23 05:48:27 +08:00
Gio Della-Libera
037ee6de0a Doctor: expose sandbox registry findings (#84326)
Merged via squash.

Prepared head SHA: ab069883b0
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-22 14:42:23 -07:00
Vincent Koc
d6111ff72c refactor(macos): remove orphan helpers and view state 2026-06-23 05:36:36 +08:00
Dallin Romney
ed2dfee7d7 feat(qa): expose active memory toggles to scenarios (#95858) 2026-06-22 14:26:37 -07:00
Vincent Koc
af328b2b21 refactor(android): remove orphan helpers and aliases 2026-06-23 05:22:56 +08:00
Vincent Koc
88c3bb5391 refactor(android): test auth resolution directly 2026-06-23 05:16:19 +08:00
Vincent Koc
e9756f9e71 refactor(android): remove stale canvas and overlay helpers 2026-06-23 05:13:13 +08:00
Vincent Koc
2e0dd66d39 refactor(android): remove orphan runtime accessors 2026-06-23 05:05:41 +08:00
Vincent Koc
1423487351 refactor(android): remove stale UI helpers 2026-06-23 04:58:26 +08:00
Vincent Koc
01d212bfa3 refactor(docs-i18n): remove unreachable chunk helpers 2026-06-23 04:58:21 +08:00
Vincent Koc
3d787b5181 refactor(types): remove stale internal contract aliases 2026-06-23 04:48:02 +08:00
Vincent Koc
89c90210fb refactor(infra): trim unused fs-safe facade exports 2026-06-23 04:39:05 +08:00
Dallin Romney
65a20ca4c5 fix: allow sqlite user version guardrail (#95857) 2026-06-22 13:36:42 -07:00
Vincent Koc
d5d9a8256d fix(crabbox): route native Windows hydrate jobs 2026-06-23 04:34:03 +08:00
Vincent Koc
5dfbb9d1e0 test(ui): scope quota pill e2e selector 2026-06-23 04:29:27 +08:00
zw-xysk
3a32d24395 fix(cron): trim trailing whitespace from recognized job object keys (#95674)
* fix(cron): trim trailing whitespace from recognized job object keys (#95407)

Some tool-call extraction/serialization pipelines can produce cron object
keys with trailing spaces (e.g. 'schedule ' instead of 'schedule'), causing
gateway validation to reject the job.

Add repairPaddedCronKeys() to canonicalizeCronToolObject() that trims only
recognized CRON_RECOVERABLE_OBJECT_KEYS. Non-recognized keys (including
special ones like '__proto__') are never trimmed, preventing prototype
pollution. When both padded and canonical forms exist, the canonical key
wins.

Tests:
- add job with trailing-space keys -> trimmed
- update patch with trailing-space keys -> trimmed
- non-recognized padded keys left intact (safety)
- canonical key preserved over padded duplicate
- clean keys unchanged

133 tests pass (128 existing + 5 new).

* fix(cron): preserve padded duplicate keys when canonical form already exists (#95407)

When both a padded key (e.g. 'schedule ') and its canonical form
('schedule') exist, the padded key is now preserved so strict gateway
validation rejects the ambiguous input rather than silently picking one
value. Only padded keys without a canonical counterpart are trimmed.
2026-06-22 20:24:59 +00:00
miorbnli
90fb2ee4e1 fix(gateway.tls): reject empty/whitespace certPath and keyPath (#94054)
* fix(gateway.tls): reject empty/whitespace certPath and keyPath

gateway.tls.certPath and keyPath both accept "" and whitespace-only
strings at the schema layer (z.string().optional() with no .min(1)), and
the runtime fallback cfg.certPath ?? path.join(baseDir, "...") only
triggers on null/undefined, so empty strings reach generateSelfSignedCert
unchanged. From there path.dirname("") === "." and openssl receives
"-out "" -keyout """, producing a cryptic error.

Sibling field caPath already guards against this via truthy check, so
this brings certPath/keyPath to the same defensive style.

Three changes:
1. Schema: certPath/keyPath tightened to z.string().trim().min(1).optional()
2. Runtime: replace ?? with explicit truthy check, aligning with caPath
3. chmod errors now throw instead of .catch(() => {})

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

* chore: add :unknown type to catch callback variables

* fix(gateway.tls): restore best-effort chmod for generated cert/key

* fix(gateway.tls): preserve non-empty cert/key path bytes

Schema z.string().trim().min(1) and the runtime cfg.certPath.trim() both
trimmed non-empty paths. The schema trim silently rewrote validated config
data, and the runtime trim duplicated resolveUserPath, which already trims
and expands ~ in resolveHomeRelativePath.

Keep blank/whitespace rejection, drop the transformation: schema uses
.refine (validate only), runtime passes the original string to resolveUserPath.
Non-empty paths keep exact bytes; blank values are still rejected/defaulted.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 20:23:52 +00:00
Sash Zats
5d9daea2b0 fix(ios): centralize app accent colors (#94627)
Move iOS accent and status colors through design tokens so raw SwiftUI color literals are blocked outside token definitions.

Set the app-wide tint in SwiftUI and UIKit from code, without relying on Assets.xcassets AccentColor.
2026-06-22 20:20:37 +00:00
zhang-guiping
2dc2d73b07 fix(webchat): sessions persist after reconnects (#89017)
* fix(gateway): preserve asserted webchat sessions

* test(gateway): cover stale asserted webchat sessions

* fix(gateway): scope webchat session resume

* chore(protocol): refresh chat send models

* fix: document reconnect session resume protocol

* fix(gateway): keep reconnect resume internal

* gateway: keep reconnect resume options internal

* test(ui): avoid private resume marker lint access
2026-06-22 20:02:58 +00:00
Vincent Koc
9122e762d8 refactor(records): reuse canonical object guard 2026-06-23 03:58:08 +08:00
zhang-guiping
769579bcf0 fix(opencode-go): streaming completes when provider ends responses (#93965)
* fix(opencode-go): abort stalled SSE streams at provider-owned raw boundary

opencode-go routes through the shared OpenAI-compatible completions provider,
where a stalled SSE socket (provider emits tokens then never closes the stream)
hangs the gateway until stuckSessionAbortMs (~622s) and surfaces as
'LLM request failed' / 'Request was aborted'. Issue #93610 reports ~90% of
opencode-go cron jobs failing intermittently this way.

Add a provider-owned stream wrapper at the opencode-go raw SSE boundary that
injects an AbortController into the underlying OpenAI SDK request and aborts
it after a configurable idle window (default 30s, far below 622s) elapses
without any forward-progress event. The wrapper is:

- Provider-scoped: only applies when model.provider === 'opencode-go'; the
  shared openai-completions.ts path is untouched.
- Abortable: calls controller.abort() on the injected AbortSignal, which
  propagates through OpenAI SDK requestOptions.signal and genuinely
  interrupts the underlying fetch/stream (not just iterator return()).
- Idle-based: every event (text/tool/thinking delta, including delayed
  usage-only chunks) refreshes the timer; natural completion (done/error)
  cancels it. Normal delayed usage-only completion is preserved.
- Boundary-terminal: pushes a terminal { type: 'error', reason: 'aborted' }
  event downstream so consumers do not hang.

TDD: stream-termination.test.ts covers (a) stalled stream after first
progress is aborted within the idle window with a downstream 'aborted'
terminal event, and (b) normal delayed completion within the idle window
is not aborted and the done event is forwarded unchanged.

* fix(opencode-go): align stalled-stream idle default with runtime (120s)

Match the runtime's shared `DEFAULT_LLM_IDLE_TIMEOUT_MS` (120s) so
non-cron interactive opencode-go runs see no behavior change versus the
existing watchdog. Cron runs — for which the runtime disables its idle
watchdog entirely (`resolveLlmIdleTimeoutMs` returns 0 when trigger is
cron and no explicit timeout is set) — still get provider-owned
termination well before the ~622s stuck-session recovery.

Refs #93610

* fix(opencode-go): satisfy CI lint and test type checks

- Remove unnecessary `?? {}` fallback in spread (oxlint
  no-useless-fallback-in-spread).
- Drop non-narrowing `!` on the wrapper return type; use
  `await Promise.resolve(...)` to collapse the
  `StreamLike | Promise<StreamLike>` union before `for await`.

Refs #93610

* fix(opencode-go): arm stalled-stream idle timer only after first event

The wrapper armed the idle timer before the first upstream event, which
would mis-abort slow time-to-first-byte requests — including the
opencode-go cron runs that the runtime deliberately leaves uncapped via
resolveLlmIdleTimeoutMs. Arm only after the first forwarded event, and
add regression coverage for the slow-first-event path.

* fix(opencode-go): cover stalled stream first event

* fix(opencode-go): respect explicit stream timeout

* fix(opencode-go): preserve first-event timer after synthetic start

* fix(opencode-go): satisfy stream termination test lint

* fix(opencode-go): distinguish synthetic stream preambles

* fix(opencode-go): route stalled streams through failover
2026-06-22 19:57:21 +00:00
Vincent Koc
056e5b6b07 refactor(routing): share optional agent id normalization 2026-06-23 03:53:45 +08:00
NIO
8fdb1b61db fix(agents): classify generic LLM-request-failed error as transient timeout (#94062)
The generic assistant error text "LLM request failed." (GENERIC_ASSISTANT_ERROR_TEXT) is
produced by formatUserFacingAssistantErrorText when the underlying provider error cannot
be formatted into a specific category. For local providers (LM Studio, Ollama) this wraps
connection/availability failures when the model is not loaded or the endpoint is unreachable.

Without this match, the error is not classified as any transient type (rate_limit, overloaded,
network, server_error, timeout), so cron retry and payload.fallbacks never engage — even
though the configured fallback chain should handle provider availability failures.

Add /^llm request failed\.$/i as an exact-match regex in the timeout error patterns. This
strictly matches only the bare "LLM request failed." string, not variants like
"LLM request failed: provider rejected the request schema or tool payload." (which is a
format/schema error, not transient). Variants with specific transient reasons (connection
refused, network error, etc.) are classified through their own existing patterns.

Closes #93931
2026-06-22 19:53:26 +00:00
ly-wang19
a2d7882100 fix(cli): expose --count on infer image edit, matching image generate (#95300)
The `image edit` CLI command could not request multiple edited images while
the sibling `image generate` could, even though the shared runImageGenerate
action and generateImage thread `count` for both capabilities and providers
(xai, litellm, openai) honor edit-mode count (edit.maxCount 4). PR #94156
added --quality/--openai-moderation to both commands but left --count off
edit only. Add --count to the edit command registration, action, and
CAPABILITY_METADATA, mirroring image generate exactly.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:52:32 +00:00
Parvesh Saini
e33760c9df fix(model-catalog): strip manifest model-id prefixes by the matched length (#95744) 2026-06-22 19:52:13 +00:00
Vincent Koc
392377e7e4 chore(plugin-sdk): refresh API baseline hash 2026-06-22 21:49:53 +02:00
Vincent Koc
0a338147a5 refactor(numbers): share non-negative finite guard 2026-06-23 03:46:22 +08:00
Vincent Koc
013e33c6d3 fix(telegram): avoid duplicate progress headings 2026-06-22 21:43:47 +02:00
Hoi Hin Adrian Ip
dbd4c98b02 Handle Codex toolResult blocks in truncation (#87912)
Co-authored-by: Hoi Hin Adrian Ip <255652477+AdrianIp0204@users.noreply.github.com>
2026-06-22 19:41:30 +00:00
Vincent Koc
0529281430 refactor(sqlite): share numeric column decoding 2026-06-23 03:38:18 +08:00
Vincent Koc
284e514e19 refactor(logging): share log file path primitives 2026-06-23 03:34:43 +08:00
Vincent Koc
066700bdd0 refactor(anthropic): share Foundry bearer auth policy 2026-06-23 03:31:32 +08:00
Vincent Koc
470a0f80b6 refactor(plugins): reuse optional string normalization 2026-06-23 03:28:01 +08:00
Vincent Koc
b31bf811cb refactor(providers): share bounded error body reader 2026-06-23 03:24:54 +08:00
Yzx
1662b07810 fix(cron): expose per-job fallbacks in CLI (#93369) 2026-06-22 19:22:20 +00:00
pick-cat
cf31689a03 fix(control-ui): restore provider usage quota pill in sidebar session switcher (fixes #93041) (#94219)
* fix(control-ui): restore provider usage quota pill in sidebar session switcher

* ci: re-trigger flaky cron-service shard

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

---------

Co-authored-by: Pick-cat <266665499+Pick-cat@users.noreply.github.com>
Co-authored-by: Pick-cat <Pick-cat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 19:21:38 +00:00
Vincent Koc
a07d92ff4f refactor(net): share FormData shape guard 2026-06-23 03:20:18 +08:00
Vincent Koc
858fd2c5a2 refactor(sqlite): share user version probe 2026-06-23 03:19:45 +08:00
Vincent Koc
7c4ab782cb refactor(providers): reuse capability provider registry maps 2026-06-23 03:17:22 +08:00
Moeed Ahmed
5cafe4b0cf fix(telegram): keep bot reply answers anchored to current message (#90475)
Co-authored-by: Moeed Ahmed <moeedahmed@Moeed-Mac-mini.local>
2026-06-22 19:17:07 +00:00
Darren2030
c4cac33af6 fix(openrouter): expand short canonical model IDs to upstream API slugs (fixes #95198) (#95268)
- Add OPENROUTER_SHORT_TO_API_MODEL_ID map for short model refs like
  openrouter/deepseek-v4-flash that OpenClaw surfaces but OpenRouter API
  expects as deepseek/deepseek-v4-flash.
- In normalizeOpenRouterApiModelId, expand short refs before falling back
  to the existing namespaced strip logic.
- Add unit tests covering short refs, long refs, native routes, and
  pass-through cases.
- Add standalone reproduction script that verifies all normalization cases.
2026-06-22 19:15:25 +00:00
Masato Hoshino
965d1fff3f fix(providers): strip cache-boundary marker from non-Anthropic prompts (#89716) 2026-06-22 19:14:31 +00:00
snowzlmbot
23f94bfa78 fix(reply): normalize persisted model overrides before reset (#94752)
Co-authored-by: snowzlm <snowzlm@noreply.codeberg.org>
2026-06-22 19:14:25 +00:00
Yzx
a0ed4273ee fix(agents): resolve bound route agent for inbound sessions (#95118) 2026-06-22 19:14:17 +00:00
areslp
bfbf25e234 fix(feishu): show voice message duration via upload duration (#89172)
Voice/audio messages sent to Feishu (opus) play fine but show no duration
on the bubble. Feishu derives the voice-bubble duration from the `duration`
parameter of the file upload API (`im/v1/files`); the audio message content
only carries `{file_key}` and has no duration field, so the duration was
never set.

`sendMediaFeishu` now probes the outgoing audio with `ffprobe` and passes the
result as the upload `duration` (ms). It probes the buffer that is actually
sent (after the existing voice transcode, which caps length via
`MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS`), so the reported length matches what
is played. Probing is best-effort: on failure it logs and omits the duration,
and the message still sends. The audio message content is unchanged.

Fixes #53798

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:13:14 +00:00
Gavin Lee
8c366bfefd test(cli): add banner emission reset helper (#87121) 2026-06-22 19:12:07 +00:00
Vincent Koc
b4bc1f20c9 fix(agents): repair OpenAI responses replay pairing 2026-06-23 03:11:33 +08:00
Vincent Koc
c782fa98aa refactor(delivery): share recovery primitives 2026-06-23 03:10:30 +08:00
ly-wang19
81e1ec467c fix(imessage): strip leading echo corruption markers in the persisted echo cache (#94442)
The persisted iMessage echo-dedupe cache normalized text with CRLF->LF + trim only, not the leading attributedBody corruption-marker stripping the in-memory echo cache applies (#93511). The persisted 12h cache is the only matcher once the 4s in-memory text TTL expires, so a delayed reflected own-message echo whose text decoded with a leading NUL/replacement/BOM marker did not match the clean stored send -- the agent's own message was re-ingested as fresh inbound, causing a self-reply loop.

Extract the marker-stripping into a leaf module shared by both echo caches (the in-memory cache already imports the persisted one, so importing back would be a cycle) and apply it in the persisted normalizeText, so both caches strip identically.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:07:45 +00:00
snowzlm
10113b2c9f fix(daemon): keep systemd gateway running after child OOM (#93585)
Co-authored-by: snowzlm <snowzlm@noreply.codeberg.org>
2026-06-22 18:54:21 +00:00
jase-283
f8df80646b chore: sync yuanbao plugin catalog to 2.15.0 (#94470) 2026-06-22 18:50:07 +00:00
Vincent Koc
541f7ffc65 fix(doctor): handle unknown tool profiles in preview warnings 2026-06-22 20:41:02 +02:00
Vincent Koc
43f134ff55 refactor(security): share tool policy layering 2026-06-23 02:40:29 +08:00
Ayaan Zaidi
780f83bcfb test(agents): distill media lifecycle fixture 2026-06-23 00:09:20 +05:30
Peter Steinberger
37714f185f fix(media): pin canonical requester route 2026-06-23 00:09:20 +05:30
Peter Steinberger
9d1ba36f6b test(media): allow partial session fixtures 2026-06-23 00:09:20 +05:30
Peter Steinberger
908a71ab57 fix(media): reject account-only route conflicts 2026-06-23 00:09:20 +05:30
Peter Steinberger
253180a265 fix(media): keep pinned routes account-bound 2026-06-23 00:09:20 +05:30
Peter Steinberger
3dce88e2b3 test(media): update requester route mocks 2026-06-23 00:09:20 +05:30
Peter Steinberger
025db6cf9e fix(agents): pin media requester route at task start 2026-06-23 00:09:20 +05:30
Peter Steinberger
1ed8592467 fix(agents): keep fallback routes account-bound 2026-06-23 00:09:20 +05:30
Peter Steinberger
dc9ad35bda test(agents): prove requester account fallback 2026-06-23 00:09:20 +05:30
wanglu241
d6d7a4c4b8 test(announce-delivery): satisfy curly rule for cross-channel guard test
oxlint(curly) rejected the bare `if (!params) continue;` continue inside
the regression test added for #86034. Wrap the body in braces. No logic
change.
2026-06-23 00:09:20 +05:30
wanglu241
4e24dcf396 test(announce-delivery): cross-channel lastTo must not leak into telegram delivery (#86034)
Locks the mergeDeliveryContext channelsConflict guard so a stale lastChannel that differs from the completion origin's channel cannot import its lastTo. Addresses ClawSweeper's contract question on PR #89949.

node_modules not available in this worktree; vitest was not run locally. CI is the gate.
2026-06-23 00:09:20 +05:30
wanglu241
ab3d2b44ac test(announce-delivery): clean up temp session store on assertion failure 2026-06-23 00:09:20 +05:30
wanglu241
e5f3df6538 fix(announce-delivery): backfill effectiveDirectOrigin.to from requester session entry
When a media-generation task is created off the direct-reply path (heartbeat,
cron, subagent spawn), `agentTo` is undefined and the persisted
`requesterOrigin` lacks `to`. Every downstream `Boolean(channel && to)` gate
then short-circuits, so the generated artifact is never delivered even though
the artifact exists on disk and `task_runs.status` is later marked failed with
`completion delivery failed after successful generation`.

The requester session entry already carries `lastTo`/`lastChannel`/
`lastAccountId` and is loaded in the same function further down. Merge that
context back into `effectiveDirectOrigin` before the deliverability decision,
as the existing comment at the same site already promises.

Fixes #86034 (Hypothesis A). Hypothesis B (wake-false skips direct fallback)
remains a separate follow-up - see issue thread for details.
2026-06-23 00:09:20 +05:30
Vincent Koc
5d48a2ec54 refactor(auth): dedupe blocked profile stats construction 2026-06-23 02:34:24 +08:00
Vincent Koc
b49395ddb1 refactor(gateway): dedupe MCP loopback auth gate 2026-06-23 02:29:48 +08:00
ruomuxydt
105a30b5a5 fix(config): fail closed when configure runs without an interactive TTY (#93953) (#94238)
Both `openclaw configure` and the no-subcommand `openclaw config` route through `configureCommandFromSectionsArg`, so a single guard there fail-closes both entry points when stdin/stdout are not TTYs instead of partially entering the wizard and exiting dirty (exit 13) on a piped stdin.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 18:28:55 +00:00
Zechen Wang
4d636335db fix(google): add gemini-3.5-flash model catalog entry (#94726)
* fix(google): add gemini-3.5-flash model catalog entry

gemini-3.5-flash was missing from the bundled Google model catalog,
causing it to silently fall back to DEFAULT_CONTEXT_TOKENS (200k)
instead of its documented 1,048,576-token input window.

Add the catalog entry and forward-compat routing so the model
resolves with the correct context window.

Closes: openclaw/openclaw#94723
Co-Authored-By: Claude <noreply@anthropic.com>

* chore: retry CI (flaky test)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 18:28:01 +00:00
ml12580
1585ec54f1 perf(plugins): cache existence probes within bundle manifest scan [AI-assisted] (#93919)
* perf(plugins): cache existence probes within bundle manifest scan

Bundle plugin discovery re-probes the same marker paths (skills/, commands/,
agents/, .mcp.json, .lsp.json, settings.json, hooks/hooks.json) once in
detectBundleManifestFormat and again in loadBundleManifest's capability
builders. Across the bundled plugin tree this is thousands of redundant
synchronous fs.existsSync calls; #76209 reports 25.4s of self-time on a
Windows cold start.

Add a scan-scoped existence cache (plugin-scan-existence-cache.ts) entered
only around discoverBundleInRoot. pluginScanExistsSync memoizes inside the
active scan and falls back to plain fs.existsSync outside it, so install,
hooks, and doctor flows stay uncached. The cache is push/pop per
discoverBundleInRoot call (try/finally), so a later install/repair pass
re-reads the filesystem — no process-global staleness.

Measured on Windows over a 25-plugin fixture: 550 -> 325 fs.existsSync
calls (41% fewer), 294.75ms -> 208.49ms. Discovery results unchanged.

Closes #76209

* fix(plugins): drop unused test reset helper and satisfy oxlint

Remove __resetPluginScanExistenceCacheForTest: the scan cache is push/pop
balanced by try/finally in withPluginScanExistenceCache, so the stack never
leaks between tests and the helper was dead code. It also tripped oxlint
no-underscore-dangle. Refactor the integration test to count existsSync calls
via a const-returning helper so there is no useless assignment.
2026-06-22 18:27:36 +00:00
Yuval Dinodia
f257c0609d fix(sessions): keep bound channel identity across non-delivery turns (#95467)
* fix(sessions): keep bound channel identity across non-delivery turns

mergeOrigin reset channel-keyed origin fields (nativeChannelId,
nativeDirectUserId, accountId, threadId) whenever the new turn's
provider/surface/account differed, intended for a real Slack -> Telegram
switch. A non-delivery turn (gateway webchat send, heartbeat/cron/webhook
tick) derives origin.provider as the internal channel, so it was treated
as a channel switch and wiped the session's live channel/thread identity
even though the session never left that channel.

Gate the reset on the new turn being a real, deliverable channel so
internal non-delivery turns preserve the bound channel identity while a
genuine cross-channel switch still resets it.

* fix(sessions): also exclude system-event providers from the channel-switch reset

cron-event and exec-event turns (and heartbeat) carry no channel of their
own. They can reach mergeOrigin through the non-skip callers
(recordSessionMetaFromInbound / updateLastRoute) that derive an origin
without skipSystemEventOrigin, so the channel-switch reset would wipe a
bound session's native channel/thread identity. Add isSystemEventProvider
to the non-deliverable gate (reusing it to de-dupe the same check already
inlined in deriveSessionOrigin).
2026-06-22 18:27:23 +00:00
ly-wang19
d63389ccf6 fix(qqbot): recognize GFM table separators with one or two dashes (#95637)
`isTableSeparatorLine` required 3+ dashes per cell (`/^:?-{3,}:?$/`), but a
GFM delimiter cell needs only one or more dashes. So a valid table whose
separator used 1 or 2 dashes (e.g. `|--|--|`) was not recognized: the header
stayed pending and was silently overwritten by each following row, so the
table's header, separator, and every row but the last vanished from the sent
message.

Accept `-+` so valid GFM separators are recognized, matching the spec and the
sibling LINE channel. Every existing test separator already uses 3+ dashes, so
they are byte-identical.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:27:01 +00:00
Vincent Koc
420a0e6fce fix(doctor): ignore unknown profile preview grants 2026-06-23 02:24:29 +08:00
Vincent Koc
96c6f8022c fix(auto-reply): suppress quoted silent replies 2026-06-23 02:24:29 +08:00
Peter Steinberger
03ba09bfa8 fix(doctor): accept missing profile policies 2026-06-22 14:20:09 -04:00
Vincent Koc
8d5fe80303 ci(windows): clarify WSL2 reboot requirement 2026-06-22 20:18:24 +02:00
Vincent Koc
24fc2e9a88 refactor(doctor): reuse TTS plugin location matcher 2026-06-23 02:13:31 +08:00
Vincent Koc
a1181023ba refactor(plugins): share provider catalog filter 2026-06-23 02:10:20 +08:00
NIO
be43c55398 fix(control-ui): rewrite manifest hrefs for configured base path (#94204)
Serve Control UI index.html with base-path-prefixed public asset links so
browsers do not prefetch /manifest.webmanifest at the host root behind
reverse proxies.

Fixes #94157
2026-06-22 18:09:25 +00:00
Amer Sheeny
b8434386b8 fix(acp): recover stale persistent sessions by structured resume-required code (#93547)
Persistent ACP threads died on the second turn for Kiro: when the backend
can no longer resume a stale session, acpx raises a SessionResumeRequiredError
whose reason text varies by backend ("Resource not found" for Claude,
"Internal error" / RequestError -32603 for Kiro). The recovery gate matched
the human reason text and required "resource not found", so Kiro's "Internal
error" never triggered the fresh-session retry and the thread produced no
reply (ACP_TURN_FAILED).

Recover by acpx's structured detail code instead of the reason text: acpx
tags every such failure with detailCode "SESSION_RESUME_REQUIRED"
(retryable), independent of wording. The two AcpRuntimeError construction
seams were discarding detailCode, so preserve it on AcpRuntimeError and match
it across the error and its cause chain. This fixes every backend's
resume-required failure and is more precise than the reason regex — a generic
"Internal error" without the code is still surfaced rather than silently
retried.

Fixes #87830. Reported by @chouzz.
2026-06-22 18:08:56 +00:00
Jason O'Neal
92264fbb8f fix(ollama): skip auto-discovery for remote/cloud base URLs (#93956)
* fix(ollama): skip auto-discovery for remote/cloud base URLs

When the Ollama provider base URL points to a remote/cloud instance
(e.g. ollama.com), the plugin should not auto-discover all available
models via /api/tags. Cloud instances are shared tenants where the
provider manages the model catalog; users should only get models they
explicitly configure.

- Add remote-baseUrl guard in resolveOllamaDiscoveryResult
- Local/loopback URLs still auto-discover as before
- Remote URLs with explicit models return only those models
- Remote URLs without explicit models return null (skip discovery)
- Add tests covering remote guard, explicit models, and local fallback

* fix ollama cloud discovery ci

* fix(ollama): narrow discovery guard to hosted Ollama Cloud only

The previous guard blocked auto-discovery for ALL remote base URLs
without explicit models. This was too broad — it also blocked
self-hosted Ollama instances at custom domains (e.g.,
https://ollama.mycompany.com).

Replace the !isLocalOllamaBaseUrl() check with a targeted
isHostedOllamaCloud() check that only matches *.ollama.com
hostnames. Remote self-hosted Ollama endpoints now correctly
auto-discover as before.

Add isHostedOllamaCloud() helper with unit tests and a
regression test confirming remote self-hosted URLs still
auto-discover.

* fix(ollama): ensure models array in explicit-models return path

* fix(ollama): replace deprecated config-types import with local type

The openclaw/plugin-sdk/config-types subpath is deprecated and flagged
by the CI architecture check. Replace it with a local OllamaProviderConfigInput
type alias defined from non-deprecated provider-model-shared exports.

- discovery-shared.ts: define OllamaProviderConfigInput locally
- provider-base-url.ts: define OllamaProviderConfigInput locally
- Both files: remove import from openclaw/plugin-sdk/config-types

* chore(ollama): drop unrelated formatting churn
2026-06-22 18:08:05 +00:00
zhouhe-xydt
7c8ca26364 fix(setup): point non-interactive health hints at onboard flags (#93994)
The recovery hint printed by setup --non-interactive referenced --install-daemon
and --skip-health, which are only registered on openclaw onboard. Update the
message to reference openclaw onboard --install-daemon and
openclaw onboard --skip-health.

Fixes #93947
2026-06-22 18:07:13 +00:00
Dirk
96e49705a6 fix(matrix): prune finished fake-indexeddb transactions to prevent OOM (#94942)
fake-indexeddb@6.2.5 retains finished transactions in raw.transactions
array indefinitely. For Matrix E2EE crypto stores, this causes unbounded
heap growth and eventual OOM crashes.

Add a transaction pruner that patches IDBDatabase.prototype.transaction
to automatically remove finished transactions for Matrix crypto databases
(::matrix-sdk-crypto and ::matrix-sdk-crypto-meta suffixes).

Fixes #90455
2026-06-22 18:06:55 +00:00
YEEE
e3a496a29a [agent] fix: repair telegram cache message types (#82909) 2026-06-22 18:04:28 +00:00
Vincent Koc
8f2882f94a refactor(tools): consolidate provider policy resolution 2026-06-23 02:00:04 +08:00
Vincent Koc
25090056dc refactor(gateway): remove unused device auth normalizer 2026-06-23 01:50:57 +08:00
Mark
e8a31ddbce fix(xai): request encrypted reasoning include for all reasoning models (#95686)
Merged via squash.

Prepared head SHA: 8b3be0aaab
Co-authored-by: geraint0923 <923382+geraint0923@users.noreply.github.com>
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Reviewed-by: @fuller-stack-dev
2026-06-22 11:50:50 -06:00
Ben.Li
b335381247 fix(memory): preserve Windows QMD command paths (#95274) 2026-06-22 17:50:11 +00:00
JC
e90fb67641 fix(agents): recover message-tool mirror replay poison (#84708)
* fix(agents): recover message-tool mirror replay poison

Rebase-style refresh onto current upstream main.

* fix(auto-reply): narrow conversation-state 400 classification
2026-06-22 17:49:57 +00:00
wood fish
1fc4342a02 fix(ollama): honor memory embedding output dimensionality (#94811) 2026-06-22 17:49:43 +00:00
Amer Sheeny
9fbc8a74ef fix(llm): collapse cumulative openai-responses message snapshots instead of concatenating [AI-assisted] (#92399)
* fix(llm): collapse cumulative openai-responses message snapshots instead of concatenating

Some openai-responses providers (observed: Bedrock Mantle with GPT-5.x
reasoning enabled, confirmed server-side via raw curl) re-emit the
assistant message as many cumulative snapshot items — each a
prefix-superset of the previous one — instead of a single final message
item. Both stream consumers appended one text block per item, so the
final visible reply, transcript, and replay context repeated the answer
once per snapshot (observed 49-80x).

Treat a same-phase message item whose text extends the immediately
preceding text block as a replacement: the prior block takes the longer
text, the duplicate block is dropped, and the first item's signature is
kept so replay and stream-item identity stay stable. Shrinking or
identical adjacent snapshots are dropped. Any non-message output item
(reasoning, tool call) is a real boundary that resets the collapse, so
distinct post-tool messages and reasoning replay pairing are untouched,
as are different-phase (commentary/final_answer) items. Applies to the
agent transport stream, the shared LLM consumer, and completed-response
backfill.

Fixes #91959. Reported by @phoenixyy with server-side evidence from
@DaiMingNJ.

* test(llm): drop redundant stream drains from responses snapshot tests

* fix(llm): collapse only strict snapshot extensions and keep newest item signature

Address ClawSweeper P1 review findings on #92399: text-prefix relation
alone was broader than the observed corruption. Equal or shrinking
adjacent same-phase message items are now always kept as distinct blocks
(the Responses protocol allows multiple message items per response —
verified against the sibling Codex parser, codex-rs/codex-api/src/sse/
responses.rs, which emits every output_item.done message as an
independent item). With extension-only collapse a false positive can
only merge rendering of two messages; it can never remove text.

The merged block now carries the newest item's signature instead of the
first one's, so replay associates the final content with the item that
actually produced it.

* fix(llm): defer snapshot-candidate message blocks to keep the event lifecycle balanced

Address the remaining ClawSweeper P1 on #92399: collapsing a snapshot
used to pop a block whose text_start had already been emitted, leaving
per-index stream subscribers tracking a phantom block.

A message item that follows a finalized text block now defers its public
block: no text_start is emitted and deltas are withheld until the item
either diverges from the prior text (then the block opens and the
withheld prefix replays as one delta) or completes. A collapsed snapshot
therefore never starts a block — it only re-ends the prior index with
grown content, the documented resend shape — and a distinct deferred
item opens and closes its own block normally. No block is ever removed,
so every text_start has exactly one matching text_end at a live index.

Tests now assert the complete ordered event sequence for the collapse,
distinct-item, and divergence cases in both consumers.

* fix(llm): treat any non-message item as a collapse boundary in completed-response backfill

The streaming consumer resets the snapshot-collapse anchor on every
non-message output item ("any other item is a real boundary"), but the
transport's completed-response backfill only dispatched message and
function_call items, so a reasoning item between two strict-prefix
message items did not reset the anchor and the later message could
collapse across it — an asymmetry with the streaming path's documented
invariant. Reset lastTextBlock for every non-message item in the backfill
loop (one canonical place; the per-tool-call reset is now redundant and
removed). Covered by a backfill reasoning-boundary regression test.
2026-06-22 17:49:19 +00:00
Goutam Adwant
734f2aa009 fix(model-fallback): coalesce auth decision logs (#94233) 2026-06-22 17:49:06 +00:00
Evgeni Obuchowski
50e7a546a1 fix(plugins): cache plugin setup registry to fix the /models stall regression shipped since v2026.5.28 (#93356)
Since #85341 the per-model visibility probes behind the chat /models command
(isCliRuntimeProvider({ includeSetupRegistry: true }) in commands-models.ts)
rebuild the plugin setup registry on every call: a synchronous ~65ms manifest
re-scan plus plugin setup module re-execution, issued hundreds of times per
listing. On the stock bundled plugin set this pins a CPU core for ~49s per
workflow step (list -> pick provider -> pick model), in every chat channel.

Cache the manifest scan and the resolved registry in bounded PluginLruCaches
keyed by the control-plane fingerprint, discovery-env fingerprint, metadata
snapshot identity, cwd, and pluginIds scope, with clone-on-store/clone-on-hit
isolation; invalidation rides the existing plugin-metadata lifecycle clear.
Output is identical; the /models data build drops from ~49s to ~150ms and the
per-model probe from ~65ms to ~0.2ms.
2026-06-22 17:48:47 +00:00
Yzx
c51933dc23 fix: keep text transform runtime imports hashed (#95081) 2026-06-22 17:47:16 +00:00
Vincent Koc
31941f3e92 refactor(cron): remove unused sync store alias 2026-06-23 01:44:04 +08:00
Vincent Koc
305a44388b refactor(auth): centralize OAuth identity matching 2026-06-23 01:40:54 +08:00
Vincent Koc
65adb13581 refactor(doctor): dedupe configured tool grant filtering 2026-06-23 01:35:18 +08:00
Vincent Koc
0276cbbce2 test(active-memory): isolate empty recall mock 2026-06-22 19:33:21 +02:00
David
3ff0c29f9d fix: handle terminal chat send acknowledgements (#91049)
* test: cover terminal chat send acknowledgements

* test: cover Swift terminal chat send acknowledgement

* fix: handle terminal chat send acknowledgements

* fix: align terminal ack web lifecycle options

* test: fix Android terminal ack style

* fix: tidy Android terminal ack helpers

* fix: clear mic pending run after terminal ack

* fix: handle terminal talk mode chat send acks

* fix: handle terminal tui chat send acks

* fix: handle terminal acp chat send acks

* test: add Swift chat message text helper

* test: cover steer terminal chat send acknowledgements

* fix: handle terminal steer chat send acks

* test: cover terminal realtime consult send acks

* fix: reject terminal realtime consult send acks

* test: cover Swift terminal ok chat send ack

* fix: clear Swift pending run on terminal ok ack

* test: cover terminal ack helper callers

* fix: preserve terminal ack helper semantics

* fix: narrow terminal ack type guard

* test: cover mic terminal ack statuses

* fix: preserve mic terminal ack status

* fix: keep mic ack contract internal

* test: fix mic ack import order

* test: cover acp terminal ok ack

* test: narrow acp ok ack assertion

* test: cover redirect terminal acknowledgements

* fix: handle redirect terminal acknowledgements

* fix: settle terminal ack reconnect prompts

* fix: surface Android terminal ack timeouts

* fix(tui): handle detached terminal chat acknowledgements

* fix(tui): report terminal timeout send failures

* fix: satisfy iOS talk-mode SwiftFormat

* fix: keep iOS talk logs compile-safe
2026-06-22 17:27:54 +00:00
Vincent Koc
daa382611f refactor(doctor): dedupe legacy TTS location scans 2026-06-23 01:27:35 +08:00
thomas.szbay
9bf681d663 feat(channels): add directUserId support for per-DM model override (#95120)
Add optional directUserId field to ChannelModelOverrideParams so the
shared channels.modelByChannel resolver can match DM-specific config
entries. Callers pass sessionEntry.origin?.nativeDirectUserId.

Closes #53638

Co-authored-by: Thomas Zhengtao <thomas.zhengtao@gmail.com>
2026-06-22 17:26:01 +00:00
Vincent Koc
feb3694243 refactor(agents): dedupe prompt boundary construction 2026-06-23 01:23:26 +08:00
Wynne668
f3d92936b5 fix(memory-wiki): retry transient source-page rewrite race (#94443)
A concurrent atomic rewrite (write-temp + rename) of a memory-wiki source
page by the bridge re-export made fs-safe's opened-fd identity check fail
with `path-mismatch`, which the page write rethrew as a fatal "Refusing to
write" error and aborted the whole wiki_status / source-sync call. The race
is transient and benign: the file is replaced under the open handle and the
concurrent writer lands equivalent content.

Retry briefly on `path-mismatch` (the rename window closes sub-ms) and
rethrow unchanged on exhaustion, so persistent failures (directory
collision, not-file) and symlink/path-alias swaps still hard-fail exactly
as before. The identity guard is untouched; only the benign rename race is
retried, matching the sibling read path that already treats path-mismatch
as transient.

Extracts the guarded-write logic duplicated by source-page-shared.ts and
okf.ts into one writeGuardedVaultPage helper so both write paths get the
fix and the copy is removed.

Closes #92134

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:22:15 +00:00
Yuval Dinodia
c854e4e93f fix(cron): stop add/remove from dropping a due recurring job's pending run (#94323)
cron.add recomputed every job's next-run time via recomputeNextRuns after
appending the new job. recomputeNextRuns advances nextRunAtMs whenever
now >= nextRun, so an unrelated add advanced any sibling recurring job whose
slot was due but had not yet fired, discarding that occurrence with no error
and no log. lastRunAtMs stayed unchanged while nextRunAtMs jumped one interval
forward, so the run was silently lost.

Switch add and remove onto recomputeNextRunsForMaintenance plus
ensureLoaded(state, { skipRecompute: true }), matching every other ops.ts
caller (read ops, update, finalize, reload, startup). Maintenance recompute
backfills missing next-run times but never advances a present past-due slot,
preserving the invariant introduced for the timer/read/startup paths in
#13992 / #16156 / #17852.

Adds a regression test that fails on main (the due slot advances a full
interval) and passes with the fix.
2026-06-22 17:22:06 +00:00
Ted Li
405896a4a3 fix(lmstudio): canonicalize variant model keys (#95401)
* fix(lmstudio): canonicalize variant model keys

* fix(lmstudio): retain canonical key after preload failures

* fix(lmstudio): keep canonical key during preload cooldown
2026-06-22 17:20:54 +00:00
xydt-tanshanshan
a9d40b64bc [AI] fix(main-session): skip current-gen abort controllers for completed sessions (#95472)
A completed session (status: done/success) whose abort controller expires
during maintenance was incorrectly matched by markRestartAbortedMainSessions.
The matched activeRun's lifecycleGeneration matched the current generation
(no restart occurred), but entry.updatedAt < run.observedAt allowed the
entry to be marked as running+aborted, triggering a false restart recovery.

Fix: require that the timing condition (updatedAt < observedAt) only applies
for stale-generation runs (provenance: pre-restart). Current-generation runs
with observedAt after the session's updatedAt are maintenance-expired abort
controllers and must not reopen completed sessions.

Related to #95443
2026-06-22 17:20:34 +00:00
imadalin
e0d7c4c548 fix(logging): use run progress age for embedded recovery (#94701) 2026-06-22 17:20:24 +00:00
pick-cat
1f89d6d7f7 fix(agents): clean Gemini tool schemas by model id (#91559) 2026-06-22 17:19:55 +00:00
litang9
17aa9d9967 fix(reply): preserve usage footer across rollover (#95322)
* feat(reply): persist session preferences

* fix(reply): clear stale persisted preference markers

* fix(reply): preserve usage footer across rollover
2026-06-22 17:19:16 +00:00
CamB
58628604ab docs: add existing-solutions preflight guardrail (#86608) 2026-06-22 17:17:48 +00:00
Harjoth Khara
80e031cc1d docs: fix docs metadata spellcheck (#93502) 2026-06-22 17:17:35 +00:00
Vincent Koc
92b283da84 refactor(process): remove unused orphan reconciliation API 2026-06-23 01:13:13 +08:00
Vincent Koc
9616035a91 refactor(skills): dedupe remote probe failure context 2026-06-23 01:10:00 +08:00
Vincent Koc
a9c7397cde refactor(process): remove unused supervisor registry methods 2026-06-23 01:07:40 +08:00
wood fish
cb84041cab fix(ui): render persisted history text blocks (#93841)
Merged via squash.

Prepared head SHA: bfe4f67ccf
Co-authored-by: mushuiyu886 <266724580+mushuiyu886@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-22 10:07:09 -07:00
xiaobao-k8s
5d892e484d fix(agents): restore model-fetch info logs (#89648)
* fix(agents): restore model-fetch info logs

* docs(logging): document [model-fetch] default info-level visibility

[model-fetch] response metadata is always emitted at info level
regardless of OPENCLAW_DEBUG_MODEL_TRANSPORT, so users see basic
model transport hygiene (provider, API, model, status, latency)
without needing debug flags.

* docs(logging): clarify model-fetch start metadata visibility
2026-06-22 17:02:16 +00:00
Vincent Koc
8c8eb86fff fix(llm): preserve browser-safe provider imports 2026-06-23 00:59:33 +08:00
Vincent Koc
5636c6044b refactor(runtime): share error normalization helper 2026-06-23 00:59:33 +08:00
Andy Ye
0a9b1526ac fix(provider-usage): honor proxy env for usage fetch (#93943)
* fix(provider-usage): honor proxy env for usage fetch

* refactor(mcp): remove unused Claude permission type
2026-06-22 16:56:07 +00:00
snowzlmbot
604d607311 fix(onboard): refresh provider plugin registry after setup installs (#95792)
Merged via squash.

Prepared head SHA: c99d09f762
Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-22 09:55:08 -07:00
Peter Steinberger
303e7781c1 fix(plugin-sdk): bound live model catalog success body (#95827)
Merged via squash.

Prepared head SHA: 870ef762c9
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-22 09:54:37 -07:00
ly-wang19
9a54e5b292 fix(sdk): classify failed/blocked tool events as tool.call.failed (#95383)
normalizeAgentEventType checked the `phase:"end" || status==="completed"`
branch before the `failed/blocked` branch, but terminal tool/item events are
emitted with phase:"end" AND the real status, so failed and blocked tools were
normalized to tool.call.completed and the tool.call.failed branch was dead for
the item stream. SDK consumers filtering on tool.call.failed never saw tool
failures (they looked like successes). Reorder so failed/blocked is classified
before end/completed.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:54:14 +00:00
Sahibzada
de60f42767 fix(sessions): clarify cross-agent visibility guidance (#90489)
* fix(sessions): clarify cross-agent visibility guidance

* fix(sessions): clarify optional agent allow policy

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-22 16:45:01 +00:00
Vincent Koc
c6aa355b5c refactor(core): share error normalization helper 2026-06-23 00:43:50 +08:00
Vincent Koc
f00f42abf7 refactor(process): share error normalization helper 2026-06-23 00:40:45 +08:00
Vincent Koc
af7797b0ad refactor(media): share error normalization helper 2026-06-23 00:38:07 +08:00
Vincent Koc
80805ad7a5 refactor(agents): share error normalization helpers 2026-06-23 00:36:31 +08:00
Vincent Koc
86ea382121 fix(discord): preserve progress preview final edits 2026-06-22 18:35:14 +02:00
Mike Harrison
e1ecfa5200 fix(diagnostics-otel): keep full model id on spans instead of collapsing to "unknown" (#89981)
* fix(diagnostics-otel): keep full model id on spans (was collapsing to "unknown")

* test(diagnostics-otel): cover slash model span attribution

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-22 16:32:50 +00:00
Vincent Koc
7f6a93eb8e refactor(agents): share embedded runner error normalization 2026-06-23 00:29:48 +08:00
Vincent Koc
aa79ab1403 refactor(outbound): reuse channel action context builder 2026-06-23 00:25:53 +08:00
Vincent Koc
a87aed4108 refactor(agents): reuse shared error normalization 2026-06-23 00:23:13 +08:00
Hannes Rudolph
69c4d1aa85 Revert "feat(discord): add server management helper actions"
This reverts commit ae22f485ec.
2026-06-22 10:20:19 -06:00
Vincent Koc
7c90351ff3 refactor(gateway): share MCP bearer token classification 2026-06-23 00:20:11 +08:00
zerone0x
3a7cdaf32c fix: include persisted plugin contracts for migrations (#89612) 2026-06-22 16:18:48 +00:00
Hannes Rudolph
ae22f485ec feat(discord): add server management helper actions 2026-06-22 10:18:28 -06:00
Vincent Koc
dab145ef76 refactor(infra): share Windows port inspection 2026-06-23 00:16:38 +08:00
Vincent Koc
336494c863 refactor(agents): share session tool output rendering 2026-06-23 00:14:44 +08:00
Vincent Koc
7588bd7b75 refactor(gateway): share control-plane identity normalization 2026-06-23 00:10:48 +08:00
Vincent Koc
37ac0f0dd2 refactor(infra): remove stale utility re-exports 2026-06-23 00:07:13 +08:00
Vincent Koc
345ad9862d refactor(agents): remove stale facade exports 2026-06-23 00:01:42 +08:00
Vincent Koc
206552c697 refactor(agents): remove stale runner facades 2026-06-22 23:40:06 +08:00
Vincent Koc
451ae8c678 fix(agents): normalize hallucinated Office file extensions (#95805)
* fix(agents): normalize hallucinated Office file extensions

Co-authored-by: lizeyu-xydt <41978486+lzyyzznl@users.noreply.github.com>

Co-authored-by: Dirk <279172199+xzh-icenter@users.noreply.github.com>

* fix(sessions): remove unused runtime store binding

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Dirk <279172199+xzh-icenter@users.noreply.github.com>
2026-06-22 23:38:24 +08:00
Vincent Koc
a29edce409 refactor(infra): share error normalization 2026-06-22 23:35:04 +08:00
Vincent Koc
e3058efa10 fix(sessions): drop unused runtime context binding 2026-06-22 23:32:06 +08:00
Vincent Koc
b3b5b08e67 fix(memory): preserve Windows session transcript paths 2026-06-22 23:32:06 +08:00
Vincent Koc
71ef6b2312 refactor(tools): remove stale inventory re-exports 2026-06-22 23:20:31 +08:00
Vincent Koc
a6390b2b90 refactor(agents): share bundle runtime allowlist gating 2026-06-22 23:07:58 +08:00
Vincent Koc
e2e678326e refactor(tools): share inventory presentation helpers 2026-06-22 23:05:49 +08:00
Vincent Koc
4ec006da66 refactor(doctor): share primary model resolution 2026-06-22 23:01:14 +08:00
Vincent Koc
8fe181c2b0 refactor(tasks): share audit JSON payload formatting 2026-06-22 22:55:53 +08:00
Vincent Koc
e66aa357f8 refactor(models): share auth command agent resolution 2026-06-22 22:49:50 +08:00
Vincent Koc
8b78ae2855 fix(session-memory): sanitize model artifacts before saving memory (#95791)
* fix(session-memory): sanitize model artifacts before saving memory

Co-authored-by: Sophia <44297511+SweetSophia@users.noreply.github.com>

Co-authored-by: YBoy <231405196+YB0y@users.noreply.github.com>

* fix(sdk): update plugin surface budgets

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: YBoy <231405196+YB0y@users.noreply.github.com>
2026-06-22 22:48:03 +08:00
Vincent Koc
b10fedb7de refactor(acp): reuse shared error normalization 2026-06-22 22:45:53 +08:00
Vincent Koc
008d101b16 refactor(sessions): share runtime transcript context resolution 2026-06-22 22:39:59 +08:00
Vincent Koc
28b374a8a7 fix(cron): compare thread IDs when deduping failure destinations (#95794)
* fix(cron): compare thread IDs when deduping failure destinations

* fix(clownfish): address review for gitcrawl-1889-autonomous-bulk-20260622a (1)

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-22 22:39:19 +08:00
Vincent Koc
ae6bea1771 refactor(gateway): reuse session message count helper 2026-06-22 22:35:28 +08:00
Vincent Koc
f7d6a059a4 refactor(sessions): share bounded file range reads 2026-06-22 22:32:36 +08:00
Vincent Koc
f6da93db0f refactor(gateway): share transcript metadata parsing 2026-06-22 22:27:04 +08:00
Vincent Koc
905c9759a7 refactor(voice-call): share path normalization 2026-06-22 22:18:49 +08:00
Vincent Koc
9c85b812fe chore(tlon): remove inert SSRF policy helper 2026-06-22 22:12:48 +08:00
Vincent Koc
83cfb6112c chore(deadcode): remove stale session test facades 2026-06-22 22:07:37 +08:00
Vincent Koc
8744e86e67 refactor: remove test-only production helpers 2026-06-22 22:00:15 +08:00
Andy Ye
da63854f58 fix(cron): clean up isolated sessions after runs
* Clean up isolated cron sessions after runs

* Clean up isolated cron sessions after runs

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-22 21:58:11 +08:00
Gio Della-Libera
a2b8f67395 fix(web-ui): skip hidden subagent picker pages
* fix(web-ui): skip hidden subagent picker pages

* test(ui): cover hidden chat picker pages in browser

* fix(web-ui): skip hidden subagent picker pages

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-22 21:55:09 +08:00
Josh Lehman
d3781cc4b8 refactor: add memory and QMD session identity mapping (#95087) 2026-06-22 06:28:54 -07:00
Anson_H
3895c9341b perf(cli): speed up precomputed command help startup
* perf: speed up precomputed command help

* perf: precompute sessions and tasks help

* Speed up precomputed command help startup

* Speed up precomputed command help startup

---------

Co-authored-by: Zeheng Huang <153708448+hunjaiboy@users.noreply.github.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-22 21:27:47 +08:00
Vincent Koc
7626ca38b3 chore(release): refresh generated metadata 2026-06-22 21:25:38 +08:00
Vincent Koc
49fac864d4 refactor(acp): remove stale type re-export shim 2026-06-22 21:23:14 +08:00
mjamiv
4e5b788234 fix(auto-reply): clear runtime model cache on reset
Merges the Clownfish-repaired contributor branch for #77339. Clownfish preflight cleared security/comments/review, accepted pnpm check:changed, and the PR is clean/mergeable on head f610324c08.
2026-06-22 21:17:23 +08:00
Vincent Koc
c149d217da refactor(memory): remove duplicate embedding input facade 2026-06-22 21:10:23 +08:00
Vincent Koc
3288291a08 refactor(agents): remove unused image helper 2026-06-22 21:03:58 +08:00
Song Zhenlin
afa1045238 fix(cli): document Commander rawArgs dependency
Merges the Clownfish-repaired contributor branch for #91193. Clownfish preflight cleared security/comments/review, accepted pnpm check:changed, and the PR is clean/mergeable on head a05c170345.
2026-06-22 21:01:52 +08:00
Vincent Koc
dbc07ad84d refactor(agents): remove unused helper wrappers 2026-06-22 20:56:59 +08:00
WadydX
6b11bd97d9 meta(issue-template): add dedicated docs bug report form
Merges the Clownfish-repaired contributor branch for #76668. Clownfish preflight cleared security/comments/review, accepted pnpm check:changed, and the PR is clean/mergeable on head c04a40d92c.
2026-06-22 20:52:50 +08:00
Vincent Koc
0a2ca1f7ac refactor(auto-reply): remove unused thinking exports 2026-06-22 20:43:04 +08:00
tayoun
73930764e6 fix(build): allow tsdown heap override
Merges the Clownfish-repaired contributor branch for #94622. Clownfish preflight cleared security/comments/review, accepted pnpm check:changed, and the PR is clean/mergeable on head 8de57351f7.
2026-06-22 20:36:42 +08:00
Vincent Koc
a4eb49a176 refactor(qa): share gateway message text extraction 2026-06-22 20:25:26 +08:00
Vincent Koc
db21588636 refactor(qa): share suite summary file loading 2026-06-22 20:24:20 +08:00
Vincent Koc
88b64e4b86 fix(discord): drain queued voice replies after stream close 2026-06-22 20:20:56 +08:00
Vincent Koc
fcb4c5d041 refactor(cli): share gateway argv prefix scan 2026-06-22 20:18:11 +08:00
Narahari Raghava
49869c2e41 fix(ui): roll values near 1M over from k to M in compact token format (#95485)
Merged via squash.

Prepared head SHA: deb462f0d9
Co-authored-by: NarahariRaghava <70995755+NarahariRaghava@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 20:14:46 +08:00
ooiuuii
a0fedcfb7e feat(cli): add --message-file to openclaw agent
Merges the Clownfish-repaired contributor branch for #93351. The latest repair preserves inline --message whitespace, adds --message-file coverage for gateway and local embedded runs, and the PR is clean/mergeable on head 4897f2fc20.
2026-06-22 20:13:57 +08:00
Vincent Koc
1b7a6a3138 refactor(code-mode): share VM execution lifecycle 2026-06-22 20:10:43 +08:00
Vincent Koc
f236217d5b fix(cron): preserve no-config delivery validation (#95754)
Merged via squash.

Prepared head SHA: 68720295d7
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 19:59:45 +08:00
Vincent Koc
927d0aefeb refactor(elevenlabs): share TTS request assembly 2026-06-22 19:52:02 +08:00
Vincent Koc
362c26a986 test(gateway): align cron delivery channel fixtures 2026-06-22 19:46:03 +08:00
Helck
96e27c6ea8 Fix config patch restart-required notices
Merges the Clownfish-repaired contributor branch for #83041. Clownfish merge preflight cleared security/comments/review and accepted pnpm check:changed; the remaining cron shard failure is present on current main.
2026-06-22 19:45:26 +08:00
Vincent Koc
90cf265f29 refactor(google): share TTS request preparation 2026-06-22 19:38:30 +08:00
Vincent Koc
f40071cc0f refactor(doctor): share tool normalization findings 2026-06-22 19:20:08 +08:00
Vincent Koc
f378de9d5b refactor(slack): share posted chunk loop 2026-06-22 19:16:55 +08:00
Vincent Koc
77f4e45c35 fix(scripts): support npm node command shims 2026-06-22 13:16:33 +02:00
Vincent Koc
d48dcc664b fix(scripts): use requested platform for shim checks 2026-06-22 13:16:33 +02:00
Vincent Koc
ca360d3d90 test(scripts): normalize agent shard path separators 2026-06-22 13:16:32 +02:00
Vincent Koc
54d24cd956 fix(scripts): preserve node command shim arguments on Windows 2026-06-22 13:16:32 +02:00
Vincent Koc
3939da7a09 refactor(media): share single-image request mapping 2026-06-22 19:12:22 +08:00
Vincent Koc
a641c0d560 fix(channels): keep ownerless config visible but undeliverable 2026-06-22 19:12:05 +08:00
Vincent Koc
482e6cb5cb fix(codeql): clean OpenClaw quality findings 2026-06-22 19:11:46 +08:00
Vincent Koc
35bafea757 refactor(providers): share reasoning payload normalization 2026-06-22 19:08:00 +08:00
Vincent Koc
5dc6e0ea77 test(scripts): align SDK surface budget assertion 2026-06-22 18:58:59 +08:00
1085 changed files with 38634 additions and 13437 deletions

View File

@@ -15,7 +15,7 @@ committed `inventory/` report tree.
This skill owns the operational workflow for:
- `taxonomy.yaml`
- `docs/maturity-scores.yaml`
- `qa/maturity-scores.yaml`
- `docs/concepts/qa-e2e-automation.md`
- `qa/scenarios/index.yaml`
@@ -37,28 +37,35 @@ out of this repo. If a score needs private evidence, use the redacted
coverage IDs. Do not promote generic IDs into standalone feature names.
- Avoid duplicate coverage-ID bundles under different feature names in one
category.
- `docs/maturity-scores.yaml` is the aggregate score source committed in this
repo. It is the only committed score data; do not add generated inventory
directories.
- There is no committed maturity-doc renderer or `pnpm maturity:*` script in
this repo. Do not invent generated scorecard files; update the source YAML
and current docs directly.
- `qa-evidence.json` artifacts provide per-run QA scorecard evidence. They can
enrich generated artifact docs, but they are not committed as inventory.
- `qa/maturity-scores.yaml` is the committed aggregate source for Quality,
Completeness, and LTS review state.
- `extensions/qa-lab/src/scorecard-taxonomy.ts` exports
`qaMaturityScoresSchema` and `readValidatedQaMaturityScoreSources`; use those
QA Lab utilities to validate score output.
- Generated public docs are `docs/maturity/scorecard.md` and
`docs/maturity/taxonomy.md`; both come from `pnpm maturity:render`. Do not
hand-edit generated Markdown to change score results.
- `qa-evidence.json` artifacts provide per-run QA scorecard evidence. Release
profile artifacts are the source of truth for Coverage. They can enrich
generated artifact docs, but they are not committed as inventory.
## Commands
Run from the openclaw repo root.
Validate YAML structure after source edits:
Validate taxonomy YAML structure and the maturity score schema after source
edits:
```bash
node <<'NODE'
const fs = require("node:fs");
const YAML = require("yaml");
for (const file of ["taxonomy.yaml", "docs/maturity-scores.yaml", "qa/scenarios/index.yaml"]) {
node --import tsx --input-type=module <<'NODE'
import fs from "node:fs";
import YAML from "yaml";
import { readValidatedQaMaturityScoreSources } from "./extensions/qa-lab/src/scorecard-taxonomy.ts";
for (const file of ["taxonomy.yaml", "qa/scenarios/index.yaml"]) {
YAML.parse(fs.readFileSync(file, "utf8"));
}
readValidatedQaMaturityScoreSources();
NODE
```
@@ -83,17 +90,17 @@ When asked to score or refresh a surface:
`.agents/skills/claw-score/references/completeness/`.
3. Gather public repo evidence from docs, source, tests, and QA scenario
metadata.
4. Prefer existing `qa-evidence.json` artifacts for executed proof. Do not use
discrawl or unredacted private archives.
5. Update `docs/maturity-scores.yaml` only when the score change is backed by
public or redacted artifact evidence.
6. Run the YAML validation command from this skill.
4. Prefer existing release profile `qa-evidence.json` artifacts for executed
proof.
5. Update `qa/maturity-scores.yaml` only for Quality, Completeness, and LTS
review state backed by public or redacted artifact evidence.
6. Run the schema validation command from this skill.
7. Run `pnpm check:docs` if docs prose changed, and focused QA coverage checks
if coverage IDs or profile membership changed.
For subjective score changes, make the smallest defensible edit and leave the
evidence path in the PR or task summary. Keep manual prose in current docs and
keep score data in `docs/maturity-scores.yaml`.
keep score data in `qa/maturity-scores.yaml`.
## Default Completeness Process
@@ -152,15 +159,16 @@ Default Completeness bands:
## Score Semantics
- Coverage: public or redacted proof that the feature is exercised by docs,
tests, QA scenarios, live lanes, or release evidence.
- Coverage: deterministic release validation coverage derived from the release
profile `qa-evidence.json.scorecard` feature fulfillment data.
- Quality: reliability, maintainability, operator safety, and regression
confidence for the category.
- Completeness: how much of the intended operator-visible workflow exists for
the category. Use the default completeness process plus any surface-specific
variation before changing this score.
- LTS: derived from score thresholds and `human_lts_override`; do not hand-edit
generated Markdown to change LTS status.
- LTS: derived from Quality, release-evidence Coverage, and
`human_lts_override`; do not hand-edit generated Markdown to change LTS
status.
Bands:

View File

@@ -0,0 +1,76 @@
name: Docs bug report
description: Report documentation defects (incorrect, missing, outdated, or contradictory docs).
title: "[Docs Bug]: "
labels:
- bug
- docs
body:
- type: markdown
attributes:
value: |
Report a documentation defect with concrete evidence from current docs behavior/content.
Please only report one documentation defect per submission.
- type: textarea
id: summary
attributes:
label: Summary
description: One-sentence statement of what is wrong in the docs.
placeholder: The WhatsApp config example defines duplicate top-level keys in one JSON5 block.
validations:
required: true
- type: input
id: doc_paths
attributes:
label: Affected docs path(s) or URL(s)
description: Repo-relative docs file path(s) or published docs URL(s).
placeholder: docs/gateway/config-channels.md or https://docs.openclaw.ai/gateway/config-channels
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce / verify
description: Minimal steps to observe the docs defect in the current docs.
placeholder: |
1. Open docs/gateway/config-channels.md
2. Go to the WhatsApp example block
3. Observe duplicate top-level key definitions
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected docs behavior/content
description: What the docs should say/show instead.
placeholder: The example should use a single merged top-level object with no duplicate keys.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual docs behavior/content
description: What the docs currently say/show.
placeholder: The snippet defines the same top-level key twice in one object.
validations:
required: true
- type: textarea
id: impact
attributes:
label: Impact
description: Who is affected and practical consequence.
placeholder: Users who copy-paste the snippet can end up with ambiguous config behavior.
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Evidence
description: Links/snippets/screenshots proving the docs defect.
placeholder: Include exact file links and line ranges.
validations:
required: true
- type: textarea
id: additional_information
attributes:
label: Additional information
description: Optional context, related issues/PRs, or constraints.

View File

@@ -261,6 +261,6 @@ jobs:
- name: Run Testbox
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
if: success()
if: always()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -179,6 +179,6 @@ jobs:
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: success()
if: always()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -33,7 +33,7 @@ jobs:
contents: read
name: "check"
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ fromJSON(inputs.timeout_minutes || '30') }}
timeout-minutes: ${{ fromJSON(inputs.timeout_minutes || '120') }}
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
@@ -168,6 +168,6 @@ jobs:
- name: Run Testbox
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
if: success()
if: always()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -305,6 +305,7 @@ jobs:
shard_name: shard.shardName,
groups: shard.groups,
configs: shard.configs,
env: shard.env,
includePatterns: shard.includePatterns,
requires_dist: shard.requiresDist,
runner: shard.runner,
@@ -1237,6 +1238,7 @@ jobs:
NODE_OPTIONS: --max-old-space-size=8192
OPENCLAW_NODE_TEST_GROUPS_JSON: ${{ toJson(matrix.groups || null) }}
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
OPENCLAW_NODE_TEST_ENV_JSON: ${{ toJson(matrix.env) }}
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "300000"
@@ -1255,6 +1257,7 @@ jobs:
? groups
: [{
configs: JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]"),
env: JSON.parse(process.env.OPENCLAW_NODE_TEST_ENV_JSON ?? "null"),
includePatterns: JSON.parse(
process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null",
),
@@ -1270,6 +1273,13 @@ jobs:
...process.env,
...(plan.shard_name ? { OPENCLAW_VITEST_SHARD_NAME: plan.shard_name } : {}),
};
if (plan.env && typeof plan.env === "object" && !Array.isArray(plan.env)) {
for (const [key, value] of Object.entries(plan.env)) {
if (typeof value === "string") {
childEnv[key] = value;
}
}
}
if (Array.isArray(plan.includePatterns) && plan.includePatterns.length > 0) {
const includeFile = join(
process.env.RUNNER_TEMP ?? ".",
@@ -2223,7 +2233,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/.android-sdk
key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-37.0-build-tools-36.0.0
key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-36-build-tools-36.0.0
restore-keys: |
${{ runner.os }}-android-sdk-v1-
@@ -2253,7 +2263,7 @@ jobs:
yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null
sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \
"platform-tools" \
"platforms;android-37.0" \
"platforms;android-36" \
"build-tools;36.0.0"
- name: Run Android ${{ matrix.task }}

View File

@@ -27,10 +27,8 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const childProcess = require("node:child_process");
const zlib = require("node:zlib");
const marker = "<!-- openclaw-ios-periphery-dead-code -->";
const run = context.payload.workflow_run;
@@ -126,10 +124,7 @@ jobs:
archive_format: "zip",
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ios-periphery-"));
const archivePath = path.join(dir, "artifact.zip");
const archiveBuffer = Buffer.from(archive.data);
fs.writeFileSync(archivePath, archiveBuffer);
const allowedArtifactFiles = new Set([
"periphery.json",
@@ -240,19 +235,59 @@ jobs:
return;
}
entries.set(name, { uncompressedSize });
entries.set(name, {
compressedSize,
compressionMethod,
localHeaderOffset: readUInt32(offset + 42),
uncompressedSize,
});
offset = nextOffset;
}
const readZipEntry = (name, entry) => {
const localHeaderOffset = entry.localHeaderOffset;
if (
localHeaderOffset + 30 > archiveBuffer.length ||
readUInt32(localHeaderOffset) !== 0x04034b50
) {
throw new Error(`${name} has an invalid local header.`);
}
const localNameLength = readUInt16(localHeaderOffset + 26);
const localExtraLength = readUInt16(localHeaderOffset + 28);
const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength;
const dataEnd = dataStart + entry.compressedSize;
if (dataEnd > archiveBuffer.length) {
throw new Error(`${name} exceeds archive bounds.`);
}
const compressed = archiveBuffer.subarray(dataStart, dataEnd);
let contents;
if (entry.compressionMethod === 0) {
contents = compressed;
} else {
try {
contents = zlib.inflateRawSync(compressed, { maxOutputLength: maxEntryBytes });
} catch (error) {
if (error && error.code === "ERR_BUFFER_TOO_LARGE") {
throw new Error(`${name} exceeded the per-file size limit while reading.`);
}
throw error;
}
}
if (contents.length !== entry.uncompressedSize || contents.length > maxEntryBytes) {
throw new Error(`${name} exceeded the per-file size limit while reading.`);
}
return contents.toString("utf8");
};
const files = new Map();
for (const [name, entry] of entries) {
const contents = childProcess.execFileSync("unzip", ["-p", archivePath, name], {
encoding: "utf8",
maxBuffer: Math.max(1, entry.uncompressedSize + 1024),
timeout: 5000,
});
if (Buffer.byteLength(contents, "utf8") > maxEntryBytes) {
core.warning(`Skipping ${artifactName}; ${name} exceeded the per-file size limit while reading.`);
let contents;
try {
contents = readZipEntry(name, entry);
} catch (error) {
core.warning(`Skipping ${artifactName}; ${error instanceof Error ? error.message : String(error)}`);
return;
}
files.set(name, contents);

View File

@@ -220,7 +220,7 @@ jobs:
with:
name: ios-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ios-periphery
if-no-files-found: warn
if-no-files-found: error
retention-days: 14
- name: Fail on dead code

View File

@@ -171,4 +171,4 @@ jobs:
name: mantis-discord-smoke-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/mantis/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error

View File

@@ -540,7 +540,7 @@ jobs:
name: mantis-discord-status-reactions-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Create Mantis GitHub App token
id: mantis_app_token

View File

@@ -547,7 +547,7 @@ jobs:
with:
name: mantis-discord-thread-attachment-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
if-no-files-found: warn
if-no-files-found: error
retention-days: 14
- name: Create Mantis GitHub App token

View File

@@ -458,7 +458,7 @@ jobs:
name: mantis-slack-desktop-smoke-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Create Mantis GitHub App token
id: mantis_app_token

View File

@@ -556,7 +556,7 @@ jobs:
name: mantis-telegram-desktop-proof-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.inspect.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Create Mantis GitHub App token
id: mantis_app_token

View File

@@ -506,7 +506,7 @@ jobs:
name: mantis-telegram-live-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
- name: Create Mantis GitHub App token
id: mantis_app_token

358
.github/workflows/maturity-scorecard.yml vendored Normal file
View File

@@ -0,0 +1,358 @@
name: Maturity scorecard
on:
workflow_dispatch:
inputs:
qa_evidence_run_id:
description: Optional workflow run id containing qa-evidence.json
required: false
type: string
ref:
description: OpenClaw branch, tag, or SHA containing the maturity score source
required: true
default: main
type: string
permissions:
actions: read
contents: read
concurrency:
group: ${{ format('{0}-{1}-{2}', github.workflow, inputs.ref, inputs.qa_evidence_run_id || github.run_id) }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
jobs:
validate_selected_ref:
name: Validate selected ref
runs-on: ubuntu-24.04
outputs:
selected_revision: ${{ steps.validate.outputs.selected_revision }}
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Validate selected ref
id: validate
env:
INPUT_REF: ${{ inputs.ref }}
shell: bash
run: |
set -euo pipefail
selected_revision="$(git rev-parse HEAD)"
trusted_reason=""
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
trusted_reason="main-ancestor"
elif git tag --points-at "$selected_revision" | grep -Eq '^v'; then
trusted_reason="release-tag"
elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then
git fetch --no-tags origin "+refs/heads/${INPUT_REF}:refs/remotes/origin/${INPUT_REF}"
release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")"
if [[ "$selected_revision" == "$release_branch_sha" ]]; then
trusted_reason="release-branch-head"
fi
fi
if [[ -z "$trusted_reason" ]]; then
echo "Ref '${INPUT_REF}' resolved to $selected_revision, which is not trusted for this secret-bearing maturity scorecard run." >&2
echo "Allowed refs must be on main, point to a release tag, or match a release branch head." >&2
exit 1
fi
echo "selected_revision=$selected_revision" >> "$GITHUB_OUTPUT"
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
{
echo "### Target"
echo
echo "- Requested ref: \`${INPUT_REF}\`"
echo "- Resolved SHA: \`$selected_revision\`"
echo "- Trust reason: \`$trusted_reason\`"
} >> "$GITHUB_STEP_SUMMARY"
generate_qa_evidence:
name: Generate full taxonomy QA evidence
needs: validate_selected_ref
if: ${{ inputs.qa_evidence_run_id == '' }}
uses: ./.github/workflows/qa-profile-evidence.yml
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
qa_profile: all
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
publish:
name: Publish maturity docs PR
needs:
- validate_selected_ref
- generate_qa_evidence
if: ${{ always() && needs.validate_selected_ref.result == 'success' && (inputs.qa_evidence_run_id != '' || needs.generate_qa_evidence.result == 'success') }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
actions: read
contents: read
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
- name: Download provided QA evidence artifact
if: ${{ inputs.qa_evidence_run_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
run: |
set -euo pipefail
mkdir -p .artifacts/maturity-evidence
gh run download "$QA_EVIDENCE_RUN_ID" \
--repo "$GITHUB_REPOSITORY" \
--dir .artifacts/maturity-evidence
- name: Download generated QA evidence artifact
if: ${{ inputs.qa_evidence_run_id == '' }}
env:
GENERATED_ARTIFACT_NAME: ${{ needs.generate_qa_evidence.outputs.artifact_name }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [[ -z "${GENERATED_ARTIFACT_NAME:-}" ]]; then
echo "Generated QA evidence workflow did not expose an artifact name." >&2
exit 1
fi
mkdir -p .artifacts/maturity-evidence
gh run download "$GITHUB_RUN_ID" \
--repo "$GITHUB_REPOSITORY" \
--name "$GENERATED_ARTIFACT_NAME" \
--dir .artifacts/maturity-evidence
- name: Require one QA evidence file
id: evidence
env:
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
run: |
set -euo pipefail
mapfile -t evidence_paths < <(find .artifacts/maturity-evidence -type f -name qa-evidence.json | sort)
if [[ "${#evidence_paths[@]}" -eq 0 ]]; then
echo "Expected a qa-evidence.json file in the downloaded QA evidence artifact." >&2
exit 1
fi
if [[ "${#evidence_paths[@]}" -gt 1 ]]; then
echo "Expected exactly one qa-evidence.json file, found ${#evidence_paths[@]}:" >&2
printf '%s\n' "${evidence_paths[@]}" >&2
exit 1
fi
echo "qa_evidence_path=${evidence_paths[0]}" >> "$GITHUB_OUTPUT"
{
echo "### QA evidence"
echo
echo "- Evidence path: \`${evidence_paths[0]}\`"
echo "- Evidence source run: \`${QA_EVIDENCE_RUN_ID:-$GITHUB_RUN_ID}\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Validate QA evidence manifest
env:
QA_EVIDENCE_PATH: ${{ steps.evidence.outputs.qa_evidence_path }}
TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_revision }}
run: |
set -euo pipefail
node --input-type=module <<'NODE'
import fs from "node:fs";
import path from "node:path";
const evidencePath = process.env.QA_EVIDENCE_PATH;
const targetSha = process.env.TARGET_SHA;
if (!evidencePath) {
throw new Error("QA_EVIDENCE_PATH is required");
}
if (!targetSha) {
throw new Error("TARGET_SHA is required");
}
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
if (evidence.profile !== "all") {
throw new Error(`qa-evidence.json profile must be all, got ${JSON.stringify(evidence.profile)}`);
}
const artifactDir = path.dirname(evidencePath);
const manifestNames = fs
.readdirSync(artifactDir)
.filter((name) => name.endsWith("qa-profile-evidence-manifest.json"))
.sort();
if (manifestNames.length !== 1) {
throw new Error(
`Expected exactly one QA profile evidence manifest next to qa-evidence.json, found ${manifestNames.length}`,
);
}
const manifestPath = path.join(artifactDir, manifestNames[0]);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const manifestProfile = manifest.qaProfile ?? evidence.profile;
if (manifestProfile !== "all") {
throw new Error(`QA evidence manifest profile must be all, got ${JSON.stringify(manifestProfile)}`);
}
if (manifest.targetSha !== targetSha) {
throw new Error(`QA evidence manifest targetSha ${manifest.targetSha} does not match selected ref ${targetSha}`);
}
NODE
- name: Validate maturity score sources
run: |
node --import tsx --input-type=module <<'NODE'
import { readValidatedQaMaturityScoreSources } from "./extensions/qa-lab/src/scorecard-taxonomy.ts";
const { warnings } = readValidatedQaMaturityScoreSources({
scoresPath: "qa/maturity-scores.yaml",
taxonomyPath: "taxonomy.yaml",
});
for (const warning of warnings) {
console.error(`warning: ${warning}`);
}
NODE
- name: Render artifact docs
run: |
set -euo pipefail
pnpm maturity:render -- \
--output-dir .artifacts/maturity-docs \
--static-assets-dir .artifacts/maturity-docs/assets/maturity \
--scores qa/maturity-scores.yaml \
--evidence-dir .artifacts/maturity-evidence \
--strict-inputs
{
echo "### Maturity scorecard docs"
echo
echo "- Source validation: passed"
echo "- Artifact docs: \`.artifacts/maturity-docs\`"
echo "- Strict inputs: \`true\`"
echo "- QA evidence: included"
} >> "$GITHUB_STEP_SUMMARY"
- name: Render committed docs preview
run: |
set -euo pipefail
pnpm maturity:render -- \
--output-dir docs \
--scores qa/maturity-scores.yaml \
--evidence-dir .artifacts/maturity-evidence \
--strict-inputs
- name: Create generated docs PR app token
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- name: Create generated docs PR fallback app token
if: ${{ steps.app-token.outcome == 'failure' }}
id: app-token-fallback
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
permission-contents: write
permission-pull-requests: write
- name: Open generated docs PR
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
REF_INPUT: ${{ inputs.ref }}
run: |
set -euo pipefail
if [[ -z "${GH_TOKEN:-}" ]]; then
echo "Maturity scorecard PR creation requires the OpenClaw GitHub App token secrets." >&2
exit 1
fi
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
{
echo
echo "- Pull request: skipped; generated scorecard matches selected ref"
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
evidence_run_id="${QA_EVIDENCE_RUN_ID:-$GITHUB_RUN_ID}"
branch="automation/maturity-scorecard-${evidence_run_id}"
base_branch="${REF_INPUT:-main}"
if ! git ls-remote --exit-code --heads origin "$base_branch" >/dev/null 2>&1; then
base_branch="main"
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
gh auth setup-git
git fetch --no-tags --depth=1 origin "refs/heads/${branch}:refs/remotes/origin/${branch}" || true
git switch -C "$branch"
git add qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md
if git ls-files --error-unmatch docs/maturity-scores.yaml >/dev/null 2>&1 || [[ -e docs/maturity-scores.yaml ]]; then
git add docs/maturity-scores.yaml
fi
git commit -m "docs: update maturity scorecard"
git push --force-with-lease origin "$branch"
body_file=".artifacts/maturity-scorecard-pr-body.md"
mkdir -p "$(dirname "$body_file")"
cat > "$body_file" <<BODY
## Summary
- render maturity scorecard docs from \`qa/maturity-scores.yaml\` and release QA evidence
- maturity source ref: ${REF_INPUT}
- QA evidence run: ${evidence_run_id}
## Verification
- QA Lab maturity score validation passed
- Maturity scorecard workflow rendered docs from release profile qa-evidence.json artifacts with strict inputs
BODY
pr_url="$(gh pr list --head "$branch" --state open --json url --jq '.[0].url // ""')"
if [[ -n "$pr_url" ]]; then
gh pr edit "$pr_url" \
--title "docs: update maturity scorecard" \
--body-file "$body_file"
else
pr_url="$(gh pr create \
--base "$base_branch" \
--head "$branch" \
--title "docs: update maturity scorecard" \
--body-file "$body_file")"
fi
{
echo
echo "- Pull request: ${pr_url}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload maturity docs artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: maturity-scorecard-docs-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/maturity-docs/
retention-days: 30
if-no-files-found: error

View File

@@ -273,4 +273,4 @@ jobs:
name: npm-telegram-beta-e2e-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error

View File

@@ -226,7 +226,7 @@ jobs:
name: qa-parity-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
run_live_runtime_token_efficiency:
name: Run live runtime token-efficiency lane
@@ -315,7 +315,7 @@ jobs:
name: qa-live-runtime-token-efficiency-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
run_live_matrix:
name: Run Matrix live QA lane
@@ -391,7 +391,7 @@ jobs:
name: qa-live-matrix-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
run_live_matrix_sharded:
name: Run Matrix live QA lane (${{ matrix.profile }})
@@ -475,7 +475,7 @@ jobs:
name: qa-live-matrix-${{ matrix.profile }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
run_live_telegram:
name: Run Telegram live QA lane with Convex leases
@@ -570,7 +570,7 @@ jobs:
name: qa-live-telegram-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
run_live_discord:
name: Run Discord live QA lane with Convex leases
@@ -665,7 +665,7 @@ jobs:
name: qa-live-discord-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
run_live_whatsapp:
name: Run WhatsApp live QA lane with Convex leases
@@ -763,7 +763,7 @@ jobs:
name: qa-live-whatsapp-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
if-no-files-found: error
run_live_slack:
name: Run Slack live QA lane with Convex leases
@@ -859,4 +859,4 @@ jobs:
name: qa-live-slack-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
if-no-files-found: error

View File

@@ -0,0 +1,375 @@
name: QA Profile Evidence
run-name: ${{ format('QA Profile Evidence {0} {1}', inputs.qa_profile, inputs.ref) }}
on:
workflow_dispatch:
inputs:
ref:
description: OpenClaw branch, tag, or SHA to run
required: true
default: main
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
qa_profile:
description: Taxonomy QA profile id to run (for example release or all)
required: true
default: release
type: string
workflow_call:
inputs:
ref:
description: OpenClaw branch, tag, or SHA to run
required: true
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
qa_profile:
description: Taxonomy QA profile id to run
required: true
type: string
secrets:
OPENAI_API_KEY:
description: OpenAI API key used by live QA profile scenarios
required: true
outputs:
artifact_name:
description: Uploaded QA profile evidence artifact name
value: ${{ jobs.run_qa_profile.outputs.artifact_name }}
qa_profile:
description: Taxonomy QA profile id that produced the evidence
value: ${{ jobs.run_qa_profile.outputs.qa_profile }}
qa_exit_code:
description: Exit code from the QA profile run; non-zero evidence is still uploaded
value: ${{ jobs.run_qa_profile.outputs.qa_exit_code }}
qa_passed:
description: Whether the QA profile command exited successfully
value: ${{ jobs.run_qa_profile.outputs.qa_passed }}
target_sha:
description: Resolved OpenClaw SHA that produced the evidence
value: ${{ jobs.run_qa_profile.outputs.target_sha }}
trusted_reason:
description: Trust reason accepted before the secret-bearing QA job
value: ${{ jobs.run_qa_profile.outputs.trusted_reason }}
qa_evidence_path:
description: Path to qa-evidence.json inside the uploaded artifact
value: ${{ jobs.run_qa_profile.outputs.qa_evidence_path }}
permissions:
contents: read
concurrency:
group: qa-profile-evidence-${{ inputs.qa_profile }}-${{ inputs.expected_sha || inputs.ref }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
jobs:
authorize_actor:
name: Authorize workflow actor
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
if (context.eventName !== "workflow_dispatch") {
core.info(`Skipping manual actor permission check for ${context.eventName}.`);
core.setOutput("authorized", "true");
return;
}
const allowed = new Set(["admin", "maintain", "write"]);
const { owner, repo } = context.repo;
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: context.actor,
});
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
validate_selected_ref:
name: Validate selected ref
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
selected_revision: ${{ steps.validate.outputs.selected_revision }}
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Validate selected ref
id: validate
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
INPUT_REF: ${{ inputs.ref }}
shell: bash
run: |
set -euo pipefail
selected_revision="$(git rev-parse HEAD)"
expected_sha="${EXPECTED_SHA,,}"
trusted_reason=""
if [[ -n "${expected_sha// }" && ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "expected_sha must be a full 40-character SHA; got: ${EXPECTED_SHA}" >&2
exit 1
fi
if [[ -n "${expected_sha// }" && "${selected_revision,,}" != "$expected_sha" ]]; then
echo "Ref '${INPUT_REF}' resolved to ${selected_revision}, expected ${EXPECTED_SHA}." >&2
exit 1
fi
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
trusted_reason="main-ancestor"
elif git tag --points-at "$selected_revision" | grep -Eq '^v'; then
trusted_reason="release-tag"
elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then
git fetch --no-tags origin "+refs/heads/${INPUT_REF}:refs/remotes/origin/${INPUT_REF}"
release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")"
if [[ "$selected_revision" == "$release_branch_sha" ]]; then
trusted_reason="release-branch-head"
fi
fi
if [[ -z "$trusted_reason" ]]; then
echo "Ref '${INPUT_REF}' resolved to $selected_revision, which is not trusted for this secret-bearing QA evidence run." >&2
echo "Allowed refs must be on main, point to a release tag, or match a release branch head." >&2
exit 1
fi
echo "selected_revision=$selected_revision" >> "$GITHUB_OUTPUT"
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
{
echo "### Target"
echo
echo "- Requested ref: \`${INPUT_REF}\`"
echo "- Resolved SHA: \`$selected_revision\`"
echo "- Trust reason: \`$trusted_reason\`"
} >> "$GITHUB_STEP_SUMMARY"
run_qa_profile:
name: Generate QA profile evidence
needs: validate_selected_ref
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
outputs:
artifact_name: ${{ steps.evidence.outputs.artifact_name }}
qa_profile: ${{ steps.profile.outputs.profile }}
qa_exit_code: ${{ steps.evidence.outputs.qa_exit_code }}
qa_passed: ${{ steps.evidence.outputs.qa_passed }}
target_sha: ${{ steps.evidence.outputs.target_sha }}
trusted_reason: ${{ steps.evidence.outputs.trusted_reason }}
qa_evidence_path: ${{ steps.evidence.outputs.qa_evidence_path }}
environment: qa-live-shared
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
- name: Validate QA profile input
id: profile
env:
QA_PROFILE: ${{ inputs.qa_profile }}
shell: bash
run: |
set -euo pipefail
node --import tsx --input-type=module <<'NODE'
import fs from "node:fs";
import { readQaScorecardTaxonomyReport } from "./extensions/qa-lab/src/scorecard-taxonomy.ts";
const requested = process.env.QA_PROFILE?.trim() ?? "";
if (!/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(requested)) {
throw new Error(`qa_profile must use a taxonomy profile id, got ${JSON.stringify(process.env.QA_PROFILE)}`);
}
const taxonomy = readQaScorecardTaxonomyReport([]);
const profile = taxonomy.profiles.find((entry) => entry.id === requested);
if (!profile) {
const available = taxonomy.profiles.map((entry) => entry.id).join(", ");
throw new Error(`Unknown QA profile ${requested}. Available profiles: ${available}`);
}
fs.appendFileSync(process.env.GITHUB_OUTPUT, `profile=${profile.id}\n`);
NODE
echo "QA profile: \`${QA_PROFILE}\`" >> "$GITHUB_STEP_SUMMARY"
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: node scripts/build-all.mjs qaRuntime
- name: Run QA profile
id: run_profile
env:
QA_PROFILE: ${{ steps.profile.outputs.profile }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
shell: bash
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/profile-${QA_PROFILE}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
qa_exit_code=0
pnpm openclaw qa run \
--repo-root . \
--qa-profile "${QA_PROFILE}" \
--output-dir "${output_dir}" || qa_exit_code=$?
echo "qa_exit_code=${qa_exit_code}" >> "$GITHUB_OUTPUT"
- name: Validate QA profile evidence
id: evidence
if: always()
env:
ARTIFACT_NAME: qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }}
OUTPUT_DIR: ${{ steps.run_profile.outputs.output_dir }}
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
QA_PROFILE: ${{ steps.profile.outputs.profile }}
REQUESTED_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_revision }}
TRUSTED_REASON: ${{ needs.validate_selected_ref.outputs.trusted_reason }}
shell: bash
run: |
set -euo pipefail
node --input-type=module <<'NODE'
import fs from "node:fs";
import path from "node:path";
const outputDir = process.env.OUTPUT_DIR;
if (!outputDir) {
throw new Error("OUTPUT_DIR is required");
}
if (!process.env.QA_EXIT_CODE) {
throw new Error("QA_EXIT_CODE is required");
}
const evidencePath = path.join(outputDir, "qa-evidence.json");
const payload = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
if (payload.profile !== process.env.QA_PROFILE) {
throw new Error(`qa-evidence.json profile must be ${process.env.QA_PROFILE}, got ${JSON.stringify(payload.profile)}`);
}
if (!payload.scorecard || !Array.isArray(payload.scorecard.categoryReports)) {
throw new Error("QA profile qa-evidence.json must include scorecard.categoryReports");
}
if (payload.scorecard.categoryReports.length === 0) {
throw new Error("QA profile qa-evidence.json scorecard has no category reports");
}
const manifest = {
artifactName: process.env.ARTIFACT_NAME,
generatedAt: new Date().toISOString(),
qaProfile: process.env.QA_PROFILE,
qaExitCode: Number(process.env.QA_EXIT_CODE),
qaPassed: process.env.QA_EXIT_CODE === "0",
requestedRef: process.env.REQUESTED_REF,
targetSha: process.env.TARGET_SHA,
trustedReason: process.env.TRUSTED_REASON,
evidenceMode: payload.evidenceMode,
qaEvidencePath: "qa-evidence.json",
scorecard: {
categories: payload.scorecard.categories,
features: payload.scorecard.features,
categoryReports: payload.scorecard.categoryReports.length,
},
};
fs.writeFileSync(
path.join(outputDir, "qa-profile-evidence-manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
NODE
echo "artifact_name=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT"
echo "qa_profile=${QA_PROFILE}" >> "$GITHUB_OUTPUT"
echo "qa_exit_code=${QA_EXIT_CODE}" >> "$GITHUB_OUTPUT"
if [[ "$QA_EXIT_CODE" == "0" ]]; then
echo "qa_passed=true" >> "$GITHUB_OUTPUT"
else
echo "qa_passed=false" >> "$GITHUB_OUTPUT"
echo "::warning::QA profile '${QA_PROFILE}' completed with exit code ${QA_EXIT_CODE}; evidence was still validated and uploaded."
fi
echo "target_sha=${TARGET_SHA}" >> "$GITHUB_OUTPUT"
echo "trusted_reason=${TRUSTED_REASON}" >> "$GITHUB_OUTPUT"
echo "qa_evidence_path=qa-evidence.json" >> "$GITHUB_OUTPUT"
{
echo "### QA profile evidence"
echo
echo "- Artifact: \`${ARTIFACT_NAME}\`"
echo "- QA profile: \`${QA_PROFILE}\`"
echo "- QA exit code: \`${QA_EXIT_CODE}\`"
echo "- Target SHA: \`${TARGET_SHA}\`"
echo "- Evidence path: \`${OUTPUT_DIR}/qa-evidence.json\`"
echo "- Manifest: \`${OUTPUT_DIR}/qa-profile-evidence-manifest.json\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload QA profile evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }}
path: ${{ steps.run_profile.outputs.output_dir }}
retention-days: 30
if-no-files-found: error
- name: Fail if QA profile failed
if: always()
env:
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
QA_PROFILE: ${{ steps.profile.outputs.profile }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${QA_EXIT_CODE:-}" ]]; then
echo "QA profile did not report an exit code." >&2
exit 1
fi
if [[ "$QA_EXIT_CODE" != "0" ]]; then
echo "QA profile '${QA_PROFILE}' failed with exit code ${QA_EXIT_CODE}." >&2
exit "$QA_EXIT_CODE"
fi

View File

@@ -24,7 +24,9 @@ jobs:
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event.pull_request.base.sha }}
# Old PR events can carry a stale base SHA that predates current
# trusted checker scripts. Use the workflow revision instead.
ref: ${{ github.workflow_sha }}
persist-credentials: false
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
id: app-token

View File

@@ -1,42 +0,0 @@
name: TUI PTY
on:
pull_request:
paths:
- "src/tui/**"
- "scripts/dev/tui-pty-test-watch.ts"
- "scripts/test-projects.test-support.mjs"
- "package.json"
- "pnpm-lock.yaml"
- "test/scripts/test-projects.test.ts"
- "test/vitest/vitest.test-shards.mjs"
- "test/vitest/vitest.tui-pty.config.ts"
- ".github/workflows/tui-pty.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
tui-pty:
runs-on: ubuntu-24.04
timeout-minutes: 8
env:
OPENCLAW_TUI_PTY_INCLUDE_LOCAL: "1"
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run TUI PTY tests
run: timeout --kill-after=30s 240s node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts

View File

@@ -150,6 +150,7 @@ jobs:
git --version
- name: Run Testbox
if: always()
shell: bash
run: |
set -euo pipefail

View File

@@ -297,6 +297,10 @@ jobs:
if: ${{ always() && !cancelled() && inputs.require_wsl2 }}
run: |
if ($env:OPENCLAW_WSL2_PROBE_OK -ne "true") {
if ($env:OPENCLAW_WSL2_RESTART_REQUIRED -eq "true") {
Write-Error "WSL2 probe enabled required Windows features, but the runner needs a reboot before WSL2 can start."
exit 1
}
Write-Error "WSL2 probe failed or WSL2 is unavailable on this Windows runner."
exit 1
}

View File

@@ -8,6 +8,7 @@ Skills own workflows; root owns hard policy and routing.
- Repo: `https://github.com/openclaw/openclaw`
- 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.
- Existing-solutions preflight: before proposing or building a custom system, feature, workflow, tool, integration, or automation, do a lightweight check for open-source projects, maintained libraries, existing OpenClaw plugins, or free platforms that already solve it well enough. Prefer those when adequate. Build custom only when existing options are unsuitable, too expensive, unmaintained, unsafe, non-compliant, or the user explicitly asks for custom. Avoid paid-service recommendations unless the user explicitly approves spend. Keep this to a brief preflight gate, not a broad research assignment.
- 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.

View File

@@ -61,7 +61,7 @@ We prioritize secure defaults, but also expose clear knobs for trusted high-powe
## Plugins & Memory
OpenClaw has an extensive plugin API.
Core stays lean; optional capability should usually ship as plugins.
Core stays lean; optional capabilities should usually ship as plugins.
We are generally slimming down core while expanding what plugins can do.
If a useful feature cannot be built as a plugin yet, we welcome PRs and design discussions that extend the plugin API instead of adding one-off core behavior.

View File

@@ -59,7 +59,7 @@ plugins {
android {
namespace = "ai.openclaw.app"
compileSdk = 37
compileSdk = 36
// Release signing is local-only; keep the keystore path and passwords out of the repo.
signingConfigs {

View File

@@ -1,8 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<permission
android:name="${applicationId}.permission.RUN_VOICE_E2E"
android:protectionLevel="signature" />
<uses-permission android:name="${applicationId}.permission.RUN_VOICE_E2E" />
<application>
<receiver
android:name=".VoiceE2eReceiver"
android:exported="true">
android:permission="${applicationId}.permission.RUN_VOICE_E2E"
android:exported="false">
<intent-filter>
<action android:name="ai.openclaw.app.debug.RUN_VOICE_E2E" />
</intent-filter>

View File

@@ -0,0 +1,160 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
data class GatewayExecApprovalSummary(
val id: String,
val commandText: String,
val commandPreview: String?,
val allowedDecisions: List<String>,
val host: String?,
val nodeId: String?,
val agentId: String?,
val createdAtMs: Long?,
val expiresAtMs: Long?,
val resolvingDecision: String? = null,
val errorText: String? = null,
)
internal fun parseGatewayExecApprovalListPayload(
payloadJson: String,
json: Json,
): List<GatewayExecApprovalSummary> =
try {
(json.parseToJsonElement(payloadJson) as? JsonArray)
?.mapNotNull(::parseGatewayExecApprovalListEntry)
?.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
.orEmpty()
} catch (_: Throwable) {
emptyList()
}
internal fun parseGatewayExecApprovalListEntry(item: JsonElement): GatewayExecApprovalSummary? {
val obj = item.asObjectOrNull() ?: return null
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return null
val request = obj["request"].asObjectOrNull()
val commandText = gatewayExecApprovalListCommandText(obj, request)
return GatewayExecApprovalSummary(
id = id,
commandText = commandText,
commandPreview = gatewayExecApprovalListCommandPreview(obj, request, commandText),
allowedDecisions = emptyList(),
host =
request
?.get("host")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
nodeId =
request
?.get("nodeId")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
agentId =
request
?.get("agentId")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
createdAtMs = obj.long("createdAtMs"),
expiresAtMs = obj.long("expiresAtMs"),
)
}
internal fun parseGatewayExecApprovalDetail(
obj: JsonObject,
createdAtMs: Long?,
): GatewayExecApprovalSummary? {
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return null
return GatewayExecApprovalSummary(
id = id,
commandText =
obj["commandText"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: "Command request",
commandPreview =
obj["commandPreview"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
allowedDecisions = gatewayExecApprovalAllowedDecisions(obj),
host = obj["host"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
nodeId = obj["nodeId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
agentId = obj["agentId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
createdAtMs = createdAtMs,
expiresAtMs = obj.long("expiresAtMs"),
)
}
private fun gatewayExecApprovalListCommandText(obj: JsonObject, request: JsonObject?): String =
obj["commandText"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: request
?.get("command")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: "Command request"
private fun gatewayExecApprovalListCommandPreview(
obj: JsonObject,
request: JsonObject?,
commandText: String,
): String? {
val preview =
obj["commandPreview"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: request
?.get("commandPreview")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
return preview?.takeIf { it != commandText }
}
private fun gatewayExecApprovalAllowedDecisions(request: JsonObject?): List<String> {
val explicit = parseGatewayExecApprovalDecisions(request?.get("allowedDecisions") as? JsonArray)
if (explicit.isNotEmpty()) return explicit
val allowed =
if (request
?.get("ask")
.asStringOrNull()
?.trim()
?.lowercase() == "always"
) {
listOf("allow-once", "deny")
} else {
listOf("allow-once", "allow-always", "deny")
}
val unavailable = parseGatewayExecApprovalDecisions(request?.get("unavailableDecisions") as? JsonArray).toSet()
return allowed.filterNot { it == "allow-always" && it in unavailable }
}
private fun parseGatewayExecApprovalDecisions(items: JsonArray?): List<String> =
items
?.mapNotNull { item ->
when (item.asStringOrNull()?.trim()) {
"allow-once" -> "allow-once"
"allow-always" -> "allow-always"
"deny" -> "deny"
else -> null
}
}?.distinct()
.orEmpty()
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()

View File

@@ -204,6 +204,9 @@ class MainViewModel(
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls }
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = runtimeState(initial = emptyList()) { it.execApprovals }
val execApprovalsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.execApprovalsRefreshing }
val execApprovalsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.execApprovalsErrorText }
val canvas: CanvasController
get() = ensureRuntime().canvas
@@ -537,6 +540,17 @@ class MainViewModel(
ensureRuntime().refreshNodesDevices()
}
fun refreshExecApprovals() {
ensureRuntime().refreshExecApprovals()
}
fun resolveExecApproval(
id: String,
decision: String,
) {
ensureRuntime().resolveExecApproval(id = id, decision = decision)
}
fun refreshChannels() {
ensureRuntime().refreshChannels()
}

View File

@@ -14,6 +14,7 @@ import ai.openclaw.app.gateway.GatewayTlsProbeFailure
import ai.openclaw.app.gateway.GatewayTlsProbeResult
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
import ai.openclaw.app.gateway.normalizeGatewayTlsFingerprint
import ai.openclaw.app.gateway.parseChatSendAck
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
import ai.openclaw.app.node.A2UIHandler
import ai.openclaw.app.node.CalendarHandler
@@ -73,7 +74,9 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.util.Collections
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
/**
@@ -399,6 +402,15 @@ class NodeRuntime(
private val _nodesDevicesErrorText = MutableStateFlow<String?>(null)
val nodesDevicesErrorText: StateFlow<String?> = _nodesDevicesErrorText.asStateFlow()
private val nodeApprovalRefreshGuard = GatewayNodeApprovalRefreshGuard()
private val _execApprovals = MutableStateFlow<List<GatewayExecApprovalSummary>>(emptyList())
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = _execApprovals.asStateFlow()
private val _execApprovalsRefreshing = MutableStateFlow(false)
val execApprovalsRefreshing: StateFlow<Boolean> = _execApprovalsRefreshing.asStateFlow()
private val _execApprovalsErrorText = MutableStateFlow<String?>(null)
val execApprovalsErrorText: StateFlow<String?> = _execApprovalsErrorText.asStateFlow()
private val execApprovalsRefreshSeq = AtomicLong(0)
private val execApprovalsStateLock = Any()
private val resolvedExecApprovalIds = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
private val _channelsSummary = MutableStateFlow(GatewayChannelsSummary(channels = emptyList()))
val channelsSummary: StateFlow<GatewayChannelsSummary> = _channelsSummary.asStateFlow()
private val _channelsRefreshing = MutableStateFlow(false)
@@ -448,6 +460,7 @@ class NodeRuntime(
micCapture.onGatewayConnectionChanged(true)
scope.launch {
subscribeOperatorSessionEvents()
refreshExecApprovalsFromGateway()
refreshHomeCanvasOverviewIfConnected()
if (voiceReplySpeakerLazy.isInitialized()) {
voiceReplySpeaker.refreshConfig()
@@ -477,6 +490,11 @@ class NodeRuntime(
pendingDevices = emptyList(),
pairedDevices = emptyList(),
)
invalidateExecApprovalRefreshes()
resolvedExecApprovalIds.clear()
_execApprovals.value = emptyList()
_execApprovalsRefreshing.value = false
_execApprovalsErrorText.value = null
_channelsSummary.value = GatewayChannelsSummary(channels = emptyList())
_dreamingSummary.value = GatewayDreamingSummary()
_healthLogsSummary.value = GatewayHealthLogsSummary()
@@ -632,7 +650,11 @@ class NodeRuntime(
put("idempotencyKey", JsonPrimitive(idempotencyKey))
}
val response = operatorSession.request("chat.send", params.toString())
parseChatSendRunId(response) ?: idempotencyKey
val ack = parseChatSendAck(json, response)
ack.copy(runId = ack.runId ?: idempotencyKey)
},
refreshAfterTerminalSuccess = {
chat.refresh()
},
speakAssistantReply = { text ->
// Voice-tab replies should speak through the dedicated reply speaker.
@@ -820,6 +842,24 @@ class NodeRuntime(
}
}
fun refreshExecApprovals() {
scope.launch {
refreshExecApprovalsFromGateway()
}
}
fun resolveExecApproval(
id: String,
decision: String,
) {
val normalizedId = id.trim()
val normalizedDecision = decision.trim()
if (normalizedId.isEmpty() || normalizedDecision.isEmpty()) return
scope.launch {
resolveExecApprovalOnGateway(id = normalizedId, decision = normalizedDecision)
}
}
fun refreshChannels() {
scope.launch {
refreshChannelsFromGateway()
@@ -995,6 +1035,9 @@ class NodeRuntime(
_isForeground.value = value
if (value) {
reconnectPreferredGatewayOnForeground()
scope.launch {
refreshExecApprovalsFromGateway()
}
} else {
stopManualVoiceSession()
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Background, throttleRecentSuccess = true)
@@ -1824,11 +1867,47 @@ class NodeRuntime(
if (event == "update.available") {
_gatewayUpdateAvailable.value = parseGatewayUpdateAvailable(payloadJson)
}
handleExecApprovalGatewayEvent(event = event, payloadJson = payloadJson)
micCapture.handleGatewayEvent(event, payloadJson)
talkMode.handleGatewayEvent(event, payloadJson)
chat.handleGatewayEvent(event, payloadJson)
}
private fun handleExecApprovalGatewayEvent(
event: String,
payloadJson: String?,
) {
when (event) {
"exec.approval.requested" -> {
val approvalId = parseExecApprovalEventId(payloadJson)
approvalId?.let(resolvedExecApprovalIds::remove)
scope.launch {
if (approvalId == null) {
refreshExecApprovalsFromGateway()
} else {
refreshExecApprovalFromGateway(approvalId)
}
}
}
"exec.approval.resolved" -> {
val approvalId = parseExecApprovalEventId(payloadJson) ?: return
markExecApprovalResolved(approvalId)
}
}
}
private fun parseExecApprovalEventId(payloadJson: String?): String? =
try {
payloadJson
?.let { json.parseToJsonElement(it).asObjectOrNull() }
?.get("id")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
} catch (_: Throwable) {
null
}
private fun parseGatewayUpdateAvailable(payloadJson: String?): GatewayUpdateAvailableSummary? {
return try {
val root = payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() }
@@ -1843,15 +1922,6 @@ class NodeRuntime(
}
}
private fun parseChatSendRunId(response: String): String? {
return try {
val root = json.parseToJsonElement(response).asObjectOrNull() ?: return null
root["runId"].asStringOrNull()
} catch (_: Throwable) {
null
}
}
private fun parseTalkSessionId(response: String): String {
val root = json.parseToJsonElement(response).asObjectOrNull()
val sessionId =
@@ -2084,6 +2154,196 @@ class NodeRuntime(
}
}
private suspend fun refreshExecApprovalsFromGateway() {
val refreshGeneration = execApprovalsRefreshSeq.incrementAndGet()
_execApprovalsRefreshing.value = true
_execApprovalsErrorText.value = null
if (!operatorConnected) {
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
_execApprovals.value = emptyList()
_execApprovalsRefreshing.value = false
}
return
}
try {
val res = operatorSession.request("exec.approval.list", "{}")
val existing = _execApprovals.value.associateBy { it.id }
val rows =
parseGatewayExecApprovalListPayload(res, json)
.filterNot { it.id in resolvedExecApprovalIds }
.map { row ->
val hydrated =
try {
fetchExecApprovalDetailFromGateway(
id = row.id,
createdAtMs = row.createdAtMs ?: System.currentTimeMillis(),
)
} catch (_: Throwable) {
null
} ?: row.copy(errorText = "Could not load approval details. Refresh and try again.")
val current = existing[row.id]
if (current == null) {
hydrated
} else {
hydrated.copy(
resolvingDecision = current.resolvingDecision,
errorText = current.errorText ?: hydrated.errorText,
)
}
}
publishExecApprovalsIfCurrent(refreshGeneration, rows)
} catch (_: Throwable) {
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
_execApprovalsErrorText.value = "Could not load approvals."
}
} finally {
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
_execApprovalsRefreshing.value = false
}
}
}
private suspend fun refreshExecApprovalFromGateway(id: String) {
if (!operatorConnected) return
if (id in resolvedExecApprovalIds) return
try {
val current = _execApprovals.value.firstOrNull { it.id == id }
val row =
fetchExecApprovalDetailFromGateway(
id = id,
createdAtMs = current?.createdAtMs ?: System.currentTimeMillis(),
) ?: return
if (id in resolvedExecApprovalIds) return
invalidateExecApprovalRefreshes()
upsertExecApproval(row)
} catch (_: Throwable) {
refreshExecApprovalsFromGateway()
}
}
private suspend fun fetchExecApprovalDetailFromGateway(
id: String,
createdAtMs: Long,
): GatewayExecApprovalSummary? {
val params = buildJsonObject { put("id", JsonPrimitive(id)) }.toString()
val res = operatorSession.request("exec.approval.get", params)
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null
return parseGatewayExecApprovalDetail(root, createdAtMs = createdAtMs)
}
private suspend fun resolveExecApprovalOnGateway(
id: String,
decision: String,
) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || id in resolvedExecApprovalIds) return
val currentRows = _execApprovals.value
if (currentRows.none { it.id == id }) return
invalidateExecApprovalRefreshes()
_execApprovals.value =
currentRows.map { row ->
if (row.id == id) row.copy(resolvingDecision = decision, errorText = null) else row
}
}
try {
val params =
buildJsonObject {
put("id", JsonPrimitive(id))
put("decision", JsonPrimitive(decision))
}.toString()
operatorSession.request("exec.approval.resolve", params)
markExecApprovalResolved(id)
} catch (_: Throwable) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || id in resolvedExecApprovalIds) return
_execApprovals.value =
_execApprovals.value.map { row ->
if (row.id == id) {
row.copy(resolvingDecision = null, errorText = "Could not resolve approval. Refresh and try again.")
} else {
row
}
}
}
}
}
private fun upsertExecApproval(row: GatewayExecApprovalSummary) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || row.id in resolvedExecApprovalIds) return
if (row.isExpiredExecApproval()) return
val rows = _execApprovals.value
val replaced = rows.any { it.id == row.id }
val nextRows =
(
if (replaced) {
rows.map { current ->
if (current.id == row.id) {
row.copy(
resolvingDecision = current.resolvingDecision,
errorText = current.errorText,
)
} else {
current
}
}
} else {
rows + row
}
).filterActiveExecApprovals()
.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
_execApprovals.value = nextRows
scheduleExecApprovalExpiryPrune(nextRows)
}
}
private fun invalidateExecApprovalRefreshes() {
execApprovalsRefreshSeq.incrementAndGet()
_execApprovalsRefreshing.value = false
}
private fun markExecApprovalResolved(id: String) {
synchronized(execApprovalsStateLock) {
resolvedExecApprovalIds.add(id)
invalidateExecApprovalRefreshes()
_execApprovals.value = _execApprovals.value.filterNot { it.id == id }
}
}
private fun publishExecApprovalsIfCurrent(
refreshGeneration: Long,
rows: List<GatewayExecApprovalSummary>,
) {
synchronized(execApprovalsStateLock) {
if (execApprovalsRefreshSeq.get() == refreshGeneration && operatorConnected) {
val nextRows = rows.filterNot { it.id in resolvedExecApprovalIds }.filterActiveExecApprovals()
_execApprovals.value = nextRows
scheduleExecApprovalExpiryPrune(nextRows)
}
}
}
private fun scheduleExecApprovalExpiryPrune(rows: List<GatewayExecApprovalSummary>) {
val now = System.currentTimeMillis()
val nextExpiry = rows.mapNotNull { it.expiresAtMs }.filter { it > now }.minOrNull() ?: return
scope.launch {
delay((nextExpiry - now + 250).coerceAtLeast(0))
pruneExpiredExecApprovals()
}
}
private fun pruneExpiredExecApprovals() {
synchronized(execApprovalsStateLock) {
_execApprovals.value = _execApprovals.value.filterActiveExecApprovals()
}
}
private fun GatewayExecApprovalSummary.isExpiredExecApproval(nowMs: Long = System.currentTimeMillis()): Boolean = expiresAtMs?.let { it <= nowMs } == true
private fun List<GatewayExecApprovalSummary>.filterActiveExecApprovals(
nowMs: Long = System.currentTimeMillis(),
): List<GatewayExecApprovalSummary> = filterNot { it.isExpiredExecApproval(nowMs) }
private fun invalidateNodeCapabilityApprovalState() {
val refreshGeneration = nodeApprovalRefreshGuard.begin()
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
@@ -2198,12 +2458,19 @@ class NodeRuntime(
}.orEmpty()
private fun parseGatewayLogEntry(line: String): GatewayLogEntry {
val sanitizedLine = sanitizeGatewayLogText(line)
val root =
try {
json.parseToJsonElement(line).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return GatewayLogEntry(time = null, level = null, subsystem = null, message = line.trim().ifEmpty { "Empty log entry" })
} ?: return GatewayLogEntry(
time = null,
level = null,
subsystem = null,
message = sanitizedLine.trim().ifEmpty { "Empty log entry" },
raw = sanitizedLine,
)
val meta = root["_meta"].asObjectOrNull()
val time = root["time"].asStringOrNull() ?: meta?.get("date").asStringOrNull()
val level = normalizeLogLevel(meta?.get("logLevelName").asStringOrNull() ?: meta?.get("level").asStringOrNull())
@@ -2221,7 +2488,7 @@ class NodeRuntime(
?: root["message"].asStringOrNull()
?: line
val normalizedMessage =
message
sanitizeGatewayLogText(message)
.trim()
.replace(Regex("\\s+"), " ")
.take(240)
@@ -2229,8 +2496,9 @@ class NodeRuntime(
return GatewayLogEntry(
time = time,
level = level,
subsystem = subsystem?.trim()?.takeIf { it.isNotEmpty() },
subsystem = subsystem?.let(::sanitizeGatewayLogText)?.trim()?.takeIf { it.isNotEmpty() },
message = normalizedMessage,
raw = sanitizedLine,
)
}
@@ -2319,6 +2587,7 @@ class NodeRuntime(
if (name.isEmpty()) return@mapNotNull null
val missing = obj["missing"].asObjectOrNull()
GatewaySkillSummary(
skillKey = obj["skillKey"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: name,
name = name,
description = obj["description"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
source = obj["source"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "unknown",
@@ -2769,11 +3038,6 @@ internal fun resolveOperatorSessionConnectAuth(
)
}
internal fun shouldConnectOperatorSession(
auth: NodeRuntime.GatewayConnectAuth,
storedOperatorToken: String?,
): Boolean = resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
private enum class HomeCanvasGatewayState {
Connected,
Connecting,
@@ -2846,6 +3110,7 @@ data class GatewaySkillsSummary(
)
data class GatewaySkillSummary(
val skillKey: String,
val name: String,
val description: String?,
val source: String,
@@ -3043,8 +3308,19 @@ data class GatewayLogEntry(
val level: String?,
val subsystem: String?,
val message: String,
val raw: String,
)
private val gatewayAnsiControlPattern = Regex("\\u001B\\[[0-?]*[ -/]*[@-~]")
private val gatewayEscapedAnsiControlPattern = Regex("""\\u001[Bb]\[[0-?]*[ -/]*[@-~]""")
private val gatewayVisibleSgrPattern = Regex("\\[(?:0|\\d{1,3}(?:;\\d{1,3})*)m(?!])")
internal fun sanitizeGatewayLogText(value: String): String =
value
.replace(gatewayAnsiControlPattern, "")
.replace(gatewayEscapedAnsiControlPattern, "")
.replace(gatewayVisibleSgrPattern, "")
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()
private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toDoubleOrNull()

View File

@@ -393,12 +393,6 @@ class SecurePrefs(
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}"

View File

@@ -6,14 +6,6 @@ internal fun normalizeMainKey(raw: String?): String {
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
if (trimmed == "global") return true
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()

View File

@@ -1,6 +1,7 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.parseChatSendAck
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -19,11 +20,21 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
class ChatController(
class ChatController internal constructor(
private val scope: CoroutineScope,
private val session: GatewaySession,
private val json: Json,
private val requestGateway: suspend (method: String, paramsJson: String?) -> String,
) {
constructor(
scope: CoroutineScope,
session: GatewaySession,
json: Json,
) : this(
scope = scope,
json = json,
requestGateway = { method, paramsJson -> session.request(method, paramsJson) },
)
private var appliedMainSessionKey = "main"
private val _sessionKey = MutableStateFlow("main")
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
@@ -267,8 +278,9 @@ class ChatController(
)
}
}
val res = session.request("chat.send", params.toString())
val actualRunId = parseRunId(res) ?: runId
val res = requestGateway("chat.send", params.toString())
val ack = parseChatSendAck(json, res)
val actualRunId = ack.runId ?: runId
if (actualRunId != runId) {
// Gateway may return a canonical run id; move all pending bookkeeping to that id.
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
@@ -279,7 +291,24 @@ class ChatController(
_pendingRunCount.value = pendingRuns.size
}
}
true
if (ack.isTerminal) {
clearPendingRun(actualRunId)
removeOptimisticMessage(actualRunId)
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
if (ack.isTerminalSuccess) {
refreshCurrentHistoryBestEffort()
true
} else {
// Terminal timeout/error means the gateway did not accept a runnable turn.
// Surface failed acceptance instead of letting a cleared composer look successful.
_errorText.value = "Chat failed before the run started; try again."
false
}
} else {
true
}
} catch (err: Throwable) {
clearPendingRun(runId)
removeOptimisticMessage(runId)
@@ -303,7 +332,7 @@ class ChatController(
put("sessionKey", JsonPrimitive(_sessionKey.value))
put("runId", JsonPrimitive(runId))
}
session.request("chat.abort", params.toString())
requestGateway("chat.abort", params.toString())
} catch (_: Throwable) {
// best-effort
}
@@ -356,7 +385,7 @@ class ChatController(
) {
try {
val historyJson =
session.request(
requestGateway(
"chat.history",
buildJsonObject { put("sessionKey", JsonPrimitive(sessionKey)) }.toString(),
)
@@ -391,7 +420,7 @@ class ChatController(
put("includeUnknown", JsonPrimitive(false))
if (limit != null && limit > 0) put("limit", JsonPrimitive(limit))
}
val res = session.request("sessions.list", params.toString())
val res = requestGateway("sessions.list", params.toString())
_sessions.value = parseSessions(res)
} catch (_: Throwable) {
// best-effort
@@ -408,7 +437,7 @@ class ChatController(
if (!force && last != null && now - last < 10_000) return
lastHealthPollAtMs = now
try {
session.request("health", null)
requestGateway("health", null)
_healthOk.value = true
} catch (_: Throwable) {
_healthOk.value = false
@@ -451,7 +480,7 @@ class ChatController(
val currentSessionKey = _sessionKey.value
val currentGeneration = historyLoadGeneration.get()
val historyJson =
session.request(
requestGateway(
"chat.history",
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
)
@@ -509,8 +538,7 @@ class ChatController(
}
}
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? =
payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? = payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
private fun handleAgentEvent(payloadJson: String) {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
@@ -632,6 +660,45 @@ class ChatController(
optimisticMessagesByRunId.entries.removeAll { entry -> entry.value !in retained }
}
private fun refreshCurrentHistoryBestEffort() {
scope.launch {
try {
val currentSessionKey = _sessionKey.value
val currentGeneration = historyLoadGeneration.get()
val historyJson =
requestGateway(
"chat.history",
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
)
if (
!isCurrentHistoryLoad(
currentSessionKey,
_sessionKey.value,
currentGeneration,
historyLoadGeneration.get(),
)
) {
return@launch
}
val history =
parseHistory(
historyJson,
sessionKey = currentSessionKey,
previousMessages = _messages.value,
)
prunePersistedOptimisticMessages(history.messages)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_sessionId.value = history.sessionId
history.thinkingLevel
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { _thinkingLevel.value = it }
} catch (_: Throwable) {
// best-effort
}
}
}
private fun parseHistory(
historyJson: String,
sessionKey: String,
@@ -679,9 +746,16 @@ class ChatController(
): ChatSessionEntry? {
if (obj == null) return null
val key =
obj["key"].asStringOrNull()?.trim().orEmpty()
.ifEmpty { obj["sessionKey"].asStringOrNull()?.trim().orEmpty() }
.ifEmpty { fallbackKey?.trim().orEmpty() }
obj["key"]
.asStringOrNull()
?.trim()
.orEmpty()
.ifEmpty {
obj["sessionKey"]
.asStringOrNull()
?.trim()
.orEmpty()
}.ifEmpty { fallbackKey?.trim().orEmpty() }
if (key.isEmpty()) return null
return ChatSessionEntry(
key = key,
@@ -728,17 +802,6 @@ class ChatController(
_sessions.value = _sessions.value.filterNot { it.key == key }
}
private fun parseRunId(resJson: String): String? =
try {
json
.parseToJsonElement(resJson)
.asObjectOrNull()
?.get("runId")
.asStringOrNull()
} catch (_: Throwable) {
null
}
private fun normalizeThinking(raw: String): String =
when (raw.trim().lowercase()) {
"low" -> "low"

View File

@@ -0,0 +1,46 @@
package ai.openclaw.app.gateway
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
internal data class ChatSendAck(
val runId: String?,
val status: String?,
) {
val normalizedStatus: String
get() = status?.trim()?.lowercase().orEmpty()
val isTerminalSuccess: Boolean
get() = normalizedStatus == "ok"
val isTerminalFailure: Boolean
get() = normalizedStatus == "timeout" || normalizedStatus == "error"
val isTerminal: Boolean
get() = isTerminalSuccess || isTerminalFailure
}
internal fun chatSendAckHistorySinceSeconds(
ack: ChatSendAck,
startedAtSeconds: Double,
): Double? = if (ack.isTerminalSuccess) null else startedAtSeconds
internal fun parseChatSendAck(
json: Json,
responseJson: String,
): ChatSendAck =
try {
val obj = json.parseToJsonElement(responseJson).asObjectOrNull()
ChatSendAck(
runId = obj?.get("runId").asStringOrNull(),
status = obj?.get("status").asStringOrNull(),
)
} catch (_: Throwable) {
ChatSendAck(runId = null, status = null)
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.gateway
import android.annotation.TargetApi
import android.content.Context
import android.net.ConnectivityManager
import android.net.DnsResolver
@@ -12,6 +11,7 @@ import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.CancellationSignal
import android.util.Log
import androidx.annotation.RequiresApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -49,18 +49,8 @@ import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private fun createDnsResolver(context: Context): DnsResolver =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
createContextDnsResolver(context)
} else {
createLegacyDnsResolver()
}
@TargetApi(Build.VERSION_CODES.CINNAMON_BUN)
private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null)
@Suppress("DEPRECATION")
private fun createLegacyDnsResolver(): DnsResolver = DnsResolver.getInstance()
private fun createDnsResolver(): DnsResolver = DnsResolver.getInstance()
/**
* Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways.
@@ -71,7 +61,7 @@ class GatewayDiscovery(
) {
private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val dns = createDnsResolver(context)
private val dns = createDnsResolver()
private val serviceType = "_openclaw-gw._tcp."
private val wideAreaDomain = System.getenv("OPENCLAW_WIDE_AREA_DOMAIN")
private val logTag = "OpenClaw/GatewayDiscovery"
@@ -166,14 +156,6 @@ class GatewayDiscovery(
}
}
private fun stopLocalDiscovery() {
try {
nsd.stopServiceDiscovery(discoveryListener)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun startUnicastDiscovery(domain: String) {
unicastJob =
scope.launch(Dispatchers.IO) {
@@ -197,7 +179,7 @@ class GatewayDiscovery(
}
}
@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private fun resolveWithServiceInfoCallback(serviceInfo: NsdServiceInfo) {
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
val id = stableId(serviceName, "local.")

View File

@@ -260,24 +260,6 @@ class GatewaySession(
currentConnection?.closeQuietly()
}
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(
method = "node.pluginSurface.refresh",
params = buildJsonObject { put("surface", JsonPrimitive("canvas")) },
timeoutMs = timeoutMs,
)
if (!refreshed.isNullOrBlank()) {
pluginSurfaceUrls = pluginSurfaceUrls + ("canvas" to refreshed)
}
return refreshed
}
fun currentMainSessionKey(): String? = mainSessionKey
/** Sends a best-effort node.event and returns false instead of throwing on failure. */
suspend fun sendNodeEvent(
event: String,
@@ -297,28 +279,6 @@ class GatewaySession(
}
}
private suspend fun refreshPluginSurfaceUrl(
method: String,
params: JsonElement?,
timeoutMs: Long,
): String? {
val conn = currentConnection ?: return null
return try {
val res = conn.request(method, params, timeoutMs)
if (!res.ok) return null
val obj = res.payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() } ?: return null
val raw =
obj["pluginSurfaceUrls"]
.asObjectOrNull()
?.get("canvas")
.asStringOrNull()
normalizeCanvasHostUrl(raw, conn.endpoint, isTlsConnection = conn.tls != null)
} catch (err: Throwable) {
Log.d("OpenClawGateway", "$method failed: ${err.message ?: err::class.java.simpleName}")
null
}
}
/** Sends node.event and preserves the gateway RPC error shape for callers that need diagnostics. */
suspend fun sendNodeEventDetailed(
event: String,

View File

@@ -97,8 +97,6 @@ class CanvasController {
fun currentUrl(): String? = url
fun isDefaultCanvas(): Boolean = url == null
fun setDebugStatusEnabled(enabled: Boolean) {
debugStatusEnabled = enabled
applyDebugStatus()
@@ -205,24 +203,6 @@ class CanvasController {
}
}
suspend fun snapshotPngBase64(maxWidth: Int?): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
try {
val scaled = bmp.scaleForMaxWidth(maxWidth)
try {
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
} finally {
if (scaled !== bmp) scaled.recycle()
}
} finally {
bmp.recycle()
}
}
/** Captures the WebView as PNG/JPEG base64 with optional width and quality bounds. */
suspend fun snapshotBase64(
format: SnapshotFormat,

View File

@@ -4,6 +4,7 @@ import ai.openclaw.app.BuildConfig
import ai.openclaw.app.SensitiveFeatureConfig
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.content.Context
import android.content.Intent
@@ -63,7 +64,7 @@ private class AndroidDeviceAppSource(
val appInfos =
if (includeNonLaunchable) {
packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
visibleInstalledApplications(packageManager)
} else {
launchablePackages.mapNotNull { packageName ->
runCatching { packageManager.getApplicationInfo(packageName, 0) }.getOrNull()
@@ -90,6 +91,13 @@ private class AndroidDeviceAppSource(
.sortedWith(compareBy<DeviceAppEntry> { it.label.lowercase() }.thenBy { it.packageName })
.toList()
}
@SuppressLint("QueryPermissionsNeeded")
private fun visibleInstalledApplications(packageManager: PackageManager): List<ApplicationInfo> {
// Android package visibility intentionally bounds this result to packages the app can see.
// OpenClaw should not request QUERY_ALL_PACKAGES for this optional device-context surface.
return packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
}
}
private data class DeviceAppsRequest(

View File

@@ -109,6 +109,3 @@ 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

@@ -5,6 +5,7 @@ import ai.openclaw.app.GatewayModelSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawSeparatedColumn
import ai.openclaw.app.ui.design.ClawTextField
@@ -94,7 +95,11 @@ internal fun CommandPalette(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
CommandIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search", onClick = onDismiss)
ClawPlainIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close search",
onClick = onDismiss,
)
Text(text = "Search", style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
CommandAvatar(text = "OC")
}
@@ -262,19 +267,6 @@ private fun CommandSessionListRow(
}
}
@Composable
private fun CommandIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun CommandAvatar(text: String) {
Surface(

View File

@@ -5,8 +5,7 @@ import ai.openclaw.app.GatewayDreamingSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawStatusRow
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
@@ -92,19 +91,19 @@ private fun DreamingPanel(summary: GatewayDreamingSummary) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
DreamingHealthRow(
ClawStatusRow(
title = "Memory Store",
value = if (summary.storeHealthy) "Healthy" else "Needs attention",
healthy = summary.storeHealthy,
)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
DreamingHealthRow(
ClawStatusRow(
title = "Signal Index",
value = if (summary.phaseSignalHealthy) "Healthy" else "Needs attention",
healthy = summary.phaseSignalHealthy,
)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
DreamingHealthRow(
ClawStatusRow(
title = "Promoted",
value = "${summary.promotedToday} today · ${summary.promotedTotal} total",
healthy = true,
@@ -115,23 +114,6 @@ private fun DreamingPanel(summary: GatewayDreamingSummary) {
}
}
@Composable
private fun DreamingHealthRow(
title: String,
value: String,
healthy: Boolean,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Box(modifier = Modifier.size(7.dp))
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
}
}
@Composable
private fun DreamDiaryPanel(summary: GatewayDreamingSummary) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {

View File

@@ -206,9 +206,6 @@ 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 =

View File

@@ -7,7 +7,10 @@ import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawStatusRow
import ai.openclaw.app.ui.design.ClawTheme
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -15,13 +18,18 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
@@ -43,6 +51,7 @@ internal fun HealthLogsSettingsScreen(
val logsSummary by viewModel.healthLogsSummary.collectAsState()
val logsRefreshing by viewModel.healthLogsRefreshing.collectAsState()
val logsErrorText by viewModel.healthLogsErrorText.collectAsState()
var selectedLogEntry by remember { mutableStateOf<GatewayLogEntry?>(null) }
LaunchedEffect(isConnected) {
if (isConnected) {
@@ -52,6 +61,11 @@ internal fun HealthLogsSettingsScreen(
}
}
selectedLogEntry?.let { entry ->
GatewayLogDetailSettingsScreen(entry = entry, onBack = { selectedLogEntry = null })
return
}
SettingsDetailFrame(
title = "Health",
subtitle = "Gateway status, phone node readiness, and recent log stream.",
@@ -93,7 +107,46 @@ internal fun HealthLogsSettingsScreen(
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary)
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary, onLogClick = { selectedLogEntry = it })
}
}
@Composable
private fun GatewayLogDetailSettingsScreen(
entry: GatewayLogEntry,
onBack: () -> Unit,
) {
BackHandler(onBack = onBack)
SettingsDetailFrame(
title = "Log Entry",
subtitle = "Readable gateway log detail.",
icon = Icons.Default.Settings,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Time", compactLogTime(entry.time)),
SettingsMetric("Level", entry.level?.uppercase() ?: "LOG"),
SettingsMetric("Subsystem", entry.subsystem ?: "Unknown"),
),
)
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Message", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = entry.message, style = ClawTheme.type.body, color = ClawTheme.colors.text)
}
}
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Raw", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(
text = entry.raw.take(4_000),
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
}
}
}
@@ -113,41 +166,26 @@ private fun HealthStatusPanel(
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
HealthStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
ClawStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
HealthStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
ClawStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
HealthStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
ClawStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
HealthStatusRow(title = "Models", value = models, healthy = modelsReady)
ClawStatusRow(title = "Models", value = models, healthy = modelsReady)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
HealthStatusRow(title = "Voice", value = voice, healthy = voiceReady)
ClawStatusRow(title = "Voice", value = voice, healthy = voiceReady)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
HealthStatusRow(title = "Runs", value = runs, healthy = true)
ClawStatusRow(title = "Runs", value = runs, healthy = true)
}
}
}
@Composable
private fun HealthStatusRow(
title: String,
value: String,
healthy: Boolean,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
}
}
@Composable
private fun GatewayLogsPanel(
isConnected: Boolean,
summary: GatewayHealthLogsSummary,
onLogClick: (GatewayLogEntry) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
@@ -170,7 +208,7 @@ private fun GatewayLogsPanel(
val entries = summary.entries.takeLast(12)
Column {
entries.forEachIndexed { index, entry ->
GatewayLogRow(entry = entry)
GatewayLogRow(entry = entry, onClick = { onLogClick(entry) })
if (index != entries.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
@@ -185,9 +223,16 @@ private fun GatewayLogsPanel(
}
@Composable
private fun GatewayLogRow(entry: GatewayLogEntry) {
private fun GatewayLogRow(
entry: GatewayLogEntry,
onClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
modifier =
Modifier
.fillMaxWidth()
.clickable(onClickLabel = "Open log entry", onClick = onClick)
.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
@@ -199,6 +244,11 @@ private fun GatewayLogRow(entry: GatewayLogEntry) {
}
}
ClawStatusPill(text = entry.level?.uppercase() ?: "LOG", status = logLevelStatus(entry.level))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = ClawTheme.colors.textSubtle,
)
}
}

View File

@@ -1378,7 +1378,12 @@ private fun rememberPermissionState(
photosGranted = permissions[photosPermission] ?: photosGranted
contactsGranted = permissions[Manifest.permission.READ_CONTACTS] ?: contactsGranted
calendarGranted = permissions[Manifest.permission.READ_CALENDAR] ?: calendarGranted
notificationsGranted = permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
notificationsGranted =
if (Build.VERSION.SDK_INT >= 33) {
permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
} else {
true
}
motionGranted = permissions[Manifest.permission.ACTIVITY_RECOGNITION] ?: motionGranted
smsGranted =
(permissions[Manifest.permission.SEND_SMS] ?: smsGranted) &&

View File

@@ -9,14 +9,10 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LocalOpenClawDarkTheme = staticCompositionLocalOf { true }
/**
* App theme wrapper that installs dynamic Material colors and legacy mobile color tokens.
*/
@@ -34,7 +30,6 @@ fun OpenClawTheme(
CompositionLocalProvider(
LocalMobileColors provides mobileColors,
LocalOpenClawDarkTheme provides isDark,
) {
MaterialTheme(colorScheme = colorScheme, content = content)
}
@@ -55,21 +50,3 @@ internal fun OpenClawSystemBarAppearance(lightAppearance: Boolean) {
}
}
}
/**
* Overlay background token tuned for panels floating over the mobile canvas.
*/
@Composable
fun overlayContainerColor(): Color {
val scheme = MaterialTheme.colorScheme
val isDark = LocalOpenClawDarkTheme.current
val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh
// Light mode keeps overlays away from pure-white glare on the app canvas.
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

@@ -2,6 +2,7 @@ package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawTheme
@@ -55,7 +56,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. */
/** Session browser for recent and current chat sessions. */
@Composable
internal fun SessionsScreen(
viewModel: MainViewModel,
@@ -73,7 +74,7 @@ internal fun SessionsScreen(
.let { rows ->
when (filter) {
SessionFilter.Recent -> rows
SessionFilter.Live -> rows.filter { it.key == chatSessionKey }
SessionFilter.Current -> rows.filter { it.key == chatSessionKey }
}
}.let { rows ->
if (recentFirst) {
@@ -92,12 +93,12 @@ internal fun SessionsScreen(
}
ClawScaffold(
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
contentPadding = PaddingValues(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 4.dp),
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(7.dp),
verticalArrangement = Arrangement.spacedBy(9.dp),
contentPadding = PaddingValues(bottom = 4.dp),
) {
item {
@@ -106,16 +107,16 @@ internal fun SessionsScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 17.4.sp, lineHeight = 21.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
SessionPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
SessionPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
ClawPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
ClawPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
FilterPill(text = "Recent", icon = Icons.Outlined.AccessTime, active = filter == SessionFilter.Recent, onClick = { filter = SessionFilter.Recent })
FilterPill(text = "Live", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Live, live = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Live })
FilterPill(text = "Current", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Current, showDot = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Current })
}
}
@@ -179,7 +180,7 @@ private fun FilterPill(
text: String,
icon: ImageVector? = null,
active: Boolean = false,
live: Boolean = false,
showDot: Boolean = false,
dropdown: Boolean = false,
onClick: (() -> Unit)? = null,
) {
@@ -198,7 +199,7 @@ private fun FilterPill(
) {
icon?.let { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.text) }
Text(text = text, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
if (live) {
if (showDot) {
Box(modifier = Modifier.size(4.dp).clip(CircleShape).background(ClawTheme.colors.success))
}
if (dropdown) {
@@ -258,7 +259,7 @@ private fun SessionRow(
Text(text = subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
SessionMiniTag(text = "Workspace")
SessionMiniTag(text = if (active) "Active" else "OpenClaw")
SessionMiniTag(text = if (active) "Current" else "OpenClaw")
}
}
}
@@ -273,19 +274,6 @@ private fun SessionRow(
}
}
@Composable
private fun SessionPlainIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun SessionOutlineIconButton(
icon: ImageVector,
@@ -320,21 +308,21 @@ private fun SessionMiniTag(text: String) {
private enum class SessionFilter {
Recent,
Live,
Current,
}
/** 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"
SessionFilter.Current -> "No current 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."
SessionFilter.Current -> "Open Chat to start or resume the current session."
}
/** Formats session timestamps for compact mobile metadata. */

View File

@@ -4,6 +4,7 @@ import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayCronJobSummary
import ai.openclaw.app.GatewayExecApprovalSummary
import ai.openclaw.app.GatewayUsageProviderSummary
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
@@ -14,6 +15,7 @@ import ai.openclaw.app.ui.design.ClawDetailRow
import ai.openclaw.app.ui.design.ClawIconBadge
import ai.openclaw.app.ui.design.ClawListPanel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawSecondaryButton
@@ -90,7 +92,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
@@ -106,6 +107,7 @@ internal enum class SettingsRoute {
Profile,
Voice,
Agents,
ProvidersModels,
Approvals,
CronJobs,
Usage,
@@ -136,6 +138,7 @@ internal fun SettingsDetailScreen(
SettingsRoute.Profile -> ProfileSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Voice -> VoiceSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Agents -> AgentsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.ProvidersModels -> ProvidersModelsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Approvals -> ApprovalsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.CronJobs -> CronJobsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Usage -> UsageSettingsScreen(viewModel = viewModel, onBack = onBack)
@@ -299,29 +302,62 @@ private fun ApprovalsSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val execApprovals by viewModel.execApprovals.collectAsState()
val execApprovalsRefreshing by viewModel.execApprovalsRefreshing.collectAsState()
val execApprovalsErrorText by viewModel.execApprovalsErrorText.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val waitingCount = pendingToolCalls.count { it.isError != true }
val issueCount = pendingToolCalls.count { it.isError == true }
val issueCount = execApprovals.count { it.errorText != null } + pendingToolCalls.count { it.isError == true }
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshExecApprovals()
}
}
SettingsDetailFrame(title = "Approvals", subtitle = "Review actions that need your attention.", icon = Icons.Default.Lock, onBack = onBack) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Pending", waitingCount.toString()),
SettingsMetric("Gateway Pending", execApprovals.size.toString()),
SettingsMetric("Session Activity", pendingToolCalls.size.toString()),
SettingsMetric("Issues", issueCount.toString()),
SettingsMetric("Active Runs", pendingRunCount.toString()),
),
)
if (pendingToolCalls.isEmpty()) {
ClawSecondaryButton(
text = if (execApprovalsRefreshing) "Refreshing" else "Refresh",
onClick = viewModel::refreshExecApprovals,
enabled = isConnected && !execApprovalsRefreshing,
modifier = Modifier.fillMaxWidth(),
)
if (execApprovalsErrorText != null) {
ClawPanel {
Text(text = execApprovalsErrorText ?: "", style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
if (!isConnected) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Nothing needs approval.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "OpenClaw will show action requests here when a session pauses for review.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(text = "Gateway disconnected.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Connect the gateway to load approval requests in the app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
} else if (execApprovals.isEmpty()) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "No gateway approvals.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Exec approval requests will appear here while this phone is connected.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
} else {
ApprovalsPanel(toolCalls = pendingToolCalls)
ExecApprovalsPanel(approvals = execApprovals, onResolve = viewModel::resolveExecApproval)
}
if (pendingToolCalls.isNotEmpty()) {
Text(text = "Session activity", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Chat tool calls waiting in the active session remain visible here.", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
SessionToolCallsPanel(toolCalls = pendingToolCalls)
}
}
}
@@ -820,6 +856,7 @@ private fun GatewaySettingsScreen(
var bootstrapTokenInput by remember { mutableStateOf("") }
var passwordInput by remember { mutableStateOf("") }
var validationText by remember { mutableStateOf<String?>(null) }
var showSetupCodeHelp by remember { mutableStateOf(false) }
SettingsDetailFrame(title = "Gateway", subtitle = "Connection between this phone and OpenClaw.", icon = Icons.Default.Cloud, onBack = onBack) {
SettingsMetricPanel(
@@ -840,7 +877,17 @@ private fun GatewaySettingsScreen(
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Pair New Gateway", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Clear this phone's saved gateway access and scan a fresh setup code.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.fillMaxWidth(), icon = Icons.Default.QrCode2)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.weight(1f), icon = Icons.Default.QrCode2)
ClawSecondaryButton(text = "Setup Code", onClick = { showSetupCodeHelp = !showSetupCodeHelp }, modifier = Modifier.weight(1f), icon = Icons.Default.Info)
}
if (showSetupCodeHelp) {
Text(
text = "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.",
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
}
}
ClawPanel {
@@ -1061,7 +1108,11 @@ internal fun SettingsDetailFrame(
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
item {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
SettingsBackButton(onClick = onBack)
ClawPlainIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
onClick = onBack,
)
Text(text = title, style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
SettingsIconMark(icon = icon)
}
@@ -1098,7 +1149,70 @@ internal data class SettingsMetric(
)
@Composable
private fun ApprovalsPanel(toolCalls: List<ChatPendingToolCall>) {
private fun ExecApprovalsPanel(
approvals: List<GatewayExecApprovalSummary>,
onResolve: (String, String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
approvals.forEach { approval ->
ExecApprovalCard(approval = approval, onResolve = onResolve)
}
}
}
@Composable
private fun ExecApprovalCard(
approval: GatewayExecApprovalSummary,
onResolve: (String, String) -> Unit,
) {
val resolving = approval.resolvingDecision != null
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(9.dp)) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = approval.commandText, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 2, overflow = TextOverflow.Ellipsis)
approval.commandPreview?.let { preview ->
Text(text = preview, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
ClawStatusPill(text = if (resolving) "Sending" else "Review", status = if (resolving) ClawStatus.Warning else ClawStatus.Success)
}
Text(text = execApprovalMetadata(approval), style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 2, overflow = TextOverflow.Ellipsis)
approval.errorText?.let { errorText ->
Text(text = errorText, style = ClawTheme.type.caption, color = ClawTheme.colors.warning)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if ("allow-once" in approval.allowedDecisions) {
ClawPrimaryButton(
text = if (approval.resolvingDecision == "allow-once") "Allowing" else "Allow Once",
onClick = { onResolve(approval.id, "allow-once") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
}
if ("allow-always" in approval.allowedDecisions) {
ClawSecondaryButton(
text = if (approval.resolvingDecision == "allow-always") "Saving" else "Always",
onClick = { onResolve(approval.id, "allow-always") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
}
if ("deny" in approval.allowedDecisions) {
ClawSecondaryButton(
text = if (approval.resolvingDecision == "deny") "Denying" else "Deny",
onClick = { onResolve(approval.id, "deny") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@Composable
private fun SessionToolCallsPanel(toolCalls: List<ChatPendingToolCall>) {
ClawListPanel(items = toolCalls) { toolCall ->
ApprovalListRow(toolCall = toolCall)
}
@@ -1231,6 +1345,30 @@ private fun approvalSubtitle(
return if (minutes < 1) "Waiting for review" else "Waiting ${minutes}m"
}
private fun execApprovalMetadata(approval: GatewayExecApprovalSummary): String {
val target =
when {
approval.host == "node" && approval.nodeId != null -> "Node ${approval.nodeId.take(8)}"
approval.host != null -> approval.host.replaceFirstChar { it.uppercaseChar() }
else -> "Gateway"
}
val agent = approval.agentId?.let { "Agent ${it.take(8)}" }
val age = approval.createdAtMs?.let { "Waiting ${formatApprovalDuration(System.currentTimeMillis() - it)}" }
val expires = approval.expiresAtMs?.let { "Expires ${formatApprovalDuration(it - System.currentTimeMillis())}" }
return listOfNotNull(target, agent, age, expires).joinToString(" · ")
}
private fun formatApprovalDuration(deltaMs: Long): String {
val safeDelta = deltaMs.coerceAtLeast(0L)
val minutes = safeDelta / 60_000L
val hours = minutes / 60L
return when {
minutes < 1 -> "soon"
hours < 1 -> "${minutes}m"
else -> "${hours}h"
}
}
/** 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}"
@@ -1394,15 +1532,6 @@ internal fun SettingsMetricPanel(rows: List<SettingsMetric>) {
}
}
@Composable
private fun SettingsBackButton(onClick: () -> Unit) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun SettingsIconMark(icon: ImageVector) {
Surface(

View File

@@ -1253,16 +1253,6 @@ 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(
containerColor = mobileDanger,
contentColor = Color.White,
disabledContainerColor = mobileDanger.copy(alpha = 0.45f),
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 =

View File

@@ -10,17 +10,24 @@ import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextBadge
import ai.openclaw.app.ui.design.ClawTheme
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -37,6 +44,7 @@ internal fun SkillsSettingsScreen(
val skills = skillsSummary.skills
val readyCount = skills.count { skillReady(it) }
val needsSetupCount = skills.count { skillNeedsSetup(it) }
var selectedSkillKey by remember { mutableStateOf<String?>(null) }
LaunchedEffect(isConnected) {
if (isConnected) {
@@ -44,6 +52,17 @@ internal fun SkillsSettingsScreen(
}
}
selectedSkillKey?.let { skillKey ->
val selectedSkill = skills.firstOrNull { it.skillKey == skillKey }
SkillDetailSettingsScreen(
skill = selectedSkill,
skillKey = skillKey,
isConnected = isConnected,
onBack = { selectedSkillKey = null },
)
return
}
SettingsDetailFrame(
title = "Skills",
subtitle = "Installed capabilities available to OpenClaw.",
@@ -83,25 +102,117 @@ internal fun SkillsSettingsScreen(
Text(text = "Skills installed on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
else -> SkillsPanel(skills = skills)
else -> SkillsPanel(skills = skills, onSkillClick = { selectedSkillKey = it.skillKey })
}
}
}
@Composable
private fun SkillsPanel(skills: List<GatewaySkillSummary>) {
ClawListPanel(items = skills) { skill ->
SkillListRow(skill = skill)
private fun SkillDetailSettingsScreen(
skill: GatewaySkillSummary?,
skillKey: String,
isConnected: Boolean,
onBack: () -> Unit,
) {
BackHandler(onBack = onBack)
SettingsDetailFrame(
title = skill?.name ?: skillKey,
subtitle = "Inspect installed skill capability and setup state.",
icon = Icons.Default.Settings,
onBack = onBack,
) {
skill?.let { summary ->
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Status", skillStatusText(summary)),
SettingsMetric("Source", skillSourceLabel(summary)),
SettingsMetric("Missing", summary.missingCount.toString()),
),
)
SkillSetupPanel(summary)
}
SkillDetailPanel(skill = skill, isConnected = isConnected)
}
}
@Composable
private fun SkillListRow(skill: GatewaySkillSummary) {
private fun SkillSetupPanel(skill: GatewaySkillSummary) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Setup", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = skillConfigurationText(skill), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
@Composable
private fun SkillDetailPanel(
skill: GatewaySkillSummary?,
isConnected: Boolean,
) {
if (!isConnected) {
ClawPanel {
Text(text = "Connect the gateway to load skill details.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
return
}
if (skill == null) {
ClawPanel {
Text(text = "Skill detail is not available in the current skills status.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
return
}
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Skill Key", skill.skillKey),
SettingsMetric("Display", skill.name),
SettingsMetric("Source", skillSourceLabel(skill)),
SettingsMetric("Install Options", skill.installCount.toString()),
),
)
skill.description?.let { description ->
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Description", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = description, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
}
@Composable
private fun SkillsPanel(
skills: List<GatewaySkillSummary>,
onSkillClick: (GatewaySkillSummary) -> Unit,
) {
ClawListPanel(items = skills) { skill ->
SkillListRow(skill = skill, onClick = { onSkillClick(skill) })
}
}
@Composable
private fun SkillListRow(
skill: GatewaySkillSummary,
onClick: () -> Unit,
) {
ClawDetailRow(
title = skill.name,
subtitle = skillSubtitle(skill),
modifier = Modifier.clickable(onClickLabel = "Open skill detail", onClick = onClick),
leading = { ClawTextBadge(text = skillBadge(skill)) },
trailing = { ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) },
trailing = {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = ClawTheme.colors.textSubtle,
)
}
},
)
}
@@ -135,6 +246,15 @@ private fun skillSubtitle(skill: GatewaySkillSummary): String {
return listOfNotNull(skill.description, skillSourceLabel(skill), issue).joinToString(" · ")
}
private fun skillConfigurationText(skill: GatewaySkillSummary): String =
when {
skill.disabled -> "This skill is disabled on the gateway. Android shows detail only; enable or configure it from desktop or CLI."
skill.blockedByAllowlist -> "This skill is blocked by the gateway allowlist. Android can inspect it, but allowlist changes stay on desktop or CLI."
skill.missingCount > 0 -> "This skill needs ${skill.missingCount} setup item(s). Android shows what is installed; setup/config changes stay on desktop or CLI."
!skill.eligible -> "This skill is installed but not currently eligible to run. Use desktop or CLI for configuration changes."
else -> "Ready on this gateway. Android detail is read-only; install, update, and configuration changes stay on desktop or CLI."
}
private fun skillSourceLabel(skill: GatewaySkillSummary): String =
when (skill.source) {
"openclaw-bundled" -> if (skill.bundled) "Built-in" else "Bundled"

View File

@@ -1,8 +1,10 @@
package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.VoiceCaptureMode
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
@@ -68,6 +70,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -177,8 +180,8 @@ fun VoiceScreen(
Modifier
.fillMaxSize()
.imePadding()
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(9.dp),
) {
VoiceHeader(
statusText = voiceAttentionStatus ?: if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.",
@@ -267,12 +270,12 @@ private fun DictationScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = "Dictation", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
Text(text = "Transcribe then send", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
VoicePlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
ClawPlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
}
Surface(
@@ -404,7 +407,7 @@ private fun TalkSessionScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Realtime Talk", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
@@ -423,7 +426,7 @@ private fun TalkSessionScreen(
)
}
}
VoicePlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
ClawPlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
}
Surface(
@@ -547,14 +550,19 @@ private fun VoiceHeader(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
painter = painterResource(id = R.drawable.openclaw_logo),
contentDescription = null,
modifier = Modifier.size(25.dp),
tint = ClawTheme.colors.text,
)
Text(
text = "O P E N C L A W",
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
text = "OpenClaw",
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
)
VoicePlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
VoiceAvatar(text = "OC")
ClawPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
}
Row(
modifier = Modifier.fillMaxWidth(),
@@ -562,7 +570,7 @@ private fun VoiceHeader(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text)
Text(
text = statusText,
style = ClawTheme.type.body,
@@ -571,7 +579,7 @@ private fun VoiceHeader(
overflow = TextOverflow.Ellipsis,
)
}
VoicePlainIconButton(
ClawPlainIconButton(
icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
onClick = onToggleSpeaker,
@@ -580,34 +588,6 @@ private fun VoiceHeader(
}
}
@Composable
private fun VoiceAvatar(text: String) {
Surface(
modifier = Modifier.size(34.dp),
shape = CircleShape,
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
}
}
}
@Composable
private fun VoicePlainIconButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun VoiceHero(
gatewayStatus: String,
@@ -861,8 +841,10 @@ private fun VoiceOrb(
Surface(
modifier = Modifier.size(112.dp),
shape = CircleShape,
color = if (active) ClawTheme.colors.surfacePressed else ClawTheme.colors.surface,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
color = if (active || listening || speaking) Color(0xFF1976D2) else Color(0xFF123B63),
contentColor = Color.White,
tonalElevation = 3.dp,
shadowElevation = 7.dp,
) {
Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -875,7 +857,7 @@ private fun VoiceOrb(
},
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = ClawTheme.colors.text,
tint = Color.White,
)
Waveform(active = active)
}
@@ -892,7 +874,7 @@ private fun Waveform(active: Boolean) {
Modifier
.size(width = 2.dp, height = (if (active) height else 6 + index % 3 * 3).dp)
.clip(RoundedCornerShape(999.dp))
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
.background(if (active) Color.White else Color.White.copy(alpha = 0.52f)),
)
}
}

View File

@@ -1,6 +1,7 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatMessageContent
import ai.openclaw.app.chat.ChatPendingToolCall
@@ -39,6 +40,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Mic
@@ -63,6 +65,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -153,12 +156,11 @@ fun ChatScreen(
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 18.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ChatHeader(
sessionTitle = currentSessionTitle(sessionKey = sessionKey, sessions = sessions),
thinkingLevel = thinkingLevel,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
onMore = {
@@ -261,11 +263,11 @@ private fun ChatSessionSwitcher(
if (sessions.size > choices.size) {
Surface(
onClick = onOpenSessions,
modifier = Modifier.heightIn(min = 36.dp),
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = ClawTheme.colors.canvas,
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f),
contentColor = ClawTheme.colors.textMuted,
border = BorderStroke(1.dp, ClawTheme.colors.border),
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.7f)),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
@@ -288,11 +290,11 @@ private fun ChatSessionChip(
) {
Surface(
onClick = onClick,
modifier = Modifier.heightIn(min = 36.dp),
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = if (active) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised,
contentColor = if (active) ClawTheme.colors.primaryText else ClawTheme.colors.text,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.primary else ClawTheme.colors.border),
color = if (active) ClawTheme.colors.surfacePressed.copy(alpha = 0.9f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f),
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.7f)),
) {
Text(
text = text,
@@ -307,48 +309,56 @@ private fun ChatSessionChip(
@Composable
private fun ChatHeader(
sessionTitle: String,
thinkingLevel: String,
healthOk: Boolean,
pendingRunCount: Int,
onMore: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(modifier = Modifier.size(ClawTheme.spacing.touchTarget))
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
painter = painterResource(id = R.drawable.openclaw_logo),
contentDescription = null,
modifier = Modifier.size(25.dp),
tint = ClawTheme.colors.text,
)
Text(
text = sessionTitle,
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
text = "OpenClaw",
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
ModelPill(
text =
when {
pendingRunCount > 0 -> "Working"
healthOk -> "auto"
else -> "offline"
healthOk -> "Ready"
else -> "Offline"
},
status =
when {
pendingRunCount > 0 -> ClawStatus.Warning
healthOk -> ClawStatus.Neutral
healthOk -> ClawStatus.Success
else -> ClawStatus.Danger
},
)
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
}
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Chat", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, maxLines = 1)
Text(
text = sessionTitle,
style = ClawTheme.type.caption.copy(fontSize = 13.sp, lineHeight = 17.sp),
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
}
}
@@ -365,7 +375,13 @@ private fun ModelPill(
}
Surface(
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = ClawTheme.colors.surfaceRaised,
color =
when (status) {
ClawStatus.Success -> ClawTheme.colors.successSoft
ClawStatus.Warning -> ClawTheme.colors.warningSoft
ClawStatus.Danger -> ClawTheme.colors.dangerSoft
ClawStatus.Neutral -> ClawTheme.colors.surfaceRaised
},
contentColor = ClawTheme.colors.textMuted,
border = BorderStroke(1.dp, borderColor),
) {
@@ -577,13 +593,15 @@ private fun ChatBubble(
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Surface(
modifier = Modifier.fillMaxWidth(if (isUser) 0.64f else 0.56f),
modifier = Modifier.fillMaxWidth(if (isUser) 0.84f else 0.94f),
shape = RoundedCornerShape(7.dp),
color = ClawTheme.colors.surfaceRaised,
color = if (isUser) ClawTheme.colors.surfacePressed.copy(alpha = 0.86f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.84f),
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.45f)),
tonalElevation = 1.dp,
shadowElevation = 2.dp,
) {
Column(modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.5.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text =
when {
@@ -764,7 +782,7 @@ private fun ChatContextMeter(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textSubtle)
Text(
text = contextMeterLabel(contextUsage, thinkingLevel),
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
@@ -936,7 +954,7 @@ internal fun resolveChatContextUsage(
sessionKey = sessionKey,
mainSessionKey = mainSessionKey,
)
}
}
return ChatContextUsage(
totalTokens = entry?.totalTokens,
totalTokensFresh = entry?.totalTokensFresh,
@@ -973,24 +991,6 @@ private fun userFacingChatError(error: String): String {
}
}
/** Normalizes persisted thinking values into compact UI labels. */
private fun thinkingDisplay(value: String): String =
when (value.lowercase(Locale.US)) {
"low" -> "Low"
"medium" -> "Medium"
"high" -> "High"
else -> "Off"
}
/** Converts displayed thinking labels back to gateway request values. */
private fun thinkingValue(display: String): String =
when (display.lowercase(Locale.US)) {
"low" -> "low"
"medium" -> "medium"
"high" -> "high"
else -> "off"
}
/** Cycles through context budget presets from the compact composer control. */
private fun nextThinkingValue(value: String): String =
when (value.lowercase(Locale.US)) {

View File

@@ -185,6 +185,53 @@ internal fun ClawIconButton(
}
}
/** Transparent circular icon button for low-emphasis toolbar actions. */
@Composable
internal fun ClawPlainIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
shape = CircleShape,
color = Color.Transparent,
contentColor = ClawTheme.colors.text,
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
/** Compact label/value row for health and readiness summaries. */
@Composable
internal fun ClawStatusRow(
title: String,
value: String,
healthy: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Text(
text = title,
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
maxLines = 1,
)
ClawStatusPill(
text = value,
status = if (healthy) ClawStatus.Success else ClawStatus.Warning,
)
}
}
/** Compact status chip with a semantic color dot. */
@Composable
internal fun ClawStatusPill(

View File

@@ -95,15 +95,17 @@ internal fun ClawBottomNav(
Box(modifier = modifier.fillMaxWidth().background(ClawTheme.colors.canvas)) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
border = BorderStroke(1.dp, ClawTheme.colors.border),
color = ClawTheme.colors.surface.copy(alpha = 0.92f),
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.42f)),
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
tonalElevation = 2.dp,
shadowElevation = 8.dp,
) {
Row(
modifier =
Modifier
.windowInsetsPadding(safeInsets)
.padding(horizontal = 8.dp, vertical = 8.dp),
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -131,13 +133,13 @@ private fun ClawBottomNavItem(
onClick = onClick,
modifier = modifier.heightIn(min = 48.dp),
shape = RoundedCornerShape(ClawTheme.radii.control),
color = if (selected) ClawTheme.colors.primary else Color.Transparent,
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
color = if (selected) ClawTheme.colors.surfacePressed.copy(alpha = 0.72f) else Color.Transparent,
contentColor = if (selected) ClawTheme.colors.text else ClawTheme.colors.textMuted,
) {
Column(
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Icon(imageVector = item.icon, contentDescription = item.label, modifier = Modifier.size(18.dp))
Text(text = item.label, style = ClawTheme.type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)

View File

@@ -27,31 +27,11 @@ internal fun ClawPanel(
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.surfaceRaised,
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.82f),
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(contentPadding)) {
content()
}
}
}
/**
* Bottom-sheet container with the app surface treatment and top-only rounding.
*/
@Composable
internal fun ClawSheetSurface(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(18.dp),
content: @Composable () -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
color = ClawTheme.colors.surface,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
border = null,
tonalElevation = 2.dp,
shadowElevation = 4.dp,
) {
Column(modifier = Modifier.padding(contentPadding)) {
content()

View File

@@ -4,7 +4,6 @@ import ai.openclaw.app.ui.LocalMobileColors
import ai.openclaw.app.ui.darkMobileColors
import ai.openclaw.app.ui.lightMobileColors
import ai.openclaw.app.ui.mobileFontFamily
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
@@ -190,12 +189,6 @@ internal fun ClawDesignTheme(
}
}
/**
* Returns the system dark-mode preference for callers that expose theme selection.
*/
@Composable
internal fun rememberClawDarkPreference(): Boolean = isSystemInDarkTheme()
private fun clawTypography(fontFamily: FontFamily) =
ClawTypography(
display =

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.ChatSendAck
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
@@ -43,7 +44,7 @@ data class VoiceConversationEntry(
)
/** Coordinates live mic transcription, queued sends, and assistant audio replies. */
class MicCaptureManager(
internal class MicCaptureManager(
private val context: Context,
private val scope: CoroutineScope,
private val createTranscriptionSession: suspend () -> String,
@@ -54,11 +55,12 @@ class MicCaptureManager(
) -> Unit,
private val closeTranscriptionSession: suspend (sessionId: String) -> Unit,
/**
* Send [message] to the gateway and return the run ID.
* Send [message] to the gateway and return the full chat.send ACK.
* [onRunIdKnown] is called with the idempotency key *before* the network
* round-trip so [pendingRunId] is set before any chat events can arrive.
*/
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> String?,
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> ChatSendAck,
private val refreshAfterTerminalSuccess: suspend () -> Unit = {},
private val speakAssistantReply: suspend (String) -> Unit = {},
) {
companion object {
@@ -483,24 +485,30 @@ class MicCaptureManager(
scope.launch {
try {
val runId =
val ack =
sendToGateway(next) { earlyRunId ->
// Called with the idempotency key before chat.send fires so that
// pendingRunId is populated before any chat events can arrive.
pendingRunId = earlyRunId
}
val runId = ack.runId
// Update to the real runId if the gateway returned a different one.
if (runId != null && runId != pendingRunId) pendingRunId = runId
if (runId == null) {
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
removeFirstQueuedMessage()
publishQueue()
_isSending.value = false
pendingAssistantEntryId = null
sendQueuedIfIdle()
} else {
armPendingRunTimeout(runId)
when {
ack.isTerminalSuccess -> {
completePendingTurn()
refreshAfterTerminalSuccess()
}
ack.isTerminalFailure -> {
completePendingTurn()
_statusText.value = "Send failed: Chat failed before the run started; try again."
}
runId == null -> {
completePendingTurn()
}
else -> {
armPendingRunTimeout(runId)
}
}
} catch (err: Throwable) {
pendingRunTimeoutJob?.cancel()

View File

@@ -1,6 +1,9 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.ChatSendAck
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.chatSendAckHistorySinceSeconds
import ai.openclaw.app.gateway.parseChatSendAck
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
@@ -108,7 +111,6 @@ class TalkModeManager internal constructor(
private const val tag = "TalkMode"
private const val realtimeSampleRateHz = 24_000
private const val realtimeAudioFrameMs = 100
private const val listenWatchdogMs = 12_000L
private const val chatFinalWaitMs = 45_000L
private const val maxCachedRunCompletions = 128
private const val maxConversationEntries = 40
@@ -381,11 +383,20 @@ class TalkModeManager internal constructor(
reloadConfig()
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
val prompt = buildPrompt(command)
val runId = sendChat(prompt, session)
val ok = waitForChatFinal(runId)
val ack = sendChat(prompt, session)
val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id")
if (ack.isTerminalFailure) {
_statusText.value = if (ack.normalizedStatus == "error") "Chat error" else "Aborted"
return@launch
}
val ok = if (ack.isTerminalSuccess) true else waitForChatFinal(runId)
val assistant =
consumeRunText(runId)
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
?: waitForAssistantText(
session,
chatSendAckHistorySinceSeconds(ack, startedAt),
if (ok) 12_000 else 25_000,
)
if (!assistant.isNullOrBlank()) {
val playbackToken = playbackGeneration.incrementAndGet()
cancelActivePlayback()
@@ -398,8 +409,9 @@ class TalkModeManager internal constructor(
}
} catch (err: Throwable) {
Log.w(tag, "speakWakeCommand failed: ${err.message}")
} finally {
onComplete()
}
onComplete()
}
}
@@ -1604,16 +1616,26 @@ class TalkModeManager internal constructor(
try {
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}")
val runId = sendChat(prompt, session)
Log.d(tag, "chat.send ok runId=$runId")
val ok = waitForChatFinal(runId)
val ack = sendChat(prompt, session)
val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id")
Log.d(tag, "chat.send ok runId=$runId status=${ack.status}")
if (ack.isTerminalFailure) {
_statusText.value = if (ack.normalizedStatus == "error") "Chat error" else "Aborted"
start()
return
}
val ok = if (ack.isTerminalSuccess) true else waitForChatFinal(runId)
if (!ok) {
Log.w(tag, "chat final timeout runId=$runId; attempting history fallback")
}
// Use text cached from the final event first — avoids chat.history polling
val assistant =
consumeRunText(runId)
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
?: waitForAssistantText(
session,
chatSendAckHistorySinceSeconds(ack, startedAt),
if (ok) 12_000 else 25_000,
)
if (assistant.isNullOrBlank()) {
_statusText.value = "No reply"
Log.w(tag, "assistant text timeout runId=$runId")
@@ -1679,7 +1701,7 @@ class TalkModeManager internal constructor(
private suspend fun sendChat(
message: String,
session: GatewaySession,
): String {
): ChatSendAck {
val runId = UUID.randomUUID().toString()
armPendingRun(runId)
val params =
@@ -1692,11 +1714,15 @@ class TalkModeManager internal constructor(
}
try {
val res = session.request("chat.send", params.toString())
val parsed = parseRunId(res) ?: runId
if (parsed != runId) {
pendingRunId = parsed
val parsed = parseChatSendAck(json, res)
val actualRunId = parsed.runId ?: runId
if (actualRunId != runId) {
pendingRunId = actualRunId
}
return parsed
if (parsed.isTerminal) {
clearPendingRun(actualRunId)
}
return parsed.copy(runId = actualRunId)
} catch (err: Throwable) {
clearPendingRun(runId)
throw err
@@ -1777,7 +1803,7 @@ class TalkModeManager internal constructor(
private suspend fun waitForAssistantText(
session: GatewaySession,
sinceSeconds: Double,
sinceSeconds: Double?,
timeoutMs: Long,
): String? {
val deadline = SystemClock.elapsedRealtime() + timeoutMs

View File

@@ -3,13 +3,11 @@
<uses-permission
android:name="android.permission.READ_MEDIA_IMAGES"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_MEDIA_VIDEO"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:node="remove" />
</manifest>

View File

@@ -33,44 +33,44 @@ class GatewayBootstrapAuthTest {
@Test
fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() {
assertFalse(
shouldConnectOperatorSession(
resolveOperatorSessionConnectAuth(
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
storedOperatorToken = "",
),
) != null,
)
assertFalse(
shouldConnectOperatorSession(
resolveOperatorSessionConnectAuth(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
),
) != null,
)
}
@Test
fun connectsOperatorSessionWhenSharedPasswordOrStoredAuthExists() {
assertTrue(
shouldConnectOperatorSession(
resolveOperatorSessionConnectAuth(
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
),
) != null,
)
assertTrue(
shouldConnectOperatorSession(
resolveOperatorSessionConnectAuth(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = "shared-password"),
storedOperatorToken = null,
),
) != null,
)
assertTrue(
shouldConnectOperatorSession(
resolveOperatorSessionConnectAuth(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = "stored-token",
),
) != null,
)
assertTrue(
shouldConnectOperatorSession(
resolveOperatorSessionConnectAuth(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null),
storedOperatorToken = null,
),
) != null,
)
}

View File

@@ -0,0 +1,101 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GatewayExecApprovalParsingTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun parsesGatewayExecApprovalListPayload() {
val rows =
parseGatewayExecApprovalListPayload(
"""
[
{
"id": "approval-2",
"createdAtMs": 20,
"expiresAtMs": 120,
"request": {
"host": "node",
"nodeId": "node-1",
"agentId": "agent-1",
"command": "Sanitized command",
"commandPreview": "Sanitized preview",
"systemRunPlan": {
"commandText": "/bin/sh -lc 'echo secret'",
"commandPreview": "echo secret"
},
"allowedDecisions": ["allow-once", "deny"]
}
},
{
"id": "approval-1",
"createdAtMs": 10,
"expiresAtMs": 110,
"request": {
"host": "gateway",
"command": "pnpm test --token secret",
"commandPreview": "pnpm test",
"unavailableDecisions": ["allow-always"]
}
}
]
""".trimIndent(),
json,
)
assertEquals(listOf("approval-1", "approval-2"), rows.map { it.id })
assertEquals("pnpm test --token secret", rows[0].commandText)
assertEquals("pnpm test", rows[0].commandPreview)
assertEquals(emptyList<String>(), rows[0].allowedDecisions)
assertEquals("Sanitized command", rows[1].commandText)
assertEquals("Sanitized preview", rows[1].commandPreview)
assertEquals("node-1", rows[1].nodeId)
assertEquals("agent-1", rows[1].agentId)
}
@Test
fun parsesGatewayExecApprovalGetPayload() {
val root =
json
.parseToJsonElement(
"""
{
"id": "approval-1",
"commandText": "rm -rf build",
"commandPreview": "rm build",
"allowedDecisions": ["allow-once", "allow-always", "deny"],
"host": "gateway",
"nodeId": null,
"agentId": "agent-main",
"expiresAtMs": 200
}
""".trimIndent(),
).asObjectOrNull()
requireNotNull(root)
val row = parseGatewayExecApprovalDetail(root, createdAtMs = 100)
requireNotNull(row)
assertEquals("approval-1", row.id)
assertEquals("rm -rf build", row.commandText)
assertEquals("rm build", row.commandPreview)
assertEquals(listOf("allow-once", "allow-always", "deny"), row.allowedDecisions)
assertEquals("gateway", row.host)
assertNull(row.nodeId)
assertEquals("agent-main", row.agentId)
assertEquals(100L, row.createdAtMs)
assertEquals(200L, row.expiresAtMs)
}
@Test
fun ignoresMalformedGatewayExecApprovalListPayload() {
assertTrue(parseGatewayExecApprovalListPayload("""{"approvals":[]}""", json).isEmpty())
assertTrue(parseGatewayExecApprovalListPayload("not json", json).isEmpty())
}
}

View File

@@ -0,0 +1,46 @@
package ai.openclaw.app
import org.junit.Assert.assertEquals
import org.junit.Test
class GatewayLogTextTest {
@Test
fun sanitizeGatewayLogTextRemovesAnsiSgrSequences() {
assertEquals(
"hindsight: Skipping retain",
sanitizeGatewayLogText("\u001B[38;5;103mhindsight:\u001B[0m Skipping retain"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesVisibleSgrFragments() {
assertEquals(
"hindsight: Skipping retain",
sanitizeGatewayLogText("[38;5;103mhindsight:[0m Skipping retain"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesSingleParameterVisibleSgrFragments() {
assertEquals(
"error and bold",
sanitizeGatewayLogText("[31merror[0m and [1mbold[0m"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesJsonEscapedAnsiSgrSequences() {
assertEquals(
"""{"1":"hindsight: Skipping retain"}""",
sanitizeGatewayLogText("""{"1":"\u001b[38;5;103mhindsight:\u001b[0m Skipping retain"}"""),
)
}
@Test
fun sanitizeGatewayLogTextKeepsPlainBracketedText() {
assertEquals(
"cache ttl [5m] expired",
sanitizeGatewayLogText("cache ttl [5m] expired"),
)
}
}

View File

@@ -0,0 +1,144 @@
package ai.openclaw.app.chat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatControllerTerminalAckTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalTimeoutAckRemovesOptimisticUserEchoAndSurfacesFailedAcceptance() =
runTest {
var requestedMethod: String? = null
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
requestedMethod = method
"""{"runId":"run-timeout","status":"timeout"}"""
},
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that times out before start",
thinkingLevel = "off",
attachments = emptyList(),
)
assertFalse(accepted)
assertEquals("chat.send", requestedMethod)
assertEquals(0, controller.pendingRunCount.value)
assertEquals("Chat failed before the run started; try again.", controller.errorText.value)
assertFalse(controller.messages.value.hasUserText("message that times out before start"))
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun nonTerminalStartedAckRetainsOptimisticUserEchoAndPendingRun() =
runTest {
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { _, _ -> """{"runId":"run-started","status":"started"}""" },
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that started",
thinkingLevel = "off",
attachments = emptyList(),
)
assertTrue(accepted)
assertEquals(1, controller.pendingRunCount.value)
assertNull(controller.errorText.value)
assertTrue(controller.messages.value.hasUserText("message that started"))
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalOkAckClearsOptimisticUserEchoAndRefreshesHistory() =
runTest {
val requestedMethods = mutableListOf<String>()
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
requestedMethods += method
when (method) {
"chat.send" -> """{"runId":"run-ok","status":"ok"}"""
"chat.history" ->
"""
{
"sessionId": "session-1",
"messages": [
{ "role": "assistant", "content": "cached success reply", "timestamp": 1 }
]
}
""".trimIndent()
else -> "{}"
}
},
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that already completed",
thinkingLevel = "off",
attachments = emptyList(),
)
advanceUntilIdle()
assertTrue(accepted)
assertEquals(listOf("chat.send", "chat.history"), requestedMethods)
assertEquals(0, controller.pendingRunCount.value)
assertNull(controller.errorText.value)
assertFalse(controller.messages.value.hasUserText("message that already completed"))
assertTrue(controller.messages.value.any { message -> message.role == "assistant" && message.content.any { part -> part.text == "cached success reply" } })
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalErrorAckRemovesOptimisticUserEchoAndSurfacesErrorText() =
runTest {
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { _, _ -> """{"runId":"run-error","status":"error"}""" },
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that errors before start",
thinkingLevel = "off",
attachments = emptyList(),
)
assertFalse(accepted)
assertEquals(0, controller.pendingRunCount.value)
assertEquals("Chat failed before the run started; try again.", controller.errorText.value)
assertFalse(controller.messages.value.hasUserText("message that errors before start"))
}
private fun List<ChatMessage>.hasUserText(text: String): Boolean =
any { message ->
message.role == "user" && message.content.any { part -> part.text == text }
}
}

View File

@@ -0,0 +1,68 @@
package ai.openclaw.app.gateway
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatSendAckTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun parseChatSendAckPreservesNonTerminalStartedStatus() {
val ack = parseChatSendAck(json, """{"runId":"run-1","status":"started"}""")
assertEquals("run-1", ack.runId)
assertEquals("started", ack.normalizedStatus)
assertFalse(ack.isTerminal)
}
@Test
fun parseChatSendAckMarksOkAsTerminalSuccess() {
val ack = parseChatSendAck(json, """{"runId":"run-ok","status":" ok "}""")
assertEquals("run-ok", ack.runId)
assertEquals("ok", ack.normalizedStatus)
assertTrue(ack.isTerminal)
assertTrue(ack.isTerminalSuccess)
assertFalse(ack.isTerminalFailure)
}
@Test
fun parseChatSendAckMarksTimeoutAndErrorAsTerminalFailures() {
val timeout = parseChatSendAck(json, """{"runId":"run-timeout","status":"timeout"}""")
val error = parseChatSendAck(json, """{"runId":"run-error","status":" error "}""")
assertEquals("run-timeout", timeout.runId)
assertTrue(timeout.isTerminal)
assertFalse(timeout.isTerminalSuccess)
assertTrue(timeout.isTerminalFailure)
assertEquals("run-error", error.runId)
assertTrue(error.isTerminal)
assertFalse(error.isTerminalSuccess)
assertTrue(error.isTerminalFailure)
}
@Test
fun cachedOkAckUsesUnfilteredHistoryFallback() {
val startedAt = 123.0
val ok = parseChatSendAck(json, """{"runId":"run-ok","status":"ok"}""")
val started = parseChatSendAck(json, """{"runId":"run-started","status":"started"}""")
assertNull(chatSendAckHistorySinceSeconds(ok, startedAt))
assertEquals(startedAt, chatSendAckHistorySinceSeconds(started, startedAt) ?: -1.0, 0.0)
}
@Test
fun parseChatSendAckToleratesMalformedPayloads() {
val ack = parseChatSendAck(json, "not-json")
assertNull(ack.runId)
assertEquals("", ack.normalizedStatus)
assertFalse(ack.isTerminal)
assertFalse(ack.isTerminalSuccess)
assertFalse(ack.isTerminalFailure)
}
}

View File

@@ -204,17 +204,18 @@ class GatewayConfigResolverTest {
}
@Test
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
fun resolveScannedSetupCodeResultAcceptsRawSetupCode() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCode(setupCode)
val resolved = resolveScannedSetupCodeResult(setupCode)
assertEquals(setupCode, resolved)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
}
@Test
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
fun resolveScannedSetupCodeResultAcceptsQrJsonPayload() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val qrJson =
@@ -227,49 +228,55 @@ class GatewayConfigResolverTest {
}
""".trimIndent()
val resolved = resolveScannedSetupCode(qrJson)
val resolved = resolveScannedSetupCodeResult(qrJson)
assertEquals(setupCode, resolved)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
}
@Test
fun resolveScannedSetupCodeRejectsInvalidInput() {
val resolved = resolveScannedSetupCode("not-a-valid-setup-code")
assertNull(resolved)
fun resolveScannedSetupCodeResultRejectsInvalidInput() {
val resolved = resolveScannedSetupCodeResult("not-a-valid-setup-code")
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
}
@Test
fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() {
fun resolveScannedSetupCodeResultRejectsJsonWithInvalidSetupCode() {
val qrJson = """{"setupCode":"invalid"}"""
val resolved = resolveScannedSetupCode(qrJson)
assertNull(resolved)
val resolved = resolveScannedSetupCodeResult(qrJson)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
}
@Test
fun resolveScannedSetupCodeRejectsJsonWithNonStringSetupCode() {
fun resolveScannedSetupCodeResultRejectsJsonWithNonStringSetupCode() {
val qrJson = """{"setupCode":{"nested":"value"}}"""
val resolved = resolveScannedSetupCode(qrJson)
assertNull(resolved)
val resolved = resolveScannedSetupCodeResult(qrJson)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
}
@Test
fun resolveScannedSetupCodeRejectsNonLoopbackCleartextGateway() {
fun resolveScannedSetupCodeResultRejectsNonLoopbackCleartextGateway() {
val setupCode =
encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCode(setupCode)
val resolved = resolveScannedSetupCodeResult(setupCode)
assertNull(resolved)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, resolved.error)
}
@Test
fun resolveScannedSetupCodeAcceptsPrivateLanCleartextGateway() {
fun resolveScannedSetupCodeResultAcceptsPrivateLanCleartextGateway() {
val setupCode =
encodeSetupCode("""{"url":"ws://192.168.31.100:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCode(setupCode)
val resolved = resolveScannedSetupCodeResult(setupCode)
assertEquals(setupCode, resolved)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
}
@Test

View File

@@ -1,12 +1,14 @@
package ai.openclaw.app.ui
import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayChannelSummary
import ai.openclaw.app.GatewayChannelsSummary
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPendingDeviceSummary
import ai.openclaw.app.ui.design.ClawStatus
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import org.junit.Assert.assertEquals
@@ -105,7 +107,7 @@ class ShellScreenLogicTest {
assertEquals(listOf("Approvals", "Channels", "Nodes & Devices", "Providers"), rows.map { it.title })
val providersRow = rows.single { it.title == "Providers" }
assertEquals(Tab.Settings, providersRow.tab)
assertEquals(SettingsRoute.Gateway, providersRow.settingsRoute)
assertEquals(SettingsRoute.ProvidersModels, providersRow.settingsRoute)
}
@Test
@@ -157,10 +159,206 @@ class ShellScreenLogicTest {
assertEquals("Node approval pending", rows.single().subtitle)
}
@Test
fun overviewHeaderStateReflectsGatewayConnectionAndAttention() {
assertEquals(OverviewHeaderState("Offline", ClawStatus.Neutral), overviewHeaderState(isConnected = false, hasAttention = true))
assertEquals(OverviewHeaderState("Needs attention", ClawStatus.Warning), overviewHeaderState(isConnected = true, hasAttention = true))
assertEquals(OverviewHeaderState("Online", ClawStatus.Success), overviewHeaderState(isConnected = true, hasAttention = false))
}
@Test
fun overviewHeaderRouteUsesFirstAttentionDestination() {
assertEquals(SettingsRoute.Gateway, overviewHeaderRoute(emptyList()))
assertEquals(
SettingsRoute.Approvals,
overviewHeaderRoute(
listOf(
HomeAttentionRow("Approvals", "2 pending", Icons.Default.Settings, Tab.Settings, SettingsRoute.Approvals),
HomeAttentionRow("Nodes & Devices", "Review node access", Icons.Default.Settings, Tab.Settings, SettingsRoute.NodesDevices),
),
),
)
}
@Test
fun overviewMetricCardsUseRealGatewayNodeApprovalAndSessionCounts() {
val cards =
overviewMetricCardSpecs(
isConnected = true,
hasAttention = true,
nodesDevicesSummary =
GatewayNodesDevicesSummary(
nodes =
listOf(
GatewayNodeSummary(
id = "android-node",
displayName = "Android",
remoteIp = null,
version = null,
deviceFamily = "Android",
paired = true,
connected = true,
approvalState = GatewayNodeApprovalState.PendingReapproval,
pendingRequestId = "node-request",
capabilities = emptyList(),
commands = emptyList(),
),
),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
),
pendingApprovals = 2,
sessionCount = 4,
)
assertEquals(listOf("Gateway", "Nodes", "Approvals", "Sessions"), cards.map { it.title })
assertEquals("Online", cards.single { it.title == "Gateway" }.value)
assertEquals("Review highlighted items", cards.single { it.title == "Gateway" }.subtitle)
assertEquals("1/1", cards.single { it.title == "Nodes" }.value)
assertEquals("Review node access", cards.single { it.title == "Nodes" }.subtitle)
assertEquals(ClawStatus.Warning, cards.single { it.title == "Nodes" }.status)
assertEquals(1f, cards.single { it.title == "Nodes" }.progressFraction ?: 0f, 0.001f)
assertEquals("2", cards.single { it.title == "Approvals" }.value)
assertEquals("4", cards.single { it.title == "Sessions" }.value)
}
@Test
fun overviewNodeCardShowsRoundedOnlinePercentWhenNoNodeApprovalIsPending() {
val cards =
overviewMetricCardSpecs(
isConnected = true,
hasAttention = false,
nodesDevicesSummary =
GatewayNodesDevicesSummary(
nodes =
(1..3).map { index ->
GatewayNodeSummary(
id = "node-$index",
displayName = "Node $index",
remoteIp = null,
version = null,
deviceFamily = null,
paired = true,
connected = index <= 2,
approvalState = GatewayNodeApprovalState.Approved,
pendingRequestId = null,
capabilities = emptyList(),
commands = emptyList(),
)
},
pendingDevices = emptyList(),
pairedDevices = emptyList(),
),
pendingApprovals = 0,
sessionCount = 0,
)
val nodes = cards.single { it.title == "Nodes" }
assertEquals("2/3", nodes.value)
assertEquals("67% online", nodes.subtitle)
assertEquals(2f / 3f, nodes.progressFraction ?: 0f, 0.001f)
}
@Test
fun overviewGatewayCardOnlyClaimsNominalWhenNoAttentionExists() {
val cards =
overviewMetricCardSpecs(
isConnected = true,
hasAttention = false,
nodesDevicesSummary = emptyNodesDevices(),
pendingApprovals = 0,
sessionCount = 0,
)
val gateway = cards.single { it.title == "Gateway" }
assertEquals("Healthy", gateway.value)
assertEquals("All systems nominal", gateway.subtitle)
assertEquals(ClawStatus.Success, gateway.status)
}
@Test
fun overviewAgentNameUsesDefaultAgentWhenPresent() {
val agents =
listOf(
GatewayAgentSummary(id = "main", name = "Main", emoji = null),
GatewayAgentSummary(id = "scout", name = "Scout", emoji = "🦾"),
)
assertEquals("Scout", overviewAgentName(agents = agents, defaultAgentId = "scout"))
assertEquals("Main", overviewAgentName(agents = agents, defaultAgentId = null))
assertEquals("OpenClaw", overviewAgentName(agents = emptyList(), defaultAgentId = null))
}
@Test
fun overviewAgentBadgeUsesEmojiBeforeInitials() {
val agents =
listOf(
GatewayAgentSummary(id = "main", name = "Main Agent", emoji = null),
GatewayAgentSummary(id = "scout", name = "Scout", emoji = "🦾"),
)
assertEquals("🦾", overviewAgentBadgeText(agents = agents, defaultAgentId = "scout"))
assertEquals("MA", overviewAgentBadgeText(agents = agents, defaultAgentId = "main"))
assertEquals("OC", overviewAgentBadgeText(agents = emptyList(), defaultAgentId = null))
}
@Test
fun overviewAgentActivityTextUsesRealRuntimeCounts() {
assertEquals(
"Working · 2 active runs",
overviewAgentActivityText(isConnected = true, pendingRunCount = 2, sessionCount = 50, cronJobCount = 19, statusText = "Online and ready"),
)
assertEquals(
"Monitoring · 50 sessions",
overviewAgentActivityText(isConnected = true, pendingRunCount = 0, sessionCount = 50, cronJobCount = 19, statusText = "Online and ready"),
)
assertEquals(
"Gateway offline",
overviewAgentActivityText(isConnected = false, pendingRunCount = 0, sessionCount = 50, cronJobCount = 19, statusText = "Gateway offline"),
)
}
@Test
fun sessionSourceLabelDerivesCompactSourceFromRealSessionKey() {
assertEquals("Telegram", sessionSourceLabel("telegram:8227096397"))
assertEquals("Discord", sessionSourceLabel("discord:1465779285020381361#daily-inf"))
assertEquals("Cron", sessionSourceLabel("Cron: nightly-reflection"))
assertEquals("Telegram", sessionSourceLabel("agent:main:telegram:direct:584667058"))
assertEquals("Discord", sessionSourceLabel("agent:main:discord:channel:1001"))
assertEquals("Slack", sessionSourceLabel("agent:main:slack:channel:C123"))
assertEquals("OpenClaw", sessionSourceLabel("agent:main:node-android"))
assertEquals("OpenClaw", sessionSourceLabel("agent:main:main"))
assertEquals("OpenClaw", sessionSourceLabel("Daily standup"))
}
@Test
fun sessionSourceLabelUsesGatewayChannelLabelsForFutureSources() {
val channels =
GatewayChannelsSummary(
channels =
listOf(
GatewayChannelSummary(
id = "matrix",
label = "Matrix",
accountCount = 1,
enabled = true,
configured = true,
linked = true,
running = true,
connected = true,
error = null,
),
),
)
assertEquals("Matrix", sessionSourceLabel("agent:main:matrix:room:abc", channels))
}
@Test
fun settingsSectionTitlesGroupPowerSettingsByMeaning() {
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.Gateway))
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.NodesDevices))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.ProvidersModels))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.Approvals))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.CronJobs))
assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.PhoneCapabilities))

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.ChatSendAck
import android.Manifest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
@@ -34,7 +35,7 @@ class MicCaptureManagerTest {
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-1")
null
ChatSendAck(runId = "run-1", status = "started")
},
)
@@ -84,7 +85,7 @@ class MicCaptureManagerTest {
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-1")
"run-1"
ChatSendAck(runId = "run-1", status = "started")
},
)
@@ -111,7 +112,7 @@ class MicCaptureManagerTest {
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-voice-e2e")
"run-voice-e2e"
ChatSendAck(runId = "run-voice-e2e", status = "started")
},
)
@@ -134,6 +135,88 @@ class MicCaptureManagerTest {
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalGatewayTimeoutSendDoesNotAcceptDelayedOldRunEvents() =
runTest {
val manager =
createManager(
scope = this,
sendToGateway = { _, onRunIdKnown ->
onRunIdKnown("run-terminal")
ChatSendAck(runId = "run-terminal", status = "timeout")
},
)
manager.onGatewayConnectionChanged(true)
manager.submitTranscribedMessage("terminal ack message")
runCurrent()
assertNull(privateField<String?>(manager, "pendingRunId"))
assertEquals(false, manager.isSending.value)
assertEquals("Send failed: Chat failed before the run started; try again.", manager.statusText.value)
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-terminal", text = "stale reply"))
advanceUntilIdle()
assertEquals(
listOf(VoiceConversationRole.User),
manager.conversation.value.map { it.role },
)
assertEquals(
"terminal ack message",
manager.conversation.value
.single()
.text,
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalGatewayErrorSurfacesFailureWithoutWaitingForRunEvents() =
runTest {
val manager =
createManager(
scope = this,
sendToGateway = { _, onRunIdKnown ->
onRunIdKnown("run-error")
ChatSendAck(runId = "run-error", status = "error")
},
)
manager.onGatewayConnectionChanged(true)
manager.submitTranscribedMessage("terminal error message")
runCurrent()
assertNull(privateField<String?>(manager, "pendingRunId"))
assertEquals(false, manager.isSending.value)
assertEquals("Send failed: Chat failed before the run started; try again.", manager.statusText.value)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalGatewayOkRefreshesHistoryWithoutWaitingForRunEvents() =
runTest {
var refreshCalls = 0
val manager =
createManager(
scope = this,
sendToGateway = { _, onRunIdKnown ->
onRunIdKnown("run-ok")
ChatSendAck(runId = "run-ok", status = "ok")
},
refreshAfterTerminalSuccess = { refreshCalls += 1 },
)
manager.onGatewayConnectionChanged(true)
manager.submitTranscribedMessage("terminal ok message")
runCurrent()
assertNull(privateField<String?>(manager, "pendingRunId"))
assertEquals(false, manager.isSending.value)
assertEquals(1, refreshCalls)
}
@Test
fun pcm16FramesAreEncodedAsPcmuFrames() {
val manager = createManager()
@@ -230,10 +313,11 @@ class MicCaptureManagerTest {
scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
createTranscriptionSession: suspend () -> String = { "transcription-1" },
closeTranscriptionSession: suspend (String) -> Unit = { _ -> },
sendToGateway: suspend (String, (String) -> Unit) -> String? = { _, onRunIdKnown ->
sendToGateway: suspend (String, (String) -> Unit) -> ChatSendAck = { _, onRunIdKnown ->
onRunIdKnown("run-1")
"run-1"
ChatSendAck(runId = "run-1", status = "started")
},
refreshAfterTerminalSuccess: suspend () -> Unit = {},
): MicCaptureManager =
MicCaptureManager(
context =
@@ -245,6 +329,7 @@ class MicCaptureManagerTest {
appendTranscriptionAudio = { _, _, _ -> },
closeTranscriptionSession = closeTranscriptionSession,
sendToGateway = sendToGateway,
refreshAfterTerminalSuccess = refreshAfterTerminalSuccess,
)
private fun setPrivateField(

View File

@@ -5,7 +5,7 @@ plugins {
android {
namespace = "ai.openclaw.app.benchmark"
compileSdk = 37
compileSdk = 36
defaultConfig {
minSdk = 31

View File

@@ -4,7 +4,7 @@ androidx-activity = "1.13.0"
androidx-benchmark = "1.4.1"
androidx-camera = "1.6.0"
androidx-compose-bom = "2026.05.01"
androidx-core = "1.19.0"
androidx-core = "1.18.0"
androidx-exifinterface = "1.4.2"
androidx-lifecycle = "2.10.0"
androidx-security = "1.1.0"

View File

@@ -164,7 +164,7 @@ run_mode() {
no_connect_flag=false
fi
adb shell am broadcast \
adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 \
-a "$RUN_ACTION" \
-n "$RECEIVER" \
--es mode "$test_mode" \
@@ -224,7 +224,7 @@ adb logcat -d -v time |
tail -250 >"$ARTIFACT_DIR/logcat.txt" || true
if [[ "$CLEANUP" -eq 1 ]]; then
adb shell am broadcast -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
fi
echo "$ARTIFACT_DIR"

View File

@@ -2,8 +2,24 @@ parent_config: ../../config/swiftlint.yml
included:
- Sources
- ../shared/ClawdisNodeKit/Sources
- ShareExtension
- ActivityWidget
- WatchApp
- ../shared/OpenClawKit/Sources/OpenClawChatUI
excluded:
- ../macos
type_body_length:
warning: 900
error: 1300
custom_rules:
openclaw_design_colors:
name: "OpenClaw design colors"
excluded:
- Sources/Design/OpenClawBrand.swift
- ../shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift
regex: '(Color\.accentColor|(^|[^A-Za-z0-9_])\.accentColor\b|Color\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|\.(foregroundStyle|tint|fill|stroke|strokeBorder|background)\(\s*\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|Color\(red:\s*0\s*/\s*255\.0,\s*green:\s*122\s*/\s*255\.0,\s*blue:\s*255\s*/\s*255\.0\))'
message: "Use OpenClawBrand or OpenClawChatTheme design tokens instead of raw accent/status colors."
severity: error

View File

@@ -74,23 +74,30 @@ struct OpenClawLiveActivity: Widget {
private func statusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
if state.isConnecting {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(.cyan)
.foregroundStyle(OpenClawActivityStyle.info)
} else if state.isDisconnected {
Image(systemName: "wifi.slash")
.foregroundStyle(.red)
.foregroundStyle(OpenClawActivityStyle.danger)
} else if state.isIdle {
Image(systemName: "checkmark")
.foregroundStyle(.green)
.foregroundStyle(OpenClawActivityStyle.ok)
} else {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.foregroundStyle(OpenClawActivityStyle.warn)
}
}
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
if state.isDisconnected { return .red }
if state.isConnecting { return .cyan }
if state.isIdle { return .green }
return .orange
if state.isDisconnected { return OpenClawActivityStyle.danger }
if state.isConnecting { return OpenClawActivityStyle.info }
if state.isIdle { return OpenClawActivityStyle.ok }
return OpenClawActivityStyle.warn
}
}
private enum OpenClawActivityStyle {
static let info = Color(red: 0, green: 122 / 255.0, blue: 1)
static let danger = Color(red: 185 / 255.0, green: 28 / 255.0, blue: 28 / 255.0)
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
}

View File

@@ -12,7 +12,7 @@
"platform": "IOS",
"profileKey": "OPENCLAW_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS", "APP_ATTEST"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
},
{

View File

@@ -6,6 +6,7 @@ OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_CODE_SIGN_ENTITLEMENTS = Sources/OpenClaw.entitlements
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp

View File

@@ -67,9 +67,9 @@ Release behavior:
- App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`.
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, and a production `aps-environment` entitlement.
- App Store release also switches the app to `OpenClawPushMode=appStore`, which derives relay transport, official distribution, the canonical production relay, production APNs, production relay profile, `appleStrict` proof, and the App-Attest-capable entitlement file.
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
- `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation.
- The release archive is validated before upload by inspecting the exported IPA's signed entitlements, embedded App Store profile, and push mode. The upload fails if the IPA is not an App Store production relay build.
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- `apps/ios/version.json` is the pinned iOS release version source.
@@ -83,9 +83,8 @@ Release behavior:
Relay behavior for App Store builds:
- Release builds default to `https://ios-push-relay.openclaw.ai`.
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
- App Store release builds use the canonical hosted relay at `https://ios-push-relay.openclaw.ai`.
- App Store release builds reject custom relay URL overrides. Future self-hosted relay support should use a separate explicit release path, not the public App Store build lane.
Signing setup commands:
@@ -102,6 +101,7 @@ Release-owner secrets:
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`.
- The share sheet requires the Apple Developer App Group in `apps/ios/Config/AppStoreSigning.json` to be associated with both the app and share-extension bundle IDs before App Store profiles are regenerated.
- Relay registration requires the App Attest capability on the main app ID before App Store profiles are regenerated.
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
@@ -157,29 +157,23 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
- `ai.openclawfoundation.app.activitywidget`
- `ai.openclawfoundation.app.watchkitapp`
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`.
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`. The main app must also have App Attest enabled.
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`.
```bash
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
```
5. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
4. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
```bash
pnpm ios:version:pin -- --from-gateway
```
6. Upload the build:
5. Upload the build:
```bash
pnpm ios:release:upload
```
7. Expected behavior:
6. Expected behavior:
- Fastlane reads `apps/ios/version.json`
- verifies synced iOS versioning artifacts
- resolves the next App Store Connect build number for that short version
@@ -187,15 +181,16 @@ pnpm ios:release:upload
- uploads release notes and screenshots to the editable App Store version
- generates `apps/ios/build/AppStoreRelease.xcconfig`
- archives `OpenClaw`
- validates the exported IPA's push mode, signed entitlements, and embedded App Store profile
- uploads the IPA to App Store Connect for TestFlight/App Review use
- leaves App Review submission for a maintainer to complete manually
8. Expected outputs after a successful run:
7. Expected outputs after a successful run:
- `apps/ios/build/app-store/OpenClaw-<version>.ipa`
- `apps/ios/build/app-store/OpenClaw-<version>.app.dSYM.zip`
- Fastlane log line like `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
9. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
8. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
## iOS Versioning Workflow
@@ -243,14 +238,15 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
- The app calls `registerForRemoteNotifications()` at launch.
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
- App Attest relay builds use `apps/ios/Sources/OpenClawAppAttest.entitlements`; local/direct builds do not require App Attest provisioning.
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
- Local/manual builds default to `OpenClawPushTransport=direct`, `OpenClawPushDistribution=local`, and a development `aps-environment` entitlement.
- Local/manual Debug builds default to `OpenClawPushMode=localSandbox`, direct APNs registration, and a development `aps-environment` entitlement. Local/manual Release builds default to `OpenClawPushMode=localProduction` and direct production APNs registration.
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
- Recommended gateway-host storage for the APNs `.p8` file is `~/.openclaw/credentials/apns/AuthKey_<KEYID>.p8` with restrictive permissions, then point `OPENCLAW_APNS_PRIVATE_KEY_PATH` at that file.
- `apps/ios/fastlane/.env` only covers App Store Connect / Fastlane auth; it does not provide gateway APNs credentials for local direct-push testing.
- Debug builds default to `OpenClawPushAPNsEnvironment=sandbox`; Release builds default to `production`.
- Debug builds default to sandbox APNs through `OpenClawPushMode=localSandbox`; Release builds default to production APNs through `OpenClawPushMode=localProduction`.
## APNs Expectations For Official Builds
@@ -259,7 +255,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
- Relay mode requires a reachable relay base URL and uses App Attest plus a StoreKit app transaction JWS during registration.
- App Store release mode uses the internal `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
## Official Build Relay Trust Model

View File

@@ -6,6 +6,7 @@
OPENCLAW_CODE_SIGN_STYLE = Manual
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_CODE_SIGN_ENTITLEMENTS = Sources/OpenClaw.entitlements
OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app

View File

@@ -506,7 +506,7 @@ extension AgentProTab {
func skillEditorSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()

View File

@@ -105,7 +105,7 @@ struct AgentProTab: View {
var color: Color {
switch self {
case .online: OpenClawBrand.ok
case .ready: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
case .ready: OpenClawBrand.info
}
}
}

View File

@@ -277,7 +277,7 @@ struct ChatProTab: View {
}
private var chatUserAccent: Color {
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
self.colorScheme == .light ? OpenClawBrand.info : OpenClawBrand.accent
}
private var activeAgent: AgentSummary? {

View File

@@ -1036,7 +1036,7 @@ struct IPadSkillProposalRow: View {
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
self.isSelected ? Color.red.opacity(0.08) : Color.clear,
self.isSelected ? OpenClawBrand.danger.opacity(0.08) : Color.clear,
in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
}

View File

@@ -47,11 +47,13 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
}
enum OpenClawBrand {
static let accent = Color(uiColor: UIColor { traits in
static let uiAccent = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
: UIColor(red: 183 / 255.0, green: 56 / 255.0, blue: 51 / 255.0, alpha: 1)
})
}
static let accent = Color(uiColor: Self.uiAccent)
static let accentHot = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 232 / 255.0, green: 92 / 255.0, blue: 86 / 255.0, alpha: 1)
@@ -64,6 +66,7 @@ enum OpenClawBrand {
})
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
static let info = Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
static let graphite = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 20 / 255.0, green: 22 / 255.0, blue: 24 / 255.0, alpha: 1)

View File

@@ -819,8 +819,11 @@ extension SettingsProTab {
var notificationRelayDetail: String {
if PushBuildConfig.current.usesOpenClawHostedRelay {
let host = PushBuildConfig.current.relayBaseURL.flatMap {
URLComponents(url: $0, resolvingAgainstBaseURL: false)?.host
} ?? "ios-push-relay.openclaw.ai"
return """
This build uses OpenClaw's hosted push relay at ios-push-relay.openclaw.ai for notification \
This build uses OpenClaw's hosted push relay at \(host) for notification \
delivery data.
"""
}

View File

@@ -119,7 +119,7 @@ extension SettingsProTab {
self.gatewayActionButton(
title: "Diagnose",
icon: "cross.case",
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
color: OpenClawBrand.info,
isBusy: self.isRefreshingGateway)
{
Task { await self.runDiagnostics() }
@@ -476,7 +476,7 @@ extension SettingsProTab {
self.gatewayActionButton(
title: "Run Diagnostics",
icon: "cross.case",
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
color: OpenClawBrand.info,
isBusy: self.isRefreshingGateway)
{
Task { await self.runDiagnostics() }
@@ -1040,7 +1040,7 @@ extension SettingsProTab {
func settingsSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()

View File

@@ -90,7 +90,7 @@ private struct ExecApprovalPromptCard: View {
if let errorText = self.normalized(self.errorText) {
Text(errorText)
.font(.footnote)
.foregroundStyle(.red)
.foregroundStyle(OpenClawBrand.danger)
}
if self.isResolving {

View File

@@ -443,12 +443,54 @@ enum GatewaySettingsStore {
}
enum GatewayDiagnostics {
struct ScopedLogger {
private let prefix: String
fileprivate init(prefix: String) {
self.prefix = prefix
}
func stage(_ message: String) {
GatewayDiagnostics.log("\(self.prefix): \(GatewayDiagnostics.sanitizeScopedMessage(message))")
}
func skipped(_ reason: String) {
self.stage("registration skipped reason=\(reason)")
}
func failed(_ stage: String, error: Error) {
let nsError = error as NSError
self
.stage(
"\(stage) failed errorType=\(String(reflecting: type(of: error))) domain=\(nsError.domain) code=\(nsError.code)")
}
}
private static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "GatewayDiag")
private static let queue = DispatchQueue(label: "ai.openclawfoundation.app.gateway.diagnostics")
private static let maxLogBytes: Int64 = 512 * 1024
private static let keepLogBytes: Int64 = 256 * 1024
private static let logSizeCheckEveryWrites = 50
private static let logWritesSinceCheck = OSAllocatedUnfairLock(initialState: 0)
private static let maxScopedMessageCharacters = 320
/// Keep relay diagnostics stage-based. Push tokens, relay grants, proofs,
/// receipts, signed payloads, and handles must never enter this cache log.
static let pushRelay = ScopedLogger(prefix: "push relay")
private static func sanitizeScopedMessage(_ value: String) -> String {
let collapsed = value
.replacingOccurrences(of: "\r", with: " ")
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\t", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard collapsed.count > self.maxScopedMessageCharacters else {
return collapsed
}
let end = collapsed.index(collapsed.startIndex, offsetBy: self.maxScopedMessageCharacters)
return String(collapsed[..<end]) + "..."
}
private static func isoTimestamp() -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]

View File

@@ -82,14 +82,10 @@
<string>$(OPENCLAW_APP_GROUP_ID)</string>
<key>OpenClawCanonicalVersion</key>
<string>$(OPENCLAW_IOS_VERSION)</string>
<key>OpenClawPushAPNsEnvironment</key>
<string>$(OPENCLAW_PUSH_APNS_ENVIRONMENT)</string>
<key>OpenClawPushDistribution</key>
<string>$(OPENCLAW_PUSH_DISTRIBUTION)</string>
<key>OpenClawPushMode</key>
<string>$(OPENCLAW_PUSH_MODE)</string>
<key>OpenClawPushRelayBaseURL</key>
<string>$(OPENCLAW_PUSH_RELAY_BASE_URL)</string>
<key>OpenClawPushTransport</key>
<string>$(OPENCLAW_PUSH_TRANSPORT)</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View File

@@ -4102,39 +4102,64 @@ extension NodeAppModel {
}
private func registerAPNsTokenIfNeeded() async {
guard self.gatewayConnected else { return }
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
guard self.gatewayConnected else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("gateway_offline")
}
return
}
guard let token = self.apnsDeviceTokenHex?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("missing_apns_token")
}
return
}
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
if !usesRelayTransport, token == self.apnsLastRegisteredTokenHex {
return
}
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
!topic.isEmpty
else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("missing_topic")
}
return
}
do {
let gatewayIdentity: PushRelayGatewayIdentity?
if usesRelayTransport {
guard self.operatorConnected else { return }
guard self.operatorConnected else {
GatewayDiagnostics.pushRelay.skipped("operator_offline")
return
}
GatewayDiagnostics.pushRelay.stage("gateway identity request start")
gatewayIdentity = try await self.fetchPushRelayGatewayIdentity()
GatewayDiagnostics.pushRelay.stage("gateway identity request complete")
} else {
gatewayIdentity = nil
}
if usesRelayTransport {
GatewayDiagnostics.pushRelay.stage("gateway registration payload start")
}
let payloadJSON = try await self.pushRegistrationManager.makeGatewayRegistrationPayload(
apnsTokenHex: token,
topic: topic,
gatewayIdentity: gatewayIdentity)
await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: payloadJSON)
self.apnsLastRegisteredTokenHex = token
if usesRelayTransport {
GatewayDiagnostics.pushRelay.stage("gateway registration event published")
}
} catch {
self.pushWakeLogger.error(
"APNs registration publish failed: \(error.localizedDescription, privacy: .public)")
if usesRelayTransport {
GatewayDiagnostics.pushRelay.failed("registration", error: error)
}
}
}

View File

@@ -40,7 +40,7 @@ struct OnboardingIntroStep: View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title3.weight(.semibold))
.foregroundStyle(.orange)
.foregroundStyle(OpenClawBrand.warn)
.frame(width: 24)
.padding(.top, 2)
@@ -177,7 +177,7 @@ struct OnboardingModeRow: View {
}
Spacer()
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
.foregroundStyle(self.selected ? OpenClawBrand.accent : Color.secondary)
}
.contentShape(Rectangle())
}

View File

@@ -378,7 +378,7 @@ struct OnboardingWizardView: View {
private func onboardingSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()
@@ -575,7 +575,7 @@ struct OnboardingWizardView: View {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.green)
.foregroundStyle(OpenClawBrand.ok)
.padding(.bottom, 20)
Text("Connected")

View File

@@ -632,6 +632,7 @@ struct OpenClawApp: App {
var body: some Scene {
WindowGroup {
RootTabs()
.tint(OpenClawBrand.accent)
.preferredColorScheme(self.appearancePreference.colorScheme)
.environment(self.appModel)
.environment(self.appModel.voiceWake)
@@ -686,6 +687,7 @@ struct OpenClawApp: App {
.flatMap(\.windows)
.forEach { window in
window.overrideUserInterfaceStyle = style
window.tintColor = OpenClawBrand.uiAccent
}
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>$(OPENCLAW_APP_ATTEST_ENVIRONMENT)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -15,14 +15,38 @@ enum PushAPNsEnvironment: String {
case production
}
enum PushRelayProfile: String {
case production
case deviceSandbox
case simulatorSandbox
}
enum PushProofPolicy: String {
case appleStrict
case appleDevelopment
case internalSimulator
}
enum PushBuildMode: String {
case localSandbox
case localProduction
case appStore
case deviceSandbox
case simulatorSandbox
}
struct PushBuildConfig {
let mode: PushBuildMode
let transport: PushTransportMode
let distribution: PushDistributionMode
let relayBaseURL: URL?
let apnsEnvironment: PushAPNsEnvironment
let relayProfile: PushRelayProfile
let proofPolicy: PushProofPolicy
static let current = PushBuildConfig()
static let openClawHostedRelayHost = "ios-push-relay.openclaw.ai"
static let openClawSandboxRelayHost = "ios-push-relay-sandbox.openclaw.ai"
var usesOpenClawHostedRelay: Bool {
guard self.transport == .relay, self.distribution == .official else { return false }
@@ -32,29 +56,71 @@ struct PushBuildConfig {
return false
}
return components.scheme?.lowercased() == "https"
&& components.host?.lowercased() == Self.openClawHostedRelayHost
&& [Self.openClawHostedRelayHost, Self.openClawSandboxRelayHost]
.contains(components.host?.lowercased() ?? "")
&& components.user == nil
&& components.password == nil
}
init(bundle: Bundle = .main) {
self.transport = Self.readEnum(
bundle: bundle,
key: "OpenClawPushTransport",
fallback: .direct)
self.distribution = Self.readEnum(
bundle: bundle,
key: "OpenClawPushDistribution",
fallback: .local)
self.apnsEnvironment = Self.readEnum(
bundle: bundle,
key: "OpenClawPushAPNsEnvironment",
fallback: Self.defaultAPNsEnvironment)
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
self.init(readValue: { bundle.object(forInfoDictionaryKey: $0) })
}
private static func readURL(bundle: Bundle, key: String) -> URL? {
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
init(infoDictionary: [String: Any]) {
self.init(readValue: { infoDictionary[$0] })
}
private init(readValue: (String) -> Any?) {
self.mode = Self.readEnum(
readValue: readValue,
key: "OpenClawPushMode",
fallback: .localSandbox)
let relayBaseURLOverride = Self.readURL(
readValue: readValue,
key: "OpenClawPushRelayBaseURL")
switch self.mode {
case .localSandbox:
self.transport = .direct
self.distribution = .local
self.relayBaseURL = nil
self.apnsEnvironment = .sandbox
self.relayProfile = .deviceSandbox
self.proofPolicy = .appleDevelopment
case .localProduction:
self.transport = .direct
self.distribution = .local
self.relayBaseURL = nil
self.apnsEnvironment = .production
self.relayProfile = .production
self.proofPolicy = .appleStrict
case .appStore:
self.transport = .relay
self.distribution = .official
self.relayBaseURL = URL(string: "https://\(Self.openClawHostedRelayHost)")!
self.apnsEnvironment = .production
self.relayProfile = .production
self.proofPolicy = .appleStrict
case .deviceSandbox:
self.transport = .relay
self.distribution = .official
self.relayBaseURL = relayBaseURLOverride
?? URL(string: "https://\(Self.openClawSandboxRelayHost)")!
self.apnsEnvironment = .sandbox
self.relayProfile = .deviceSandbox
self.proofPolicy = .appleDevelopment
case .simulatorSandbox:
self.transport = .relay
self.distribution = .official
self.relayBaseURL = relayBaseURLOverride
?? URL(string: "https://\(Self.openClawSandboxRelayHost)")!
self.apnsEnvironment = .sandbox
self.relayProfile = .simulatorSandbox
self.proofPolicy = .internalSimulator
}
}
private static func readURL(readValue: (String) -> Any?, key: String) -> URL? {
guard let raw = readValue(key) as? String else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let components = URLComponents(string: trimmed),
@@ -72,14 +138,12 @@ struct PushBuildConfig {
}
private static func readEnum<T: RawRepresentable>(
bundle: Bundle,
readValue: (String) -> Any?,
key: String,
fallback: T)
-> T where T.RawValue == String {
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return fallback }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return T(rawValue: trimmed) ?? fallback
guard let raw = readValue(key) as? String else { return fallback }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return T(rawValue: trimmed) ?? T(rawValue: trimmed.lowercased()) ?? fallback
}
private static let defaultAPNsEnvironment: PushAPNsEnvironment = .sandbox
}

View File

@@ -69,12 +69,16 @@ actor PushRegistrationManager {
async throws -> String {
guard self.buildConfig.distribution == .official else {
throw PushRelayError.relayMisconfigured(
"Relay transport requires OpenClawPushDistribution=official")
}
guard self.buildConfig.apnsEnvironment == .production else {
throw PushRelayError.relayMisconfigured(
"Relay transport requires OpenClawPushAPNsEnvironment=production")
"Relay transport requires an official push build mode")
}
try Self.validateRelayContract(
relayProfile: self.buildConfig.relayProfile,
apnsEnvironment: self.buildConfig.apnsEnvironment,
proofPolicy: self.buildConfig.proofPolicy)
GatewayDiagnostics.pushRelay.stage(
"contract validated apns=\(self.buildConfig.apnsEnvironment.rawValue) "
+ "profile=\(self.buildConfig.relayProfile.rawValue) "
+ "proof=\(self.buildConfig.proofPolicy.rawValue)")
guard let relayClient = self.relayClient else {
throw PushRelayError.relayBaseURLMissing
}
@@ -96,9 +100,13 @@ actor PushRegistrationManager {
stored.installationId == installationId,
stored.gatewayDeviceId == gatewayIdentity.deviceId,
stored.relayOrigin == relayOrigin,
stored.apnsEnvironment == self.buildConfig.apnsEnvironment.rawValue,
stored.relayProfile == self.buildConfig.relayProfile.rawValue,
stored.proofPolicy == self.buildConfig.proofPolicy.rawValue,
stored.lastAPNsTokenHashHex == tokenHashHex,
!Self.isExpired(stored.relayHandleExpiresAtMs)
{
GatewayDiagnostics.pushRelay.stage("using cached relay registration")
return try Self.encodePayload(
RelayGatewayPushRegistrationPayload(
relayHandle: stored.relayHandle,
@@ -112,14 +120,17 @@ actor PushRegistrationManager {
tokenDebugSuffix: stored.tokenDebugSuffix))
}
let response = try await relayClient.register(
GatewayDiagnostics.pushRelay.stage("relay registration cache miss")
let response = try await relayClient.register(PushRelayRegistrationInput(
installationId: installationId,
bundleId: bundleId,
appVersion: DeviceInfoHelper.appVersion(),
environment: self.buildConfig.apnsEnvironment,
relayProfile: self.buildConfig.relayProfile,
proofPolicy: self.buildConfig.proofPolicy,
distribution: self.buildConfig.distribution,
apnsTokenHex: apnsTokenHex,
gatewayIdentity: gatewayIdentity)
gatewayIdentity: gatewayIdentity))
let registrationState = PushRelayRegistrationStore.RegistrationState(
relayHandle: response.relayHandle,
sendGrant: response.sendGrant,
@@ -129,8 +140,12 @@ actor PushRegistrationManager {
tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix),
lastAPNsTokenHashHex: tokenHashHex,
installationId: installationId,
lastTransport: self.buildConfig.transport.rawValue)
lastTransport: self.buildConfig.transport.rawValue,
apnsEnvironment: self.buildConfig.apnsEnvironment.rawValue,
relayProfile: self.buildConfig.relayProfile.rawValue,
proofPolicy: self.buildConfig.proofPolicy.rawValue)
_ = PushRelayRegistrationStore.saveRegistrationState(registrationState)
GatewayDiagnostics.pushRelay.stage("stored relay registration hasExpiry=\(response.expiresAtMs != nil)")
return try Self.encodePayload(
RelayGatewayPushRegistrationPayload(
relayHandle: response.relayHandle,
@@ -151,6 +166,30 @@ actor PushRegistrationManager {
return expiresAtMs <= nowMs + 60000
}
private static func validateRelayContract(
relayProfile: PushRelayProfile,
apnsEnvironment: PushAPNsEnvironment,
proofPolicy: PushProofPolicy)
throws {
switch relayProfile {
case .production:
guard apnsEnvironment == .production, proofPolicy == .appleStrict else {
throw PushRelayError.relayMisconfigured(
"production relay profile requires production APNs and appleStrict proof")
}
case .deviceSandbox:
guard apnsEnvironment == .sandbox, proofPolicy == .appleDevelopment else {
throw PushRelayError.relayMisconfigured(
"deviceSandbox relay profile requires sandbox APNs and appleDevelopment proof")
}
case .simulatorSandbox:
guard apnsEnvironment == .sandbox, proofPolicy == .internalSimulator else {
throw PushRelayError.relayMisconfigured(
"simulatorSandbox relay profile requires sandbox APNs and internalSimulator proof")
}
}
}
private static func sha256Hex(_ value: String) -> String {
let digest = SHA256.hash(data: Data(value.utf8))
return digest.map { String(format: "%02x", $0) }.joined()

View File

@@ -40,6 +40,9 @@ private struct PushRelayRegisterSignedPayload: Encodable {
var installationId: String
var bundleId: String
var environment: String
var relayProfile: String
var apnsEnvironment: String
var proofPolicy: String
var distribution: String
var gateway: PushRelayGatewayIdentity
var appVersion: String
@@ -63,12 +66,16 @@ private struct PushRelayRegisterRequest: Encodable {
var installationId: String
var bundleId: String
var environment: String
var relayProfile: String
var apnsEnvironment: String
var proofPolicy: String
var distribution: String
var gateway: PushRelayGatewayIdentity
var appVersion: String
var apnsToken: String
var appAttest: PushRelayAppAttestPayload
var receipt: PushRelayReceiptPayload
var appAttest: PushRelayAppAttestPayload?
var receipt: PushRelayReceiptPayload?
var simulatorProof: PushRelaySimulatorProofPayload?
}
struct PushRelayRegisterResponse: Decodable {
@@ -93,23 +100,34 @@ private struct PushRelayAppAttestProof {
var signedPayloadBase64: String
}
private struct PushRelaySimulatorProofPayload: Encodable {
var signedPayloadBase64: String
var hmacSha256Base64Url: String
}
private final class PushRelayAppAttestService {
func createProof(challenge: String, signedPayload: Data) async throws -> PushRelayAppAttestProof {
func createProof(
challenge: String,
signedPayload: Data,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> PushRelayAppAttestProof {
let service = DCAppAttestService.shared
guard service.isSupported else {
throw PushRelayError.unsupportedAppAttest
}
let keyID = try await self.loadOrCreateKeyID(using: service)
let keyID = try await self.loadOrCreateKeyID(using: service, scope: scope)
let attestationObject = try await self.attestKeyIfNeeded(
service: service,
keyID: keyID,
challenge: challenge)
challenge: challenge,
scope: scope)
let signedPayloadHash = Data(SHA256.hash(data: signedPayload))
let assertion = try await self.generateAssertion(
service: service,
keyID: keyID,
signedPayloadHash: signedPayloadHash)
signedPayloadHash: signedPayloadHash,
scope: scope)
return PushRelayAppAttestProof(
keyId: keyID,
@@ -119,21 +137,27 @@ private final class PushRelayAppAttestService {
signedPayloadBase64: signedPayload.base64EncodedString())
}
private func loadOrCreateKeyID(using service: DCAppAttestService) async throws -> String {
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(), !existing.isEmpty {
private func loadOrCreateKeyID(
using service: DCAppAttestService,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> String {
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(scope: scope),
!existing.isEmpty
{
return existing
}
let keyID = try await service.generateKey()
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID)
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID, scope: scope)
return keyID
}
private func attestKeyIfNeeded(
service: DCAppAttestService,
keyID: String,
challenge: String)
challenge: String,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> String? {
if PushRelayRegistrationStore.loadAttestedKeyID() == keyID {
if PushRelayRegistrationStore.loadAttestedKeyID(scope: scope) == keyID {
return nil
}
let challengeData = Data(challenge.utf8)
@@ -142,20 +166,21 @@ private final class PushRelayAppAttestService {
// Apple treats App Attest key attestation as a one-time operation. Save the
// attested marker immediately so later receipt/network failures do not cause a
// permanently broken re-attestation loop on the same key.
_ = PushRelayRegistrationStore.saveAttestedKeyID(keyID)
_ = PushRelayRegistrationStore.saveAttestedKeyID(keyID, scope: scope)
return attestation.base64EncodedString()
}
private func generateAssertion(
service: DCAppAttestService,
keyID: String,
signedPayloadHash: Data)
signedPayloadHash: Data,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> Data {
do {
return try await service.generateAssertion(keyID, clientDataHash: signedPayloadHash)
} catch {
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
_ = PushRelayRegistrationStore.clearAttestedKeyID()
_ = PushRelayRegistrationStore.clearAppAttestKeyID(scope: scope)
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: scope)
throw error
}
}
@@ -190,6 +215,47 @@ private final class PushRelayReceiptProvider {
}
}
private final class PushRelaySimulatorProofProvider {
func createProof(signedPayload: Data) throws -> PushRelaySimulatorProofPayload {
#if targetEnvironment(simulator)
guard let secret = ProcessInfo.processInfo.environment["OPENCLAW_SIMULATOR_PUSH_PROOF_SECRET"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!secret.isEmpty
else {
throw PushRelayError.relayMisconfigured("Simulator push proof secret missing")
}
let signedPayloadBase64 = signedPayload.base64EncodedString()
let signature = HMAC<SHA256>.authenticationCode(
for: Data(signedPayloadBase64.utf8),
using: SymmetricKey(data: Data(secret.utf8)))
return PushRelaySimulatorProofPayload(
signedPayloadBase64: signedPayloadBase64,
hmacSha256Base64Url: Self.base64URL(Data(signature)))
#else
throw PushRelayError.relayMisconfigured("Simulator proof is only available in iOS Simulator")
#endif
}
private static func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
struct PushRelayRegistrationInput {
var installationId: String
var bundleId: String
var appVersion: String
var environment: PushAPNsEnvironment
var relayProfile: PushRelayProfile
var proofPolicy: PushProofPolicy
var distribution: PushDistributionMode
var apnsTokenHex: String
var gatewayIdentity: PushRelayGatewayIdentity
}
/// The client is constructed once and used behind PushRegistrationManager actor isolation.
final class PushRelayClient: @unchecked Sendable {
private let baseURL: URL
@@ -198,6 +264,7 @@ final class PushRelayClient: @unchecked Sendable {
private let jsonEncoder = JSONEncoder()
private let appAttest = PushRelayAppAttestService()
private let receiptProvider = PushRelayReceiptProvider()
private let simulatorProofProvider = PushRelaySimulatorProofProvider()
init(baseURL: URL, session: URLSession = .shared) {
self.baseURL = baseURL
@@ -208,46 +275,93 @@ final class PushRelayClient: @unchecked Sendable {
Self.normalizeBaseURLString(self.baseURL)
}
func register(
installationId: String,
bundleId: String,
appVersion: String,
environment: PushAPNsEnvironment,
distribution: PushDistributionMode,
apnsTokenHex: String,
gatewayIdentity: PushRelayGatewayIdentity)
async throws -> PushRelayRegisterResponse {
let challenge = try await self.fetchChallenge()
func register(_ input: PushRelayRegistrationInput) async throws -> PushRelayRegisterResponse {
GatewayDiagnostics.pushRelay.stage(
"registration start origin=\(self.normalizedBaseURLString) "
+ "apns=\(input.environment.rawValue) "
+ "profile=\(input.relayProfile.rawValue) "
+ "proof=\(input.proofPolicy.rawValue)")
let challenge: PushRelayChallengeResponse
do {
GatewayDiagnostics.pushRelay.stage("challenge request start")
challenge = try await self.fetchChallenge()
GatewayDiagnostics.pushRelay.stage("challenge received")
} catch {
GatewayDiagnostics.pushRelay.failed("challenge request", error: error)
throw error
}
let signedPayload = PushRelayRegisterSignedPayload(
challengeId: challenge.challengeId,
installationId: installationId,
bundleId: bundleId,
environment: environment.rawValue,
distribution: distribution.rawValue,
gateway: gatewayIdentity,
appVersion: appVersion,
apnsToken: apnsTokenHex)
installationId: input.installationId,
bundleId: input.bundleId,
environment: input.environment.rawValue,
relayProfile: input.relayProfile.rawValue,
apnsEnvironment: input.environment.rawValue,
proofPolicy: input.proofPolicy.rawValue,
distribution: input.distribution.rawValue,
gateway: input.gatewayIdentity,
appVersion: input.appVersion,
apnsToken: input.apnsTokenHex)
let signedPayloadData = try self.jsonEncoder.encode(signedPayload)
let appAttest = try await self.appAttest.createProof(
challenge: challenge.challenge,
signedPayload: signedPayloadData)
let receiptBase64 = try await self.receiptProvider.loadReceiptBase64()
let appAttestScope = PushRelayRegistrationStore.AppAttestScope(
relayOrigin: self.normalizedBaseURLString,
apnsEnvironment: input.environment.rawValue,
relayProfile: input.relayProfile.rawValue,
proofPolicy: input.proofPolicy.rawValue)
let appAttest: PushRelayAppAttestProof?
do {
GatewayDiagnostics.pushRelay.stage("app attest proof start")
appAttest = try await self.createAppAttestProofIfNeeded(
proofPolicy: input.proofPolicy,
challenge: challenge.challenge,
signedPayloadData: signedPayloadData,
scope: appAttestScope)
GatewayDiagnostics.pushRelay.stage("app attest proof complete included=\(appAttest != nil)")
} catch {
GatewayDiagnostics.pushRelay.failed("app attest proof", error: error)
throw error
}
let receipt: PushRelayReceiptPayload?
do {
GatewayDiagnostics.pushRelay.stage("receipt proof start")
receipt = try await self.createReceiptIfNeeded(proofPolicy: input.proofPolicy)
GatewayDiagnostics.pushRelay.stage("receipt proof complete included=\(receipt != nil)")
} catch {
GatewayDiagnostics.pushRelay.failed("receipt proof", error: error)
throw error
}
let simulatorProof: PushRelaySimulatorProofPayload?
do {
simulatorProof = try self.createSimulatorProofIfNeeded(
proofPolicy: input.proofPolicy,
signedPayloadData: signedPayloadData)
GatewayDiagnostics.pushRelay.stage("simulator proof complete included=\(simulatorProof != nil)")
} catch {
GatewayDiagnostics.pushRelay.failed("simulator proof", error: error)
throw error
}
let requestBody = PushRelayRegisterRequest(
challengeId: signedPayload.challengeId,
installationId: signedPayload.installationId,
bundleId: signedPayload.bundleId,
environment: signedPayload.environment,
relayProfile: signedPayload.relayProfile,
apnsEnvironment: signedPayload.apnsEnvironment,
proofPolicy: signedPayload.proofPolicy,
distribution: signedPayload.distribution,
gateway: signedPayload.gateway,
appVersion: signedPayload.appVersion,
apnsToken: signedPayload.apnsToken,
appAttest: PushRelayAppAttestPayload(
keyId: appAttest.keyId,
attestationObject: appAttest.attestationObject,
assertion: appAttest.assertion,
clientDataHash: appAttest.clientDataHash,
signedPayloadBase64: appAttest.signedPayloadBase64),
receipt: PushRelayReceiptPayload(base64: receiptBase64))
appAttest: appAttest.map {
PushRelayAppAttestPayload(
keyId: $0.keyId,
attestationObject: $0.attestationObject,
assertion: $0.assertion,
clientDataHash: $0.clientDataHash,
signedPayloadBase64: $0.signedPayloadBase64)
},
receipt: receipt,
simulatorProof: simulatorProof)
let endpoint = self.baseURL.appending(path: "v1/push/register")
var request = URLRequest(url: endpoint)
@@ -256,20 +370,75 @@ final class PushRelayClient: @unchecked Sendable {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try self.jsonEncoder.encode(requestBody)
let (data, response) = try await self.session.data(for: request)
let data: Data
let response: URLResponse
do {
GatewayDiagnostics.pushRelay.stage("register request start")
(data, response) = try await self.session.data(for: request)
} catch {
GatewayDiagnostics.pushRelay.failed("register request", error: error)
throw error
}
let status = Self.statusCode(from: response)
GatewayDiagnostics.pushRelay.stage("register response status=\(status)")
guard (200..<300).contains(status) else {
if status == 401 {
// If the relay rejects registration, drop local App Attest state so the next
// attempt re-attests instead of getting stuck without an attestation object.
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
_ = PushRelayRegistrationStore.clearAttestedKeyID()
_ = PushRelayRegistrationStore.clearAppAttestKeyID(scope: appAttestScope)
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: appAttestScope)
}
throw PushRelayError.requestFailed(
let relayError = PushRelayError.requestFailed(
status: status,
message: Self.decodeErrorMessage(data: data))
GatewayDiagnostics.pushRelay.stage("register response failed status=\(status)")
throw relayError
}
return try self.decode(PushRelayRegisterResponse.self, from: data)
do {
let decoded = try self.decode(PushRelayRegisterResponse.self, from: data)
GatewayDiagnostics.pushRelay.stage("registration response decoded")
return decoded
} catch {
GatewayDiagnostics.pushRelay.failed("registration response decode", error: error)
throw error
}
}
private func createAppAttestProofIfNeeded(
proofPolicy: PushProofPolicy,
challenge: String,
signedPayloadData: Data,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> PushRelayAppAttestProof? {
guard proofPolicy != .internalSimulator else { return nil }
return try await self.appAttest.createProof(
challenge: challenge,
signedPayload: signedPayloadData,
scope: scope)
}
private func createReceiptIfNeeded(
proofPolicy: PushProofPolicy)
async throws -> PushRelayReceiptPayload? {
switch proofPolicy {
case .appleStrict:
return try await PushRelayReceiptPayload(base64: self.receiptProvider.loadReceiptBase64())
case .appleDevelopment:
guard let receiptBase64 = try? await self.receiptProvider.loadReceiptBase64() else {
return nil
}
return PushRelayReceiptPayload(base64: receiptBase64)
case .internalSimulator:
return nil
}
}
private func createSimulatorProofIfNeeded(
proofPolicy: PushProofPolicy,
signedPayloadData: Data)
throws -> PushRelaySimulatorProofPayload? {
guard proofPolicy == .internalSimulator else { return nil }
return try self.simulatorProofProvider.createProof(signedPayload: signedPayloadData)
}
private func fetchChallenge() async throws -> PushRelayChallengeResponse {

View File

@@ -1,3 +1,4 @@
import CryptoKit
import Foundation
private struct StoredPushRelayRegistrationState: Codable {
@@ -10,6 +11,9 @@ private struct StoredPushRelayRegistrationState: Codable {
var lastAPNsTokenHashHex: String
var installationId: String
var lastTransport: String
var apnsEnvironment: String?
var relayProfile: String?
var proofPolicy: String?
}
enum PushRelayRegistrationStore {
@@ -18,6 +22,13 @@ enum PushRelayRegistrationStore {
private static let appAttestKeyIDAccount = "app-attest-key-id"
private static let appAttestedKeyIDAccount = "app-attested-key-id"
struct AppAttestScope {
var relayOrigin: String
var apnsEnvironment: String
var relayProfile: String
var proofPolicy: String
}
struct RegistrationState: Codable {
var relayHandle: String
var sendGrant: String
@@ -28,6 +39,9 @@ enum PushRelayRegistrationStore {
var lastAPNsTokenHashHex: String
var installationId: String
var lastTransport: String
var apnsEnvironment: String
var relayProfile: String
var proofPolicy: String
}
static func loadRegistrationState() -> RegistrationState? {
@@ -48,7 +62,10 @@ enum PushRelayRegistrationStore {
tokenDebugSuffix: decoded.tokenDebugSuffix,
lastAPNsTokenHashHex: decoded.lastAPNsTokenHashHex,
installationId: decoded.installationId,
lastTransport: decoded.lastTransport)
lastTransport: decoded.lastTransport,
apnsEnvironment: decoded.apnsEnvironment ?? "production",
relayProfile: decoded.relayProfile ?? "production",
proofPolicy: decoded.proofPolicy ?? "appleStrict")
}
@discardableResult
@@ -62,7 +79,10 @@ enum PushRelayRegistrationStore {
tokenDebugSuffix: state.tokenDebugSuffix,
lastAPNsTokenHashHex: state.lastAPNsTokenHashHex,
installationId: state.installationId,
lastTransport: state.lastTransport)
lastTransport: state.lastTransport,
apnsEnvironment: state.apnsEnvironment,
relayProfile: state.relayProfile,
proofPolicy: state.proofPolicy)
guard let data = try? JSONEncoder().encode(stored),
let raw = String(data: data, encoding: .utf8)
else {
@@ -71,37 +91,66 @@ enum PushRelayRegistrationStore {
return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
}
static func loadAppAttestKeyID() -> String? {
let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
static func loadAppAttestKeyID(scope: AppAttestScope) -> String? {
let value = KeychainStore.loadString(
service: self.service,
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
return nil
}
@discardableResult
static func saveAppAttestKeyID(_ keyID: String) -> Bool {
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestKeyIDAccount)
static func saveAppAttestKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
KeychainStore.saveString(
keyID,
service: self.service,
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
}
@discardableResult
static func clearAppAttestKeyID() -> Bool {
KeychainStore.delete(service: self.service, account: self.appAttestKeyIDAccount)
static func clearAppAttestKeyID(scope: AppAttestScope) -> Bool {
KeychainStore.delete(
service: self.service,
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
}
static func loadAttestedKeyID() -> String? {
let value = KeychainStore.loadString(service: self.service, account: self.appAttestedKeyIDAccount)?
static func loadAttestedKeyID(scope: AppAttestScope) -> String? {
let value = KeychainStore.loadString(
service: self.service,
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
return nil
}
@discardableResult
static func saveAttestedKeyID(_ keyID: String) -> Bool {
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestedKeyIDAccount)
static func saveAttestedKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
KeychainStore.saveString(
keyID,
service: self.service,
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
}
@discardableResult
static func clearAttestedKeyID() -> Bool {
KeychainStore.delete(service: self.service, account: self.appAttestedKeyIDAccount)
static func clearAttestedKeyID(scope: AppAttestScope) -> Bool {
KeychainStore.delete(
service: self.service,
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
}
private static func scopedAccount(_ baseAccount: String, scope: AppAttestScope) -> String {
let raw = [
scope.relayOrigin,
scope.apnsEnvironment,
scope.relayProfile,
scope.proofPolicy,
].joined(separator: "\n")
let digest = SHA256.hash(data: Data(raw.utf8))
.map { String(format: "%02x", $0) }
.joined()
// A relay sees an App Attest key as attested only after receiving that
// key's attestation object, so keep key state isolated per relay context.
return "\(baseAccount)-\(digest)"
}
}

View File

@@ -1008,39 +1008,60 @@ final class TalkModeManager: NSObject {
self.logger.info(
"chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)")
GatewayDiagnostics.log("talk: chat.send start sessionKey=\(sessionKey) chars=\(prompt.count)")
let runId = try await self.sendChat(prompt, gateway: gateway)
self.logger.info("chat.send ok runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat.send ok runId=\(runId)")
let ack = try await self.sendChat(prompt, gateway: gateway)
let runId = ack.runId
let normalizedStatus = Self.normalizedChatSendStatus(ack.status)
self.logger.info(
"chat.send ok runId=\(runId, privacy: .public) status=\(normalizedStatus, privacy: .public)")
GatewayDiagnostics.log("talk: chat.send ok runId=\(runId) status=\(normalizedStatus)")
if Self.isTerminalChatSendFailure(ack.status) {
self.statusText = normalizedStatus == "error" ? "Chat error" : "Aborted"
self.logger.warning(
"chat.send terminal ack runId=\(runId, privacy: .public) status=\(normalizedStatus, privacy: .public)")
GatewayDiagnostics.log(
"talk: chat.send terminal ack runId=\(runId) status=\(normalizedStatus)")
if restartAfter {
await self.start()
}
return
}
let shouldIncremental = self.shouldUseIncrementalTTS()
var streamingTask: Task<Void, Never>?
if shouldIncremental {
self.resetIncrementalSpeech()
streamingTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.streamAssistant(runId: runId, gateway: gateway)
let completion: ChatCompletionResult
if Self.isTerminalChatSendSuccess(ack.status) {
GatewayDiagnostics.log("talk: chat.send terminal ok runId=\(runId); using history fallback")
completion = ChatCompletionResult(state: .final, assistantText: nil)
} else {
if shouldIncremental {
self.resetIncrementalSpeech()
streamingTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.streamAssistant(runId: runId, gateway: gateway)
}
}
completion = await self.waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120)
if completion.state == .timeout {
self.logger.warning(
"chat completion timeout runId=\(runId, privacy: .public); attempting history fallback")
GatewayDiagnostics.log("talk: chat completion timeout runId=\(runId)")
} else if completion.state == .aborted {
self.statusText = "Aborted"
self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat completion aborted runId=\(runId)")
streamingTask?.cancel()
await self.finishIncrementalSpeech()
await self.start()
return
} else if completion.state == .error {
self.statusText = "Chat error"
self.logger.warning("chat completion error runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat completion error runId=\(runId)")
streamingTask?.cancel()
await self.finishIncrementalSpeech()
await self.start()
return
}
}
let completion = await waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120)
if completion.state == .timeout {
self.logger.warning(
"chat completion timeout runId=\(runId, privacy: .public); attempting history fallback")
GatewayDiagnostics.log("talk: chat completion timeout runId=\(runId)")
} else if completion.state == .aborted {
self.statusText = "Aborted"
self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat completion aborted runId=\(runId)")
streamingTask?.cancel()
await self.finishIncrementalSpeech()
await self.start()
return
} else if completion.state == .error {
self.statusText = "Chat error"
self.logger.warning("chat completion error runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat completion error runId=\(runId)")
streamingTask?.cancel()
await self.finishIncrementalSpeech()
await self.start()
return
}
var assistantText = completion.assistantText
@@ -1053,7 +1074,7 @@ final class TalkModeManager: NSObject {
if assistantText == nil {
assistantText = try await self.waitForAssistantTextFromHistory(
gateway: gateway,
since: startedAt,
since: Self.chatSendHistorySince(response: ack, startedAt: startedAt),
timeoutSeconds: completion.state == .final ? 12 : 25)
}
guard let assistantText else {
@@ -1343,8 +1364,27 @@ final class TalkModeManager: NSObject {
var assistantText: String?
}
private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> String {
struct SendResponse: Decodable { let runId: String }
private static func normalizedChatSendStatus(_ status: String) -> String {
status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
private static func isTerminalChatSendSuccess(_ status: String) -> Bool {
self.normalizedChatSendStatus(status) == "ok"
}
private static func isTerminalChatSendFailure(_ status: String) -> Bool {
let normalized = self.normalizedChatSendStatus(status)
return normalized == "timeout" || normalized == "error"
}
private static func chatSendHistorySince(
response: OpenClawChatSendResponse,
startedAt: Double) -> Double?
{
self.isTerminalChatSendSuccess(response.status) ? nil : startedAt
}
private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> OpenClawChatSendResponse {
let payload: [String: Any] = [
"sessionKey": mainSessionKey,
"message": message,
@@ -1360,8 +1400,7 @@ final class TalkModeManager: NSObject {
userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"])
}
let res = try await gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
let decoded = try JSONDecoder().decode(SendResponse.self, from: res)
return decoded.runId
return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
}
private func waitForChatCompletion(
@@ -1440,7 +1479,7 @@ final class TalkModeManager: NSObject {
private func waitForAssistantTextFromHistory(
gateway: GatewayNodeSession,
since: Double,
since: Double?,
timeoutSeconds: Int) async throws -> String?
{
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))

View File

@@ -35,7 +35,7 @@ struct TalkPermissionPromptView: View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: self.iconSystemName)
.font(.title3.weight(.semibold))
.foregroundStyle(self.requestIsPending ? Color.orange : Color.accentColor)
.foregroundStyle(self.requestIsPending ? OpenClawBrand.warn : OpenClawBrand.accent)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 6) {
@@ -51,7 +51,7 @@ struct TalkPermissionPromptView: View {
if let failureMessage = self.state.failureMessage {
Label(failureMessage, systemImage: "exclamationmark.triangle.fill")
.font(.footnote)
.foregroundStyle(.red)
.foregroundStyle(OpenClawBrand.danger)
.fixedSize(horizontal: false, vertical: true)
}
@@ -99,7 +99,7 @@ struct TalkPermissionPromptView: View {
.overlay {
if self.style == .card || self.style == .sheet {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.accentColor.opacity(0.20), lineWidth: 1)
.stroke(OpenClawBrand.accent.opacity(0.20), lineWidth: 1)
}
}
.task(id: self.pollTaskKey) {

View File

@@ -0,0 +1,50 @@
import Foundation
import Testing
@testable import OpenClaw
struct PushBuildConfigTests {
@Test func `app store mode derives production relay contract`() {
let config = PushBuildConfig(infoDictionary: [
"OpenClawPushMode": "appStore",
"OpenClawPushRelayBaseURL": "https://wrong.example.com",
])
#expect(config.mode == .appStore)
#expect(config.transport == .relay)
#expect(config.distribution == .official)
#expect(config.relayBaseURL?.absoluteString == "https://ios-push-relay.openclaw.ai")
#expect(config.apnsEnvironment == .production)
#expect(config.relayProfile == .production)
#expect(config.proofPolicy == .appleStrict)
}
@Test func `simulator sandbox mode derives internal proof contract`() {
let config = PushBuildConfig(infoDictionary: [
"OpenClawPushMode": "simulatorSandbox",
"OpenClawPushRelayBaseURL": "https://staging-relay.example.com",
])
#expect(config.mode == .simulatorSandbox)
#expect(config.transport == .relay)
#expect(config.distribution == .official)
#expect(config.relayBaseURL?.absoluteString == "https://staging-relay.example.com")
#expect(config.apnsEnvironment == .sandbox)
#expect(config.relayProfile == .simulatorSandbox)
#expect(config.proofPolicy == .internalSimulator)
}
@Test func `local release mode remains direct production push`() {
let config = PushBuildConfig(infoDictionary: [
"OpenClawPushMode": "localProduction",
"OpenClawPushRelayBaseURL": "https://ios-push-relay.openclaw.ai",
])
#expect(config.mode == .localProduction)
#expect(config.transport == .direct)
#expect(config.distribution == .local)
#expect(config.relayBaseURL == nil)
#expect(config.apnsEnvironment == .production)
#expect(config.relayProfile == .production)
#expect(config.proofPolicy == .appleStrict)
}
}

View File

@@ -495,6 +495,9 @@ def produce_services_for_target(target)
if target.fetch("capabilities").include?("APP_GROUPS")
services[:app_group] = "on"
end
if target.fetch("capabilities").include?("APP_ATTEST")
services[:app_attest] = "on"
end
services
end
@@ -605,6 +608,15 @@ def validate_match_profile_capabilities!(target)
)
end
end
if capabilities.include?("APP_ATTEST")
app_attest_environments = profile_plist_array_values(profile_path, "Entitlements:com.apple.developer.devicecheck.appattest-environment")
unless app_attest_environments.include?("production")
UI.user_error!(
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing production App Attest entitlement; actual environments: #{app_attest_environments.empty? ? "missing" : app_attest_environments.join(", ")}."
)
end
end
end
def sync_app_store_signing!(readonly:)
@@ -742,6 +754,11 @@ def prepare_app_store_release!(version:, build_number:)
release_xcconfig
end
def validate_app_store_ipa!(ipa_path)
script_path = File.join(repo_root, "scripts", "ios-validate-app-store-ipa.sh")
sh(shell_join(["bash", script_path, "--ipa", ipa_path]))
end
def build_app_store_release(context)
version = context[:version]
project_path = File.join(ios_root, "OpenClaw.xcodeproj")
@@ -792,6 +809,7 @@ def build_app_store_release(context)
UI.user_error!("xcodebuild export produced multiple IPAs in #{output_directory}: #{exported_ipas.join(", ")}") if exported_ipas.length > 1
exported_ipa = exported_ipas.first
FileUtils.mv(exported_ipa, expected_ipa_path) unless exported_ipa == expected_ipa_path
validate_app_store_ipa!(expected_ipa_path)
{
archive_path: archive_path,
@@ -911,25 +929,12 @@ platform :ios do
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Build + upload an App Store distribution build to App Store Connect"
lane :app_store do
context = prepare_app_store_context(require_api_key: true)
build = build_app_store_release(context)
upload_to_testflight(
api_key: context[:api_key],
ipa: build[:ipa_path],
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
ensure
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Generate screenshots, update App Store version metadata, then upload an App Store build"
lane :release_upload do
unless ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.")
end
release_signing_check!
preserve_local_signing do
screenshots

View File

@@ -65,7 +65,7 @@ pnpm ios:release:signing:check
pnpm ios:release:signing:setup
```
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app and share extension also require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app also requires App Attest, and the main app and share extension both require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
Shared encrypted signing storage:
@@ -112,12 +112,9 @@ Upload to App Store Connect:
pnpm ios:release:upload
```
Direct Fastlane entry point:
```bash
cd apps/ios
fastlane ios release_upload
```
Direct Fastlane TestFlight upload is disabled. Use the package script so the
release wrapper, App Store push mode, and exported-IPA validation gate all run
in the same path.
Maintainer recovery path for a fresh clone on the same Mac:
@@ -144,13 +141,7 @@ fastlane ios auth_check
pnpm ios:version:pin -- --from-gateway
```
5. Set the official relay URL before release:
```bash
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
```
6. Upload:
5. Upload:
```bash
pnpm ios:release:upload
@@ -159,6 +150,7 @@ pnpm ios:release:upload
Quick verification after upload:
- confirm `apps/ios/build/app-store/OpenClaw-<version>.ipa` exists
- confirm Fastlane validates the exported IPA before upload
- confirm Fastlane prints `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
- remember that App Store Connect/TestFlight processing can take a few minutes after the upload succeeds
@@ -175,5 +167,7 @@ Versioning rules:
- `pnpm ios:version:check` validates that checked-in iOS version artifacts are in sync
- The release flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
- Local App Store signing uses a temporary generated xcconfig with profile names from `apps/ios/Config/AppStoreSigning.json` and leaves local development signing overrides untouched
- App Store release uses `OpenClawPushMode=appStore`, which derives the canonical production hosted relay, production APNs, production relay profile, and `appleStrict` proof. The release lane rejects custom production relay URL overrides.
- The exported IPA is validated before upload by inspecting its push mode, signed entitlements, and embedded App Store profile.
- `pnpm ios:release:upload` generates and uploads screenshots and release notes before archiving, then uploads the IPA without submitting it for App Review
- See `apps/ios/VERSIONING.md` for the detailed workflow

View File

@@ -107,7 +107,7 @@ targets:
settings:
base:
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
CODE_SIGN_ENTITLEMENTS: "$(OPENCLAW_CODE_SIGN_ENTITLEMENTS)"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
@@ -120,17 +120,15 @@ targets:
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
configs:
Debug:
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: development
OPENCLAW_PUSH_TRANSPORT: direct
OPENCLAW_PUSH_DISTRIBUTION: local
OPENCLAW_PUSH_MODE: localSandbox
OPENCLAW_PUSH_RELAY_BASE_URL: ""
OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
Release:
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: production
OPENCLAW_PUSH_TRANSPORT: direct
OPENCLAW_PUSH_DISTRIBUTION: local
OPENCLAW_PUSH_MODE: localProduction
OPENCLAW_PUSH_RELAY_BASE_URL: ""
OPENCLAW_PUSH_APNS_ENVIRONMENT: production
info:
path: Sources/Info.plist
properties:
@@ -172,10 +170,8 @@ targets:
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for talk mode and voice wake.
NSSupportsLiveActivities: true
ITSAppUsesNonExemptEncryption: false
OpenClawPushTransport: "$(OPENCLAW_PUSH_TRANSPORT)"
OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)"
OpenClawPushMode: "$(OPENCLAW_PUSH_MODE)"
OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)"
OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)"
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown

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