Compare commits

...

112 Commits

Author SHA1 Message Date
Dallin Romney
42140a2d70 fix: preserve non-oneOf schema array order 2026-06-10 01:10:44 -07:00
Vincent Koc
3b180d5d99 fix(qa): wait for restart wake before capability check 2026-06-10 17:09:18 +09:00
Ayaan Zaidi
b9095bf70d refactor(channel): share draft chunking resolver 2026-06-10 13:35:19 +05:30
Ayaan Zaidi
049c3c4877 test(telegram): cover callback API metadata 2026-06-10 13:29:11 +05:30
Ayaan Zaidi
1265da2a5c fix(telegram): use concrete callback API calls 2026-06-10 13:29:11 +05:30
Josh Avant
cfdabfbaab Fix stale visible reply recovery (#91840)
* fix visible reply stale recovery

* fix visible recovery lint loop

* fix visible reply registry recovery

* test: cover failed visible recovery admission
2026-06-10 02:56:15 -05:00
Vincent Koc
e3fe6715af test(sessions): allow canonical recovery path aliases 2026-06-10 16:46:06 +09:00
Vincent Koc
0948bd648a test(e2e): widen kitchen sink RPC coverage 2026-06-10 16:42:47 +09:00
Vincent Koc
d07cd4c968 fix(ci): give QA builds larger runners 2026-06-10 16:42:24 +09:00
openclaw-clownfish[bot]
db5b883a9c fix(ci): include ACPX in shared live-test image
* fix(ci): include ACPX in shared live-test image

* fix(clownfish): address review for clawsweeper-commit-openclaw-openclaw-806a0119f3cd (1)

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-10 16:40:25 +09:00
Vincent Koc
fd7e181500 fix(ci): raise QA build heap limit 2026-06-10 16:35:34 +09:00
Ayaan Zaidi
3407402b2c test(plugins): guard dedicated channel sidecars 2026-06-10 13:05:24 +05:30
Ayaan Zaidi
05a0dfdd08 refactor(extensions): split channel contract sidecars 2026-06-10 13:05:24 +05:30
Ayaan Zaidi
7f9f687d82 refactor(channels): remove bundled contract fallbacks 2026-06-10 13:05:24 +05:30
Vincent Koc
7f1d82ab25 revert(sessions): defer session metadata sqlite
Reverts 538d36eaaa while preserving subsequent main changes. The beta-only SQLite downgrade rescue and reverse migration remain excluded.
2026-06-10 16:34:06 +09:00
Andy Ye
9408380ae7 fix(memory-core): keep qmd json search one-shot (#91837) 2026-06-10 16:30:15 +09:00
Vincent Koc
0a3aa5f278 fix(test): add session ids to Slack fixtures 2026-06-10 16:18:43 +09:00
Vincent Koc
a3d5e5bc72 fix(test): support macOS Bash 3 script suites 2026-06-10 15:37:15 +09:00
Vincent Koc
7cb2571a99 test(sessions): migrate fixtures to sqlite store 2026-06-10 15:35:10 +09:00
Vincent Koc
7b7e8f6e88 test(memory): stop asserting private sync return 2026-06-10 15:23:58 +09:00
Vincent Koc
60459d4061 test(chutes): normalize discovery request headers 2026-06-10 15:22:43 +09:00
Vincent Koc
54288a1e7c test(imessage): align echo cache assertion 2026-06-10 15:20:05 +09:00
brokemac79
de4b8d8ebf feat(plugins): allow installed trusted policy contracts
Allow explicitly enabled installed plugins to register declared trusted tool policies and agent tool result middleware, with trusted policy ids scoped by plugin owner.\n\nVerification covered targeted plugin/agent tests, typecheck, build, lint, local autoreview, and a Blacksmith Testbox runtime proof (tbx_01ktr1nq0rhq47fjkwrepm7fd3).
2026-06-10 16:18:23 +10:00
Vincent Koc
52bc2a12bc fix(ci): disable memory slot in release smoke config 2026-06-10 14:56:21 +09:00
Vincent Koc
ca4e4d93d2 fix(ci): use cursor pagination for closed issues 2026-06-10 14:53:40 +09:00
Vincent Koc
92418fc9da fix(memory-core): filter stale recall entries in REM harness preview 2026-06-10 14:35:44 +09:00
Ayaan Zaidi
c7b4c6bfc5 fix(gateway): handle missing launchd gui domains 2026-06-10 10:51:19 +05:30
FullerStackDev
c39dea917a fix(gateway): route headless doctor hints 2026-06-10 10:51:19 +05:30
FullerStackDev
40aad24e8a fix(gateway): surface headless LaunchAgent state 2026-06-10 10:51:19 +05:30
Vincent Koc
69a73b6278 chore(codex): bump app-server to 0.139.0 2026-06-10 14:14:53 +09:00
openclaw-clownfish[bot]
54c400a975 fix(plugin-sdk): refresh API baseline hash 2026-06-10 14:12:38 +09:00
Patrick Erichsen
e9671ed603 feat: feature openrouter in onboarding provider picker 2026-06-09 21:59:56 -07:00
kenny
b9280d5863 feat: add OpenRouter OAuth login
(cherry picked from commit dccfb60656)
2026-06-09 21:59:56 -07:00
Vincent Koc
b4cdd92119 fix(codex): avoid guardian review for local models (#88630)
* fix(codex): avoid guardian review for local models

* fix(codex): route app-server auto exec review

* fix(codex): make guardian requirements provider-aware

* fix(codex): block unrouted bound approvals

* fix(channels): satisfy ingress queue lint

* fix(codex): use local-model policy for side forks

* fix(extensions): satisfy ingress lint

* fix(codex): require trusted exec reviewer model

* fix(exec): share control command approval guards

* fix(codex): fail closed for unknown guardian model provider

* fix(codex): reject custom exec reviewer endpoints

* fix(codex): preserve bound providers on app-server reuse

* fix(codex): prefer qualified app-server model providers

* fix(codex): preserve guardian on model control switches

* fix(codex): retain local providers across model switches

* fix(codex): distrust aliased reviewer model refs

* fix(codex): preserve providers after thread rotation

* fix(codex): clear stale providers on qualified model switches

* fix(codex): prefer qualified models over legacy providers

* fix(codex): validate reviewer trust before auto approvals

* fix(codex): recompute reviewer policy after binding rotation

* fix(codex): normalize reviewer aliases before trust checks

* fix(codex): retain bound providers for slashed local models

* fix(codex): normalize provider trust checks for exec review

* fix(codex): ignore stale bindings for explicit providers

* fix(codex): share trusted reviewer endpoint policy

* fix(codex): keep network approvals on plugin path

* fix(codex): route provider-qualified model refs

* fix(codex): reject blank masked OpenAI base overrides

* fix(codex): scope exec reviewer alias trust

* fix(codex): distrust exec reviewer transport overrides
2026-06-09 21:38:22 -07:00
Patrick Erichsen
5a0b95269d docs: add plugin validation fixes guide (#91819) 2026-06-09 21:14:17 -07:00
dependabot[bot]
69b95c3447 chore(deps): bump useblacksmith/setup-docker-builder (#91666)
Bumps the actions group with 1 update: [useblacksmith/setup-docker-builder](https://github.com/useblacksmith/setup-docker-builder).


Updates `useblacksmith/setup-docker-builder` from 1.8.0 to 1.9.0
- [Release notes](https://github.com/useblacksmith/setup-docker-builder/releases)
- [Commits](722e97d12b...ab5c1da94f)

---
updated-dependencies:
- dependency-name: useblacksmith/setup-docker-builder
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 20:57:06 -07:00
Colin Johnson
bf89552e67 Improve iPad and iPhone control surfaces (#91557)
* feat(ios): expand iPad layout support

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: improve iPad and iPhone control surfaces

* fix: preserve workboard dispatch compatibility

* fix: keep Talk reachable on iPad

* fix: add universal iPad app icons

* fix: address ready-review iOS feedback

* fix: avoid workboard board id shadowing

* fix ios sidebar separators

---------

Co-authored-by: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-06-09 21:46:02 -05:00
Josh Avant
a8d33f23a0 Fix context-engine compaction ownership for Codex sessions (#91590)
* fix(agents): keep context-engine compaction primary

* fix(codex): request native compaction after context engines

* test: cover 90496 compaction and reset edge cases

* fix(codex): guard secondary native compaction binding

* fix(codex): keep native compaction hint internal

* fix(codex): wait for active native turns before resume
2026-06-09 21:33:00 -05:00
Omar Shahine
6c045c5ca3 fix(imessage): surface inbound startup diagnostics (#91785)
Merged via squash.

Prepared head SHA: 597684c365

Proof:
- Focused tests, lint/type/diff checks, and autoreview passed before merge.
- ClawSweeper re-review marked proof and patch quality platinum after lobster live monitor proof.
- Maintainer accepted the diagnostics-only default-log privacy/noise tradeoff.

Lobster proof id: openclaw-lobster-live-monitor-proof-ada22165-6306-46b6-8ed0-6c94fcab6bbc

Reviewed-by: @omarshahine
2026-06-09 19:10:09 -07:00
Omar Shahine
bfccbc3fee fix(imessage): harden outbound send transport (#91783)
Merged via squash.

Prepared head SHA: 39ea25767b

Proof:
- Focused tests, docs/config generation, lint/type/doc checks passed before merge.
- ClawSweeper re-review marked proof and patch quality platinum after lobster live send proof.
- Maintainer accepted the `channels.imessage.sendTransport` config surface and compatibility-risk tradeoff.

Lobster proof id: openclaw-lobster-live-proof-c74895c2-b629-4bb0-abcb-e6521069b3d8

Reviewed-by: @omarshahine
2026-06-09 19:09:15 -07:00
Vincent Koc
9a1f2022b1 fix(security): avoid crypto hash for oauth lock names 2026-06-10 09:54:38 +09:00
Vincent Koc
5967ae61bd fix(security): audit oauth lock hash 2026-06-10 09:32:32 +09:00
Vincent Koc
48ec58a584 fix(security): remediate openclaw alerts 2026-06-10 09:02:00 +09:00
openclaw-clownfish[bot]
c0a4a7890d fix(doctor): keep TTS legacy migration on supported paths (#91787)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-10 08:54:36 +09:00
Vincent Koc
0a6a10193d fix(release): guard Parallels skip-restore lanes 2026-06-10 08:27:59 +09:00
Vincent Koc
c350c35fad fix(release): allow QA capability restore patch
(cherry picked from commit db711701d2)
2026-06-10 08:27:59 +09:00
Vincent Koc
56dc53f6d2 fix(release): harden Parallels smoke validation
(cherry picked from commit 810a821c65)
2026-06-10 08:27:59 +09:00
Dallin Romney
ec0f311f7f fix(config): clarify retired skill workshop plugin warning (#91757) 2026-06-09 16:26:02 -07:00
Agustin Rivera
f0d8048aa3 fix(search): enforce native web search tool policy (#91750)
* fix(search): enforce native web search tool policy

* fix(search): apply session policy to native web search

* fix(search): gate direct OpenAI native search

* fix(search): redact native web search provider context
2026-06-09 16:25:15 -07:00
openclaw-clownfish[bot]
54415d322f fix(ui): drain restored chat queue after session switch (#91780)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com>
2026-06-10 08:20:12 +09:00
colmbrogan
3a9ea1d85b fix(imessage): skip idle approval discovery scans (#88530)
* fix(imessage): bound idle approval discovery scans

* fix(imessage): complete bounded approval discovery

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
2026-06-09 16:03:48 -07:00
Michael Appel
a90eb93452 Harden sandbox bind source validation (#91741) 2026-06-09 15:59:03 -07:00
clawsweeper[bot]
468db12c21 fix(mcp): lowercase SSE event-source header keys to prevent duplicate Authorization (401) (#91773)
Summary:
- The branch lowercases SSE EventSource SDK and operator header keys before merging and adds a regression test for duplicate case-variant Authorization headers.
- PR surface: Source 0, Tests +59. Total +59 across 2 files.
- Reproducibility: yes. Source inspection shows current main can preserve both lowercase `authorization` from  ... K EventSource hook and configured `Authorization`, and the PR adds a focused regression test for that path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(mcp): lowercase SSE event-source header keys to prevent duplicate…

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

Prepared head SHA: c8f7a7940e
Review: https://github.com/openclaw/openclaw/pull/91773#issuecomment-4664644390

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-09 22:52:29 +00:00
Patrick Erichsen
4ca6ac326e docs: remove superpowers spec draft 2026-06-09 15:51:37 -07:00
Andy Ye
9833f3ea9b fix(ui): require user intent for chat sessions (#91480)
Summary:
- The PR adds an explicit user-intent argument to `createChatSession`, updates the New Chat and `/new` action callers to pass it, adds helper regression coverage, and carries minor gateway formatting/import ordering churn.
- PR surface: Source +8, Tests +9. Total +17 across 8 files.
- Reproducibility: yes. at source level: current main lets `createChatSession(state)` reach `sessions.create`  ... ct flow, so the exact user-path reproduction remains integration-level rather than locally reproduced here.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(tasks): restore timers before maintenance apply
- PR branch already contained follow-up commit before automerge: Merge remote-tracking branch 'origin/main' into HEAD

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

Prepared head SHA: e7cd79006b
Review: https://github.com/openclaw/openclaw/pull/91480#issuecomment-4651778423

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-09 22:40:07 +00:00
Jacob Tomlinson
e9bd90d209 docs(security): clarify env var report scope (#91765) 2026-06-10 06:14:24 +09:00
Agustin Rivera
314de694c4 fix(mcp): harden stdio env filtering (#91751) 2026-06-09 14:08:47 -07:00
Shakker
4648701fc1 chore: fix ACP guard lint issues 2026-06-09 22:07:05 +01:00
Shakker
3b7631e50d perf: bound ACP metadata key repair lookup 2026-06-09 22:07:05 +01:00
Shakker
21104cd52e test: use valid deleted ACP bridge fixture 2026-06-09 22:07:05 +01:00
Shakker
6ab084e89d test: use valid configured ACP bridge fixture 2026-06-09 22:07:05 +01:00
Shakker
e16193bad5 fix: skip ACP metadata probe for configured agents 2026-06-09 22:07:05 +01:00
Shakker
31d49c59d7 test: require ACP metadata in resolve unit fixture 2026-06-09 22:07:05 +01:00
Shakker
ef2ca9e50e fix: repair ACP metadata in deleted-agent guard 2026-06-09 22:07:05 +01:00
Shakker
09854d9de7 fix: make ACP metadata key repair idempotent 2026-06-09 22:07:05 +01:00
Shakker
d31d26ef42 fix: validate migrated ACP metadata at canonical key 2026-06-09 22:07:05 +01:00
Shakker
440284f879 fix: rekey ACP metadata during session key migration 2026-06-09 22:07:05 +01:00
Shakker
784e86433c fix: preserve ACP metadata key during deleted-agent checks 2026-06-09 22:07:05 +01:00
Shakker
a82abc771a fix: align session resolve deleted-agent entry type 2026-06-09 22:07:05 +01:00
Shakker
a93bc61a84 fix: read canonical ACP metadata for deleted-agent guard 2026-06-09 22:07:05 +01:00
Shakker
b502a92bf1 fix: require ACP metadata for deleted-agent bypass 2026-06-09 22:07:05 +01:00
Agustin Rivera
21410d1c32 fix(codex): guard sandbox http requests (#91752)
* fix(codex): guard sandbox http requests

* fix(codex): align sandbox http policy
2026-06-09 13:54:24 -07:00
Agustin Rivera
a4e02cd1dd fix(elevated): reject group ids as senders (#91748)
* fix(elevated): reject group ids as senders

* fix(elevated): keep channel parsing out of core
2026-06-09 13:20:36 -07:00
Agustin Rivera
b6a3f2988c fix(gateway): restrict non-owner loopback tools (#91749)
* fix(gateway): restrict non-owner loopback tools

* fix(gateway): split loopback owner cache key
2026-06-09 13:15:48 -07:00
Alex Knight
bf95883812 feat(diagnostics-otel): capture tool input/output content via trusted channel (#91256)
diagnostics.otel.captureContent.{toolInputs,toolOutputs} were documented
and config-wired but never produced any span content. Emit tool args and
results over the trusted private-data diagnostic channel (mirroring the
model-content path), and have the OTel exporter bound/redact/truncate them
before span export. Raw tool content never rides the public event bus.

Scope: core embedded-runner tool path (canonical producer). Codex
(async-batched) and Claude CLI remain follow-ups tracked by the issue.

Refs #77391
2026-06-10 05:52:52 +10:00
Agustin Rivera
d2ddc26e89 fix(msteams): require admin for group actions (#91746) 2026-06-09 12:52:24 -07:00
Niels Kaspers
96a49caffa docs: clarify trusted-proxy websocket scopes (#85950) 2026-06-09 12:40:12 -07:00
Agustin Rivera
2649064548 fix(discord): require sender for moderation actions (#91745) 2026-06-09 12:33:38 -07:00
Dallin Romney
370cef2e3b docs: align Feishu DM policy defaults (#91755) 2026-06-09 12:31:47 -07:00
Dallin Romney
a2dd821908 docs: clarify matrix plugin upgrade repair (#91753) 2026-06-09 12:22:59 -07:00
Shubhankar Tripathy
443115c632 fix(config): warn for retired skill-workshop plugin entry instead of failing validation (#90244) (#90838) 2026-06-09 12:20:34 -07:00
openclaw-release-bot
5b9cb3bd3a chore(release): update appcast for 2026.6.5 2026-06-09 19:13:54 +00:00
Dallin Romney
8b84e951e5 perf(tui): prewarm runtime plugins before first send (#90782)
* perf: prewarm TUI runtime plugins before first send

* fix: satisfy TUI prewarm lint

* fix(tui): clarify runtime warmup submit block

* refactor(tui): warm embedded runtime during history load

* fix(tui): align runtime prewarm workspace
2026-06-09 11:30:28 -07:00
scotthuang
52154eda0d fix: preserve configured ACP deleted-agent guard 2026-06-09 18:31:40 +01:00
scotthuang
3853eb15af test(gateway): add store integration proof for ACP deleted-agent guard 2026-06-09 18:31:40 +01:00
scotthuang
696c1ecd20 fix(gateway): skip deleted-agent guard for ACP harness session keys
ACP session keys use agent:<harnessId>:acp:<uuid>, so sessions_send and
sessions.resolve must not treat harness ids as agents.list owners.
2026-06-09 18:31:40 +01:00
NVIDIAN
2da7dc9f2c test: cover auto-enable cache freshness 2026-06-09 18:08:28 +01:00
NVIDIAN
439b0582e2 perf(config): avoid stale implicit auto-enable cache 2026-06-09 18:08:28 +01:00
NVIDIAN
f13b6ea151 perf(config): dedupe plugin auto-enable fanout work 2026-06-09 18:08:28 +01:00
Shakker
56d201fa67 fix: retry workflow sanity checkout fetches 2026-06-09 17:39:01 +01:00
Shakker
9bb68b55dd fix: avoid gateway restart for tui footer config 2026-06-09 17:35:03 +01:00
Shakker
d48778994f fix: gate tui host footer behind config 2026-06-09 17:35:03 +01:00
WB
479e2aaae3 fix(tui): show connection host in footer 2026-06-09 17:35:03 +01:00
brokemac79
1893a0727a fix(status): restore Codex synthetic usage line
Restores the Codex/OpenAI usage line in status by routing Codex-harness usage through the Codex app-server provider hook. Preserves configured app-server startup options, selected OpenAI/Codex auth profiles, weekly-window cadence, and Codex credit wording while skipping unsupported API-key usage probes. Fixes #91694.
2026-06-10 01:32:33 +09:00
Shakker
61e93a800d fix: bound model catalog state cache 2026-06-09 17:27:33 +01:00
Shakker
da7f9c51df fix: isolate model catalog cache contexts 2026-06-09 17:27:33 +01:00
Shakker
55156a1241 fix: keep model catalog cache keys current 2026-06-09 17:27:33 +01:00
ai-hpc
cc856cde1b fix(models): refresh persisted catalog cache keys 2026-06-09 17:27:33 +01:00
ai-hpc
4106f446bd fix(models): persist agent catalog cache 2026-06-09 17:27:33 +01:00
Jacob Tomlinson
8c3ba33463 fix(mattermost): keep default replies in existing threads
Restores the documented Mattermost default where replyToMode="off" does not start new threads for top-level messages, but still preserves replies that arrive inside an existing Mattermost thread.

Manual Mattermost proof and focused monitor tests cover threaded channel replies, top-level off-mode messages, and direct messages.
2026-06-09 17:08:51 +01:00
Pavan Kumar Gondhi
86bab9699d fix: block git protocol env controls [AI] (#91619)
* fix: block git protocol env controls

* fix: preserve restrictive git protocol env

* fix: preserve restrictive git allowlists

* fix: filter inherited git protocol allowlists

* test: cover restrictive git allowlists

* test: avoid opengrep fixture false positives

* test: type env fixture helper narrowly

* fix: preserve zero git protocol booleans

* fix: preserve invalid git protocol booleans

* fix: force git protocol from user off

* fix: share git inherited env sanitization
2026-06-09 21:09:14 +05:30
Shakker
d2a6529f04 fix: avoid mcp shutdown response snapshot allocation 2026-06-09 16:05:14 +01:00
Shakker
2dcfd9f218 fix: close mcp loopback streams on shutdown 2026-06-09 16:05:14 +01:00
Shakker
d86069ded0 test: strengthen mcp loopback transport coverage 2026-06-09 16:05:14 +01:00
Cameron Beeley
7269b26926 fix(gateway): validate Origin before auth on GET/DELETE; merge-safe test token
Addresses review:
- Reorder the new GET and DELETE branches so rejectsBrowserLoopbackRequest()
  runs BEFORE bearer auth, matching the POST path — a browser-Origin loopback
  request is now rejected (403) before auth, preserving the loopback Origin
  boundary even for unauthenticated browser requests. Added focused tests:
  browser-Origin GET and DELETE with no bearer return 403 (before auth).
- The new transport tests now read the loopback owner token from
  getActiveMcpLoopbackRuntime().ownerToken instead of resolveMcpLoopbackBearerToken,
  so they don't depend on that helper's import (which current main's test file
  no longer carries).
2026-06-09 16:05:14 +01:00
Cameron Beeley
224ea76d29 fix(gateway): keep MCP loopback stateless; add DELETE no-op + transport tests
Addresses review feedback on the Streamable HTTP transport:

- Keep the loopback server stateless: drop the advertised Mcp-Session-Id header
  (the server owns no session lifecycle, so advertising a session id clients
  would echo back was misleading). Resolves the stateless-vs-sessionful concern.
- Add DELETE /mcp as an auth-gated 200 no-op (Streamable HTTP teardown), so
  clients that send DELETE on close get a clean 200 instead of 405; Allow now
  advertises GET, POST, DELETE.
- Keep the GET/SSE notification channel (the actual fix for the 'still
  connecting' hang) auth-gated and browser-origin-rejected.
- Add focused gateway tests: GET 200 + text/event-stream, GET 401 (no auth),
  GET 403 (browser origin), DELETE 200, DELETE 401, unsupported 405 with the
  correct Allow, and POST stays stateless (no Mcp-Session-Id).
2026-06-09 16:05:14 +01:00
Cameron Beeley
cc0a18da4f fix: remove DELETE handler — loopback MCP is stateless, no sessions to terminate
The DELETE path acknowledged Mcp-Session-Id without validating,
terminating, or expiring anything. Since the loopback server is
stateless (session ID is cosmetic for spec compliance), return 405
instead of pretending to support session teardown.
2026-06-09 16:05:14 +01:00
Cameron Beeley
48b3cb69b7 fix: route GET SSE through browser-origin gate 2026-06-09 16:05:14 +01:00
Cameron Beeley
cfe0bac99a fix: flush SSE response and gate DELETE behind auth + browser-origin check
- flushHeaders() + initial SSE comment so clients don't hang
- DELETE requires bearer auth (matching GET/POST gates)
- DELETE checks browser-origin rejection (matching POST gate)
2026-06-09 16:05:14 +01:00
Cameron Beeley
7f69fe009a fix(gateway): support Streamable HTTP MCP transport (GET/SSE + DELETE)
The MCP loopback server generates config with `"type": "http"` for
Claude Code, but only handled POST requests. Claude Code's Streamable
HTTP client sends GET to open an SSE notification channel before
completing initialization. The 405 rejection on GET caused Claude Code
to hang at "still connecting" indefinitely.

- Accept GET /mcp with bearer auth, return text/event-stream (idle SSE)
- Accept DELETE /mcp for session termination (spec compliance)
- Add Mcp-Session-Id header to POST responses (spec requirement)
- Update 405 Allow header to reflect supported methods
2026-06-09 16:05:14 +01:00
Pavan Kumar Gondhi
7cdec28706 fix: block rustup toolchain env overrides [AI] (#91615)
* fix: block rustup toolchain env overrides [AI]

* test: cover inherited rustup env stripping [AI]

* fix: preserve inherited rustup env [AI]

* fix: filter ignored opengrep changed paths [AI]

* fix: honor opengrep ignored directory globs [AI]

* fix: match ignored opengrep descendants [AI]

* fix: cover rustup mirror overrides [AI]

* fix: preserve opengrep directory-only ignores [AI]

* chore: drop opengrep cleanup from rustup fix [AI]
2026-06-09 20:03:32 +05:30
Pavan Kumar Gondhi
9f413acc18 fix: expand unsafe host env denylist (#91618)
* fix: expand unsafe host env denylist

* test: annotate host env security fixtures

* test: align opengrep fixture suppressions

* test: keep opengrep suppressions inline

* test: avoid opengrep fixture call patterns
2026-06-09 19:44:54 +05:30
547 changed files with 30051 additions and 8023 deletions

View File

@@ -112,7 +112,7 @@ jobs:
persist-credentials: false
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -245,7 +245,7 @@ jobs:
- name: Set up Blacksmith Docker Builder
if: steps.existing.outputs.exists != 'true'
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -429,7 +429,7 @@ jobs:
run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF"
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -542,7 +542,7 @@ jobs:
persist-credentials: false
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000

View File

@@ -36,7 +36,7 @@ jobs:
password: ${{ github.token }}
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000

View File

@@ -126,7 +126,7 @@ jobs:
fetch-depth: 1
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000

View File

@@ -1497,7 +1497,7 @@ jobs:
- name: Setup Docker builder
if: steps.image_exists.outputs.needs_build == '1'
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -1593,8 +1593,11 @@ jobs:
run: |
set -euo pipefail
repository="${GITHUB_REPOSITORY,,}"
live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}"
live_image_extensions="matrix,acpx"
live_image_tag_suffix="${live_image_extensions//,/-}"
live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}-${live_image_tag_suffix}"
echo "live_image=${live_image}" >> "$GITHUB_OUTPUT"
echo "live_image_extensions=${live_image_extensions}" >> "$GITHUB_OUTPUT"
echo "Shared live-test image: \`${live_image}\`" >> "$GITHUB_STEP_SUMMARY"
- name: Log in to GHCR
@@ -1617,7 +1620,7 @@ jobs:
- name: Setup Docker builder
if: steps.image_exists.outputs.exists != '1'
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -1629,7 +1632,7 @@ jobs:
file: ./Dockerfile
target: build
build-args: |
OPENCLAW_EXTENSIONS=matrix
OPENCLAW_EXTENSIONS=${{ steps.image.outputs.live_image_extensions }}
platforms: linux/amd64
tags: ${{ steps.image.outputs.live_image }}
sbom: true

View File

@@ -159,7 +159,7 @@ jobs:
run_mock_parity:
name: Run QA Lab mock parity lane
needs: [validate_selected_ref]
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 30
env:
QA_PARITY_CONCURRENCY: "1"
@@ -186,7 +186,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run OpenAI candidate lane
@@ -232,7 +232,7 @@ jobs:
name: Run live runtime token-efficiency lane
needs: [authorize_actor, validate_selected_ref]
if: github.event_name == 'schedule'
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 45
environment: qa-live-shared
env:
@@ -267,7 +267,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run live runtime parity lane
@@ -321,7 +321,7 @@ jobs:
name: Run Matrix live QA lane
needs: [authorize_actor, validate_selected_ref]
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.matrix_profile == 'all') }}
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
steps:
@@ -352,7 +352,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run Matrix live lane
@@ -397,7 +397,7 @@ jobs:
name: Run Matrix live QA lane (${{ matrix.profile }})
needs: [authorize_actor, validate_selected_ref]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.matrix_profile == 'all' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
strategy:
@@ -437,7 +437,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run Matrix live lane shard
@@ -480,7 +480,7 @@ jobs:
run_live_telegram:
name: Run Telegram live QA lane with Convex leases
needs: [authorize_actor, validate_selected_ref]
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
steps:
@@ -520,7 +520,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run Telegram live lane
@@ -575,7 +575,7 @@ jobs:
run_live_discord:
name: Run Discord live QA lane with Convex leases
needs: [authorize_actor, validate_selected_ref]
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
steps:
@@ -615,7 +615,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run Discord live lane
@@ -669,7 +669,7 @@ jobs:
run_live_whatsapp:
name: Run WhatsApp live QA lane with Convex leases
needs: [authorize_actor, validate_selected_ref]
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
concurrency:
group: qa-live-whatsapp-shared
@@ -712,7 +712,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run WhatsApp live lane
@@ -766,7 +766,7 @@ jobs:
run_live_slack:
name: Run Slack live QA lane with Convex leases
needs: [authorize_actor, validate_selected_ref]
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
steps:
@@ -806,7 +806,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run Slack live lane

View File

@@ -509,60 +509,62 @@ jobs:
let locked = 0;
let inspected = 0;
let cursor = null;
let page = 1;
while (true) {
const { data: issues } = await github.rest.issues.listForRepo({
owner,
repo,
state: "closed",
sort: "updated",
direction: "desc",
per_page: perPage,
page,
});
const result = await github.graphql(
`query ClosedIssuesForLocking(
$owner: String!
$repo: String!
$cursor: String
$perPage: Int!
) {
repository(owner: $owner, name: $repo) {
issues(
first: $perPage
after: $cursor
states: CLOSED
orderBy: { field: CREATED_AT, direction: ASC }
) {
nodes {
number
locked
closedAt
comments(last: 1) {
nodes {
createdAt
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}`,
{
owner,
repo,
cursor,
perPage,
},
);
const issues = result.repository.issues;
if (issues.length === 0) {
break;
}
for (const issue of issues) {
if (issue.pull_request) {
continue;
}
if (issue.locked) {
continue;
}
if (!issue.closed_at) {
for (const issue of issues.nodes) {
if (issue.locked || !issue.closedAt) {
continue;
}
inspected += 1;
const closedAtMs = Date.parse(issue.closed_at);
if (!Number.isFinite(closedAtMs)) {
continue;
}
if (closedAtMs > cutoffMs) {
const closedAtMs = Date.parse(issue.closedAt);
if (!Number.isFinite(closedAtMs) || closedAtMs > cutoffMs) {
continue;
}
let lastCommentMs = 0;
if (issue.comments > 0) {
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issue.number,
per_page: 1,
page: 1,
sort: "created",
direction: "desc",
});
if (comments.length > 0) {
lastCommentMs = Date.parse(comments[0].created_at);
}
}
const lastComment = issue.comments.nodes[0];
const lastCommentMs = lastComment ? Date.parse(lastComment.createdAt) : 0;
const lastActivityMs = Math.max(closedAtMs, lastCommentMs || 0);
if (lastActivityMs > cutoffMs) {
continue;
@@ -578,7 +580,10 @@ jobs:
locked += 1;
}
page += 1;
if (!issues.pageInfo.hasNextPage || !issues.pageInfo.endCursor) {
break;
}
cursor = issues.pageInfo.endCursor;
}
core.info(`Inspected ${inspected} closed issues; locked ${locked}.`);

View File

@@ -34,10 +34,25 @@ jobs:
git init "$GITHUB_WORKSPACE"
git -C "$GITHUB_WORKSPACE" config gc.auto 0
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
fetch_checkout_ref() {
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
fetch_status="$?"
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
return "$fetch_status"
fi
if [ "$attempt" = "3" ]; then
return "$fetch_status"
fi
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
sleep 5
done
}
fetch_checkout_ref
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Fail on tabs in workflow files
@@ -78,10 +93,25 @@ jobs:
git init "$GITHUB_WORKSPACE"
git -C "$GITHUB_WORKSPACE" config gc.auto 0
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
fetch_checkout_ref() {
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
fetch_status="$?"
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
return "$fetch_status"
fi
if [ "$attempt" = "3" ]; then
return "$fetch_status"
fi
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
sleep 5
done
}
fetch_checkout_ref
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Setup Python
@@ -190,10 +220,25 @@ jobs:
git init "$GITHUB_WORKSPACE"
git -C "$GITHUB_WORKSPACE" config gc.auto 0
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
fetch_checkout_ref() {
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
fetch_status="$?"
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
return "$fetch_status"
fi
if [ "$attempt" = "3" ]; then
return "$fetch_status"
fi
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
sleep 5
done
}
fetch_checkout_ref
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Setup Node environment

View File

@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
- Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.
- Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.
- Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.
- Memory: keep doctor REM harness previews aligned with live REM by dropping short-term recall snippets whose source files disappeared before rendering preview output. Thanks @samzong and @frankekn.
- Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.
- Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.
- Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)

View File

@@ -48,6 +48,7 @@ These patterns are usually not vulnerabilities by themselves:
- Prompt injection without a policy, auth, approval, sandbox, or tool-boundary bypass.
- A trusted operator using an intentional local feature, such as local shell access or browser/script execution.
- A report whose only primitive is changing the process or child-process environment before running OpenClaw or an executable OpenClaw invokes.
- A malicious plugin after a trusted operator installs or enables it.
- Multiple adversarial users sharing one Gateway host/config and expecting per-user isolation.
- Scanner-only, dependency-only, or stale-path reports without a working repro and demonstrated OpenClaw impact.
@@ -103,6 +104,7 @@ These are frequently reported but are typically closed with no code change:
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
- Reports that depend on replacing or rewriting an already-approved executable path on a trusted host (same-path inode/content swap) without showing an untrusted path to perform that write.
- Reports that depend on attacker-controlled environment variables changing executable behavior, including variables that redirect lookup paths, preload code, select wrappers/interpreters, alter package-manager or runtime hooks, or make one executable call another executable. Control of the process or child-process environment is trusted host/operator control in OpenClaw's model; these reports need a separate OpenClaw boundary bypass that lets untrusted input set or mutate that environment.
- Reports that depend on pre-existing symlinked skill/workspace filesystem state (for example symlink chains involving `skills/*/SKILL.md`) without showing an untrusted path that can create/control that state.
- Missing HSTS findings on default local/loopback deployments.
- Reports against test-only harnesses, QA Lab, QE Lab, E2E fixtures, benchmark rigs, or maintainer-only debugging tools when the vulnerable code is not shipped as a supported production surface.
@@ -161,6 +163,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
- Reports where exploitability depends on attacker-controlled pre-existing symlink/hardlink filesystem state in trusted local paths (for example extraction/install target trees) unless a separate untrusted boundary bypass is shown that creates that state.
- Reports whose only claim is sandbox/workspace read expansion through trusted local skill/workspace symlink state (for example `skills/*/SKILL.md` symlink chains) unless a separate untrusted boundary bypass is shown that creates/controls that state.
- Reports whose only claim is post-approval executable identity drift on a trusted host via same-path file replacement/rewrite unless a separate untrusted boundary bypass is shown for that host write primitive.
- Reports whose only claim is environment-variable-driven executable behavior change, including path lookup changes, preload hooks, wrapper/interpreter selection, package-manager/runtime hooks, or variables that make an executable invoke another executable, unless a separate OpenClaw boundary bypass lets untrusted input set or mutate that environment.
- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary
- Reports whose only claim is use of an explicit trusted-operator control surface (for example `canvas.eval`, browser evaluate/script execution, or direct `node.invoke` execution) without demonstrating an auth, policy, allowlist, approval, or sandbox bypass.
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
@@ -181,6 +184,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
OpenClaw security guidance assumes:
- The host where OpenClaw runs is within a trusted OS/admin boundary.
- Anyone who can set or mutate the OpenClaw process environment, launcher environment, or child-process environment is inside that trusted host/operator boundary.
- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator.
- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary.
- Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries.

View File

@@ -2,6 +2,86 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.6.5</title>
<pubDate>Tue, 09 Jun 2026 19:06:49 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2606000590</sparkle:version>
<sparkle:shortVersionString>2026.6.5</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.6.5</h2>
<h3>Highlights</h3>
<ul>
<li>QQBot now strips model reasoning/thinking scaffolding before native delivery, preventing raw <code><thinking></code> content from leaking into channel replies. (#89913, #90132) Thanks @openperf.</li>
<li>MCP tool results now coerce <code>resource_link</code>, <code>resource</code>, <code>audio</code>, malformed image, and future non-text/image blocks at the materialize boundary, preventing Anthropic 400s and poisoned session history after a tool returns richer MCP content. (#90710, #90728) Thanks @RanSHammer and @849261680.</li>
<li>Anthropic extended-thinking sessions recover after prompt-cache expiry or Gateway restart because stream start events wait for <code>message_start</code>, letting pre-generation signature errors trigger the existing recovery retry. (#90667, #90697) Thanks @openperf.</li>
<li>Parallel is now a bundled <code>web_search</code> provider with <code>PARALLEL_API_KEY</code> discovery, guarded endpoint handling, cache-safe session ids, onboarding picker support, and docs. (#85158) Thanks @NormallyGaussian.</li>
<li>Google Vertex ADC users get static catalog rows and runtime model resolution again, while single-provider cooldown recovery and memory adapter status checks are more reliable. (#90506, #90609, #90717, #90816) Thanks @849261680.</li>
<li>Matrix can preflight voice notes before mention gating, preserve thread reads/replies through Matrix relations pagination, and carry QA coverage for voice and thread flows. (#78016, #90415)</li>
<li>Auth and plugin install state is more durable: auth profiles now live in SQLite, official npm plugin install records keep their trusted pins, and prerelease fallback integrity checks avoid carrying stale integrity forward. (#89102, #88585)</li>
<li>Agent, tool, and provider loops are stricter around MCP lease timestamps, prompt-cache tool names, local tool catalogs, unreadable dynamic tools, owner-only HTTP tools, and provider catalog metadata, reducing hidden retries and unsafe exposure. (#91124, #91233, #90022, #90261)</li>
<li>macOS node mode no longer silently self-reconnects away from a healthy direct Gateway session, reducing unexpected companion app session churn. (#90668, #90815) Thanks @vrurg.</li>
<li>Upgrade and service paths are safer: cron legacy JSON stores migrate during doctor preflight, service env placeholders no longer mask state-dir secrets, WhatsApp startup waits are bounded, and disabled WhatsApp accounts tear down on config reload. (#90072, #90208, #90277, #90488, #90486, #87951, #87965) Thanks @MonkeyLeeT, @sallyom, @mcaxtr, and @MukundaKatta.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Search/providers: add the Parallel bundled web-search plugin, live provider tests, registration contracts, onboarding/docs wiring, and guarded <code>api.parallel.ai/v1/search</code> support. (#85158) Thanks @NormallyGaussian.</li>
<li>Matrix/channels: add voice-message preflight and thread-aware read/reply behavior, including Matrix QA scenario wiring and docs for voice-message behavior. (#78016, #90415)</li>
<li>Skills/ClawHub: install ClawHub skills backed by GitHub repositories through the resolved install API, download the pinned GitHub commit, keep install-policy checks, and report install telemetry after success. (#90478) Thanks @Patrick-Erichsen.</li>
<li>Skills/ClawHub: avoid one filesystem watcher per skill file during refresh, keeping large skill trees from exhausting watcher limits.</li>
<li>Google Chat/channels: add native approval card actions and click handling so Google Chat approvals use platform-native cards instead of generic message flow.</li>
<li>Mobile: Android provider/model screens now surface expiring, unavailable, unresolved, and attention states more clearly, Android adds theme mode selection, and iOS settings and Talk tabs keep diagnostics, gateway rows, attachment labels, fallback copy, and unavailable Talk controls reachable. (#90752, #91201)</li>
<li>Memory: QMD search can use the new rerank toggle, and memory adapter status uses the resolved default model identity when checking plain status. (#61834)</li>
<li>Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward <code>web_fetch</code>, clarify legacy <code>openai-codex</code> auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev.</li>
<li>Release/process: switch release trains to <code>YYYY.M.PATCH</code> monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at <code>2026.6.5</code>.</li>
<li>Release/process: defer the session-metadata SQLite migration from the <code>2026.6.5</code> beta train so this release keeps the existing JSON-backed session metadata path while the migration risk is worked on <code>main</code>.</li>
<li>Release metadata: align OpenClaw, publishable plugin manifests, generated shrinkwraps, app version metadata, iOS release notes, Matrix plugin changelog, and generated release baselines with the <code>2026.6.5</code> release train.</li>
<li>Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601)</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf.</li>
<li>Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680.</li>
<li>Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until <code>message_start</code>, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii.</li>
<li>Agents/Codex/tools: MCP lease release no longer refreshes <code>lastUsedAt</code>, prompt-cache tool names are guarded, lean local tool catalogs stay compact, unreadable dynamic tools are quarantined, orphan tool errors still surface, native subagent completion results survive app-server monitoring, and background-session name derivation avoids regex backtracking risk. (#91124, #90612, #90022, #91235, #91233)</li>
<li>Provider/model resolution: preserve Google Vertex ADC auth markers in generated catalogs, re-probe a single-provider primary after cooldown, share Codex model visibility, fail closed for unknown model auth, preserve Codex alias availability, keep unresolved profile refs unknown, and avoid resolving auth while listing models. (#90506, #90609, #90717, #90702) Thanks @849261680.</li>
<li>Provider/model resolution: live provider model catalogs keep helper coverage, Ollama catalog metadata is preserved, Google provider prefixes are stripped from Gemini paths, Foundry Responses reasoning replay ids survive, MiniMax M3 thinking stays enabled, Vertex multi-region calls use the right regional host, and OpenRouter streamed generation cost is reconciled. (#91125)</li>
<li>Gateway/macOS/mobile: avoid duplicate Gateway probe warnings by identity, rate-limit node pairing requests while preserving paired-node reconnects, keep macOS node mode on a healthy direct Gateway session, keep iOS diagnostics and gateway rows reachable, and avoid Linux ARM Gradle resource tasks during Android builds. (#85791, #90147, #90668, #90815) Thanks @giodl73-repo and @vrurg.</li>
<li>Gateway/security/config: owner-only HTTP tools are gated, sandbox skills remain readable in writable sandboxes, legacy agent registry and Codex model metadata migrate safely, and stalled MCP response bodies time out instead of tying up Gateway workers. (#90261)</li>
<li>Gateway/config: <code>config.patch</code> now preserves explicit array replacement semantics for arrays without merge keys, so replacement patches do not accidentally merge stale entries. (#91551)</li>
<li>SDK: event pump failures now surface to clients instead of being swallowed behind a quiet iterator shutdown.</li>
<li>Agents/transcripts: inline image payload redaction now catches data URLs and repaired transcript images before they can leak raw image bytes into stored or exported transcripts. (#91529)</li>
<li>Plugins/Gateway: legacy flat Control UI descriptors from shipped JavaScript plugins now normalize <code>name</code> and missing surface fields into session descriptors, restoring Kitchen Sink RPC descriptor proof for package-backed plugin validation.</li>
<li>TUI/chat/Workboard/auto-reply: optimistic user messages stay stable across stale history reloads, runId reassignment, and abort windows instead of disappearing, jumping, or lingering as ghost rows; Workboard stale lifecycle bulk updates no longer overwrite newer status/provenance; message-tool sends now count as delivery. (#86205, #89600, #88592, #90123) Thanks @RomneyDa.</li>
<li>Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, isolated agent turn payload messages preserve timeout context, service env planning skips unresolved placeholders that would mask state-dir <code>.env</code> values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #91230, #90277, #90488) Thanks @MonkeyLeeT and @sallyom.</li>
<li>State/storage: Matrix sync and crypto sidecars, memory-wiki import/source-sync state, sandbox registry state, ACPX process state, device-pair notify state, Zalo hosted media, and plugin SDK dedupe state now use SQLite-owned storage instead of ad hoc runtime files. (#91100, #91108, #91056)</li>
<li>Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145)</li>
<li>Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on <code>globalThis</code> and default replies stay inside existing Mattermost threads instead of starting new ones; Feishu streaming cards preserve full merged content; iMessage private-API failures and send timeouts explain themselves while split-send coalescing honors balloon metadata; voice-call tracks Twilio streams after connect; ClickClack reply tools respect <code>toolsAllow</code>; Discord runtime adapters stay resolvable; and outbound delivery retries survive budget deferrals. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500, #91041, #90858, #91119, #91241) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, @sahibzada-allahyar, and @jacobtomlinson.</li>
<li>Feishu: retry transient send rate-limit errors (HTTP 429, per-chat code 230020, tenant-level code 11232) with linear backoff, including SDK responses that fulfill with rate-limit bodies instead of throwing, and route streaming-card sends through the retry wrapper. (#89659) Thanks @ladygege.</li>
<li>WhatsApp: captured replies after restart now route through the successor controller instead of the stale pre-restart controller. (#85823)</li>
<li>Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp.</li>
<li>Release/CI/E2E: installed-package root dist verification now allows the current package's JavaScript file count while keeping dependency, per-file-size, and scan-bound checks active.</li>
<li>Release/CI/E2E: Chutes OAuth model-discovery proof now accepts standard <code>Headers</code> requests, and QR package install smoke caps Docker CPU requests to the hosted runner capacity so beta validation fails on real package regressions.</li>
<li>Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.</li>
<li>Release/CI/E2E: Docker E2E CPU limits now cap to the runner capacity, keeping package Telegram acceptance on hosted 8-vCPU runners focused on package regressions instead of impossible Docker resource requests.</li>
<li>Release/CI/E2E: task maintenance release checks now reset pinned config around isolated temp state dirs, keeping normal CI focused on the active session-store fixture instead of stale process snapshots.</li>
<li>Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.</li>
<li>Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.</li>
<li>Release/CI/E2E: browser snapshot, release-scenario, release-user-journey, Telegram desktop/RTT/package, web-search, Parallels update, plugin update, doctor switch, and upgrade-survivor diagnostics now stream or bound log/artifact reads so failed proof stays inspectable without unbounded output.</li>
<li>Release/CI/E2E: Parallels smoke validation now runs without requiring <code>pnpm</code> on the host, supports already-started Windows/Linux guests without snapshots, reports empty snapshot metadata clearly, and finds portable user-local Node on Windows.</li>
<li>Release/CI/E2E: ClawHub publish jobs prepare dependencies after checking out the target ref, and Docker store seed package discovery now targets the intended production packages. (#91547)</li>
<li>Release/CI/E2E: QA Lab capability-flip release validation now marks intentional <code>tools.deny</code> restores as array replacements, so beta validation fails only on real capability regressions.</li>
<li>Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.</li>
<li>Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.</li>
<li>Tests/state isolation: QA Lab runtime parity now treats matching controlled tool errors as equivalent and falls back to transcript tool results when mock debug rows miss async image-generation starts.</li>
<li>Tests/state isolation: QA suites now fail closed on skipped summaries, missing runtime tool proof, planned-only rows, loose release limits, missing live/provider artifacts, failed agent reply markers, and package Telegram summary failures.</li>
<li>Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)</li>
<li>Sessions: the beta SQLite downgrade rescue now skips extra pre-reads for active non-empty JSON session stores, preserving cache race detection while still restoring missing or empty beta session files.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClaw-2026.6.5.zip" length="55725877" type="application/octet-stream" sparkle:edSignature="EKr7gCfpEVStis9HSADJk1CWYbmH2MHMqSgNfZvLbBFCBWmk3pjBJS6K2qkxkq5lIbTj4H+Lo7Iri6ip/xTGDA=="/>
</item>
<item>
<title>2026.6.1</title>
<pubDate>Wed, 03 Jun 2026 21:26:22 +0000</pubDate>
@@ -193,52 +273,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.28/OpenClaw-2026.5.28.zip" length="54750142" type="application/octet-stream" sparkle:edSignature="U4O55uMdPU+OqSx9QR1ApUJ8wg65wxTydzD7iyCn1GHtm1MBK9noEeiA/yoUKkqb/bx0hzi1gNhn+ye19RXnCA=="/>
</item>
<item>
<title>2026.5.27</title>
<pubDate>Thu, 28 May 2026 12:12:19 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026052790</sparkle:version>
<sparkle:shortVersionString>2026.5.27</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.5.27</h2>
<h3>Highlights</h3>
<ul>
<li>Stronger security and content boundaries: group prompt text is kept out of the system prompt, repeated-dot hostnames are normalized, side-effecting command wrappers and unsafe Node runtime env overrides are blocked, no-auth Tailscale exposure is rejected, and node/device-role approvals now require admin authority. (#87144, #87305, #87292, #87308, #87146) Thanks @eleqtrizit and @pgondhi987.</li>
<li>More reliable Codex app-server runs: Codex runtime models resolve first, workspace memory is routed through tools, shared app-server clients survive startup and spawned-helper failures, native hook relay generations survive restarts and rotate on fresh fallbacks, and false runtime live switches are avoided. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.</li>
<li>Faster Gateway and reply paths: session reads, plugin metadata fingerprints, auth env snapshots, auto-enabled plugin config, tool-search catalogs, and stable metadata caches do less hot-path rediscovery while visible replies no longer inherit hidden cleanup timeouts. (#86439, #87044) Thanks @keshavbotagent.</li>
<li>Better provider and model coverage: OpenAI-compatible embedding providers are core, DeepInfra catalog browsing loads the full credential-aware model set, Pixverse adds video generation and API region selection, VLLM thinking params are wired, Claude CLI OAuth overlays load for PI auth profiles, and bare direct Anthropic model ids work. (#85269, #84549, #87167) Thanks @dutifulbob, @ats3v, and @joshavant.</li>
<li>Channel delivery is steadier: Telegram <code>sendMessage</code> actions use durable outbound delivery, iMessage suppresses duplicate native exec approval prompts and sends, Slack keeps delivered final replies during late cleanup, Matrix mention previews/finals are stricter, QQBot fallback approval buttons honor slash-command auth, Discord guild requester checks are tighter, recovered Discord tool-warning artifacts stay out of successful replies, and Google Chat stops thread sends in DMs. (#87261, #87154) Thanks @mbelinky and @eleqtrizit.</li>
<li>Release, package, and CI proof paths are harder to wedge: npm/package inventory honors dist exclusions, shrinkwrap override pins merge correctly, Docker runtime workspace templates are packaged and smoked, release postpublish checks are stricter, beta smoke rejects empty runs, and E2E log/probe waits are bounded.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Memory: add a core OpenAI-compatible embedding provider for local and hosted OpenAI-style endpoints, with config, doctor, and docs support. (#85269) Thanks @dutifulbob.</li>
<li>Plugin SDK: mark memory-specific embedding provider registration as deprecated compatibility and surface non-bundled usage in plugin compatibility diagnostics. (#85072) Thanks @mbelinky.</li>
<li>Providers: add the Pixverse video generation provider, API region selection, docs, and external plugin packaging support.</li>
<li>DeepInfra: load the full model catalog when users browse models during onboarding, preserve configured API-key catalogs, refresh media/video defaults, and keep pricing/default model metadata aligned. (#84549) Thanks @ats3v.</li>
<li>Plugin SDK: expose plugin approval action metadata and stop exporting Vitest test helpers from the public SDK surface. (#87120) Thanks @RomneyDa.</li>
<li>Channel SDK: move channel message compatibility into core, remove old channel turn runtime aliases, and preserve runtime catalog markdown metadata for plugins.</li>
<li>ClawHub: add plugin display metadata so catalog/package listings use cleaner names. (#87354) Thanks @thewilloftheshadow.</li>
<li>Agents: split the heartbeat runtime template out of docs assets and add compatibility repair for legacy heartbeat template content. (#85416) Thanks @hxy91819.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Security/content boundaries: route untrusted group prompt metadata outside system prompts, normalize repeated trailing hostname dots, block side-effecting command wrappers, reject unsafe Node runtime env overrides, reject no-auth Tailscale exposure, block untrusted Microsoft Teams service URLs, enforce <code>/allowlist configWrites</code> origin policy, gate QQBot fallback approval buttons, and require admin for node/device-role approvals. (#87144, #87305, #87292, #87308, #87146, #87154, #87334) Thanks @eleqtrizit and @pgondhi987.</li>
<li>Codex: resolve Codex runtime models before generic routing, route workspace memory through tools, preserve shared app-server clients after startup and spawned-helper failures, preserve native hook relay generations across restarts and fresh fallbacks, keep raw reasoning/source-reply guards intact, report quarantined dynamic tools, keep the attempt watchdog armed for queued terminal turns, and route Codex OAuth compaction through OpenAI-Codex. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.</li>
<li>Agents/runtime: avoid session event queue self-waits, bound compaction wake and steering retries, preserve grace for pending error diagnostics, avoid false Codex runtime live switches, avoid stale restart continuation reuse, preserve session fallback errors, suppress duplicate Claude CLI skill prompts, keep runtime context before active user turns, strip stale Anthropic thinking, quarantine unsupported tool schemas, recover completed write timeouts safely, release retained session write locks on timeout abort, and validate forced plugin harness support before pinning. (#86123, #55424, #86855, #74341, #87278) Thanks @luoyanglang, @cathrynlavery, and @openperf.</li>
<li>Reply/session delivery: keep visible turn admission unbounded, keep visible fallback delivery on latest targets, preserve bridge hook context, classify direct fallback targets by channel grammar, report approval resolutions in bridge mode, and avoid stale source-reply artifacts. (#87044) Thanks @keshavbotagent.</li>
<li>Channels: make Telegram <code>sendMessage</code> action replies durable and preserve SecretRef prompt config, suppress duplicate iMessage native exec approval prompts and sends, keep iMessage approval polling alive after denied reactions, keep Slack delivered final replies during late cleanup, keep Matrix mention previews/finals mention-inert and normally delivered, ignore filename-embedded Matrix IDs, suppress recovered Discord tool-warning artifacts from successful replies, suppress Google Chat thread sends in DMs, and harden Discord guild requester checks. (#87261, #87452) Thanks @mbelinky.</li>
<li>Memory: salvage QMD search JSON after nonzero exits and keep workspace memory routing through the Codex tool path where possible. (#87225, #87383, #87403) Thanks @osolmaz.</li>
<li>Providers/models: forward cached token usage in OpenAI-compatible chat completions, load Claude CLI OAuth overlays for PI auth profiles, send bare direct Anthropic model ids, wire configured VLLM thinking params, honor OpenAI-compatible cache retention, normalize OpenAI Responses replay tool ids, resolve OpenAI <code>gpt-5.5</code> without a cached catalog, preserve <code>retry-after</code> fallback handling, bound GitHub Copilot auth requests, and load DeepInfra custom/live catalogs consistently. (#82062, #87167, #84549) Thanks @caz0075, @joshavant, and @ats3v.</li>
<li>Gateway/performance: borrow read-only session metadata and active session working stores, cache current/stable plugin metadata fingerprints, cache auto-enabled plugin config, slim metadata identity caches, trust current metadata lifecycle caches, stabilize isolated cron prompt-cache affinity, persist model auth profile suffixes, drain probe client closes, expire browser tokens after auth rotation, and keep default status fast paths bounded. Thanks @ferminquant.</li>
<li>CLI/help/config: reject loose or malformed numeric options for gateway timeouts, model limits, directory limits, message options, webhooks, and partial values; respect subcommand version options; route generated/root/plugin help targets correctly; keep skills JSON output flushing naturally; and keep plugin descriptor loading quiet in root help. (#87398) Thanks @Patrick-Erichsen.</li>
<li>Plugin state/tool search: evict the current namespace when plugin rows hit caps, reuse unchanged tool-search catalogs, align the release catalog reuse wrapper, and keep fallback tool warnings mention-inert.</li>
<li>Install/package/release: match npm globstar exclusions, honor dist package exclusions in inventory, omit unpacked test helpers, skip Homebrew until macOS packages need it, package Docker runtime workspace templates, smoke Docker runtime templates during full validation, merge nested shrinkwrap override pins, preserve forked shrinkwrap pins, pin aged <code>lru-cache</code>, harden postpublish verification, accept main full-validation proof, and reject empty beta smoke runs.</li>
<li>E2E/QA/Crabbox: bound Telegram, Open WebUI, ClawHub, Matrix, Tool Search, MCP, gateway network, bundled runtime, kitchen-sink, codex media, config reload, and agent-turn assertion waits; prefer Azure for Windows targets; reinitialize invalid changed-gate git dirs; full-sync sparse container runs; and fail empty explicit test requests. (#87186)</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.27/OpenClaw-2026.5.27.zip" length="54488811" type="application/octet-stream" sparkle:edSignature="c5w2T1UO6vpPs70hyYH93cIyWEOd5sl5z2NkhU53E+XQBSd+jAr+xd0qf3KzWbeX2mfXYMQmnx+VMls3L22EDg=="/>
</item>
</channel>
</rss>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,7 @@ import SwiftUI
struct AgentProDreamingDestination: View {
@Environment(NodeAppModel.self) private var appModel
let headerLeadingAction: OpenClawSidebarHeaderAction?
let overview: AgentOverviewSnapshot?
let gatewayConnected: Bool
let overviewLoading: Bool
@@ -20,6 +21,7 @@ struct AgentProDreamingDestination: View {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.header
self.detailSummaryCard(
icon: "moon",
title: "Dreaming",
@@ -57,6 +59,23 @@ struct AgentProDreamingDestination: View {
.navigationBarTitleDisplayMode(.inline)
}
@ViewBuilder
private var header: some View {
if let headerLeadingAction {
OpenClawAdaptiveHeaderRow(
title: "Dreaming",
subtitle: self.dreamingDetail,
titleFont: .title3.weight(.semibold),
subtitleFont: .callout)
{
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private enum DreamAction: String, CaseIterable, Identifiable {
case backfill
case repair

View File

@@ -3,6 +3,7 @@ import SwiftUI
import UIKit
struct AgentProNodesDestination: View {
let headerLeadingAction: OpenClawSidebarHeaderAction?
let overview: AgentOverviewSnapshot?
let gatewayConnected: Bool
let agentCount: Int
@@ -16,6 +17,7 @@ struct AgentProNodesDestination: View {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.header
self.summaryCard
self.totalsCard
self.nodesList
@@ -27,16 +29,33 @@ struct AgentProNodesDestination: View {
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Nodes")
.navigationTitle("Instances")
.navigationBarTitleDisplayMode(.inline)
}
@ViewBuilder
private var header: some View {
if let headerLeadingAction {
OpenClawAdaptiveHeaderRow(
title: "Instances",
subtitle: self.instancesDetail,
titleFont: .title3.weight(.semibold),
subtitleFont: .callout)
{
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private var summaryCard: some View {
ProCard {
HStack(spacing: 12) {
ProIconBadge(systemName: "display", color: self.instancesColor)
VStack(alignment: .leading, spacing: 3) {
Text("Nodes")
Text("Instances")
.font(.headline)
Text(self.instancesDetail)
.font(.caption)
@@ -70,16 +89,16 @@ struct AgentProNodesDestination: View {
private var nodesList: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Connected Nodes")
ProSectionHeader(title: "Connected Instances")
ProCard(padding: 0) {
let nodes = self.sortedPresenceEntries
if nodes.isEmpty {
self.emptyRow(
icon: "display",
title: self.gatewayConnected ? "No nodes connected" : "Nodes unavailable",
title: self.gatewayConnected ? "No instances connected" : "Instances unavailable",
detail: self.gatewayConnected
? "The gateway did not report any system presence entries."
: "Connect a gateway to inspect connected nodes.")
: "Connect a gateway to inspect connected instances.")
.padding(14)
} else {
VStack(spacing: 0) {
@@ -114,7 +133,7 @@ struct AgentProNodesDestination: View {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
VStack(alignment: .leading, spacing: 4) {
Text(Self.presenceLabel(entry) ?? "Node")
Text(Self.presenceLabel(entry) ?? "Instance")
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(Self.presenceDetail(entry))
@@ -153,7 +172,7 @@ struct AgentProNodesDestination: View {
HStack(spacing: 12) {
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
VStack(alignment: .leading, spacing: 3) {
Text(Self.presenceLabel(entry) ?? "Node")
Text(Self.presenceLabel(entry) ?? "Instance")
.font(.headline)
Text(Self.presenceDetail(entry))
.font(.caption)
@@ -192,7 +211,7 @@ struct AgentProNodesDestination: View {
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle(Self.presenceLabel(entry) ?? "Node")
.navigationTitle(Self.presenceLabel(entry) ?? "Instance")
.navigationBarTitleDisplayMode(.inline)
}

View File

@@ -6,10 +6,12 @@ extension AgentProTab {
@ViewBuilder
func destination(for route: AgentRoute) -> some View {
switch route {
case .agents:
self.agentsDestination
case .skills:
self.skillsDestination
case .nodes:
self.nodesDestination
case .instances:
self.instancesDestination
case .cron:
self.cronDestination
case .usage:
@@ -19,6 +21,26 @@ extension AgentProTab {
}
}
var agentsDestination: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.rosterHeader
self.agentFilters
self.agentsSection
}
.padding(.vertical, 18)
}
.refreshable {
await self.refreshOverview(force: true)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Agents")
.navigationBarTitleDisplayMode(.inline)
}
var skillsDestination: some View {
ZStack {
OpenClawProBackground()
@@ -46,8 +68,9 @@ extension AgentProTab {
.navigationBarTitleDisplayMode(.inline)
}
var nodesDestination: some View {
var instancesDestination: some View {
AgentProNodesDestination(
headerLeadingAction: self.directHeaderLeadingAction(for: .instances),
overview: self.overview,
gatewayConnected: self.gatewayConnected,
agentCount: self.appModel.gatewayAgents.count,
@@ -64,6 +87,10 @@ extension AgentProTab {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.directHeader(
for: .cron,
title: "Cron Jobs",
subtitle: self.cronDetail)
self.detailSummaryCard(
icon: "clock.arrow.circlepath",
title: "Cron Jobs",
@@ -89,6 +116,10 @@ extension AgentProTab {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.directHeader(
for: .usage,
title: "Usage",
subtitle: self.usageDetail)
self.detailSummaryCard(
icon: "chart.line.uptrend.xyaxis",
title: "Usage",
@@ -111,6 +142,7 @@ extension AgentProTab {
var dreamingDestination: some View {
AgentProDreamingDestination(
headerLeadingAction: self.directHeaderLeadingAction(for: .dreaming),
overview: self.overview,
gatewayConnected: self.gatewayConnected,
overviewLoading: self.overviewLoading,
@@ -122,6 +154,27 @@ extension AgentProTab {
})
}
@ViewBuilder
func directHeader(for route: AgentRoute, title: String, subtitle: String) -> some View {
if let headerLeadingAction = self.directHeaderLeadingAction(for: route) {
OpenClawAdaptiveHeaderRow(
title: title,
subtitle: subtitle,
titleFont: .title3.weight(.semibold),
subtitleFont: .callout)
{
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
func directHeaderLeadingAction(for route: AgentRoute) -> OpenClawSidebarHeaderAction? {
self.directRoute == route ? self.headerLeadingAction : nil
}
func detailSummaryCard(
icon: String,
title: String,

View File

@@ -5,18 +5,19 @@ import SwiftUI
extension AgentProTab {
var rosterHeader: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text("Agents")
.font(.system(size: 28, weight: .bold))
Text("\(self.sortedAgents.count) total")
.font(.subheadline)
.foregroundStyle(.secondary)
OpenClawAdaptiveHeaderRow(
title: self.headerTitle,
subtitle: "\(self.sortedAgents.count) total",
titleFont: .system(size: 28, weight: .bold),
subtitleFont: .subheadline,
subtitleLineLimit: 1)
{
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
Spacer(minLength: 8)
} accessory: {
HStack(spacing: 10) {
self.gatewayPillButton
self.headerIconButton(
systemName: "magnifyingglass",
label: "Search agents",
@@ -56,6 +57,19 @@ extension AgentProTab {
.padding(.top, 6)
}
@ViewBuilder
private var gatewayPillButton: some View {
if let openSettings {
Button(action: openSettings) {
OpenClawGatewayCompactPill()
}
.buttonStyle(.plain)
.accessibilityHint("Opens Settings / Gateway")
} else {
OpenClawGatewayCompactPill()
}
}
var agentFilters: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
@@ -140,7 +154,7 @@ extension AgentProTab {
value: self.instancesValue,
detail: self.instancesDetail,
color: self.instancesColor,
route: .nodes)
route: .instances)
self.metricTile(
icon: "clock.arrow.circlepath",
title: "Cron",

View File

@@ -6,6 +6,12 @@ struct AgentProTab: View {
@Environment(NodeAppModel.self) var appModel
@Environment(\.colorScheme) var colorScheme
@Environment(\.scenePhase) var scenePhase
let initialRoute: AgentRoute?
let directRoute: AgentRoute?
let headerLeadingAction: OpenClawSidebarHeaderAction?
let headerTitle: String
let openSettings: (() -> Void)?
@State var navigationPath: [AgentRoute] = []
@State var overview: AgentOverviewSnapshot?
@State var overviewErrorText: String?
@State var overviewLoading: Bool = false
@@ -31,8 +37,9 @@ struct AgentProTab: View {
@State var cronActionStatusText: String?
enum AgentRoute: Hashable {
case agents
case skills
case nodes
case instances
case cron
case usage
case dreaming
@@ -119,8 +126,42 @@ struct AgentProTab: View {
}
}
init(
initialRoute: AgentRoute? = nil,
directRoute: AgentRoute? = nil,
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
headerTitle: String = "Agents",
openSettings: (() -> Void)? = nil)
{
self.initialRoute = initialRoute
self.directRoute = directRoute
self.headerLeadingAction = headerLeadingAction
self.headerTitle = headerTitle
self.openSettings = openSettings
}
var body: some View {
NavigationStack {
Group {
if let directRoute {
self.directDestination(for: directRoute)
} else {
self.overviewNavigation
}
}
.task(id: self.overviewTaskID) {
await self.refreshOverview(force: false)
}
.sheet(item: self.$skillEditorSelection) { selection in
if let skill = self.skillByKey(selection.id) {
self.skillEditorSheet(skill)
} else {
self.missingSkillEditorSheet
}
}
}
private var overviewNavigation: some View {
NavigationStack(path: self.$navigationPath) {
ZStack {
OpenClawProBackground()
ScrollView {
@@ -143,15 +184,22 @@ struct AgentProTab: View {
self.destination(for: route)
}
}
.task(id: self.overviewTaskID) {
await self.refreshOverview(force: false)
}
.sheet(item: self.$skillEditorSelection) { selection in
if let skill = self.skillByKey(selection.id) {
self.skillEditorSheet(skill)
} else {
self.missingSkillEditorSheet
}
.onAppear {
self.applyInitialRouteIfNeeded()
}
}
private func directDestination(for route: AgentRoute) -> some View {
self.destination(for: route)
.toolbar(
self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden,
for: .navigationBar)
}
private func applyInitialRouteIfNeeded() {
guard self.directRoute == nil else { return }
guard let initialRoute else { return }
guard self.navigationPath != [initialRoute] else { return }
self.navigationPath = [initialRoute]
}
}

View File

@@ -7,6 +7,25 @@ struct ChatProTab: View {
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel: OpenClawChatViewModel?
@State private var viewModelUsesAppleReviewDemoTransport = false
let headerLeadingAction: OpenClawSidebarHeaderAction?
let headerTitle: String?
let headerSubtitle: String?
let showsAgentBadge: Bool
let openSettings: (() -> Void)?
init(
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
headerTitle: String? = nil,
headerSubtitle: String? = nil,
showsAgentBadge: Bool = true,
openSettings: (() -> Void)? = nil)
{
self.headerLeadingAction = headerLeadingAction
self.headerTitle = headerTitle
self.headerSubtitle = headerSubtitle
self.showsAgentBadge = showsAgentBadge
self.openSettings = openSettings
}
var body: some View {
NavigationStack {
@@ -67,7 +86,30 @@ struct ChatProTab: View {
}
private var header: some View {
HStack(spacing: 11) {
OpenClawAdaptiveHeaderRow(
title: self.headerDisplayTitle,
subtitle: self.headerDisplaySubtitle,
titleFont: .headline.weight(.semibold),
subtitleFont: .caption,
subtitleLineLimit: 1)
{
HStack(spacing: 11) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
self.headerIdentityBadge
}
} accessory: {
self.connectionPillButton
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 8)
.padding(.bottom, 4)
}
@ViewBuilder
private var headerIdentityBadge: some View {
if self.showsAgentBadge {
Text(self.agentBadge)
.font(.system(size: self.agentBadge.count > 2 ? 13 : 16, weight: .bold, design: .rounded))
.foregroundStyle(.white)
@@ -86,24 +128,9 @@ struct ChatProTab: View {
endPoint: .bottomTrailing)))
.overlay(Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1))
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: 10, y: 5)
VStack(alignment: .leading, spacing: 1) {
Text(self.agentDisplayName)
.font(.headline.weight(.semibold))
.lineLimit(1)
Text("AI Assistant")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
self.connectionPill
} else {
ProIconBadge(systemName: "bubble.left", color: OpenClawBrand.accent)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 8)
.padding(.bottom, 4)
}
private func syncChatViewModel() {
@@ -162,37 +189,93 @@ struct ChatProTab: View {
?? "main"
}
@ViewBuilder
private var connectionPillButton: some View {
if let openSettings {
Button(action: openSettings) {
self.connectionPill
}
.buttonStyle(.plain)
.accessibilityHint("Opens Settings / Gateway")
} else {
self.connectionPill
}
}
private var connectionPill: some View {
HStack(spacing: 6) {
ProStatusDot(color: self.gatewayConnected ? OpenClawBrand.ok : .orange)
Text(self.gatewayConnected ? "Connected" : "Connecting")
ProStatusDot(color: self.gatewayPillColor)
Text(Self.gatewayPillTitle(state: self.gatewayDisplayState, isGatewayUsable: self.gatewayConnected))
.font(.caption.weight(.semibold))
.lineLimit(1)
}
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .orange)
.foregroundStyle(self.gatewayPillColor)
.padding(.horizontal, 10)
.frame(height: 30)
.background {
Capsule()
.fill((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.11))
.fill(self.gatewayPillColor.opacity(0.11))
}
.overlay {
Capsule()
.strokeBorder((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.16), lineWidth: 1)
.strokeBorder(self.gatewayPillColor.opacity(0.16), lineWidth: 1)
}
}
private var gatewayConnected: Bool {
guard GatewayStatusBuilder.build(appModel: self.appModel) == .connected else {
guard self.gatewayDisplayState == .connected else {
return false
}
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
}
private var gatewayDisplayState: GatewayDisplayState {
GatewayStatusBuilder.build(appModel: self.appModel)
}
private var gatewayPillColor: Color {
switch self.gatewayDisplayState {
case .connected:
self.gatewayConnected ? OpenClawBrand.ok : .secondary
case .connecting:
OpenClawBrand.accent
case .error:
OpenClawBrand.warn
case .disconnected:
.secondary
}
}
nonisolated static func gatewayPillTitle(state: GatewayDisplayState, isGatewayUsable: Bool) -> String {
switch state {
case .connected:
isGatewayUsable ? "Connected" : "Unavailable"
case .connecting:
"Connecting"
case .error:
"Attention"
case .disconnected:
"Offline"
}
}
private var messagePlaceholder: String {
self.gatewayConnected ? "Message \(self.agentDisplayName)..." : "Connect to a gateway"
}
private var headerDisplayTitle: String {
self.normalized(self.headerTitle)
?? Self.defaultHeaderTitle(showsAgentBadge: self.showsAgentBadge, agentDisplayName: self.agentDisplayName)
}
private var headerDisplaySubtitle: String {
self.normalized(self.headerSubtitle) ?? "AI Assistant"
}
nonisolated static func defaultHeaderTitle(showsAgentBadge: Bool, agentDisplayName: String) -> String {
showsAgentBadge ? agentDisplayName : "Chat"
}
private var chatUserAccent: Color {
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
}

View File

@@ -23,7 +23,7 @@ struct CommandPanel<Content: View>: View {
tint: self.tint,
isProminent: self.isProminent,
padding: self.padding,
radius: 12)
radius: OpenClawProMetric.cardRadius)
{
self.content
}
@@ -34,40 +34,15 @@ struct CommandControlBackground: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
LinearGradient(
colors: self.colorScheme == .dark ? self.darkColors : self.lightColors,
startPoint: .top,
endPoint: .bottom)
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
.overlay(alignment: .top) {
if self.colorScheme == .light {
LinearGradient(
colors: [
Color.white.opacity(0.34),
Color.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
.frame(height: 260)
Color.white.opacity(0.20)
.frame(height: 140)
}
}
.ignoresSafeArea()
}
private var darkColors: [Color] {
[
Color(red: 12 / 255, green: 13 / 255, blue: 15 / 255),
Color(red: 7 / 255, green: 8 / 255, blue: 10 / 255),
Color(red: 4 / 255, green: 5 / 255, blue: 6 / 255),
]
}
private var lightColors: [Color] {
[
Color(red: 247 / 255, green: 248 / 255, blue: 249 / 255),
Color(red: 251 / 255, green: 252 / 255, blue: 253 / 255),
.white,
]
}
}
struct CommandSessionRow: View {
@@ -114,12 +89,12 @@ struct CommandSessionRow: View {
}
}
.padding(.horizontal, 10)
.padding(.vertical, 9)
.padding(.vertical, 8)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.fill(self.rowFill)
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.strokeBorder(self.rowBorder, lineWidth: 1)
}
}
@@ -136,11 +111,11 @@ struct CommandSessionRow: View {
}
private var rowFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
}
private var rowBorder: Color {
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
}
}
@@ -154,21 +129,21 @@ struct CommandViewMoreRow: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.fill(self.rowFill)
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.strokeBorder(self.rowBorder, lineWidth: 1)
}
}
}
private var rowFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
}
private var rowBorder: Color {
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
}
}
@@ -199,13 +174,13 @@ struct CommandEmptyStateRow: View {
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 9)
.padding(.vertical, 8)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.black.opacity(0.06))
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.fill(Color(uiColor: .systemBackground))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color.primary.opacity(0.055), lineWidth: 1)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.strokeBorder(Color(uiColor: .separator).opacity(0.22), lineWidth: 1)
}
}
}

View File

@@ -2,13 +2,17 @@ import OpenClawChatUI
import SwiftUI
struct CommandCenterTab: View {
fileprivate static let recentSessionsFetchLimit = 200
static let recentSessionsFetchLimit = 200
@Environment(NodeAppModel.self) private var appModel
@Environment(\.colorScheme) private var colorScheme
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.scenePhase) private var scenePhase
@State private var defaultChatSessionEntry: OpenClawChatSessionEntry?
@State private var recentChatSessions: [OpenClawChatSessionEntry] = []
var headerTitle: String = "OpenClaw"
var headerLeadingAction: OpenClawSidebarHeaderAction?
var showsHeaderMark: Bool = true
var openChat: () -> Void
var openSettings: () -> Void
@@ -31,20 +35,37 @@ struct CommandCenterTab: View {
var body: some View {
NavigationStack {
ZStack {
CommandControlBackground()
self.commandAmbientOverlay
ScrollView {
VStack(alignment: .leading, spacing: 10) {
self.header
self.gatewayCard
self.defaultChatSessionSection
self.recentSessions
GeometryReader { geometry in
ZStack {
CommandControlBackground()
self.commandAmbientOverlay
ScrollView {
VStack(alignment: .leading, spacing: 14) {
self.header
self.gatewayCard
if Self.usesSplitSectionsLayout(
horizontalSizeClass: self.horizontalSizeClass,
containerWidth: geometry.size.width)
{
HStack(alignment: .top, spacing: 12) {
self.defaultChatSessionSection
.frame(maxWidth: .infinity, alignment: .topLeading)
self.recentSessions
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
} else {
self.defaultChatSessionSection
.padding(.horizontal, OpenClawProMetric.pagePadding)
self.recentSessions
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
.padding(.top, 18)
.padding(.bottom, 18)
}
.padding(.top, 16)
.padding(.bottom, 18)
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationBarHidden(true)
}
@@ -53,12 +74,47 @@ struct CommandCenterTab: View {
}
}
static func usesSplitSectionsLayout(
horizontalSizeClass: UserInterfaceSizeClass?,
containerWidth: CGFloat) -> Bool
{
guard horizontalSizeClass == .regular else { return false }
return containerWidth >= 1000
}
static func shouldShowHeaderMark(
hasLeadingAction: Bool,
showsHeaderMark: Bool) -> Bool
{
!hasLeadingAction && showsHeaderMark
}
private var header: some View {
HStack(alignment: .center, spacing: 11) {
OpenClawProMark(size: 31, shadowRadius: 9)
Text("OpenClaw")
.font(.system(size: 27, weight: .bold, design: .rounded))
Spacer()
OpenClawAdaptiveHeaderRow(
title: self.headerTitle,
subtitle: self.gatewaySubtitle,
titleFont: .title3.weight(.semibold),
subtitleFont: .caption,
subtitleLineLimit: 1)
{
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} else if Self.shouldShowHeaderMark(
hasLeadingAction: headerLeadingAction != nil,
showsHeaderMark: self.showsHeaderMark)
{
OpenClawProMark(size: 28, shadowRadius: 5)
}
} accessory: {
Button(action: self.openSettings) {
ProCapsule(
title: self.gatewayStateText,
color: self.gatewayStatusColor,
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
}
.buttonStyle(.plain)
.accessibilityLabel("Gateway \(self.gatewayStateText)")
.accessibilityHint("Opens Settings / Gateway")
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@@ -86,7 +142,7 @@ struct CommandCenterTab: View {
title: "Gateway",
value: self.gatewayStateText,
color: self.gatewayStatusColor,
icon: self.gatewayConnected ? "hourglass" : "wifi.slash")
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
HStack(spacing: 0) {
self.gatewayFact(
@@ -160,7 +216,6 @@ struct CommandCenterTab: View {
.buttonStyle(.plain)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var recentSessions: some View {
@@ -200,7 +255,6 @@ struct CommandCenterTab: View {
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func cardHeader(
@@ -213,7 +267,8 @@ struct CommandCenterTab: View {
{
HStack(spacing: 8) {
Text(title)
.font(.subheadline.weight(.bold))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
if let badgeValue {
Text(badgeValue)
.font(.caption2.weight(.bold))
@@ -403,7 +458,7 @@ struct CommandCenterTab: View {
return result
}
fileprivate static func sessionWorkItem(
static func sessionWorkItem(
for session: OpenClawChatSessionEntry,
currentSessionKey: String) -> WorkItem
{
@@ -558,14 +613,20 @@ struct CommandCenterTab: View {
}
}
private struct CommandSessionsScreen: View {
struct CommandSessionsScreen: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.dismiss) private var dismiss
@State private var sessions: [OpenClawChatSessionEntry] = []
@State private var isLoading = false
@State private var loadErrorText: String?
let headerLeadingAction: OpenClawSidebarHeaderAction?
let openChat: () -> Void
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, openChat: @escaping () -> Void) {
self.headerLeadingAction = headerLeadingAction
self.openChat = openChat
}
var body: some View {
ZStack {
CommandControlBackground()
@@ -587,12 +648,18 @@ private struct CommandSessionsScreen: View {
}
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Sessions")
.font(.system(size: 27, weight: .bold, design: .rounded))
Text(self.headerDetail)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
HStack(alignment: .top, spacing: 12) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
VStack(alignment: .leading, spacing: 4) {
Text("Sessions")
.font(.system(size: 27, weight: .bold, design: .rounded))
Text(self.headerDetail)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}

View File

@@ -0,0 +1,244 @@
import OpenClawChatUI
import OpenClawKit
import SwiftUI
struct IPadActivityScreen: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.scenePhase) private var scenePhase
@State private var sessions: [OpenClawChatSessionEntry] = []
@State private var isLoading = false
@State private var loadErrorText: String?
let headerLeadingAction: OpenClawSidebarHeaderAction?
let openChat: () -> Void
let openSettings: () -> Void
init(
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
openChat: @escaping () -> Void,
openSettings: @escaping () -> Void)
{
self.headerLeadingAction = headerLeadingAction
self.openChat = openChat
self.openSettings = openSettings
}
var body: some View {
IPadSidebarScreenChrome(
title: "Activity",
subtitle: "Live device and gateway activity.",
headerLeadingAction: self.headerLeadingAction,
gatewayAction: self.openSettings)
{
ProMetricGrid(metrics: self.metrics)
self.activityFeed
}
.task(id: self.refreshID) {
await self.refreshSessions()
}
.refreshable {
await self.refreshSessions()
}
}
private var metrics: [ProMetric] {
[
ProMetric(
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash",
title: "Gateway",
value: self.gatewayStateText,
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary),
ProMetric(
icon: "person.2.fill",
title: "Agents",
value: self.gatewayConnected ? "\(self.appModel.gatewayAgents.count)" : "offline",
color: OpenClawBrand.accent),
ProMetric(
icon: "bubble.left.and.text.bubble.right",
title: "Sessions",
value: self.isLoading ? "..." : "\(self.sessionRows.count)",
color: OpenClawBrand.accentHot),
]
}
private var activityFeed: some View {
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
ProPanelHeader(
title: "Recent activity",
value: self.isLoading ? "Loading" : nil,
actionTitle: "Refresh",
action: {
Task { await self.refreshSessions() }
})
if let pendingExecApprovalPrompt = self.appModel.pendingExecApprovalPrompt {
ProStatusRow(
icon: "hand.raised.fill",
title: "Approval needed",
detail: pendingExecApprovalPrompt.commandPreview ?? pendingExecApprovalPrompt.commandText,
value: "pending",
color: OpenClawBrand.warn,
actionTitle: nil,
action: nil)
Divider().padding(.leading, 58)
}
ProStatusRow(
icon: self.gatewayConnected ? "network" : "wifi.slash",
title: "Gateway",
detail: self.gatewayDetailText,
value: self.gatewayStateText.lowercased(),
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
actionTitle: self.gatewayConnected ? nil : "Settings",
action: self.gatewayConnected ? nil : self.openSettings)
Divider().padding(.leading, 58)
ProStatusRow(
icon: "square.and.arrow.down",
title: "Share intake",
detail: self.appModel.lastShareEventText,
value: "iPad",
color: OpenClawBrand.accent,
actionTitle: nil,
action: nil)
if self.isLoading, self.sessions.isEmpty {
Divider().padding(.leading, 58)
ProStatusRow(
icon: "hourglass",
title: "Loading sessions",
detail: "Fetching recent activity from the gateway.",
value: "loading",
color: OpenClawBrand.accent,
actionTitle: nil,
action: nil)
} else if let loadErrorText {
Divider().padding(.leading, 58)
ProStatusRow(
icon: "exclamationmark.triangle.fill",
title: "Sessions unavailable",
detail: loadErrorText,
value: "error",
color: OpenClawBrand.warn,
actionTitle: nil,
action: nil)
} else if self.sessionRows.isEmpty {
Divider().padding(.leading, 58)
ProStatusRow(
icon: "bubble.left.and.text.bubble.right",
title: self.sessionsAvailable ? "No recent sessions" : "Session activity offline",
detail: self.sessionsAvailable
? "Start a chat and it will appear here."
: "Connect to the gateway to load recent chat activity.",
value: self.sessionsAvailable ? "empty" : "offline",
color: .secondary,
actionTitle: self.sessionsAvailable ? "Chat" : nil,
action: self.sessionsAvailable ? self.openChat : nil)
} else {
ForEach(self.sessionRows) { row in
Divider().padding(.leading, 58)
ProStatusRow(
icon: row.icon,
title: row.title,
detail: row.detail,
value: row.state,
color: row.color,
actionTitle: "Open",
action: {
self.open(row)
})
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var refreshID: String {
[
self.sessionsMode,
self.appModel.chatSessionKey,
self.scenePhase == .active ? "active" : "inactive",
].joined(separator: ":")
}
private var gatewayConnected: Bool {
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
}
private var gatewayStateText: String {
guard !self.gatewayConnected else { return "Online" }
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
return status.isEmpty ? "Offline" : status
}
private var gatewayDetailText: String {
self.normalized(self.appModel.gatewayRemoteAddress)
?? self.normalized(self.appModel.gatewayServerName)
?? "No gateway connection"
}
private var sessionsAvailable: Bool {
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
}
private var sessionsMode: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
}
private var sessionRows: [CommandCenterTab.WorkItem] {
self.sessions
.filter { CommandCenterTab.isRecentChatSession(
$0.key,
defaultSessionKey: self.appModel.defaultChatSessionKey) }
.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
.prefix(8)
.map {
CommandCenterTab.sessionWorkItem(
for: $0,
currentSessionKey: self.appModel.chatSessionKey)
}
}
private func refreshSessions() async {
guard self.scenePhase == .active else { return }
guard self.sessionsAvailable else {
self.sessions = []
self.loadErrorText = nil
return
}
self.isLoading = true
self.loadErrorText = nil
defer { self.isLoading = false }
do {
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
self.sessions = response.sessions
} catch {
self.sessions = []
self.loadErrorText = "Try again after the gateway reconnects."
}
}
private func open(_ item: CommandCenterTab.WorkItem) {
switch item.route {
case let .chat(sessionKey):
self.appModel.openChat(sessionKey: sessionKey)
self.openChat()
case .settings:
self.openSettings()
}
}
private func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@@ -0,0 +1,672 @@
import SwiftUI
#if DEBUG
#Preview("Activity states") {
IPadActivityStatesPreview()
}
#Preview("Workboard states") {
IPadWorkboardStatesPreview()
}
#Preview("Skill Workshop states") {
IPadSkillWorkshopStatesPreview()
}
#Preview(
"Skill Workshop iPad kanban lanes",
traits: .fixedLayout(width: 1180, height: 820))
{
IPadSkillWorkshopKanbanPreview()
}
#Preview("Workboard phone queue rows") {
IPadWorkboardCompactRowsPreview()
}
#Preview("Skill Workshop phone queue rows") {
IPadSkillWorkshopCompactRowsPreview()
}
#Preview(
"Workboard phone landscape",
traits: .fixedLayout(width: 852, height: 393),
.landscapeLeft)
{
IPadSidebarTaskScreenPreviewHost {
IPadWorkboardScreen(openChat: {}, openSettings: {})
}
}
#Preview(
"Skill Workshop phone landscape",
traits: .fixedLayout(width: 852, height: 393),
.landscapeLeft)
{
IPadSidebarTaskScreenPreviewHost {
IPadSkillWorkshopScreen(openSettings: {})
}
}
private struct IPadWorkboardCompactRowsPreview: View {
private let statuses = ["todo", "ready", "running", "review", "blocked", "done"]
private let cards = IPadWorkboardPreviewFixtures.cards
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 12) {
self.previewHeader
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
ProPanelHeader(
title: "Queue",
value: "\(self.cards.count)",
actionTitle: nil,
action: nil)
ForEach(Array(self.cards.enumerated()), id: \.element.id) { index, card in
if index > 0 {
Divider().padding(.leading, 58)
}
IPadWorkboardQueueRow(
card: card,
statuses: self.statuses,
isBusy: card.id == "preview-running",
inspect: {},
openSession: {},
move: { _ in },
archive: {})
}
}
}
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
ProStatusRow(
icon: "tray",
title: "No cards",
detail: "Create a card or change the filter.",
value: "empty",
color: .secondary,
actionTitle: nil,
action: nil)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.vertical, 18)
}
}
.environment(\.horizontalSizeClass, .compact)
.environment(\.verticalSizeClass, .regular)
}
private var previewHeader: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Phone queue")
.font(.headline)
Text("Tap for detail, swipe or long-press for card actions.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private struct IPadSkillWorkshopCompactRowsPreview: View {
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 12) {
self.previewHeader
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
ProPanelHeader(
title: "Queue",
value: "\(self.proposals.count)",
actionTitle: nil,
action: nil)
ForEach(Array(self.proposals.enumerated()), id: \.element.id) { index, proposal in
if index > 0 {
Divider().padding(.leading, 58)
}
IPadSkillProposalRow(
proposal: proposal,
isSelected: proposal.id == "preview-pending",
isBusy: proposal.id == "preview-held")
}
}
}
ProCard(radius: OpenClawProMetric.cardRadius) {
ProStatusRow(
icon: "hammer",
title: "No proposals",
detail: "New proposals will appear here when agents draft skills.",
value: "empty",
color: .secondary,
actionTitle: nil,
action: nil)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.vertical, 18)
}
}
.environment(\.horizontalSizeClass, .compact)
.environment(\.verticalSizeClass, .regular)
}
private var previewHeader: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Phone proposals")
.font(.headline)
Text("Tap for detail, swipe or long-press for proposal actions.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private struct IPadSidebarTaskScreenPreviewHost<Content: View>: View {
@State private var appModel = NodeAppModel()
@ViewBuilder var content: Content
var body: some View {
NavigationStack {
self.content
}
.environment(self.appModel)
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .compact)
}
}
private struct IPadActivityStatesPreview: View {
private let connectedSessions = [
CommandCenterTab.WorkItem(
id: "preview-main",
icon: "bubble.left.and.text.bubble.right",
title: "Main",
detail: "Updated just now",
state: "active",
trailing: "open",
color: OpenClawBrand.ok,
progress: nil,
route: .chat("main")),
CommandCenterTab.WorkItem(
id: "preview-ipad-audit",
icon: "bubble.left.and.text.bubble.right",
title: "iPad audit",
detail: "Updated 8m ago",
state: "recent",
trailing: "open",
color: OpenClawBrand.accent,
progress: nil,
route: .chat("ipad-audit")),
]
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 18) {
self.previewHeader("Connected")
self.activityCard(
gatewayTitle: "Gateway",
gatewayDetail: "tailscale.local:18789",
gatewayValue: "online",
gatewayColor: OpenClawBrand.ok,
sessionRows: self.connectedSessions,
tailRows: [])
self.previewHeader("Loading")
self.activityCard(
gatewayTitle: "Gateway",
gatewayDetail: "Fetching recent activity from the gateway.",
gatewayValue: "online",
gatewayColor: OpenClawBrand.ok,
sessionRows: [],
tailRows: [
ActivityPreviewRow(
icon: "hourglass",
title: "Loading sessions",
detail: "Fetching recent activity from the gateway.",
value: "loading",
color: OpenClawBrand.accent),
])
self.previewHeader("Empty")
self.activityCard(
gatewayTitle: "Gateway",
gatewayDetail: "tailscale.local:18789",
gatewayValue: "online",
gatewayColor: OpenClawBrand.ok,
sessionRows: [],
tailRows: [
ActivityPreviewRow(
icon: "bubble.left.and.text.bubble.right",
title: "No recent sessions",
detail: "Start a chat and it will appear here.",
value: "empty",
color: .secondary),
])
self.previewHeader("Error")
self.activityCard(
gatewayTitle: "Gateway",
gatewayDetail: "No gateway connection",
gatewayValue: "offline",
gatewayColor: .secondary,
sessionRows: [],
tailRows: [
ActivityPreviewRow(
icon: "exclamationmark.triangle.fill",
title: "Sessions unavailable",
detail: "Try again after the gateway reconnects.",
value: "error",
color: OpenClawBrand.warn),
])
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.vertical, 18)
}
}
}
private func previewHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
private func activityCard(
gatewayTitle: String,
gatewayDetail: String,
gatewayValue: String,
gatewayColor: Color,
sessionRows: [CommandCenterTab.WorkItem],
tailRows: [ActivityPreviewRow]) -> some View
{
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
ProPanelHeader(
title: "Recent activity",
value: nil,
actionTitle: "Refresh",
action: {})
ProStatusRow(
icon: gatewayValue == "online" ? "network" : "wifi.slash",
title: gatewayTitle,
detail: gatewayDetail,
value: gatewayValue,
color: gatewayColor,
actionTitle: gatewayValue == "online" ? nil : "Settings",
action: {})
Divider().padding(.leading, 58)
ProStatusRow(
icon: "square.and.arrow.down",
title: "Share intake",
detail: "No share events yet.",
value: "iPad",
color: OpenClawBrand.accent,
actionTitle: nil,
action: nil)
ForEach(sessionRows) { row in
Divider().padding(.leading, 58)
ProStatusRow(
icon: row.icon,
title: row.title,
detail: row.detail,
value: row.state,
color: row.color,
actionTitle: "Open",
action: {})
}
ForEach(tailRows) { row in
Divider().padding(.leading, 58)
ProStatusRow(
icon: row.icon,
title: row.title,
detail: row.detail,
value: row.value,
color: row.color,
actionTitle: nil,
action: nil)
}
}
}
}
private struct ActivityPreviewRow: Identifiable {
let id = UUID()
let icon: String
let title: String
let detail: String
let value: String
let color: Color
}
}
private struct IPadWorkboardStatesPreview: View {
private let statuses = ["todo", "running", "review"]
private let connectedCards = IPadWorkboardPreviewFixtures.cards
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 18) {
self.previewHeader("Connected")
self.connectedBoard
self.previewHeader("Empty")
IPadWorkboardKanbanColumn(
status: "todo",
cards: [],
statuses: self.statuses,
busyCardID: nil,
openSession: { _ in },
inspect: { _ in },
move: { _, _ in },
archive: { _ in })
.frame(maxWidth: 320)
self.previewHeader("Loading")
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
ProStatusRow(
icon: "arrow.clockwise",
title: "Loading cards",
detail: "Refreshing the workboard from the gateway.",
value: "loading",
color: OpenClawBrand.accent,
actionTitle: nil,
action: nil)
}
self.previewHeader("Error")
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
ProStatusRow(
icon: "exclamationmark.triangle",
title: "Cards unavailable",
detail: "Check the gateway connection, then refresh.",
value: "error",
color: OpenClawBrand.warn,
actionTitle: "Retry",
action: {})
}
}
.padding(18)
}
}
}
private var connectedBoard: some View {
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: 12) {
ForEach(self.statuses, id: \.self) { status in
IPadWorkboardKanbanColumn(
status: status,
cards: self.connectedCards.filter { $0.status == status },
statuses: self.statuses,
busyCardID: nil,
openSession: { _ in },
inspect: { _ in },
move: { _, _ in },
archive: { _ in })
.frame(width: 282)
}
}
}
}
private func previewHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
}
private enum IPadWorkboardPreviewFixtures {
static let cards = [
IPadWorkboardCard(
id: "preview-todo",
title: "Prep iPad sidebar audit",
notes: "Confirm portrait drawer behavior before device install.",
status: "todo",
priority: "normal",
labels: ["iPad", "UI"],
agentId: "main",
sessionKey: nil,
position: 0,
updatedAt: nil,
metadata: nil),
IPadWorkboardCard(
id: "preview-running",
title: "Verify phone workboard queue",
notes: "Single-list compact flow with detail sheet actions.",
status: "running",
priority: "high",
labels: ["phone"],
agentId: "main",
sessionKey: "session-preview",
position: 1,
updatedAt: nil,
metadata: nil),
IPadWorkboardCard(
id: "preview-review",
title: "Review adaptive shell",
notes: "Make sure shared destinations stay device-specific.",
status: "review",
priority: "normal",
labels: ["shell"],
agentId: "main",
sessionKey: nil,
position: 2,
updatedAt: nil,
metadata: nil),
]
}
private struct IPadSkillWorkshopStatesPreview: View {
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 18) {
self.previewHeader("Connected")
self.queueCard(self.proposals, selectedID: "preview-pending", busyID: nil)
self.previewHeader("Loading")
self.queueCard(self.proposals, selectedID: "preview-pending", busyID: "preview-pending")
self.previewHeader("Empty")
ProCard(radius: OpenClawProMetric.cardRadius) {
ProStatusRow(
icon: "hammer",
title: "No proposals",
detail: "New proposals will appear here when agents draft skills.",
value: "empty",
color: .secondary,
actionTitle: nil,
action: nil)
}
self.previewHeader("Offline / Error")
ProCard(radius: OpenClawProMetric.cardRadius) {
ProStatusRow(
icon: "wifi.slash",
title: "Workshop offline",
detail: "Connect to the gateway to load Skill Workshop proposals.",
value: "offline",
color: .secondary,
actionTitle: nil,
action: nil)
Divider().padding(.leading, 58)
ProStatusRow(
icon: "exclamationmark.triangle",
title: "Proposal unavailable",
detail: "Try again after the gateway reconnects.",
value: "error",
color: OpenClawBrand.warn,
actionTitle: nil,
action: nil)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.vertical, 18)
}
}
}
private func previewHeader(_ title: String) -> some View {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
}
private func queueCard(
_ proposals: [IPadSkillProposal],
selectedID: String?,
busyID: String?) -> some View
{
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
ProPanelHeader(
title: "Queue",
value: "\(proposals.count)",
actionTitle: nil,
action: nil)
ForEach(Array(proposals.enumerated()), id: \.element.id) { index, proposal in
if index > 0 {
Divider().padding(.leading, 58)
}
IPadSkillProposalRow(
proposal: proposal,
isSelected: proposal.id == selectedID,
isBusy: proposal.id == busyID)
}
}
}
}
}
private struct IPadSkillWorkshopKanbanPreview: View {
private let lanes = IPadSkillWorkshopPreviewFixtures.kanbanStatuses
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
var body: some View {
ZStack {
OpenClawProBackground()
VStack(alignment: .leading, spacing: 18) {
self.previewHeader
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: 12) {
ForEach(self.lanes, id: \.self) { status in
IPadSkillProposalKanbanColumn(
status: status,
proposals: self.proposals.filter { $0.status == status },
selectedProposalID: "preview-pending",
inspectingProposalID: "preview-needs-review",
canApplyProposalMutations: true,
busyAction: nil,
select: { _ in },
inspect: { _ in },
apply: { _ in },
reject: { _ in })
.frame(width: 282)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
.padding(.vertical, 22)
}
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .regular)
}
private var previewHeader: some View {
VStack(alignment: .leading, spacing: 4) {
Text("iPad kanban")
.font(.headline)
Text("Wide layout with populated, empty, held, and custom proposal lanes.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private enum IPadSkillWorkshopPreviewFixtures {
static let kanbanStatuses = [
"pending",
"quarantined",
"stale",
"applied",
"rejected",
"needs-review",
"manual_QA",
]
static let proposals = [
Self.proposal(
id: "preview-pending",
status: "pending",
title: "Add Tailscale gateway helper",
description: "Drafts a helper skill for checking local Tailscale reachability before pairing.",
minutesAgo: 9),
Self.proposal(
id: "preview-applied",
status: "applied",
title: "Summarize channel health",
description: "Adds a lightweight status summary for channel clients and recent routing failures.",
minutesAgo: 47),
Self.proposal(
id: "preview-held",
status: "quarantined",
title: "Desktop automation bridge",
description: "Held for review because it requests broader file access than mobile should expose.",
minutesAgo: 128),
Self.proposal(
id: "preview-needs-review",
status: "needs-review",
title: "Review pairing diagnostics",
description: "Adds a diagnostic checklist before trusting a new gateway certificate.",
minutesAgo: 32),
Self.proposal(
id: "preview-manual-qa",
status: "manual_QA",
title: "Manual QA runbook",
description: "Generates a device checklist for iPhone portrait and iPad split layouts.",
minutesAgo: 15),
]
private static func proposal(
id: String,
status: String,
title: String,
description: String,
minutesAgo: Int) -> IPadSkillProposal
{
let updatedAt = ISO8601DateFormatter().string(from: Date().addingTimeInterval(Double(-minutesAgo * 60)))
return IPadSkillProposal(
entry: IPadSkillProposalManifestEntry(
id: id,
kind: "skill",
status: status,
title: title,
description: description,
skillName: title,
skillKey: id,
createdAt: updatedAt,
updatedAt: updatedAt,
scanState: "complete"),
previous: nil)
}
}
#endif

View File

@@ -0,0 +1,17 @@
import Foundation
struct EmptyParams: Encodable {}
enum IPadSidebarGatewayError: Error {
case offline
case invalidPayload
var message: String {
switch self {
case .offline:
"Gateway offline."
case .invalidPayload:
"Could not encode request."
}
}
}

View File

@@ -0,0 +1,71 @@
import SwiftUI
struct IPadSidebarScreenChrome<Content: View>: View {
@Environment(\.verticalSizeClass) private var verticalSizeClass
let title: String
let subtitle: String
let headerLeadingAction: OpenClawSidebarHeaderAction?
let gatewayAction: (() -> Void)?
@ViewBuilder var content: Content
init(
title: String,
subtitle: String,
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
gatewayAction: (() -> Void)? = nil,
@ViewBuilder content: () -> Content)
{
self.title = title
self.subtitle = subtitle
self.headerLeadingAction = headerLeadingAction
self.gatewayAction = gatewayAction
self.content = content()
}
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) {
OpenClawAdaptiveHeaderRow(
title: self.title,
subtitle: self.subtitle,
titleFont: self.isCompactHeight ? .headline.weight(.semibold) : .title2.weight(.semibold),
subtitleLineLimit: self.isCompactHeight ? 1 : 2)
{
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
} accessory: {
self.gatewayPill
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
self.content
}
.padding(.vertical, self.isCompactHeight ? 10 : 18)
}
.safeAreaPadding(.bottom, self.bottomScrollInset)
}
}
private var isCompactHeight: Bool {
self.verticalSizeClass == .compact
}
@ViewBuilder
private var gatewayPill: some View {
if let gatewayAction {
Button(action: gatewayAction) {
OpenClawGatewayCompactPill()
}
.buttonStyle(.plain)
.accessibilityHint("Opens Settings / Gateway")
} else {
OpenClawGatewayCompactPill()
}
}
private var bottomScrollInset: CGFloat {
self.isCompactHeight ? 150 : OpenClawProMetric.bottomScrollInset
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
import SwiftUI
struct OpenClawDocsScreen: View {
private let docsURL = URL(string: "https://docs.openclaw.ai")!
private let gatewayURL = URL(string: "https://docs.openclaw.ai/gateway")!
private let pairingURL = URL(string: "https://docs.openclaw.ai/channels/pairing")!
let headerLeadingAction: OpenClawSidebarHeaderAction?
let gatewayAction: (() -> Void)?
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
self.headerLeadingAction = headerLeadingAction
self.gatewayAction = gatewayAction
}
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.headerCard
self.linkCard
self.versionCard
}
.padding(.vertical, 18)
}
}
.navigationTitle("Docs")
.navigationBarTitleDisplayMode(.inline)
}
private var headerCard: some View {
ProCard(radius: OpenClawProMetric.cardRadius) {
OpenClawAdaptiveHeaderRow(
title: "Docs",
subtitle: "Gateway setup, pairing, channels, and mobile node reference.",
titleFont: .headline,
subtitleFont: .caption)
{
HStack(alignment: .top, spacing: 12) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
ProIconBadge(systemName: "book", color: OpenClawBrand.accent)
}
} accessory: {
self.gatewayPill
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@ViewBuilder
private var gatewayPill: some View {
if let gatewayAction {
Button(action: gatewayAction) {
OpenClawGatewayCompactPill()
}
.buttonStyle(.plain)
.accessibilityHint("Opens Settings / Gateway")
} else {
OpenClawGatewayCompactPill()
}
}
private var linkCard: some View {
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
self.docsLinkRow(
title: "Docs Home",
detail: "Browse the current OpenClaw reference.",
icon: "book",
url: self.docsURL)
Divider().padding(.leading, 58)
self.docsLinkRow(
title: "Gateway",
detail: "Connection, auth, and diagnostics.",
icon: "network",
url: self.gatewayURL)
Divider().padding(.leading, 58)
self.docsLinkRow(
title: "Pairing",
detail: "Mobile setup codes, QR, and node approval.",
icon: "qrcode",
url: self.pairingURL)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var versionCard: some View {
ProCard(radius: OpenClawProMetric.cardRadius) {
HStack(spacing: 10) {
Text("Version")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text("v\(DeviceInfoHelper.openClawVersionString())")
.font(.caption.weight(.bold))
.foregroundStyle(.primary)
.textSelection(.enabled)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func docsLinkRow(title: String, detail: String, icon: String, url: URL) -> some View {
Link(destination: url) {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: OpenClawBrand.accent)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Image(systemName: "arrow.up.right")
.font(.caption.weight(.bold))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}

View File

@@ -1,33 +1,23 @@
import SwiftUI
enum OpenClawProMetric {
static let pagePadding: CGFloat = 20
static let cardRadius: CGFloat = 14
static let controlRadius: CGFloat = 12
static let pagePadding: CGFloat = 18
static let cardRadius: CGFloat = 10
static let controlRadius: CGFloat = 8
static let bottomScrollInset: CGFloat = 96
static let heroRadius: CGFloat = 22
static let heroRadius: CGFloat = 12
}
struct OpenClawProBackground: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
LinearGradient(
colors: OpenClawBrand.canvasColors(for: self.colorScheme),
startPoint: .top,
endPoint: .bottom)
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
.ignoresSafeArea()
.overlay(alignment: .top) {
if self.colorScheme == .light {
LinearGradient(
colors: [
OpenClawBrand.accent.opacity(0.05),
OpenClawBrand.accent.opacity(0.02),
.clear,
],
startPoint: .topTrailing,
endPoint: .bottomLeading)
.frame(height: 620)
Color.white.opacity(0.22)
.frame(height: 140)
.ignoresSafeArea()
}
}
@@ -66,7 +56,7 @@ struct ProSectionHeader: View {
struct ProCard<Content: View>: View {
var tint: Color?
var isProminent: Bool = false
var padding: CGFloat = 14
var padding: CGFloat = 12
var radius: CGFloat = OpenClawProMetric.cardRadius
@ViewBuilder var content: Content
@@ -91,78 +81,39 @@ private struct ProPanelBackground: View {
let shape = RoundedRectangle(cornerRadius: self.radius, style: .continuous)
shape
.fill(self.fill)
.overlay {
ProPanelTexture()
.opacity(self.colorScheme == .dark ? 0.22 : 0.08)
.clipShape(shape)
}
.overlay {
shape.strokeBorder(self.borderStyle, lineWidth: 1)
}
.overlay {
shape
.strokeBorder(Color.black.opacity(self.colorScheme == .dark ? 0.40 : 0.055), lineWidth: 0.7)
.padding(1)
}
.overlay(alignment: .top) {
shape
.strokeBorder(Color.white.opacity(self.colorScheme == .dark ? 0.07 : 0.36), lineWidth: 0.7)
.mask(alignment: .top) {
Rectangle().frame(height: 28)
}
if self.isProminent {
shape.strokeBorder(
OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.12 : 0.07),
lineWidth: 1)
.padding(1)
}
}
}
private var fill: AnyShapeStyle {
if self.colorScheme == .dark {
let base = self.isProminent
? Color(red: 15 / 255, green: 17 / 255, blue: 19 / 255)
: Color(red: 10 / 255, green: 12 / 255, blue: 14 / 255)
return AnyShapeStyle(base)
let base = self.isProminent
? Color(uiColor: .systemBackground)
: Color(uiColor: .secondarySystemGroupedBackground)
if let tint {
let gradient = LinearGradient(
colors: [
base,
tint.opacity(self.colorScheme == .dark ? 0.08 : 0.045),
base,
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
return AnyShapeStyle(gradient)
}
let gradient = LinearGradient(
colors: [
Color.white.opacity(0.98),
(self.tint ?? Color.white).opacity(self.tint == nil ? 0.92 : 0.12),
Color(red: 246 / 255, green: 247 / 255, blue: 249 / 255),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
return AnyShapeStyle(gradient)
return AnyShapeStyle(base)
}
private var borderStyle: AnyShapeStyle {
if self.colorScheme == .dark {
return AnyShapeStyle(Color.white.opacity(self.isProminent ? 0.15 : 0.11))
}
let gradient = LinearGradient(
colors: [
Color.white.opacity(0.72),
(self.tint ?? OpenClawBrand.accent).opacity(0.10),
Color.black.opacity(0.08),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
return AnyShapeStyle(gradient)
}
}
private struct ProPanelTexture: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Canvas { context, size in
let color = self.colorScheme == .dark ? Color.white.opacity(0.11) : Color.black.opacity(0.08)
for y in stride(from: 2.0, through: size.height, by: 6.5) {
let offset = Int(y / 6.5).isMultiple(of: 2) ? 0.0 : 3.25
for x in stride(from: 2.0 + offset, through: size.width, by: 6.5) {
let dot = CGRect(x: x, y: y, width: 0.7, height: 0.7)
context.fill(Path(ellipseIn: dot), with: .color(color))
}
}
}
AnyShapeStyle(Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.26 : 0.30))
}
}
@@ -251,9 +202,9 @@ private struct ProPanelSurfaceModifier: ViewModifier {
}
.modifier(ProLightGlassModifier(radius: self.radius))
.shadow(
color: self.colorScheme == .dark ? .black.opacity(0.60) : .black.opacity(0.045),
radius: self.isProminent ? 20 : 12,
y: self.isProminent ? 10 : 6)
color: self.colorScheme == .dark ? .black.opacity(0.22) : .black.opacity(0.028),
radius: self.isProminent ? 9 : 4,
y: self.isProminent ? 4 : 1)
}
}
@@ -263,16 +214,160 @@ struct ProIconBadge: View {
var body: some View {
Image(systemName: self.systemName)
.font(.subheadline.weight(.semibold))
.font(.caption.weight(.semibold))
.foregroundStyle(self.color)
.frame(width: 34, height: 34)
.frame(width: 30, height: 30)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(self.color.opacity(0.12))
}
}
}
struct OpenClawSidebarHeaderAction {
let systemName: String
let accessibilityLabel: String
let accessibilityIdentifier: String?
let action: () -> Void
init(
systemName: String,
accessibilityLabel: String,
accessibilityIdentifier: String? = nil,
action: @escaping () -> Void)
{
self.systemName = systemName
self.accessibilityLabel = accessibilityLabel
self.accessibilityIdentifier = accessibilityIdentifier
self.action = action
}
}
struct OpenClawSidebarRevealButton: View {
let headerAction: OpenClawSidebarHeaderAction
init(action: OpenClawSidebarHeaderAction) {
self.headerAction = action
}
init(action: @escaping () -> Void) {
self.headerAction = OpenClawSidebarHeaderAction(
systemName: "sidebar.left",
accessibilityLabel: "Show Sidebar",
action: action)
}
var body: some View {
let button = Button(action: self.headerAction.action) {
Image(systemName: self.headerAction.systemName)
.font(.system(size: 16, weight: .semibold))
.frame(width: 38, height: 38)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(OpenClawBrand.accent)
.accessibilityLabel(self.headerAction.accessibilityLabel)
if let accessibilityIdentifier = self.headerAction.accessibilityIdentifier {
button.accessibilityIdentifier(accessibilityIdentifier)
} else {
button
}
}
}
struct OpenClawSidebarHeaderLeadingSlot: View {
let action: OpenClawSidebarHeaderAction
var body: some View {
OpenClawSidebarRevealButton(action: self.action)
.frame(width: 44, height: 44, alignment: .center)
}
}
struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View {
let title: String
let subtitle: String
var titleFont: Font = .title3.weight(.semibold)
var subtitleFont: Font = .subheadline
var subtitleLineLimit: Int? = 2
@ViewBuilder let leading: Leading
@ViewBuilder let accessory: Accessory
init(
title: String,
subtitle: String,
titleFont: Font = .title3.weight(.semibold),
subtitleFont: Font = .subheadline,
subtitleLineLimit: Int? = 2,
@ViewBuilder leading: () -> Leading,
@ViewBuilder accessory: () -> Accessory)
{
self.title = title
self.subtitle = subtitle
self.titleFont = titleFont
self.subtitleFont = subtitleFont
self.subtitleLineLimit = subtitleLineLimit
self.leading = leading()
self.accessory = accessory()
}
var body: some View {
ViewThatFits(in: .horizontal) {
self.horizontalLayout
self.stackedLayout
}
}
private var horizontalLayout: some View {
HStack(alignment: .top, spacing: 12) {
self.leading
self.titleBlock
.layoutPriority(1)
Spacer(minLength: 8)
self.accessory
.fixedSize(horizontal: true, vertical: false)
}
}
private var stackedLayout: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
self.leading
self.titleBlock
.layoutPriority(1)
Spacer(minLength: 8)
}
HStack {
Spacer(minLength: 0)
self.accessory
.fixedSize(horizontal: true, vertical: false)
}
}
}
private var titleBlock: some View {
VStack(alignment: .leading, spacing: 4) {
Text(self.title)
.font(self.titleFont)
.lineLimit(2)
.minimumScaleFactor(0.86)
.fixedSize(horizontal: false, vertical: true)
Text(self.subtitle)
.font(self.subtitleFont)
.foregroundStyle(.secondary)
.lineLimit(self.subtitleLineLimit)
.fixedSize(horizontal: false, vertical: true)
}
}
}
struct ProStatusDot: View {
var color: Color
@@ -280,7 +375,6 @@ struct ProStatusDot: View {
Circle()
.fill(self.color)
.frame(width: 8, height: 8)
.shadow(color: self.color.opacity(0.35), radius: 4)
}
}
@@ -312,7 +406,7 @@ struct OpenClawProMark: View {
.resizable()
.scaledToFit()
.frame(width: self.size, height: self.size)
.shadow(color: OpenClawBrand.accent.opacity(0.28), radius: self.shadowRadius, y: self.shadowRadius / 2)
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: self.shadowRadius, y: self.shadowRadius / 3)
.accessibilityLabel("OpenClaw")
}
}
@@ -390,7 +484,10 @@ struct ProCapsule: View {
}
Text(self.title)
.font(.caption.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.78)
}
.fixedSize(horizontal: true, vertical: false)
.foregroundStyle(self.color)
.padding(.horizontal, 10)
.padding(.vertical, 7)
@@ -405,6 +502,57 @@ struct ProCapsule: View {
}
}
struct OpenClawGatewayCompactPill: View {
@Environment(NodeAppModel.self) private var appModel
var body: some View {
ProCapsule(
title: self.title,
color: self.color,
icon: self.icon)
.accessibilityLabel("Gateway \(self.title)")
}
private var title: String {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected:
"Online"
case .connecting:
"Connecting"
case .error:
"Attention"
case .disconnected:
"Offline"
}
}
private var color: Color {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected:
OpenClawBrand.ok
case .connecting:
OpenClawBrand.accent
case .error:
OpenClawBrand.warn
case .disconnected:
.secondary
}
}
private var icon: String {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected:
"checkmark.circle.fill"
case .connecting:
"arrow.triangle.2.circlepath"
case .error:
"exclamationmark.triangle.fill"
case .disconnected:
"wifi.slash"
}
}
}
struct ProSegmentedControl: View {
@Environment(\.colorScheme) private var colorScheme
let labels: [String]
@@ -531,28 +679,120 @@ struct ProMetricTile: View {
}
}
struct ProMetric: Identifiable {
let id = UUID()
let icon: String
let title: String
let value: String
let color: Color
}
struct ProMetricGrid: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
let metrics: [ProMetric]
var body: some View {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible()), count: self.columnCount),
spacing: 10)
{
ForEach(self.metrics) { metric in
ProMetricTile(
title: metric.title,
value: metric.value,
icon: metric.icon,
color: metric.color)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var columnCount: Int {
guard self.horizontalSizeClass != .compact else { return 1 }
return min(max(self.metrics.count, 1), 3)
}
}
struct ProPanelHeader: View {
let title: String
var value: String?
var actionTitle: String?
var actionIcon: String?
var actionAccessibilityLabel: String?
var isActionDisabled = false
var action: (() -> Void)?
var body: some View {
HStack(spacing: 8) {
Text(self.title)
.font(.subheadline.weight(.semibold))
if let value {
Text(value)
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
self.actionControl
}
.padding(.horizontal, 14)
.padding(.top, 12)
.padding(.bottom, 8)
}
@ViewBuilder
private var actionControl: some View {
if let action {
if let actionIcon {
Button(action: action) {
Image(systemName: actionIcon)
}
.accessibilityLabel(self.actionAccessibilityLabel ?? self.actionTitle ?? self.title)
.disabled(self.isActionDisabled)
} else if let actionTitle {
Button(actionTitle, action: action)
.font(.caption.weight(.semibold))
.disabled(self.isActionDisabled)
}
}
}
}
struct ProStatusRow: View {
let icon: String
let title: String
let detail: String
let value: String
let value: String?
let color: Color
var actionTitle: String?
var action: (() -> Void)?
var body: some View {
HStack(alignment: .center, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: self.icon, color: self.color)
VStack(alignment: .leading, spacing: 4) {
Text(self.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.lineLimit(2)
}
Spacer(minLength: 8)
ProValuePill(value: self.value, color: self.color)
VStack(alignment: .trailing, spacing: 6) {
if let value {
ProValuePill(value: value, color: self.color)
}
if let actionTitle, let action {
Button(actionTitle, action: action)
.font(.caption.weight(.semibold))
.buttonStyle(.bordered)
.controlSize(.mini)
}
}
}
.padding(.vertical, 11)
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
}

View File

@@ -0,0 +1,417 @@
import OpenClawProtocol
import SwiftUI
struct RootTabsPhoneControlHub: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var navigationPath: [RootTabs.SidebarDestination] = []
@State private var didApplyInitialDestination = false
let groups: [RootTabs.SidebarGroup]
let initialDestination: RootTabs.SidebarDestination?
let openRootDestination: (RootTabs.SidebarDestination) -> Void
var body: some View {
NavigationStack(path: self.$navigationPath) {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) {
self.headerCard
ForEach(self.groups) { group in
self.groupSection(group)
}
self.versionFooter
}
.padding(.vertical, self.isCompactHeight ? 10 : 16)
}
.safeAreaPadding(.bottom, self.bottomScrollInset)
}
.navigationTitle("Control")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: RootTabs.SidebarDestination.self) { destination in
self.detail(for: destination)
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
}
.onAppear {
self.applyInitialDestinationIfNeeded()
}
}
}
@ViewBuilder
private var headerCard: some View {
if self.isCompactHeight {
ProCard(padding: 8, radius: OpenClawProMetric.cardRadius) {
HStack(spacing: 12) {
OpenClawProMark(size: 24, shadowRadius: 3)
VStack(alignment: .leading, spacing: 3) {
Text(self.sidebarActiveAgentTitle)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(self.gatewayDisplayLabel)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 8)
ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
} else {
ProCard(radius: OpenClawProMetric.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
OpenClawProMark(size: 32, shadowRadius: 4)
VStack(alignment: .leading, spacing: 3) {
Text(self.sidebarActiveAgentTitle)
.font(.headline)
.lineLimit(1)
Text(self.gatewayDisplayLabel)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 8)
ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor)
}
self.gatewayActionRow
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private var gatewayActionRow: some View {
Button {
self.openRootDestination(.gateway)
} label: {
HStack(spacing: 10) {
ProStatusDot(color: self.gatewayStateColor)
VStack(alignment: .leading, spacing: 2) {
Text(self.gatewayStateText)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(self.gatewayDisplayLabel)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 8)
Text(self.gatewayActionTitle)
.font(.caption.weight(.semibold))
.foregroundStyle(OpenClawBrand.accent)
Image(systemName: "chevron.right")
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
}
.padding(10)
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("Gateway \(self.gatewayStateText)")
.accessibilityHint("Opens Settings / Gateway")
}
private func groupSection(_ group: RootTabs.SidebarGroup) -> some View {
VStack(alignment: .leading, spacing: self.isCompactHeight ? 6 : 8) {
ProSectionHeader(title: group.title.capitalized)
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
ForEach(Array(group.destinations.enumerated()), id: \.element.id) { index, destination in
if index > 0 {
Divider().padding(.leading, 58)
}
self.destinationRow(destination)
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@ViewBuilder
private func destinationRow(_ destination: RootTabs.SidebarDestination) -> some View {
if self.opensRootTab(destination) {
Button {
self.openRootDestination(destination)
} label: {
self.rowLabel(destination)
}
.buttonStyle(.plain)
} else {
Button {
self.navigationPath.append(destination)
} label: {
self.rowLabel(destination)
}
.buttonStyle(.plain)
}
}
private func rowLabel(_ destination: RootTabs.SidebarDestination) -> some View {
HStack(alignment: .center, spacing: 12) {
ProIconBadge(systemName: destination.systemImage, color: self.color(for: destination))
VStack(alignment: .leading, spacing: 3) {
Text(destination.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(destination.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
}
.padding(.vertical, self.isCompactHeight ? 8 : 10)
.padding(.horizontal, 14)
.contentShape(Rectangle())
}
private var versionFooter: some View {
ProCard(radius: OpenClawProMetric.cardRadius) {
HStack {
Spacer()
Text("v\(DeviceInfoHelper.openClawVersionString())")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@ViewBuilder
private func detail(for destination: RootTabs.SidebarDestination) -> some View {
switch destination {
case .chat, .talk, .agents, .gateway:
EmptyView()
case .overview:
CommandCenterTab(
headerTitle: "Overview",
headerLeadingAction: self.phoneDetailBackAction,
showsHeaderMark: false,
openChat: { self.openRootDestination(.chat) },
openSettings: { self.openRootDestination(.gateway) })
case .activity:
IPadActivityScreen(
headerLeadingAction: self.phoneDetailBackAction,
openChat: { self.openRootDestination(.chat) },
openSettings: { self.openRootDestination(.gateway) })
case .workboard:
IPadWorkboardScreen(
headerLeadingAction: self.phoneDetailBackAction,
openChat: { self.openRootDestination(.chat) },
openSettings: { self.openRootDestination(.gateway) })
case .skillWorkshop:
IPadSkillWorkshopScreen(
headerLeadingAction: self.phoneDetailBackAction,
openSettings: { self.openRootDestination(.gateway) })
case .instances:
AgentProTab(
directRoute: .instances,
headerLeadingAction: self.phoneDetailBackAction,
headerTitle: "Instances",
openSettings: { self.openRootDestination(.gateway) })
case .sessions:
CommandSessionsScreen(
headerLeadingAction: self.phoneDetailBackAction,
openChat: { self.openRootDestination(.chat) })
case .dreaming:
AgentProTab(
directRoute: .dreaming,
headerLeadingAction: self.phoneDetailBackAction,
headerTitle: "Dreaming",
openSettings: { self.openRootDestination(.gateway) })
case .usage:
AgentProTab(
directRoute: .usage,
headerLeadingAction: self.phoneDetailBackAction,
headerTitle: "Usage",
openSettings: { self.openRootDestination(.gateway) })
case .cron:
AgentProTab(
directRoute: .cron,
headerLeadingAction: self.phoneDetailBackAction,
headerTitle: "Cron Jobs",
openSettings: { self.openRootDestination(.gateway) })
case .docs:
OpenClawDocsScreen(
headerLeadingAction: self.phoneDetailBackAction,
gatewayAction: { self.openRootDestination(.gateway) })
case .settings:
EmptyView()
}
}
private var phoneDetailBackAction: OpenClawSidebarHeaderAction {
OpenClawSidebarHeaderAction(
systemName: "chevron.left",
accessibilityLabel: "Back to Control",
accessibilityIdentifier: "OpenClawPhoneDetailBackButton",
action: { self.popPhoneDetail() })
}
private func popPhoneDetail() {
guard !self.navigationPath.isEmpty else { return }
self.navigationPath.removeLast()
}
private func opensRootTab(_ destination: RootTabs.SidebarDestination) -> Bool {
RootTabs.shouldOpenRootTabFromPhoneHub(destination)
}
private func applyInitialDestinationIfNeeded() {
guard !self.didApplyInitialDestination else { return }
self.didApplyInitialDestination = true
guard let initialDestination, initialDestination != .overview else { return }
if self.opensRootTab(initialDestination) {
self.openRootDestination(initialDestination)
} else {
self.navigationPath = [initialDestination]
}
}
private var sidebarActiveAgentTitle: String {
let selectedID = self.normalized(self.appModel.selectedAgentId) ?? self.resolveDefaultAgentID()
if let agent = self.appModel.gatewayAgents.first(where: { $0.id == selectedID }) {
return self.agentTitle(for: agent)
}
return self.normalized(self.appModel.activeAgentName) ?? "Default Agent"
}
private var gatewayDisplayLabel: String {
self.normalized(self.appModel.gatewayServerName)
?? self.normalized(self.appModel.gatewayRemoteAddress)
?? self.appModel.gatewayDisplayStatusText
}
private var gatewayStateText: String {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected: "Online"
case .connecting: "Connecting"
case .error: "Attention"
case .disconnected: "Offline"
}
}
private var gatewayStateColor: Color {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected:
OpenClawBrand.ok
case .connecting:
OpenClawBrand.accent
case .error:
OpenClawBrand.warn
case .disconnected:
.secondary
}
}
private var gatewayActionTitle: String {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected:
"Manage"
case .connecting:
"Details"
case .error:
"Fix"
case .disconnected:
"Connect"
}
}
private var isCompactHeight: Bool {
self.verticalSizeClass == .compact
}
private var bottomScrollInset: CGFloat {
Self.bottomScrollInset(verticalSizeClass: self.verticalSizeClass)
}
static func bottomScrollInset(verticalSizeClass: UserInterfaceSizeClass?) -> CGFloat {
verticalSizeClass == .compact ? 72 : 112
}
private func color(for destination: RootTabs.SidebarDestination) -> Color {
switch destination {
case .chat, .talk, .overview, .gateway:
OpenClawBrand.accent
case .instances:
Color.secondary
case .activity, .usage, .docs:
OpenClawBrand.accentHot
case .agents, .workboard, .skillWorkshop, .sessions, .dreaming, .cron, .settings:
OpenClawBrand.ok
}
}
private func resolveDefaultAgentID() -> String {
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
}
private func agentTitle(for agent: AgentSummary) -> String {
let name = self.normalized(agent.name) ?? agent.id
return name == agent.id ? name : "\(name) (\(agent.id))"
}
private func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
#if DEBUG
#Preview("Phone control hub offline") {
RootTabsPhoneControlHub.preview(appModel: NodeAppModel())
}
#Preview("Phone control hub connected") {
let appModel = NodeAppModel()
appModel.enterAppleReviewDemoMode()
return RootTabsPhoneControlHub.preview(appModel: appModel)
}
#Preview("Phone control hub connecting") {
let appModel = NodeAppModel()
appModel.gatewayStatusText = "Connecting..."
return RootTabsPhoneControlHub.preview(appModel: appModel)
}
#Preview("Phone control hub gateway error") {
let appModel = NodeAppModel()
appModel.gatewayStatusText = "Gateway error: connection refused"
return RootTabsPhoneControlHub.preview(appModel: appModel)
}
#Preview(
"Phone control hub landscape",
traits: .fixedLayout(width: 852, height: 393),
.landscapeLeft)
{
RootTabsPhoneControlHub.preview(appModel: NodeAppModel())
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .compact)
}
extension RootTabsPhoneControlHub {
fileprivate static func preview(appModel: NodeAppModel) -> some View {
RootTabsPhoneControlHub(
groups: RootTabs.phoneControlGroups,
initialDestination: nil,
openRootDestination: { _ in })
.environment(appModel)
}
}
#endif

View File

@@ -0,0 +1,726 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
struct SettingsChannelsDestination: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.scenePhase) private var scenePhase
let showsSummaryCard: Bool
@State private var snapshot: ChannelsStatusResult?
@State private var isLoading = false
@State private var errorText: String?
@State private var busyOperation: SettingsChannelOperation?
init(showsSummaryCard: Bool = true) {
self.showsSummaryCard = showsSummaryCard
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
if self.showsSummaryCard {
self.summaryCard
}
self.channelsCard
}
.task(id: self.refreshID) {
await self.loadChannels(force: false)
}
.refreshable {
await self.loadChannels(force: true)
}
}
private var summaryCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
HStack(spacing: 12) {
ProIconBadge(systemName: "point.3.connected.trianglepath.dotted", color: self.summaryColor)
VStack(alignment: .leading, spacing: 3) {
Text("Channels / Integrations")
.font(.headline)
Text(self.summaryDetail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
ProValuePill(value: self.summaryValue, color: self.summaryColor)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var channelsCard: some View {
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
VStack(spacing: 0) {
ProPanelHeader(
title: "Message Routing",
value: self.headerValue,
actionIcon: self.isLoading ? "hourglass" : "arrow.clockwise",
actionAccessibilityLabel: "Refresh Channels",
isActionDisabled: self.isLoading,
action: {
Task { await self.loadChannels(force: true) }
})
if let errorText {
ProStatusRow(
icon: "exclamationmark.triangle",
title: "Channel status unavailable",
detail: errorText,
value: "error",
color: OpenClawBrand.warn)
} else if !self.canRead {
ProStatusRow(
icon: "wifi.slash",
title: "Gateway offline",
detail: "Connect to the gateway to load installed channels, accounts, and routing status.",
value: "offline",
color: .secondary)
} else if self.isLoading, self.snapshot == nil {
ProStatusRow(
icon: "hourglass",
title: "Loading channels",
detail: "Fetching installed channels, accounts, and routing status from the gateway.",
value: "loading",
color: OpenClawBrand.accent)
} else if self.channelEntries.isEmpty {
ProStatusRow(
icon: "tray",
title: "No channel plugins reported",
detail: "Install or enable channel plugins on the gateway, then refresh.",
value: "empty",
color: .secondary)
} else {
ForEach(Array(self.channelEntries.enumerated()), id: \.element.id) { index, entry in
if index > 0 {
Divider().padding(.leading, 58)
}
SettingsChannelRow(
entry: entry,
canAdmin: self.canAdmin,
busyOperation: self.busyOperation,
start: { accountID in
Task { await self.run(.start, channelID: entry.id, accountID: accountID) }
},
stop: { accountID in
Task { await self.run(.stop, channelID: entry.id, accountID: accountID) }
},
logout: { accountID in
Task { await self.run(.logout, channelID: entry.id, accountID: accountID) }
})
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var refreshID: String {
[
self.canRead ? "connected" : "offline",
self.scenePhase == .active ? "active" : "inactive",
].joined(separator: ":")
}
private var canRead: Bool {
self.appModel.isOperatorGatewayConnected
}
private var canAdmin: Bool {
self.appModel.hasOperatorAdminScope
}
static func shouldEnableChannelOperation(canRead: Bool, hasOperatorAdminScope: Bool) -> Bool {
canRead && hasOperatorAdminScope
}
private var headerValue: String? {
if self.isLoading { return "Loading" }
guard self.canRead else { return "Offline" }
return "\(self.channelEntries.count)"
}
private var summaryDetail: String {
guard self.canRead else {
return "Connect to load channel integrations."
}
if let errorText {
return errorText
}
return "Installed channel clients, account state, and message-routing readiness."
}
private var summaryValue: String {
guard self.canRead else { return "offline" }
if self.isLoading { return "loading" }
if self.errorText != nil { return "error" }
let configured = self.channelEntries.count(where: { $0.configured })
return "\(configured)/\(self.channelEntries.count)"
}
private var summaryColor: Color {
guard self.canRead else { return .secondary }
if self.errorText != nil { return OpenClawBrand.warn }
return self.channelEntries.contains(where: { $0.running || $0.connected }) ? OpenClawBrand.ok : OpenClawBrand
.accent
}
private var channelEntries: [SettingsChannelEntry] {
guard let snapshot else { return [] }
let ids = snapshot.channelorder.isEmpty ? Array(snapshot.channels.keys).sorted() : snapshot.channelorder
return ids.map { self.entry(channelID: $0, snapshot: snapshot) }
}
private func entry(channelID: String, snapshot: ChannelsStatusResult) -> SettingsChannelEntry {
let summary = snapshot.channels[channelID]?.dictionaryValue ?? [:]
let accounts = self.accounts(channelID: channelID, snapshot: snapshot)
let configured = accounts.contains(where: \.configured) || summary["configured"]?.boolValue == true
let running = accounts.contains(where: \.running)
let connected = accounts.contains(where: \.connected)
let linked = accounts.contains(where: \.linked)
let label = snapshot.channellabels[channelID]?.stringValue ?? Self.fallbackLabel(channelID)
let detail = snapshot.channeldetaillabels?[channelID]?.stringValue ?? Self.fallbackDetail(channelID)
let systemImage = snapshot.channelsystemimages?[channelID]?.stringValue ?? Self.fallbackSystemImage(channelID)
let lastActivity = accounts.compactMap(\.lastActivityMs).max()
let lastError = accounts.compactMap(\.lastError).first ?? summary["lastError"]?.stringValue
return SettingsChannelEntry(
id: channelID,
label: label,
detail: detail,
systemImage: systemImage,
configured: configured,
running: running,
connected: connected,
linked: linked,
lastActivityText: lastActivity.map(Self.relativeTime),
lastError: lastError,
unavailableReason: configured ? nil : "Configure this channel on the gateway.",
accounts: accounts)
}
private func accounts(channelID: String, snapshot: ChannelsStatusResult) -> [SettingsChannelAccount] {
let rawAccounts = snapshot.channelaccounts[channelID]?.arrayValue ?? []
return rawAccounts.compactMap { raw in
guard let dict = raw.dictionaryValue else { return nil }
let accountID = dict["accountId"]?.stringValue ?? "default"
let name = dict["name"]?.stringValue
let lastActivity = [
dict["lastInboundAt"]?.intValue,
dict["lastOutboundAt"]?.intValue,
dict["lastTransportActivityAt"]?.intValue,
]
.compactMap(\.self)
.max()
return SettingsChannelAccount(
id: accountID,
name: name,
configured: dict["configured"]?.boolValue == true,
enabled: dict["enabled"]?.boolValue != false,
running: dict["running"]?.boolValue == true,
connected: dict["connected"]?.boolValue == true,
linked: dict["linked"]?.boolValue == true,
healthState: dict["healthState"]?.stringValue,
lastError: dict["lastError"]?.stringValue,
lastActivityMs: lastActivity)
}
}
private func loadChannels(force: Bool) async {
guard self.scenePhase == .active else { return }
guard self.canRead else {
self.snapshot = nil
self.errorText = nil
return
}
if self.isLoading { return }
self.isLoading = true
self.errorText = nil
defer { self.isLoading = false }
do {
let params = ChannelsStatusParams(probe: false, timeoutms: 10000, channel: nil)
let data = try await self.request(method: "channels.status", params: params, timeoutSeconds: 12)
self.snapshot = try JSONDecoder().decode(ChannelsStatusResult.self, from: data)
} catch {
if force || self.snapshot == nil {
self.errorText = Self.message(for: error)
}
}
}
private func run(_ kind: SettingsChannelOperation.Kind, channelID: String, accountID: String?) async {
guard Self.shouldEnableChannelOperation(canRead: self.canRead, hasOperatorAdminScope: self.canAdmin),
self.busyOperation == nil
else {
return
}
self.busyOperation = SettingsChannelOperation(kind: kind, channelID: channelID, accountID: accountID)
self.errorText = nil
defer { self.busyOperation = nil }
do {
switch kind {
case .start:
let params = ChannelsStartParams(channel: channelID, accountid: accountID)
_ = try await self.request(method: "channels.start", params: params, timeoutSeconds: 20)
case .stop:
let params = ChannelsStopParams(channel: channelID, accountid: accountID)
_ = try await self.request(method: "channels.stop", params: params, timeoutSeconds: 20)
case .logout:
let params = ChannelsLogoutParams(channel: channelID, accountid: accountID)
_ = try await self.request(method: "channels.logout", params: params, timeoutSeconds: 20)
}
await self.loadChannels(force: true)
} catch {
self.errorText = Self.message(for: error)
}
}
private func request(method: String, params: some Encodable, timeoutSeconds: Int) async throws -> Data {
let data = try JSONEncoder().encode(params)
guard let json = String(data: data, encoding: .utf8) else {
throw SettingsChannelError.invalidPayload
}
return try await self.appModel.operatorSession.request(
method: method,
paramsJSON: json,
timeoutSeconds: timeoutSeconds)
}
static func fallbackLabel(_ id: String) -> String {
if let metadata = self.fallbackMetadata[id.lowercased()] {
return metadata.label
}
return id.replacingOccurrences(of: "-", with: " ")
.replacingOccurrences(of: "_", with: " ")
.split(separator: " ")
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
.joined(separator: " ")
}
static func fallbackDetail(_ id: String) -> String {
self.fallbackMetadata[id.lowercased()]?.detail ?? "Channel integration"
}
static func fallbackSystemImage(_ id: String) -> String {
self.fallbackMetadata[id.lowercased()]?.systemImage ?? "bubble.left.and.text.bubble.right"
}
private static let fallbackMetadata: [String: SettingsChannelFallbackMetadata] = [
"clickclack": SettingsChannelFallbackMetadata(
label: "ClickClack",
detail: "Self-hosted chat bot routing.",
systemImage: "bubble.left.and.bubble.right"),
]
private static func relativeTime(_ milliseconds: Int) -> String {
let age = max(0, Int(Date().timeIntervalSince1970 * 1000) - milliseconds)
let minutes = age / 60000
if minutes < 1 { return "now" }
if minutes < 60 { return "\(minutes)m ago" }
let hours = minutes / 60
if hours < 24 { return "\(hours)h ago" }
return "\(hours / 24)d ago"
}
private static func message(for error: Error) -> String {
if let channelError = error as? SettingsChannelError {
return channelError.message
}
return error.localizedDescription
}
}
struct SettingsChannelsScreen: View {
let headerLeadingAction: OpenClawSidebarHeaderAction?
let gatewayAction: (() -> Void)?
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
self.headerLeadingAction = headerLeadingAction
self.gatewayAction = gatewayAction
}
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
self.header
SettingsChannelsDestination(showsSummaryCard: false)
}
.padding(.top, 18)
.padding(.bottom, OpenClawProMetric.bottomScrollInset)
}
}
.navigationTitle("Channels")
.navigationBarTitleDisplayMode(.inline)
}
private var header: some View {
HStack(alignment: .top, spacing: 12) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
VStack(alignment: .leading, spacing: 5) {
Text("Channels / Integrations")
.font(.title3.weight(.semibold))
Text("Message routing and external channel clients.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
self.gatewayPill
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@ViewBuilder
private var gatewayPill: some View {
if let gatewayAction {
Button(action: gatewayAction) {
OpenClawGatewayCompactPill()
}
.buttonStyle(.plain)
.accessibilityHint("Opens Settings / Gateway")
} else {
OpenClawGatewayCompactPill()
}
}
}
private struct SettingsChannelRow: View {
let entry: SettingsChannelEntry
let canAdmin: Bool
let busyOperation: SettingsChannelOperation?
let start: (String?) -> Void
let stop: (String?) -> Void
let logout: (String?) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: self.entry.systemImage, color: self.entry.color)
VStack(alignment: .leading, spacing: 4) {
Text(self.entry.label)
.font(.subheadline.weight(.semibold))
Text(self.entry.detailText)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let lastError = self.entry.lastError {
Text(lastError)
.font(.caption2.weight(.medium))
.foregroundStyle(OpenClawBrand.warn)
.lineLimit(2)
}
}
Spacer(minLength: 8)
ProValuePill(value: self.entry.statusValue, color: self.entry.color)
}
if !self.entry.accounts.isEmpty {
VStack(spacing: 0) {
ForEach(Array(self.entry.accounts.enumerated()), id: \.element.id) { index, account in
if index > 0 {
Divider().padding(.leading, 38)
}
self.accountRow(account)
}
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
private func accountRow(_ account: SettingsChannelAccount) -> some View {
HStack(spacing: 10) {
Image(systemName: account.running || account.connected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(account.color)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 2) {
Text(account.displayName)
.font(.caption.weight(.semibold))
Text(account.detailText)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Menu {
if account.running {
Button("Stop") {
self.stop(account.id)
}
} else {
Button("Start") {
self.start(account.id)
}
.disabled(!account.configured || !account.enabled)
}
if account.linked {
Button("Logout", role: .destructive) {
self.logout(account.id)
}
}
} label: {
Image(systemName: self.actionMenuIcon(account))
}
.buttonStyle(.bordered)
.controlSize(.mini)
.disabled(!self.canAdmin || self.isBusy(account))
}
.padding(.vertical, 8)
}
private func actionMenuIcon(_ account: SettingsChannelAccount) -> String {
if self.isBusy(account) {
return "hourglass"
}
if !self.canAdmin {
return "lock.shield"
}
return "ellipsis.circle"
}
private func isBusy(_ account: SettingsChannelAccount) -> Bool {
self.busyOperation?.channelID == self.entry.id && self.busyOperation?.accountID == account.id
}
}
private struct SettingsChannelEntry: Identifiable {
let id: String
let label: String
let detail: String
let systemImage: String
let configured: Bool
let running: Bool
let connected: Bool
let linked: Bool
let lastActivityText: String?
let lastError: String?
let unavailableReason: String?
let accounts: [SettingsChannelAccount]
var color: Color {
if self.connected || self.running { return OpenClawBrand.ok }
if self.lastError != nil { return OpenClawBrand.warn }
return self.configured ? OpenClawBrand.accent : .secondary
}
var statusValue: String {
if self.connected { return "connected" }
if self.running { return "running" }
if self.linked { return "linked" }
if self.configured { return "configured" }
return "not set"
}
var detailText: String {
if let lastActivityText {
return "\(self.detail) • active \(lastActivityText)"
}
if let unavailableReason {
return unavailableReason
}
return self.detail
}
}
private struct SettingsChannelFallbackMetadata {
let label: String
let detail: String
let systemImage: String
}
private struct SettingsChannelAccount: Identifiable {
let id: String
let name: String?
let configured: Bool
let enabled: Bool
let running: Bool
let connected: Bool
let linked: Bool
let healthState: String?
let lastError: String?
let lastActivityMs: Int?
var displayName: String {
let trimmedName = self.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmedName.isEmpty ? self.id : "\(trimmedName) (\(self.id))"
}
var detailText: String {
let state = if self.connected {
"connected"
} else if self.running {
"running"
} else if self.linked {
"linked"
} else if self.configured {
"configured"
} else {
"not configured"
}
let enabledText = self.enabled ? "enabled" : "disabled"
if let healthState, !healthState.isEmpty {
return "\(state), \(enabledText), \(healthState)"
}
if let lastError, !lastError.isEmpty {
return "\(state), \(enabledText), error"
}
return "\(state), \(enabledText)"
}
var color: Color {
if self.connected || self.running { return OpenClawBrand.ok }
if self.lastError != nil { return OpenClawBrand.warn }
return self.configured ? OpenClawBrand.accent : .secondary
}
}
private struct SettingsChannelOperation: Equatable {
enum Kind {
case start
case stop
case logout
}
let kind: Kind
let channelID: String
let accountID: String?
}
private enum SettingsChannelError: Error {
case invalidPayload
var message: String {
switch self {
case .invalidPayload:
"Could not encode channel request."
}
}
}
#if DEBUG
#Preview("Channels states") {
SettingsChannelsStatesPreview()
}
private struct SettingsChannelsStatesPreview: View {
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.stateSection("Connected") {
SettingsChannelRow(
entry: Self.telegramEntry,
canAdmin: true,
busyOperation: nil,
start: { _ in },
stop: { _ in },
logout: { _ in })
}
self.stateSection("Loading") {
ProPanelHeader(
title: "Message Routing",
value: "Loading",
actionIcon: "hourglass",
actionAccessibilityLabel: "Refresh Channels",
isActionDisabled: true,
action: {})
ProStatusRow(
icon: "hourglass",
title: "Loading channel status",
detail: "Checking installed channel clients and account state.",
value: "loading",
color: OpenClawBrand.accent)
}
self.stateSection("Empty") {
ProPanelHeader(
title: "Message Routing",
value: "0",
actionIcon: "arrow.clockwise",
actionAccessibilityLabel: "Refresh Channels",
action: {})
ProStatusRow(
icon: "tray",
title: "No channel plugins reported",
detail: "Install or enable channel plugins on the gateway, then refresh.",
value: "empty",
color: .secondary)
}
self.stateSection("Error") {
ProStatusRow(
icon: "exclamationmark.triangle",
title: "Channel status unavailable",
detail: "Gateway returned an unexpected channel status response.",
value: "error",
color: OpenClawBrand.warn)
}
self.stateSection("Offline") {
ProStatusRow(
icon: "wifi.slash",
title: "Gateway offline",
detail: "Connect to the gateway to load installed channels, accounts, and routing status.",
value: "offline",
color: .secondary)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.vertical, 18)
}
}
}
private func stateSection(
_ title: String,
@ViewBuilder content: () -> some View) -> some View
{
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
VStack(spacing: 0) {
content()
}
}
}
}
private static let telegramEntry = SettingsChannelEntry(
id: "telegram",
label: "Telegram",
detail: "Message routing client",
systemImage: "paperplane",
configured: true,
running: true,
connected: true,
linked: true,
lastActivityText: "4m ago",
lastError: nil,
unavailableReason: nil,
accounts: [
SettingsChannelAccount(
id: "main",
name: "OpenClaw Ops",
configured: true,
enabled: true,
running: true,
connected: true,
linked: true,
healthState: "healthy",
lastError: nil,
lastActivityMs: nil),
])
}
#endif

View File

@@ -58,9 +58,38 @@ struct SettingsProTab: View {
@State var diagnosticsLastRunText = "Not run"
@State var diagnosticsIssueCount: Int?
@State var showTalkIssueDetails = false
@State private var navigationPath: [SettingsRoute] = []
let initialRoute: SettingsRoute?
let directRoute: SettingsRoute?
let headerLeadingAction: OpenClawSidebarHeaderAction?
init(
initialRoute: SettingsRoute? = nil,
directRoute: SettingsRoute? = nil,
headerLeadingAction: OpenClawSidebarHeaderAction? = nil)
{
self.initialRoute = initialRoute
self.directRoute = directRoute
self.headerLeadingAction = headerLeadingAction
}
var body: some View {
NavigationStack {
self.settingsModalPresentation(
self.settingsLifecycle(
self.settingsContent))
}
@ViewBuilder
private var settingsContent: some View {
if let directRoute {
self.destination(for: directRoute)
} else {
self.settingsNavigationStack
}
}
private var settingsNavigationStack: some View {
NavigationStack(path: self.$navigationPath) {
ZStack {
OpenClawProBackground()
ScrollView {
@@ -78,11 +107,17 @@ struct SettingsProTab: View {
.navigationDestination(for: SettingsRoute.self) { route in
self.destination(for: route)
}
}
}
private func settingsLifecycle(_ content: some View) -> some View {
content
.task {
self.previousLocationModeRaw = self.locationModeRaw
self.syncSettingsState()
self.refreshNotificationSettings()
self.applyPendingGatewaySetupLinkIfNeeded()
self.applyInitialRouteIfNeeded()
}
.onChange(of: self.scenePhase) { _, phase in
if phase == .active {
@@ -119,66 +154,76 @@ struct SettingsProTab: View {
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.applyPendingGatewaySetupLinkIfNeeded()
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
})
}
private func settingsModalPresentation(_ content: some View) -> some View {
content
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
})
}
}
}
.sheet(isPresented: self.$showTalkIssueDetails) {
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
TalkRuntimeIssueDetailsSheet(issue: issue)
.sheet(isPresented: self.$showTalkIssueDetails) {
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
TalkRuntimeIssueDetailsSheet(issue: issue)
}
}
}
.sheet(isPresented: self.$showQRScanner) {
NavigationStack {
QRScannerView(
onGatewayLink: { link in
self.handleScannedGatewayLink(link)
},
onSetupCode: { code in
self.handleScannedSetupCode(code)
},
onError: { error in
self.showQRScanner = false
self.setupStatusText = "Scanner error: \(error)"
self.scannerError = error
},
onDismiss: {
self.showQRScanner = false
})
.ignoresSafeArea()
.navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { self.showQRScanner = false }
.sheet(isPresented: self.$showQRScanner) {
NavigationStack {
QRScannerView(
onGatewayLink: { link in
self.handleScannedGatewayLink(link)
},
onSetupCode: { code in
self.handleScannedSetupCode(code)
},
onError: { error in
self.showQRScanner = false
self.setupStatusText = "Scanner error: \(error)"
self.scannerError = error
},
onDismiss: {
self.showQRScanner = false
})
.ignoresSafeArea()
.navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { self.showQRScanner = false }
}
}
}
}
}
}
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button("Reset", role: .destructive) {
self.resetOnboarding()
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button("Reset", role: .destructive) {
self.resetOnboarding()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This disconnects, clears saved gateway credentials, and reopens onboarding.")
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This disconnects, clears saved gateway credentials, and reopens onboarding.")
}
.alert(
"QR Scanner Unavailable",
isPresented: Binding(
get: { self.scannerError != nil },
set: { if !$0 { self.scannerError = nil } }))
{
Button("OK", role: .cancel) {}
} message: {
Text(self.scannerError ?? "")
}
.alert(
"QR Scanner Unavailable",
isPresented: Binding(
get: { self.scannerError != nil },
set: { if !$0 { self.scannerError = nil } }))
{
Button("OK", role: .cancel) {}
} message: {
Text(self.scannerError ?? "")
}
}
private func applyInitialRouteIfNeeded() {
guard self.directRoute == nil else { return }
guard let initialRoute else { return }
guard self.navigationPath != [initialRoute] else { return }
self.navigationPath = [initialRoute]
}
}

View File

@@ -495,6 +495,7 @@ extension SettingsProTab {
case .gateway: "Gateway"
case .approvals: "Approvals"
case .permissions: "Permissions"
case .channels: "Channels"
case .voice: "Voice & Talk"
case .diagnostics: "Diagnostics"
case .privacy: "Privacy"
@@ -503,6 +504,20 @@ extension SettingsProTab {
}
}
func subtitle(for route: SettingsRoute) -> String {
switch route {
case .gateway: "Pairing, diagnostics, and Tailscale checks."
case .approvals: "Review pending agent actions."
case .permissions: "Control device capabilities."
case .channels: "Message routing and external clients."
case .voice: "Talk mode and wake phrase settings."
case .diagnostics: "Run local health checks."
case .privacy: "Data and device privacy controls."
case .notifications: "Alert permissions and delivery."
case .about: "Version and support details."
}
}
var manualPortBinding: Binding<String> {
Binding(
get: { self.manualGatewayPortText },

View File

@@ -3,10 +3,20 @@ import SwiftUI
extension SettingsProTab {
var settingsHeader: some View {
Text("Settings")
.font(.system(size: 28, weight: .bold))
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 6)
OpenClawAdaptiveHeaderRow(
title: "Settings",
subtitle: "Gateway, permissions, voice, and device controls.",
titleFont: .title3.weight(.semibold),
subtitleFont: .callout)
{
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 6)
}
var appearanceSection: some View {
@@ -131,6 +141,11 @@ extension SettingsProTab {
title: "Permissions",
detail: self.permissionsDetail,
route: .permissions)
self.settingsListRow(
icon: "point.3.connected.trianglepath.dotted",
title: "Channels / Integrations",
detail: "Message routing and external channel clients.",
route: .channels)
self.settingsListRow(
icon: "waveform",
title: "Voice & Talk",
@@ -199,6 +214,9 @@ extension SettingsProTab {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
if self.headerLeadingAction != nil {
self.routeHeader(for: route)
}
switch route {
case .gateway:
self.gatewayDestination
@@ -206,6 +224,8 @@ extension SettingsProTab {
self.approvalsDestination
case .permissions:
self.permissionsDestination
case .channels:
SettingsChannelsDestination()
case .voice:
self.voiceDestination
case .diagnostics:
@@ -224,6 +244,24 @@ extension SettingsProTab {
}
.navigationTitle(self.title(for: route))
.navigationBarTitleDisplayMode(.inline)
.toolbar(self.headerLeadingAction == nil ? .visible : .hidden, for: .navigationBar)
}
func routeHeader(for route: SettingsRoute) -> some View {
OpenClawAdaptiveHeaderRow(
title: self.title(for: route),
subtitle: self.subtitle(for: route),
titleFont: .title3.weight(.semibold),
subtitleFont: .callout)
{
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 6)
}
var gatewayDestination: some View {

View File

@@ -1,10 +1,12 @@
import Darwin
import OpenClawKit
import SwiftUI
enum SettingsRoute: Hashable {
case gateway
case approvals
case permissions
case channels
case voice
case diagnostics
case privacy
@@ -150,3 +152,176 @@ extension SettingsProTab {
return a == 100 && b >= 64 && b <= 127
}
}
#if DEBUG
#Preview("Gateway settings states") {
SettingsGatewayStatesPreview()
}
private struct SettingsGatewayStatesPreview: View {
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.stateSection("Connected") {
self.gatewayStatusCard(
title: "Gateway online",
detail: "Connected to openclaw-gateway.tailnet.ts.net.",
value: "online",
color: OpenClawBrand.ok)
self.gatewayFactsCard(
address: "100.88.41.20:18789",
server: "openclaw-gateway",
discovered: "3",
agent: "Aiden")
}
self.stateSection("Loading") {
self.gatewayStatusCard(
title: "Checking gateway",
detail: "Refreshing connection, discovery, and device trust state.",
value: "loading",
color: OpenClawBrand.accent)
self.gatewayActionsCard(isBusy: true)
}
self.stateSection("Empty") {
self.gatewayStatusCard(
title: "No gateway configured",
detail: "Scan a setup QR code, paste a setup code, or choose a discovered gateway.",
value: "setup",
color: .secondary)
self.setupActionsCard
}
self.stateSection("Error") {
GatewayProblemBanner(
problem: Self.pairingProblem,
primaryActionTitle: "Retry",
onPrimaryAction: {},
onShowDetails: {})
self.gatewayStatusCard(
title: "Tailscale warning",
detail: "Tailscale is off on this device. Turn it on, then try again.",
value: "network",
color: OpenClawBrand.warn)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.vertical, 18)
}
}
}
private func stateSection(
_ title: String,
@ViewBuilder content: () -> some View) -> some View
{
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
content()
}
}
private func gatewayStatusCard(
title: String,
detail: String,
value: String,
color: Color) -> some View
{
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
ProStatusRow(
icon: value == "online" ? "antenna.radiowaves.left.and.right" : "wifi.slash",
title: title,
detail: detail,
value: value,
color: color,
actionTitle: value == "setup" ? "Scan QR" : nil,
action: value == "setup" ? {} : nil)
}
}
private func gatewayFactsCard(
address: String,
server: String,
discovered: String,
agent: String) -> some View
{
ProCard(radius: SettingsLayout.cardRadius) {
VStack(spacing: 0) {
self.factRow("Address", value: address)
Divider()
self.factRow("Server", value: server)
Divider()
self.factRow("Discovered", value: discovered)
Divider()
self.factRow("Default Agent", value: agent)
}
}
}
private func factRow(_ label: String, value: String) -> some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text(value)
.font(.caption.weight(.medium))
.lineLimit(1)
.truncationMode(.middle)
}
.frame(height: SettingsLayout.rowHeight)
}
private func gatewayActionsCard(isBusy: Bool) -> some View {
ProCard(radius: SettingsLayout.cardRadius) {
HStack(spacing: 10) {
self.previewButton("Reconnect", systemImage: "arrow.triangle.2.circlepath", isBusy: isBusy)
self.previewButton("Diagnose", systemImage: "cross.case", isBusy: isBusy)
}
}
}
private var setupActionsCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
self.previewButton("Scan QR", systemImage: "qrcode.viewfinder", isBusy: false)
self.previewButton("Connect", systemImage: "link", isBusy: false)
}
Text("Discovered gateways and manual setup live here when the gateway has not connected yet.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private func previewButton(
_ title: String,
systemImage: String,
isBusy: Bool) -> some View
{
Button {} label: {
Label(title, systemImage: systemImage)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(isBusy)
}
private static let pairingProblem = GatewayConnectionProblem(
kind: .pairingRequired,
owner: .gateway,
title: "Pairing required",
message: "Run /pair approve in your OpenClaw chat before this iPad can connect.",
actionCommand: "/pair approve req-ipad-preview",
requestId: "req-ipad-preview",
retryable: false,
pauseReconnect: true)
}
#endif

View File

@@ -9,8 +9,17 @@ struct TalkProTab: View {
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
@State private var showPermissionPrompt = false
@State private var showTalkIssueDetails = false
let headerLeadingAction: OpenClawSidebarHeaderAction?
var openSettings: () -> Void
init(
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
openSettings: @escaping () -> Void)
{
self.headerLeadingAction = headerLeadingAction
self.openSettings = openSettings
}
private var state: TalkProState {
TalkProState(
gatewayConnected: self.gatewayConnected,
@@ -85,6 +94,9 @@ struct TalkProTab: View {
private var header: some View {
HStack(alignment: .center, spacing: 11) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
OpenClawProMark(size: 31, shadowRadius: 9)
VStack(alignment: .leading, spacing: 2) {
Text("Talk")

View File

@@ -116,6 +116,8 @@ final class NodeAppModel {
self.operatorConnected
}
private(set) var hasOperatorAdminScope: Bool = false
var gatewayServerName: String?
var gatewayRemoteAddress: String?
var connectedGatewayID: String?
@@ -297,6 +299,7 @@ final class NodeAppModel {
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(enabled)
self.talkMode.attachGateway(self.operatorGateway)
self.refreshOperatorAdminScopeFromStore()
self.refreshLastShareEventFromRelay()
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
self.setTalkEnabled(talkEnabled)
@@ -2757,6 +2760,15 @@ extension NodeAppModel {
private func setOperatorConnected(_ connected: Bool) {
self.operatorConnected = connected
self.operatorStatusText = connected ? "Connected" : "Offline"
self.refreshOperatorAdminScopeFromStore()
}
private func refreshOperatorAdminScopeFromStore() {
let identity = DeviceIdentityStore.loadOrCreate()
self.hasOperatorAdminScope = DeviceAuthStore
.loadToken(deviceId: identity.deviceId, role: "operator")?
.scopes
.contains("operator.admin") == true
}
}
@@ -4636,6 +4648,10 @@ extension NodeAppModel {
self.gatewayConnected
}
func _test_refreshOperatorAdminScopeFromStore() {
self.refreshOperatorAdminScopeFromStore()
}
func _test_applyPendingForegroundNodeActions(
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
{

View File

@@ -8,6 +8,8 @@ struct RootTabs: View {
@Environment(VoiceWakeManager.self) private var voiceWake
@Environment(GatewayConnectionController.self) private var gatewayController
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.rootTabsUserInterfaceIdiomOverride) private var userInterfaceIdiomOverride
@Environment(\.scenePhase) private var scenePhase
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
@@ -21,10 +23,14 @@ struct RootTabs: View {
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
AppAppearancePreference.system.rawValue
@State private var selectedTab: AppTab = Self.initialTab
@State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination
@State private var isSidebarVisible: Bool = Self.initialSidebarVisibility ?? false
@State private var sidebarVisibilityUserOverridden: Bool = Self.initialSidebarVisibility != nil
@State private var isSidebarDrawerLayout: Bool = false
@State private var didResolveSidebarLayout: Bool = false
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@State private var presentedSheet: PresentedSheet?
@State private var showGatewayActions: Bool = false
@State private var showGatewayProblemDetails: Bool = false
@State private var showOnboarding: Bool = false
@State private var onboardingAllowSkip: Bool = true
@@ -34,14 +40,6 @@ struct RootTabs: View {
@State private var didApplyInitialChatSession: Bool = false
@State private var handledGatewaySetupRequestID: Int = 0
private enum AppTab: Hashable {
case control
case chat
case talk
case agent
case settings
}
private static var initialTab: AppTab {
let arguments = ProcessInfo.processInfo.arguments
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-tab") else {
@@ -66,6 +64,28 @@ struct RootTabs: View {
}
}
private static var initialSidebarDestination: SidebarDestination {
if let requested = requestedInitialSidebarDestination {
return requested
}
return Self.defaultSidebarDestination(for: initialTab)
}
private static var requestedInitialSidebarDestination: SidebarDestination? {
let arguments = ProcessInfo.processInfo.arguments
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-destination") else {
return nil
}
let valueIndex = arguments.index(after: flagIndex)
guard arguments.indices.contains(valueIndex) else { return nil }
let requested = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return SidebarDestination.allCases.first { $0.rawValue.lowercased() == requested }
}
private static var initialSidebarVisibility: Bool? {
requestedInitialSidebarVisibility(arguments: ProcessInfo.processInfo.arguments)
}
private static var initialChatSessionKey: String? {
let arguments = ProcessInfo.processInfo.arguments
guard let flagIndex = arguments.firstIndex(of: "--openclaw-chat-session") else {
@@ -87,45 +107,11 @@ struct RootTabs: View {
}
}
enum StartupPresentationRoute: Equatable {
case none
case onboarding
case settings
}
static func startupPresentationRoute(
gatewayConnected: Bool,
hasConnectedOnce: Bool,
onboardingComplete: Bool,
hasExistingGatewayConfig: Bool,
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
static func shouldUseSidebarTabs(
idiom: UIUserInterfaceIdiom,
horizontalSizeClass _: UserInterfaceSizeClass?) -> Bool
{
if gatewayConnected {
return .none
}
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
return .onboarding
}
if !hasExistingGatewayConfig {
return .settings
}
return .none
}
static func shouldPresentQuickSetup(
quickSetupDismissed: Bool,
showOnboarding: Bool,
hasPresentedSheet: Bool,
gatewayConnected: Bool,
hasExistingGatewayConfig: Bool,
discoveredGatewayCount: Int) -> Bool
{
guard !quickSetupDismissed else { return false }
guard !showOnboarding else { return false }
guard !hasPresentedSheet else { return false }
guard !gatewayConnected else { return false }
guard !hasExistingGatewayConfig else { return false }
return discoveredGatewayCount > 0
idiom == .pad
}
var body: some View {
@@ -136,20 +122,30 @@ struct RootTabs: View {
.tint(OpenClawBrand.accent))))
}
@ViewBuilder
private var tabContent: some View {
if self.usesSidebarTabs {
self.sidebarSplitContent
} else {
self.phoneTabContent
}
}
private var phoneTabContent: some View {
TabView(selection: self.$selectedTab) {
CommandCenterTab(
openChat: { self.selectedTab = .chat },
openSettings: { self.selectedTab = .settings })
.tabItem { Label("Command", systemImage: "target") }
RootTabsPhoneControlHub(
groups: Self.phoneControlGroups,
initialDestination: Self.requestedInitialSidebarDestination,
openRootDestination: { self.selectSidebarDestination($0) })
.tabItem { Label("Control", systemImage: "square.grid.2x2") }
.badge(self.appModel.pendingExecApprovalPrompt == nil ? 0 : 1)
.tag(AppTab.control)
ChatProTab()
ChatProTab(openSettings: { self.selectSidebarDestination(.gateway) })
.tabItem { Label("Chat", systemImage: "bubble.left.fill") }
.tag(AppTab.chat)
TalkProTab(openSettings: { self.selectedTab = .settings })
TalkProTab(openSettings: { self.selectSidebarDestination(.gateway) })
.tabItem {
Label(
"Talk",
@@ -157,16 +153,394 @@ struct RootTabs: View {
}
.tag(AppTab.talk)
AgentProTab()
.tabItem { Label("Agent", systemImage: "person.2.fill") }
.tag(AppTab.agent)
NavigationStack {
AgentProTab(
directRoute: .agents,
openSettings: { self.selectSidebarDestination(.gateway) })
}
.tabItem { Label("Agent", systemImage: "person.2.fill") }
.tag(AppTab.agent)
SettingsProTab()
SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)
.id(self.selectedSidebarDestination.settingsRoute.map { "\($0)" } ?? "settings")
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
.tag(AppTab.settings)
}
}
private var sidebarSplitContent: some View {
GeometryReader { proxy in
let isDrawerLayout = self.shouldUseSidebarDrawer(containerSize: proxy.size)
let sidebarWidth = self.sidebarWidth(containerWidth: proxy.size.width, isDrawerLayout: isDrawerLayout)
Group {
if isDrawerLayout {
self.sidebarDrawerContent(sidebarWidth: sidebarWidth)
} else {
self.sidebarNavigationSplitContent(sidebarWidth: sidebarWidth)
}
}
.animation(.easeInOut(duration: 0.22), value: self.isSidebarVisible)
.onAppear {
self.updateSidebarLayout(containerSize: proxy.size, force: false)
}
.onChange(of: proxy.size) { _, size in
self.updateSidebarLayout(containerSize: size, force: false)
}
}
}
private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View {
HStack(spacing: 0) {
if self.isSidebarVisible {
self.sidebarColumn
.frame(width: sidebarWidth, alignment: .topLeading)
.frame(maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .trailing) {
self.sidebarVerticalSeparator
}
.transition(.move(edge: .leading).combined(with: .opacity))
}
self.sidebarDetailNavigationShell
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.background(OpenClawProBackground())
}
private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View {
ZStack(alignment: .topLeading) {
self.sidebarDetailNavigationShell
.frame(maxWidth: .infinity, maxHeight: .infinity)
if self.isSidebarVisible {
Color.black.opacity(0.28)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {
self.hideSidebar()
}
.transition(.opacity)
self.sidebarColumn
.frame(width: sidebarWidth, alignment: .topLeading)
.frame(maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .trailing) {
self.sidebarVerticalSeparator
}
.shadow(color: .black.opacity(0.26), radius: 18, x: 8, y: 0)
.transition(.move(edge: .leading).combined(with: .opacity))
}
}
}
private var sidebarDetailShell: some View {
self.sidebarDetail
.id(self.selectedSidebarDestination.id)
}
private var sidebarColumn: some View {
VStack(spacing: 0) {
self.sidebarIdentityHeader
self.sidebarList
self.sidebarFooter
}
.safeAreaPadding(.top, 8)
.safeAreaPadding(.bottom, 8)
.background(Color(uiColor: .systemBackground))
}
private var sidebarIdentityHeader: some View {
HStack(spacing: 10) {
OpenClawProMark(size: 30, shadowRadius: 3)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text("OpenClaw")
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
HStack(spacing: 4) {
Image(systemName: "circle.fill")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(self.sidebarGatewayStatusColor)
Text(self.sidebarGatewayStatusTitle)
.lineLimit(1)
}
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
if self.isSidebarDrawerLayout {
self.sidebarHideButton
}
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(Color(uiColor: .systemBackground))
.overlay(alignment: .bottom) {
self.sidebarHorizontalSeparator
}
.accessibilityElement(children: .combine)
.accessibilityLabel("OpenClaw \(self.sidebarGatewayStatusTitle)")
}
private var sidebarGatewayStatusTitle: String {
switch self.gatewayStatus {
case .connected:
"Online"
case .connecting:
"Connecting"
case .error:
"Needs attention"
case .disconnected:
"Offline"
}
}
private var sidebarList: some View {
List {
ForEach(Self.sidebarGroups) { group in
Section(group.title.capitalized) {
ForEach(group.destinations) { destination in
self.sidebarDestinationButton(destination)
}
}
.listSectionSeparator(.hidden, edges: .all)
}
}
.listStyle(.sidebar)
.tint(OpenClawBrand.accent)
.scrollContentBackground(.hidden)
.background(Color(uiColor: .systemBackground))
}
private var sidebarFooter: some View {
VStack(spacing: 0) {
self.sidebarHorizontalSeparator
HStack(spacing: 10) {
Text("VERSION")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text("v\(DeviceInfoHelper.openClawVersionString())")
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.minimumScaleFactor(0.72)
ProStatusDot(color: self.sidebarGatewayStatusColor)
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
}
}
private var sidebarHorizontalSeparator: some View {
Rectangle()
.fill(Color(uiColor: .separator))
.frame(height: 1 / UIScreen.main.scale)
}
private var sidebarVerticalSeparator: some View {
Rectangle()
.fill(Color(uiColor: .separator))
.frame(width: 1 / UIScreen.main.scale)
}
private var sidebarGatewayStatusColor: Color {
switch self.gatewayStatus {
case .connected:
OpenClawBrand.ok
case .connecting:
OpenClawBrand.accent
case .error:
OpenClawBrand.warn
case .disconnected:
.secondary
}
}
private func sidebarDestinationButton(
_ destination: SidebarDestination,
title: String? = nil) -> some View
{
Button {
self.selectSidebarDestination(destination)
} label: {
Label(title ?? destination.sidebarTitle, systemImage: destination.systemImage)
.lineLimit(1)
.minimumScaleFactor(0.82)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.foregroundStyle(destination == self.selectedSidebarDestination ? OpenClawBrand.accent : .primary)
.listRowBackground(
destination == self.selectedSidebarDestination
? OpenClawBrand.accent.opacity(0.12)
: Color.clear)
.listRowSeparator(.hidden, edges: .all)
}
@ViewBuilder
private var sidebarDetail: some View {
switch self.selectedSidebarDestination {
case .chat:
ChatProTab(
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Chat",
headerSubtitle: "Agent conversation",
showsAgentBadge: false,
openSettings: { self.selectSidebarDestination(.gateway) })
case .talk:
TalkProTab(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openSettings: { self.selectSidebarDestination(.gateway) })
case .overview:
CommandCenterTab(
headerTitle: "Overview",
headerLeadingAction: self.sidebarHeaderLeadingAction,
showsHeaderMark: false,
openChat: { self.selectSidebarDestination(.chat) },
openSettings: { self.selectSidebarDestination(.gateway) })
case .activity:
IPadActivityScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openChat: { self.selectSidebarDestination(.chat) },
openSettings: { self.selectSidebarDestination(.gateway) })
case .workboard:
IPadWorkboardScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openChat: { self.selectSidebarDestination(.chat) },
openSettings: { self.selectSidebarDestination(.gateway) })
case .skillWorkshop:
IPadSkillWorkshopScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openSettings: { self.selectSidebarDestination(.gateway) })
case .agents:
AgentProTab(
directRoute: .agents,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Agents",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .instances:
AgentProTab(
directRoute: .instances,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Instances",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .sessions:
CommandSessionsScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openChat: { self.selectSidebarDestination(.chat) })
case .dreaming:
AgentProTab(
directRoute: .dreaming,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Dreaming",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .usage:
AgentProTab(
directRoute: .usage,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Usage",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .cron:
AgentProTab(
directRoute: .cron,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Cron Jobs",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .docs:
OpenClawDocsScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
gatewayAction: { self.selectSidebarDestination(.gateway) })
case .settings:
SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)
case .gateway:
SettingsProTab(
directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway,
headerLeadingAction: self.sidebarHeaderLeadingAction)
}
}
private var sidebarDetailNavigationShell: some View {
NavigationStack {
self.sidebarDetailShell
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.clipped()
}
private var usesSidebarTabs: Bool {
Self.shouldUseSidebarTabs(
idiom: self.userInterfaceIdiom,
horizontalSizeClass: self.horizontalSizeClass)
}
private var userInterfaceIdiom: UIUserInterfaceIdiom {
if let userInterfaceIdiomOverride {
return userInterfaceIdiomOverride
}
return UIDevice.current.userInterfaceIdiom
}
private var shouldCollapseSidebarAfterSelection: Bool {
Self.shouldCollapseSidebarAfterSelection(
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
}
private var sidebarHeaderLeadingAction: OpenClawSidebarHeaderAction? {
guard Self.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: self.isSidebarVisible,
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
else {
return nil
}
if self.isSidebarVisible {
return OpenClawSidebarHeaderAction(
systemName: "sidebar.left",
accessibilityLabel: "Hide Sidebar",
accessibilityIdentifier: Self.sidebarHideButtonAccessibilityIdentifier,
action: { self.hideSidebar() })
}
return OpenClawSidebarHeaderAction(
systemName: "sidebar.left",
accessibilityLabel: "Show Sidebar",
accessibilityIdentifier: Self.sidebarShowButtonAccessibilityIdentifier,
action: { self.showSidebar() })
}
private var sidebarHideButton: some View {
Button {
self.hideSidebar()
} label: {
Image(systemName: self.isSidebarDrawerLayout ? "xmark" : "sidebar.left")
.font(.system(size: 15, weight: .semibold))
}
.frame(width: 44, height: 44)
.contentShape(Rectangle())
.buttonStyle(.plain)
.foregroundStyle(OpenClawBrand.accent)
.accessibilityLabel("Hide Sidebar")
.accessibilityIdentifier(Self.sidebarHideButtonAccessibilityIdentifier)
}
private func shouldUseSidebarDrawer(containerSize: CGSize) -> Bool {
Self.sidebarLayoutMode(containerSize: containerSize) == .drawer
}
private func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat {
Self.sidebarWidth(containerWidth: containerWidth, isDrawerLayout: isDrawerLayout)
}
private func rootOverlays(_ content: some View) -> some View {
content
.overlay(alignment: .top) {
@@ -195,6 +569,7 @@ struct RootTabs: View {
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.overlay {
if self.appModel.cameraFlashNonce != 0 {
RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
@@ -325,7 +700,7 @@ struct RootTabs: View {
self.evaluateOnboardingPresentation(force: true)
}
.onChange(of: self.appModel.openChatRequestID) { _, _ in
self.selectedTab = .chat
self.selectSidebarDestination(.chat)
}
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.maybeOpenSettingsForGatewaySetup()
@@ -334,10 +709,6 @@ struct RootTabs: View {
private func rootPresentation(_ content: some View) -> some View {
content
.gatewayActionsDialog(
isPresented: self.$showGatewayActions,
onDisconnect: { self.appModel.disconnectGateway() },
onOpenSettings: { self.selectedTab = .settings })
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
@@ -504,6 +875,50 @@ struct RootTabs: View {
self.normalized(agent.name) ?? agent.id
}
private func selectSidebarDestination(_ destination: SidebarDestination) {
self.selectedSidebarDestination = destination
self.selectedTab = destination.appTab
guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return }
withAnimation(.easeInOut(duration: 0.22)) {
self.setSidebarVisible(false)
}
}
private func showSidebar() {
self.sidebarVisibilityUserOverridden = true
withAnimation(.easeInOut(duration: 0.22)) {
self.setSidebarVisible(true)
}
}
private func hideSidebar() {
self.sidebarVisibilityUserOverridden = true
withAnimation(.easeInOut(duration: 0.22)) {
self.setSidebarVisible(false)
}
}
private func updateSidebarLayout(containerSize: CGSize, force: Bool) {
let layoutMode = Self.sidebarLayoutMode(containerSize: containerSize)
let previousLayoutMode: SidebarLayoutMode = self.isSidebarDrawerLayout ? .drawer : .split
let didResolvePreviousLayout = self.didResolveSidebarLayout
let layoutModeDidChange = layoutMode != previousLayoutMode
self.didResolveSidebarLayout = true
self.isSidebarDrawerLayout = layoutMode == .drawer
if layoutModeDidChange && didResolvePreviousLayout {
self.sidebarVisibilityUserOverridden = false
}
guard force || !self.sidebarVisibilityUserOverridden else { return }
let preferredVisibility = Self.preferredSidebarVisibility(layoutMode: layoutMode)
guard self.isSidebarVisible != preferredVisibility else { return }
self.setSidebarVisible(preferredVisibility)
}
private func setSidebarVisible(_ isVisible: Bool) {
self.isSidebarVisible = isVisible
}
private func homeCanvasBadge(for agent: AgentSummary) -> String {
if let identity = agent.identity,
let emoji = identity["emoji"]?.value as? String,
@@ -538,7 +953,7 @@ struct RootTabs: View {
} else if problem.retryable {
Task { await self.gatewayController.connectLastKnown() }
} else {
self.selectedTab = .settings
self.selectSidebarDestination(.gateway)
}
}
@@ -565,7 +980,7 @@ struct RootTabs: View {
self.showOnboarding = true
case .settings:
self.didAutoOpenSettings = true
self.selectedTab = .settings
self.selectSidebarDestination(.gateway)
}
}
@@ -591,7 +1006,7 @@ struct RootTabs: View {
shouldPresentOnLaunch: false)
guard route == .settings else { return }
self.didAutoOpenSettings = true
self.selectedTab = .settings
self.selectSidebarDestination(.gateway)
}
private func maybeOpenSettingsForGatewaySetup() {
@@ -601,7 +1016,7 @@ struct RootTabs: View {
self.showOnboarding = false
self.presentedSheet = nil
self.didAutoOpenSettings = true
self.selectedTab = .settings
self.selectSidebarDestination(.gateway)
}
private func applyInitialChatSessionIfNeeded() {
@@ -681,3 +1096,120 @@ private struct RootCameraFlashOverlay: View {
}
}
}
extension EnvironmentValues {
@Entry var rootTabsUserInterfaceIdiomOverride: UIUserInterfaceIdiom?
}
#if DEBUG
#Preview(
"Shell iPhone portrait",
traits: .fixedLayout(width: 393, height: 852),
.portrait)
{
RootTabsPreviewHost(idiom: .phone)
}
#Preview(
"Shell iPhone connected",
traits: .fixedLayout(width: 393, height: 852),
.portrait)
{
RootTabsPreviewHost(idiom: .phone, gatewayState: .connected)
}
#Preview(
"Shell iPhone gateway error",
traits: .fixedLayout(width: 393, height: 852),
.portrait)
{
RootTabsPreviewHost(idiom: .phone, gatewayState: .error)
}
#Preview(
"Shell iPhone landscape",
traits: .fixedLayout(width: 852, height: 393),
.landscapeLeft)
{
RootTabsPreviewHost(idiom: .phone)
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .compact)
}
#Preview(
"Shell iPad portrait drawer",
traits: .fixedLayout(width: 1024, height: 1366),
.portrait)
{
RootTabsPreviewHost(idiom: .pad)
}
#Preview(
"Shell iPad landscape split",
traits: .fixedLayout(width: 1366, height: 1024),
.landscapeLeft)
{
RootTabsPreviewHost(idiom: .pad, gatewayState: .connected)
}
#Preview(
"Shell iPad connecting",
traits: .fixedLayout(width: 1366, height: 1024),
.landscapeLeft)
{
RootTabsPreviewHost(idiom: .pad, gatewayState: .connecting)
}
#Preview(
"Shell iPad gateway error",
traits: .fixedLayout(width: 1366, height: 1024),
.landscapeLeft)
{
RootTabsPreviewHost(idiom: .pad, gatewayState: .error)
}
private struct RootTabsPreviewHost: View {
@State private var appModel: NodeAppModel
@State private var gatewayController: GatewayConnectionController
private let idiom: UIUserInterfaceIdiom
init(idiom: UIUserInterfaceIdiom, gatewayState: RootTabsPreviewGatewayState = .offline) {
let appModel = NodeAppModel()
gatewayState.apply(to: appModel)
self.idiom = idiom
_appModel = State(initialValue: appModel)
_gatewayController = State(
initialValue: GatewayConnectionController(appModel: appModel, startDiscovery: false))
}
var body: some View {
RootTabs()
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.gatewayController)
.environment(\.rootTabsUserInterfaceIdiomOverride, self.idiom)
}
}
private enum RootTabsPreviewGatewayState {
case offline
case connecting
case connected
case error
@MainActor
func apply(to appModel: NodeAppModel) {
switch self {
case .offline:
break
case .connecting:
appModel.gatewayStatusText = "Connecting..."
case .connected:
appModel.enterAppleReviewDemoMode()
case .error:
appModel.gatewayStatusText = "Gateway error: connection refused"
}
}
}
#endif

View File

@@ -0,0 +1,308 @@
import CoreGraphics
import Foundation
import SwiftUI
extension RootTabs {
private static var sidebarPersistentWidthThreshold: CGFloat {
980
}
static let sidebarSplitMinimumWidth: CGFloat = 292
static let sidebarSplitIdealWidth: CGFloat = 316
static let sidebarSplitMaximumWidth: CGFloat = 340
static let sidebarDrawerMaximumWidth: CGFloat = 340
static let sidebarShowButtonAccessibilityIdentifier = "RootTabs.Sidebar.Show"
static let sidebarHideButtonAccessibilityIdentifier = "RootTabs.Sidebar.Hide"
enum AppTab: Hashable {
case control
case chat
case talk
case agent
case settings
}
enum SidebarDestination: String, CaseIterable, Hashable, Identifiable {
case chat
case talk
case overview
case activity
case agents
case workboard
case skillWorkshop
case instances
case sessions
case dreaming
case usage
case cron
case docs
case settings
case gateway
var id: String {
rawValue
}
var title: String {
switch self {
case .chat: "Chat"
case .talk: "Talk"
case .overview: "Overview"
case .activity: "Activity"
case .agents: "Agents"
case .workboard: "Workboard"
case .skillWorkshop: "Skill Workshop"
case .instances: "Instances"
case .sessions: "Sessions"
case .dreaming: "Dreaming"
case .usage: "Usage"
case .cron: "Cron Jobs"
case .docs: "Docs"
case .settings: "Settings"
case .gateway: "Settings / Gateway"
}
}
var sidebarTitle: String {
switch self {
case .gateway: "Connection"
default: self.title
}
}
var subtitle: String {
switch self {
case .chat: "Agent chat and recent work."
case .talk: "Realtime voice and fallback controls."
case .overview: "Status, entry points, health."
case .activity: "Gateway, session, and device activity."
case .agents: "Agent roster and readiness."
case .workboard: "Agent work queue and session handoff."
case .skillWorkshop: "Review and apply proposed skills."
case .instances: "Latest presence from OpenClaw nodes."
case .sessions: "Active sessions and defaults."
case .dreaming: "Memory signals and background synthesis."
case .usage: "API usage and costs."
case .cron: "Wakeups and recurring runs."
case .docs: "Reference docs and setup guides."
case .settings: "Connection, permissions, channels, and app options."
case .gateway: "Pairing, diagnostics, permissions, and device controls."
}
}
var systemImage: String {
switch self {
case .chat: "bubble.left"
case .talk: "waveform.circle"
case .overview: "chart.bar"
case .activity: "waveform.path.ecg"
case .agents: "person.2"
case .workboard: "folder"
case .skillWorkshop: "hammer"
case .instances: "dot.radiowaves.left.and.right"
case .sessions: "doc.text"
case .dreaming: "moon.stars"
case .usage: "chart.bar.xaxis"
case .cron: "timer"
case .docs: "book"
case .settings: "gearshape"
case .gateway: "gearshape"
}
}
var appTab: AppTab {
switch self {
case .chat:
.chat
case .talk:
.talk
case .agents:
.agent
case .settings, .gateway:
.settings
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming,
.usage,
.cron, .docs:
.control
}
}
var settingsRoute: SettingsRoute? {
switch self {
case .gateway:
.gateway
case .chat, .talk, .overview, .activity, .agents, .workboard, .skillWorkshop, .instances, .sessions,
.dreaming,
.usage, .cron, .settings, .docs:
nil
}
}
}
enum SidebarLayoutMode: Equatable {
case drawer
case split
}
static func sidebarLayoutMode(containerSize: CGSize) -> SidebarLayoutMode {
containerSize.width < self.sidebarPersistentWidthThreshold || containerSize.height > containerSize.width
? .drawer
: .split
}
static func preferredSidebarVisibility(layoutMode: SidebarLayoutMode) -> Bool {
layoutMode == .split
}
static func shouldCollapseSidebarAfterSelection(layoutMode: SidebarLayoutMode) -> Bool {
layoutMode == .drawer
}
static func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat {
if isDrawerLayout {
return min(self.sidebarDrawerMaximumWidth, max(280, containerWidth * 0.86))
}
return min(self.sidebarSplitMaximumWidth, max(self.sidebarSplitIdealWidth, containerWidth * 0.25))
}
static func shouldShowSidebarRevealControl(isSidebarVisible: Bool) -> Bool {
!isSidebarVisible
}
static func shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: Bool,
layoutMode: SidebarLayoutMode) -> Bool
{
switch layoutMode {
case .split:
true
case .drawer:
self.shouldShowSidebarRevealControl(isSidebarVisible: isSidebarVisible)
}
}
static func requestedInitialSidebarVisibility(arguments: [String]) -> Bool? {
guard let flagIndex = arguments.firstIndex(of: "--openclaw-sidebar-visibility") else {
return nil
}
let valueIndex = arguments.index(after: flagIndex)
guard arguments.indices.contains(valueIndex) else { return nil }
switch arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "visible", "show", "shown", "open", "true", "1":
return true
case "hidden", "hide", "closed", "false", "0":
return false
default:
return nil
}
}
static func shouldOpenRootTabFromPhoneHub(_ destination: SidebarDestination) -> Bool {
switch destination {
case .chat, .talk, .agents, .gateway, .settings:
true
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming,
.usage,
.cron, .docs:
false
}
}
static func defaultSidebarDestination(for tab: AppTab) -> SidebarDestination {
switch tab {
case .control:
.overview
case .chat:
.chat
case .talk:
.talk
case .agent:
.agents
case .settings:
.settings
}
}
enum StartupPresentationRoute: Equatable {
case none
case onboarding
case settings
}
static func startupPresentationRoute(
gatewayConnected: Bool,
hasConnectedOnce: Bool,
onboardingComplete: Bool,
hasExistingGatewayConfig: Bool,
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
{
if gatewayConnected {
return .none
}
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
return .onboarding
}
if !hasExistingGatewayConfig {
return .settings
}
return .none
}
static func shouldPresentQuickSetup(
quickSetupDismissed: Bool,
showOnboarding: Bool,
hasPresentedSheet: Bool,
gatewayConnected: Bool,
hasExistingGatewayConfig: Bool,
discoveredGatewayCount: Int) -> Bool
{
guard !quickSetupDismissed else { return false }
guard !showOnboarding else { return false }
guard !hasPresentedSheet else { return false }
guard !gatewayConnected else { return false }
guard !hasExistingGatewayConfig else { return false }
return discoveredGatewayCount > 0
}
struct SidebarGroup: Identifiable {
let title: String
let destinations: [SidebarDestination]
var id: String {
self.title
}
}
static let sidebarGroups: [SidebarGroup] = [
SidebarGroup(title: "CHAT", destinations: [.chat, .talk]),
SidebarGroup(
title: "CONTROL",
destinations: [
.overview,
.activity,
.agents,
.workboard,
.skillWorkshop,
.instances,
.sessions,
.dreaming,
.usage,
.cron,
]),
SidebarGroup(
title: "SETTINGS",
destinations: [.settings]),
SidebarGroup(title: "REFERENCE", destinations: [.docs]),
]
static var phoneControlGroups: [SidebarGroup] {
self.sidebarGroups
.map { group in
SidebarGroup(
title: group.title,
destinations: group.destinations.filter { $0 != .agents })
}
.filter { !$0.destinations.isEmpty }
}
}

View File

@@ -1,25 +0,0 @@
import SwiftUI
extension View {
func gatewayActionsDialog(
isPresented: Binding<Bool>,
onDisconnect: @escaping () -> Void,
onOpenSettings: @escaping () -> Void) -> some View
{
self.confirmationDialog(
"Gateway",
isPresented: isPresented,
titleVisibility: .visible)
{
Button("Disconnect", role: .destructive) {
onDisconnect()
}
Button("Open Settings") {
onOpenSettings()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Disconnect from the gateway?")
}
}
}

View File

@@ -31,6 +31,15 @@ Sources/Design/AgentProTab+Usage.swift
Sources/Design/AgentProTab+DetailComponents.swift
Sources/Design/AgentProTab+GatewayData.swift
Sources/Design/AgentProModels.swift
Sources/Design/IPadActivityScreen.swift
Sources/Design/IPadSidebarFeaturePreviews.swift
Sources/Design/IPadSidebarFeatureScreens.swift
Sources/Design/IPadSkillWorkshopScreen.swift
Sources/Design/IPadSidebarScreenChrome.swift
Sources/Design/IPadWorkboardScreen.swift
Sources/Design/OpenClawDocsScreen.swift
Sources/Design/RootTabsPhoneControlHub.swift
Sources/Design/SettingsChannelsDestination.swift
Sources/EventKit/EventKitAuthorization.swift
Sources/Gateway/DeepLinkAgentPromptAlert.swift
Sources/Gateway/ExecApprovalPromptDialog.swift
@@ -73,6 +82,7 @@ Sources/Push/PushRelayClient.swift
Sources/Push/PushRelayKeychainStore.swift
Sources/Reminders/RemindersService.swift
Sources/RootTabs.swift
Sources/RootTabsNavigation.swift
Sources/RootView.swift
Sources/Screen/ScreenController.swift
Sources/Screen/ScreenRecordService.swift
@@ -86,7 +96,6 @@ Sources/SessionKey.swift
Sources/Settings/PrivacyAccessSectionView.swift
Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift
Sources/Status/GatewayActionsDialog.swift
Sources/Status/GatewayStatusBuilder.swift
Sources/Status/VoiceWakeToast.swift
Sources/Voice/TalkGatewayPermissionState.swift

View File

@@ -0,0 +1,27 @@
import SwiftUI
import Testing
@testable import OpenClaw
@MainActor
@Suite struct CommandCenterTabLayoutTests {
@Test func splitLayoutDisabledForCompactWidth() {
#expect(
!CommandCenterTab.usesSplitSectionsLayout(
horizontalSizeClass: .compact,
containerWidth: 1_200))
}
@Test func splitLayoutDisabledBelowWidthThreshold() {
#expect(
!CommandCenterTab.usesSplitSectionsLayout(
horizontalSizeClass: .regular,
containerWidth: 900))
}
@Test func splitLayoutEnabledForRegularWideLayout() {
#expect(
CommandCenterTab.usesSplitSectionsLayout(
horizontalSizeClass: .regular,
containerWidth: 1_024))
}
}

View File

@@ -33,4 +33,12 @@ import Testing
#expect(state == .connecting)
}
@Test func chatGatewayPillLabelsMatchDisplayState() {
#expect(ChatProTab.gatewayPillTitle(state: .disconnected, isGatewayUsable: false) == "Offline")
#expect(ChatProTab.gatewayPillTitle(state: .connecting, isGatewayUsable: false) == "Connecting")
#expect(ChatProTab.gatewayPillTitle(state: .error, isGatewayUsable: false) == "Attention")
#expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: true) == "Connected")
#expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: false) == "Unavailable")
}
}

View File

@@ -1025,6 +1025,38 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
#expect(appModel.openChatRequestID == 1)
}
@Test @MainActor func operatorAdminScopeCacheRefreshesFromStoredToken() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let appModel = NodeAppModel()
let identity = DeviceIdentityStore.loadOrCreate()
#expect(appModel.hasOperatorAdminScope == false)
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "operator",
token: "operator-token",
scopes: ["operator.read", "operator.admin"])
appModel._test_refreshOperatorAdminScopeFromStore()
#expect(appModel.hasOperatorAdminScope == true)
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: "operator")
appModel._test_refreshOperatorAdminScopeFromStore()
#expect(appModel.hasOperatorAdminScope == false)
}
@Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async {
let appModel = NodeAppModel()
await #expect(throws: Error.self) {

View File

@@ -1,4 +1,6 @@
import SwiftUI
import Testing
import UIKit
@testable import OpenClaw
@MainActor
@@ -38,4 +40,432 @@ import Testing
#expect(!shouldPresent)
}
@Test func sidebarTabsEnabledForIPadRegularWidth() {
#expect(
RootTabs.shouldUseSidebarTabs(
idiom: .pad,
horizontalSizeClass: .regular))
}
@Test func sidebarTabsEnabledForIPadCompactWidth() {
#expect(
RootTabs.shouldUseSidebarTabs(
idiom: .pad,
horizontalSizeClass: .compact))
}
@Test func sidebarTabsDisabledForIPhone() {
#expect(
!RootTabs.shouldUseSidebarTabs(
idiom: .phone,
horizontalSizeClass: .regular))
}
@Test func sidebarGroupsMatchAdaptiveNavigationModel() {
let groups = RootTabs.sidebarGroups
let destinationIDs = RootTabs.SidebarDestination.allCases.map(\.rawValue)
#expect(groups.map(\.title) == ["CHAT", "CONTROL", "SETTINGS", "REFERENCE"])
#expect(groups[0].destinations.map(\.rawValue) == ["chat", "talk"])
#expect(groups[1].destinations == [
.overview,
.activity,
.agents,
.workboard,
.skillWorkshop,
.instances,
.sessions,
.dreaming,
.usage,
.cron,
])
#expect(groups[2].destinations == [.settings])
#expect(groups[3].destinations == [.docs])
#expect(destinationIDs == [
"chat",
"talk",
"overview",
"activity",
"agents",
"workboard",
"skillWorkshop",
"instances",
"sessions",
"dreaming",
"usage",
"cron",
"docs",
"settings",
"gateway",
])
#expect(!destinationIDs.contains("agent"))
#expect(!RootTabs.sidebarGroups.flatMap(\.destinations).contains(.gateway))
}
@Test func phoneControlGroupsAvoidDuplicatingTheAgentTab() {
let groups = RootTabs.phoneControlGroups
let destinations = groups.flatMap(\.destinations)
#expect(groups.map(\.title) == ["CHAT", "CONTROL", "SETTINGS", "REFERENCE"])
#expect(!destinations.contains(.agents))
#expect(RootTabs.sidebarGroups.flatMap(\.destinations).contains(.agents))
#expect(destinations.contains(.dreaming))
#expect(destinations.contains(.instances))
}
@Test func sidebarUsesCompactLabelsForLongRoutes() {
#expect(RootTabs.SidebarDestination.settings.title == "Settings")
#expect(RootTabs.SidebarDestination.gateway.title == "Settings / Gateway")
#expect(RootTabs.SidebarDestination.gateway.sidebarTitle == "Connection")
}
@Test func phoneHubUsesRootTabsOnlyForNativeChatAgentAndGateway() {
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.chat))
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.talk))
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.agents))
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.gateway))
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.settings))
for destination in RootTabs.SidebarDestination.allCases
where destination != .chat && destination != .talk && destination != .agents && destination != .gateway && destination != .settings
{
#expect(!RootTabs.shouldOpenRootTabFromPhoneHub(destination))
}
}
@Test func legacyInitialTabsMapToMatchingSidebarDestinations() {
#expect(RootTabs.defaultSidebarDestination(for: .control) == .overview)
#expect(RootTabs.defaultSidebarDestination(for: .chat) == .chat)
#expect(RootTabs.defaultSidebarDestination(for: .talk) == .talk)
#expect(RootTabs.defaultSidebarDestination(for: .agent) == .agents)
#expect(RootTabs.defaultSidebarDestination(for: .settings) == .settings)
}
@Test func skillWorkshopMutationsRequireAdminScope() {
#expect(IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: true, hasOperatorAdminScope: true))
#expect(!IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: true, hasOperatorAdminScope: false))
#expect(!IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: false, hasOperatorAdminScope: true))
}
@Test func skillWorkshopHeldFilterIncludesQuarantinedAndStale() {
#expect(IPadSkillWorkshopScreen.proposalStatusFilters.contains("held"))
#expect(IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "quarantined", filter: "held"))
#expect(IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "stale", filter: "held"))
#expect(!IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "pending", filter: "held"))
}
@Test func skillWorkshopBoardLanesMatchStatusFilter() {
#expect(
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
filter: "pending",
proposalStatuses: ["pending", "applied"]) == ["pending"])
#expect(
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
filter: "held",
proposalStatuses: ["quarantined", "stale"]) == ["quarantined", "stale"])
#expect(
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
filter: "all",
proposalStatuses: ["pending", "needs-review"]) == [
"pending",
"quarantined",
"stale",
"applied",
"rejected",
"needs-review",
])
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("quarantined") == "Quarantined")
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("pending") == "Pending")
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("needs-review") == "Needs Review")
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("manual_QA") == "Manual QA")
}
@Test func skillWorkshopSelectionStaysInsideActiveFilter() {
let proposals = [
(id: "applied-1", status: "applied"),
(id: "pending-1", status: "pending"),
(id: "held-1", status: "quarantined"),
]
#expect(
IPadSkillWorkshopScreen.nextSelectedProposalID(
current: "applied-1",
proposals: proposals,
filter: "pending") == "pending-1")
#expect(
IPadSkillWorkshopScreen.nextSelectedProposalID(
current: "held-1",
proposals: proposals,
filter: "held") == "held-1")
#expect(
IPadSkillWorkshopScreen.nextSelectedProposalID(
current: "pending-1",
visibleProposalIDs: ["held-1"]) == "held-1")
#expect(
IPadSkillWorkshopScreen.nextSelectedProposalID(
current: "pending-1",
visibleProposalIDs: []) == nil)
}
@Test func workboardBoardScopeLabelsStayCompact() {
#expect(IPadWorkboardScreen.normalizedScopeID(" planning ") == "planning")
#expect(IPadWorkboardScreen.boardScopeLabel(for: "") == "All boards")
#expect(IPadWorkboardScreen.boardScopeLabel(for: "planning") == "planning")
#expect(IPadWorkboardScreen.boardScopeOptions(
knownBoardIDs: ["default", " empty-board ", ""],
cardBoardIDs: ["planning", "default"]) == ["default", "empty-board", "planning"])
#expect(IPadWorkboardScreen
.workboardSubtitle(boardScopeLabel: "All boards", selectedStatus: "active") == "All boards / Active")
#expect(IPadWorkboardScreen
.workboardSubtitle(boardScopeLabel: "planning", selectedStatus: "running") == "planning / Running")
}
@Test func workboardCompactUnavailableCopyExplainsRealCapabilityState() {
#expect(IPadWorkboardScreen
.compactWriteUnavailableMessage(canRead: false) ==
"Connect from Settings to create, move, and dispatch cards.")
#expect(IPadWorkboardScreen.compactWriteUnavailableMessage(canRead: true) == "Read-only gateway.")
}
@Test func skillWorkshopAgentScopeNormalizesGatewayIds() {
#expect(IPadSkillWorkshopScreen.normalizedScopeID(" aiden ") == "aiden")
#expect(IPadSkillWorkshopScreen.normalizedScopeID(nil) == "")
}
@Test func channelLifecycleControlsRequireAdminScope() {
#expect(SettingsChannelsDestination.shouldEnableChannelOperation(canRead: true, hasOperatorAdminScope: true))
#expect(!SettingsChannelsDestination.shouldEnableChannelOperation(canRead: true, hasOperatorAdminScope: false))
#expect(!SettingsChannelsDestination.shouldEnableChannelOperation(canRead: false, hasOperatorAdminScope: true))
}
@Test func clickClackStaysInChannelsIntegrationMetadata() {
#expect(SettingsChannelsDestination.fallbackLabel("clickclack") == "ClickClack")
#expect(SettingsChannelsDestination.fallbackDetail("clickclack") == "Self-hosted chat bot routing.")
#expect(SettingsChannelsDestination.fallbackSystemImage("clickclack") == "bubble.left.and.bubble.right")
}
@Test func iPadOverviewCanSuppressStandaloneHeaderBranding() {
#expect(CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: true))
#expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: true, showsHeaderMark: true))
#expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: false))
}
@Test func chatSidebarDestinationCanUseRouteHeaderInsteadOfAgentBranding() {
let standalone = ChatProTab()
let routed = ChatProTab(
headerTitle: "Chat",
headerSubtitle: "Agent conversation",
showsAgentBadge: false,
openSettings: {})
#expect(standalone.showsAgentBadge)
#expect(standalone.headerTitle == nil)
#expect(standalone.openSettings == nil)
#expect(routed.headerTitle == "Chat")
#expect(routed.headerSubtitle == "Agent conversation")
#expect(!routed.showsAgentBadge)
#expect(routed.openSettings != nil)
#expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: true, agentDisplayName: "OpenClaw") == "OpenClaw")
#expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: false, agentDisplayName: "OpenClaw") == "Chat")
}
@Test func agentRoutesCanOpenGatewaySettingsFromHeaderPill() {
let standalone = AgentProTab()
let routed = AgentProTab(
directRoute: .instances,
headerTitle: "Instances",
openSettings: {})
#expect(standalone.headerTitle == "Agents")
#expect(standalone.directRoute == nil)
#expect(standalone.openSettings == nil)
#expect(AgentProTab(directRoute: .agents).directRoute == .agents)
#expect(routed.directRoute == .instances)
#expect(routed.headerTitle == "Instances")
#expect(routed.openSettings != nil)
}
@Test func workboardDispatchSummaryReportsStartedAndFailures() throws {
let payload = Data(
"""
{
"count": 2,
"started": [{}],
"startFailures": [{}],
"promoted": [],
"reclaimed": [],
"blocked": [],
"orchestrated": []
}
""".utf8)
let summary = try JSONDecoder().decode(IPadWorkboardDispatchSummary.self, from: payload)
#expect(summary.summaryText == "2 dispatched: 1 started, 1 failed.")
}
@Test func talkSidebarDestinationCanReceiveRevealAction() {
let action = OpenClawSidebarHeaderAction(
systemName: "sidebar.left",
accessibilityLabel: "Show Sidebar",
action: {})
let routed = TalkProTab(headerLeadingAction: action, openSettings: {})
#expect(routed.headerLeadingAction?.systemName == "sidebar.left")
#expect(routed.headerLeadingAction?.accessibilityLabel == "Show Sidebar")
}
@Test func iPadPortraitUsesHiddenDrawerSidebar() {
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1024, height: 1366))
#expect(mode == .drawer)
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: mode))
}
@Test func iPadWideLandscapeUsesVisibleSplitSidebar() {
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1366, height: 1024))
#expect(mode == .split)
#expect(RootTabs.preferredSidebarVisibility(layoutMode: mode))
}
@Test func iPadSplitSidebarWidthStaysUsable() {
let width = RootTabs.sidebarWidth(containerWidth: 1366, isDrawerLayout: false)
#expect(width >= RootTabs.sidebarSplitIdealWidth)
#expect(width <= RootTabs.sidebarSplitMaximumWidth)
}
@Test func iPadCollapsedSplitSidebarUsesHeaderRevealWithoutReservedRail() {
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: false,
layoutMode: .split))
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: true,
layoutMode: .split))
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: false,
layoutMode: .drawer))
#expect(
!RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: true,
layoutMode: .drawer))
}
@Test func initialSidebarVisibilityParsesLaunchArgument() {
#expect(
RootTabs.requestedInitialSidebarVisibility(arguments: [
"OpenClaw",
"--openclaw-sidebar-visibility",
"hidden",
]) == false)
#expect(
RootTabs.requestedInitialSidebarVisibility(arguments: [
"OpenClaw",
"--openclaw-sidebar-visibility",
"visible",
]) == true)
#expect(
RootTabs.requestedInitialSidebarVisibility(arguments: [
"OpenClaw",
"--openclaw-sidebar-visibility",
"unknown",
]) == nil)
}
@Test func sidebarControlsHaveStableAccessibilityIdentifiers() {
#expect(RootTabs.sidebarShowButtonAccessibilityIdentifier == "RootTabs.Sidebar.Show")
#expect(RootTabs.sidebarHideButtonAccessibilityIdentifier == "RootTabs.Sidebar.Hide")
}
@Test func iPadDrawerSidebarWidthStaysInsideScreen() {
let width = RootTabs.sidebarWidth(containerWidth: 744, isDrawerLayout: true)
#expect(width >= 280)
#expect(width <= RootTabs.sidebarDrawerMaximumWidth)
}
@Test func narrowLandscapeKeepsDrawerSidebar() {
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 900, height: 600))
#expect(mode == .drawer)
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: mode))
}
@Test func drawerSelectionCollapsesSidebarButSplitSelectionDoesNot() {
#expect(RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .drawer))
#expect(!RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .split))
}
@Test func hiddenSidebarShowsRevealControl() {
#expect(RootTabs.shouldShowSidebarRevealControl(isSidebarVisible: false))
}
@Test func sidebarRevealControlsHideWhenSidebarIsVisible() {
#expect(!RootTabs.shouldShowSidebarRevealControl(isSidebarVisible: true))
}
@Test func iPadSplitPrefersIntegratedVisibleSidebar() {
#expect(RootTabs.preferredSidebarVisibility(layoutMode: .split))
#expect(!RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .split))
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: .drawer))
#expect(RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .drawer))
}
@Test func destinationHeadersOwnHiddenSidebarRevealControl() {
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: false,
layoutMode: .drawer))
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: false,
layoutMode: .split))
#expect(
!RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: true,
layoutMode: .drawer))
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: true,
layoutMode: .split))
}
@Test func workboardAndSkillWorkshopUseCompactTaskFlowOnPhoneSizes() {
#expect(
IPadWorkboardScreen.usesCompactTaskFlow(
horizontalSizeClass: .compact,
verticalSizeClass: .regular))
#expect(
IPadSkillWorkshopScreen.usesCompactTaskFlow(
horizontalSizeClass: .compact,
verticalSizeClass: .regular))
#expect(
IPadWorkboardScreen.usesCompactTaskFlow(
horizontalSizeClass: .regular,
verticalSizeClass: .compact))
#expect(
IPadSkillWorkshopScreen.usesCompactTaskFlow(
horizontalSizeClass: .regular,
verticalSizeClass: .compact))
}
@Test func workboardAndSkillWorkshopKeepRegularTaskFlowOnWideIPadSizes() {
#expect(
!IPadWorkboardScreen.usesCompactTaskFlow(
horizontalSizeClass: .regular,
verticalSizeClass: .regular))
#expect(
!IPadSkillWorkshopScreen.usesCompactTaskFlow(
horizontalSizeClass: .regular,
verticalSizeClass: .regular))
}
@Test func phoneHubLeavesRoomForFloatingTabBar() {
#expect(RootTabsPhoneControlHub.bottomScrollInset(verticalSizeClass: .regular) == 112)
#expect(RootTabsPhoneControlHub.bottomScrollInset(verticalSizeClass: .compact) == 72)
}
}

View File

@@ -0,0 +1,65 @@
import Foundation
import Testing
@Suite struct RootTabsSidebarRegressionTests {
@Test func iPadSplitHiddenSidebarUsesHeaderRevealInsteadOfReservedRail() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8)
let splitContent = try Self.extract(
source,
from: "private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View",
to: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View")
#expect(splitContent.contains("HStack(spacing: 0)"))
#expect(splitContent.contains("self.sidebarColumn"))
#expect(splitContent.contains(".frame(width: sidebarWidth, alignment: .topLeading)"))
#expect(splitContent.contains(".overlay(alignment: .trailing)"))
#expect(!splitContent.contains("self.syncSidebarVisibility(from: visibility)"))
#expect(!source.contains("NavigationSplitViewVisibility"))
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
#expect(!splitContent.contains("NavigationSplitView"))
#expect(!splitContent.contains("self.collapsedSidebarRail"))
#expect(!source.contains("private var collapsedSidebarRail: some View"))
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
#expect(source.contains("shouldShowSidebarRevealInDestinationHeader"))
#expect(!navigationSource.contains("static let sidebarCollapsedRailWidth"))
#expect(!navigationSource.contains("static func sidebarSplitColumnVisibility(isSidebarVisible: Bool)"))
#expect(!navigationSource
.contains("static func sidebarIsVisible(splitColumnVisibility: NavigationSplitViewVisibility)"))
}
@Test func initialSidebarVisibilitySurvivesFirstLayoutMeasurement() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let layoutUpdate = try Self.extract(
source,
from: "private func updateSidebarLayout(containerSize: CGSize, force: Bool)",
to: "private func setSidebarVisible(_ isVisible: Bool)")
#expect(source.contains("@State private var didResolveSidebarLayout: Bool = false"))
#expect(layoutUpdate.contains("let didResolvePreviousLayout = self.didResolveSidebarLayout"))
#expect(layoutUpdate.contains("self.didResolveSidebarLayout = true"))
#expect(layoutUpdate.contains("if layoutModeDidChange && didResolvePreviousLayout"))
#expect(layoutUpdate.contains("guard force || !self.sidebarVisibilityUserOverridden else { return }"))
}
private static func rootTabsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/RootTabs.swift")
}
private static func rootTabsNavigationSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/RootTabsNavigation.swift")
}
private static func extract(_ source: String, from start: String, to end: String) throws -> String {
let startRange = try #require(source.range(of: start))
let tail = source[startRange.lowerBound...]
let endRange = try #require(tail.range(of: end))
return String(tail[..<endRange.lowerBound])
}
}

View File

@@ -0,0 +1,812 @@
import Foundation
import Testing
@Suite struct RootTabsSourceGuardTests {
@Test func hiddenSidebarRevealUsesDestinationHeaderWithoutReservedRail() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let componentSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
#expect(source.contains("sidebarHeaderLeadingAction"))
#expect(source.contains("Hide Sidebar"))
#expect(source.contains("Show Sidebar"))
#expect(source.contains("shouldShowSidebarRevealInDestinationHeader"))
#expect(source.contains("layoutMode: self.isSidebarDrawerLayout ? .drawer : .split"))
#expect(componentSource.contains("OpenClawSidebarHeaderLeadingSlot"))
#expect(componentSource.contains(".frame(width: 44, height: 44, alignment: .center)"))
#expect(source.contains(".safeAreaPadding(.top, 8)"))
#expect(source.contains("Self.sidebarShowButtonAccessibilityIdentifier"))
#expect(source.contains("Self.sidebarHideButtonAccessibilityIdentifier"))
#expect(source.contains("accessibilityLabel: \"Hide Sidebar\""))
#expect(source.contains("accessibilityLabel: \"Show Sidebar\""))
#expect(source.contains("action: { self.hideSidebar() }"))
#expect(source.contains("action: { self.showSidebar() }"))
#expect(!source.contains("private var collapsedSidebarRail: some View"))
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
#expect(source.contains("requestedInitialSidebarVisibility"))
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
#expect(!source.contains("NavigationSplitView(columnVisibility: self.$splitColumnVisibility)"))
#expect(source.contains("HStack(spacing: 0)"))
#expect(!source.contains("self.syncSidebarVisibility(from: visibility)"))
#expect(!source.contains("shouldReserveSidebarRevealInset"))
#expect(!source.contains("safeAreaInset(edge: .top"))
#expect(!source.contains("thinMaterial, in: Circle"))
#expect(!source.contains("sidebarRevealInset"))
#expect(source.contains("Color.black.opacity(0.28)"))
#expect(source.contains(".background(Color(uiColor: .systemBackground))"))
#expect(!source.contains("sidebarRevealCornerButton"))
#expect(!source.contains("shouldShowSidebarRevealOverlay"))
#expect(!source.contains("shouldShowOverviewHeaderSidebarReveal"))
}
@Test func iPadSplitUsesSlidingSidebarWhilePortraitKeepsDrawerOverlay() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let splitContent = try Self.extract(
source,
from: "private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View",
to: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View")
let drawerContent = try Self.extract(
source,
from: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View",
to: "private var sidebarDetailShell: some View")
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
#expect(!source.contains("Self.sidebarSplitColumnVisibility(isSidebarVisible:"))
#expect(!source.contains("self.syncSidebarVisibility(from: visibility)"))
#expect(splitContent.contains("HStack(spacing: 0)"))
#expect(splitContent.contains("self.sidebarColumn"))
#expect(splitContent.contains(".frame(width: sidebarWidth, alignment: .topLeading)"))
#expect(splitContent.contains(".overlay(alignment: .trailing)"))
#expect(splitContent.contains("self.sidebarVerticalSeparator"))
#expect(splitContent.contains("self.sidebarDetailNavigationShell"))
#expect(!splitContent.contains("NavigationSplitView"))
#expect(!splitContent.contains("self.collapsedSidebarRail"))
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
#expect(drawerContent.contains("ZStack(alignment: .topLeading)"))
#expect(drawerContent.contains("Color.black.opacity(0.28)"))
#expect(drawerContent.contains(".transition(.move(edge: .leading).combined(with: .opacity))"))
#expect(!drawerContent.contains("NavigationSplitView"))
}
@Test func sidebarKeepsNavigationModelDestinationOnly() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8)
let sidebarColumn = try Self.extract(
source,
from: "private var sidebarColumn: some View",
to: "private var sidebarList: some View")
#expect(source.contains("ForEach(Self.sidebarGroups)"))
#expect(!source.contains("Section(\"Context\")"))
#expect(!source.contains("sidebarAgentMenu"))
#expect(!source.contains("sidebarDeviceMenu"))
#expect(sidebarColumn.contains("self.sidebarIdentityHeader"))
#expect(source.contains("private var sidebarIdentityHeader: some View"))
#expect(source.contains("OpenClawProMark(size: 30"))
#expect(source.contains("Text(\"OpenClaw\")"))
#expect(source.contains("private var sidebarGatewayStatusTitle: String"))
#expect(source.contains("private var sidebarGatewayStatusColor: Color"))
#expect(!sidebarColumn.contains("activeAgent"))
#expect(!source.contains("shouldShowSidebarColumnHeader"))
#expect(!source.contains("private var sidebarColumnHeader: some View"))
#expect(sidebarColumn.contains(".safeAreaPadding(.top, 8)"))
#expect(source.contains(".scrollContentBackground(.hidden)"))
#expect(source.contains(".listStyle(.sidebar)"))
#expect(source.contains("private var sidebarHorizontalSeparator: some View"))
#expect(source.contains("private var sidebarVerticalSeparator: some View"))
#expect(source.contains("1 / UIScreen.main.scale"))
#expect(!source.contains("geometry.size.height >= Self.sidebarListNonScrollingMinimumHeight"))
#expect(!source.contains("private var sidebarListContent: some View"))
#expect(source.contains(".listRowSeparator(.hidden, edges: .all)"))
#expect(source.contains(".listSectionSeparator(.hidden, edges: .all)"))
#expect(source.contains("if self.isSidebarDrawerLayout {"))
#expect(source.contains("private var sidebarFooter: some View"))
#expect(!source.contains("LabeledContent(\"Version\""))
#expect(navigationSource.contains("SidebarGroup(title: \"CHAT\", destinations: [.chat, .talk])"))
#expect(!navigationSource.contains("title: \"AGENT\""))
#expect(navigationSource.contains("case settings"))
#expect(!navigationSource.contains("case settingsChannels"))
#expect(!navigationSource.contains("case settingsApprovals"))
#expect(!navigationSource.contains("case settingsPrivacy"))
#expect(navigationSource.contains("SidebarGroup(\n title: \"SETTINGS\""))
#expect(navigationSource.contains("destinations: [.settings]"))
#expect(!navigationSource.contains("destinations: [.gateway"))
#expect(!navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.settings"))
#expect(navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.docs])"))
}
@Test func sidebarRoutesUseDestinationHeadersInsteadOfRepeatedProductBranding() throws {
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
let sidebarDetail = try Self.extract(
rootSource,
from: "private var sidebarDetail: some View",
to: "private var sidebarDetailNavigationShell: some View")
#expect(sidebarDetail.contains("headerTitle: \"Chat\""))
#expect(sidebarDetail.contains("headerTitle: \"Overview\""))
#expect(sidebarDetail.contains("headerTitle: \"Agents\""))
#expect(sidebarDetail.contains("headerTitle: \"Instances\""))
#expect(!sidebarDetail.contains("headerTitle: \"Nodes\""))
#expect(sidebarDetail.contains("directRoute: .agents"))
#expect(sidebarDetail.contains("directRoute: .instances"))
#expect(sidebarDetail.contains("directRoute: .dreaming"))
#expect(sidebarDetail.contains("directRoute: .usage"))
#expect(sidebarDetail.contains("directRoute: .cron"))
#expect(!sidebarDetail.contains("initialRoute: .nodes"))
#expect(!sidebarDetail.contains("initialRoute: .usage"))
#expect(!sidebarDetail.contains("initialRoute: .cron"))
#expect(sidebarDetail.contains("headerTitle: \"Dreaming\""))
#expect(sidebarDetail.contains("headerTitle: \"Usage\""))
#expect(sidebarDetail.contains("headerTitle: \"Cron Jobs\""))
#expect(!sidebarDetail.contains("headerTitle: \"OpenClaw\""))
#expect(agentOverviewSource.contains("OpenClawAdaptiveHeaderRow("))
#expect(agentOverviewSource.contains("title: self.headerTitle"))
#expect(!agentOverviewSource.contains("Text(\"OpenClaw\")"))
#expect(docsSource.contains("OpenClawAdaptiveHeaderRow("))
#expect(docsSource.contains("title: \"Docs\""))
#expect(!docsSource.contains("Text(\"OpenClaw Docs\")"))
}
@Test func agentsDirectRouteKeepsSingleSidebarControl() throws {
let source = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8)
let destinationsSource = try String(contentsOf: Self.agentProTabDestinationsSourceURL(), encoding: .utf8)
let nodesSource = try String(contentsOf: Self.agentProNodesDestinationSourceURL(), encoding: .utf8)
let dreamingSource = try String(contentsOf: Self.agentProDreamingDestinationSourceURL(), encoding: .utf8)
let directDestination = try Self.extract(
source,
from: "private func directDestination(for route: AgentRoute) -> some View",
to: "private func applyInitialRouteIfNeeded()")
#expect(!directDestination.contains("ToolbarItem"))
#expect(directDestination.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden"))
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .instances)"))
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .dreaming)"))
#expect(destinationsSource.contains("self.directHeader(\n for: .usage"))
#expect(destinationsSource.contains("self.directHeader(\n for: .cron"))
#expect(destinationsSource.contains("self.directRoute == route ? self.headerLeadingAction : nil"))
#expect(nodesSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
#expect(dreamingSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
}
@Test func routedHeadersUseSharedAdaptiveLayout() throws {
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
let featureChromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8)
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
#expect(componentsSource.contains("struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View"))
#expect(componentsSource.contains("ViewThatFits(in: .horizontal)"))
#expect(componentsSource.contains("private var stackedLayout: some View"))
#expect(componentsSource.contains(".layoutPriority(1)"))
#expect(componentsSource.contains(".fixedSize(horizontal: true, vertical: false)"))
#expect(featureChromeSource.contains("OpenClawAdaptiveHeaderRow("))
#expect(docsSource.contains("OpenClawAdaptiveHeaderRow("))
#expect(overviewSource.contains("OpenClawAdaptiveHeaderRow("))
#expect(chatSource.contains("OpenClawAdaptiveHeaderRow("))
#expect(agentOverviewSource.contains("OpenClawAdaptiveHeaderRow("))
#expect(settingsSource.contains("OpenClawAdaptiveHeaderRow("))
}
@Test func phoneHubKeepsDocsAsDestinationOnly() throws {
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
#expect(source.contains("case .docs:"))
#expect(source.contains("OpenClawDocsScreen("))
#expect(source.contains("headerLeadingAction: self.phoneDetailBackAction"))
#expect(source.contains("gatewayAction: { self.openRootDestination(.gateway) }"))
#expect(!source.contains("Label(\"Docs\", systemImage: \"book\")"))
#expect(!source.contains("https://docs.openclaw.ai"))
}
@Test func rootShellPreviewMatrixCoversPhoneAndIPadStates() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\n \"Shell iPhone portrait\""))
#expect(source.contains("#Preview(\n \"Shell iPhone landscape\""))
#expect(source.contains("#Preview(\n \"Shell iPhone connected\""))
#expect(source.contains("#Preview(\n \"Shell iPhone gateway error\""))
#expect(source.contains("#Preview(\n \"Shell iPad portrait drawer\""))
#expect(source.contains("#Preview(\n \"Shell iPad landscape split\""))
#expect(source.contains("#Preview(\n \"Shell iPad connecting\""))
#expect(source.contains("#Preview(\n \"Shell iPad gateway error\""))
}
@Test func sharedChatPreviewMatrixCoversConnectionStates() throws {
let source = try String(contentsOf: Self.sharedChatPreviewSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Chat connected\")"))
#expect(source.contains("#Preview(\"Chat empty\")"))
#expect(source.contains("#Preview(\"Chat loading\")"))
#expect(source.contains("#Preview(\"Chat gateway error\")"))
#expect(source.contains("enum Scenario"))
#expect(source.contains("case connected"))
#expect(source.contains("case empty"))
#expect(source.contains("case loading"))
#expect(source.contains("case error"))
#expect(source.contains("Gateway not connected. Check Tailscale and retry."))
}
@Test func phoneHubKeepsContentAboveFloatingTabBar() throws {
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
#expect(source.contains(".safeAreaPadding(.bottom, self.bottomScrollInset)"))
#expect(!source.contains(".padding(.bottom, self.bottomScrollInset)"))
#expect(!source.contains("bottomViewportInset"))
#expect(!source.contains("bottomTabBarClearance"))
}
@Test func phoneHubHeaderStaysTaskFirst() throws {
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
#expect(source.contains("private var gatewayActionRow: some View"))
#expect(source.contains("self.openRootDestination(.gateway)"))
#expect(source.contains("private var phoneDetailBackAction: OpenClawSidebarHeaderAction"))
#expect(source.contains("accessibilityLabel: \"Back to Control\""))
#expect(source.contains("accessibilityIdentifier: \"OpenClawPhoneDetailBackButton\""))
#expect(source.contains(".navigationBarBackButtonHidden(true)"))
#expect(source.contains(".toolbar(.hidden, for: .navigationBar)"))
#expect(source.matches(of: /headerLeadingAction: self\.phoneDetailBackAction/).count == 10)
#expect(!source.contains("directRoute: .agents"))
#expect(!source.contains("ToolbarItem(placement: .topBarTrailing)"))
#expect(!source.contains("Image(systemName: \"gearshape\")"))
#expect(!source.contains("self.metric(label:"))
#expect(!source.contains("private func metric(label:"))
}
@Test func workboardUsesRealGatewayMethods() throws {
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
#expect(source.contains("workboard.cards.list"))
#expect(source.contains("workboard.cards.create"))
#expect(source.contains("workboard.cards.move"))
#expect(source.contains("workboard.cards.archive"))
#expect(source.contains("workboard.cards.dispatch"))
#expect(source.contains(".padding(.bottom, 12)"))
#expect(!source.contains("Workboard gateway contract unavailable"))
#expect(!source.contains("supportsGatewayContract"))
#expect(!source.contains("Compact mobile queue control"))
#expect(!source.contains("Multi-column queue control"))
}
@Test func workboardCreateActionSurfacesUnavailableReasons() throws {
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
let createFunction = try Self.extract(
source,
from: "private func createCard() async -> Bool",
to: "private func move(_ card: IPadWorkboardCard, to status: String) async")
#expect(source.contains("private var createUnavailableMessage: String?"))
#expect(source.contains("Enter a title to create a card."))
#expect(source.contains("Card creation is already in progress."))
#expect(source.contains("private func newCardButton(expands: Bool) -> some View"))
#expect(source.contains("private func beginCreateCard()"))
#expect(source.contains("self.newCardButton(expands: false)"))
#expect(source.contains("self.newCardButton(expands: true)"))
#expect(source.contains("Label(\"New Card\", systemImage: \"plus\")"))
#expect(source.contains(".accessibilityHint(\"Opens card title and notes entry\")"))
#expect(source.contains(".accessibilityHint(self.createUnavailableMessage ?? \"Creates a workboard card\")"))
#expect(source.contains("if await self.createCard()"))
#expect(source.contains(".disabled(self.isCreatingCard)"))
#expect(!source.contains("Button(\"Create\")"))
#expect(!source.contains("TextField(\"New card\""))
#expect(!source.contains(".disabled(!self.canWrite || self.draftTitle"))
#expect(createFunction.contains("self.errorText = createUnavailableMessage"))
#expect(createFunction.contains("return false"))
#expect(createFunction.contains("return true"))
}
@Test func taskScopeControlsSendRealGatewayParams() throws {
let source = try Self.iPadTaskFeatureScreensSource()
#expect(source.contains("private var boardScopeMenu: some View"))
#expect(source.contains("method: \"workboard.boards.list\""))
#expect(source.contains("IPadWorkboardListParams(boardId: selectedBoardParam)"))
#expect(source.contains("boardId: selectedBoardParam"))
#expect(source
.matches(
of: /method: "workboard\.cards\.dispatch"[\s\S]*?IPadWorkboardListParams\(boardId: selectedBoardParam\)/)
.count == 1)
#expect(source.contains("private var agentScopeMenu: some View"))
#expect(source.contains("IPadSkillProposalListParams(agentId: selectedAgentParam)"))
#expect(source.contains("agentId: selectedAgentParam"))
#expect(!source
.contains(
"params: EmptyParams(),\n timeoutSeconds: 20)\n let response = try JSONDecoder().decode(IPadSkillProposalManifest.self"))
}
@Test func compactTaskRowsKeepPhoneNativeActions() throws {
let source = try Self.iPadTaskFeatureScreensSource()
let compactControls = try Self.extract(
source,
from: "private var compactQueueControls: some View",
to: "private var compactRefreshButton: some View")
#expect(source.contains("struct IPadWorkboardQueueRow"))
#expect(source.contains("private var actionMenuItems: some View"))
#expect(source.components(separatedBy: ".contextMenu {").count - 1 >= 2)
#expect(source.components(separatedBy: ".swipeActions(edge: .leading").count - 1 >= 2)
#expect(source.components(separatedBy: ".swipeActions(edge: .trailing").count - 1 >= 2)
#expect(source.contains("@State private var presentedProposalRoute: IPadSkillProposalSheetRoute?"))
#expect(source.contains(".sheet(item: self.$presentedProposalRoute)"))
#expect(source.contains("private func selectProposal("))
#expect(!source.contains("proposalSheetPresented"))
#expect(source.contains("self.presentedSheet = .card(card)"))
#expect(!source.contains("Label(\"Gateway\", systemImage: \"network\")"))
#expect(!source.contains("Button(\"Gateway\")"))
#expect(!source.contains("actionTitle: self.canRead ? nil : \"Gateway\""))
#expect(!source.contains("Workboard offline"))
#expect(!source.contains("Workshop offline"))
#expect(!source.contains("Connect gateway to"))
#expect(source.contains("private var compactRefreshButton: some View"))
#expect(source.contains("private var compactBoardScopeMenu: some View"))
#expect(source.contains("Color(uiColor: .secondarySystemGroupedBackground)"))
#expect(source.contains(".allowsHitTesting(false)"))
#expect(compactControls.contains("self.compactRefreshButton"))
#expect(compactControls.contains("self.compactBoardScopeMenu"))
#expect(!compactControls.contains("Self.workboardSubtitle("))
#expect(!compactControls.contains("Label(\"Refresh\""))
#expect(compactControls.contains("Label(\"Dispatch\""))
}
@Test func skillWorkshopUsesKanbanLanesOnWideIPad() throws {
let source = try String(contentsOf: Self.iPadSkillWorkshopScreenSourceURL(), encoding: .utf8)
let previewSource = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
let content = try Self.extract(
source,
from: "private var proposalContent: some View",
to: "private var proposalBoard: some View")
let board = try Self.extract(
source,
from: "private var proposalBoard: some View",
to: "private var proposalList: some View")
#expect(content.contains("if self.isCompactWidth"))
#expect(content.contains("self.proposalList"))
#expect(content.contains("self.proposalBoard"))
#expect(!content.contains("self.proposalDetail"))
#expect(board.contains("ScrollView(.horizontal)"))
#expect(board.contains("IPadSkillProposalKanbanColumn("))
#expect(source.contains("private struct IPadSkillProposalKanbanCard"))
#expect(source.contains("static let defaultProposalStatusBoardLanes"))
#expect(source.contains("private func proposals(forLaneStatus status: String)"))
#expect(previewSource.contains("#Preview(\n \"Skill Workshop iPad kanban lanes\""))
#expect(previewSource.contains("private struct IPadSkillWorkshopKanbanPreview"))
#expect(previewSource.contains("IPadSkillProposalKanbanColumn("))
#expect(previewSource.contains("status: \"needs-review\""))
#expect(previewSource.contains("status: \"manual_QA\""))
}
@Test func compactTaskRowsHavePopulatedPhonePreviews() throws {
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Workboard phone queue rows\")"))
#expect(source.contains("#Preview(\"Skill Workshop phone queue rows\")"))
#expect(source.contains("private struct IPadWorkboardCompactRowsPreview"))
#expect(source.contains("private struct IPadSkillWorkshopCompactRowsPreview"))
#expect(source.contains("IPadWorkboardPreviewFixtures.cards"))
#expect(source.contains("IPadSkillWorkshopPreviewFixtures.proposals"))
}
@Test func taskScreenPreviewMatricesCoverPrimaryStates() throws {
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Workboard states\")"))
#expect(source.contains("private struct IPadWorkboardStatesPreview"))
#expect(source.contains("self.previewHeader(\"Connected\")"))
#expect(source.contains("self.previewHeader(\"Empty\")"))
#expect(source.contains("self.previewHeader(\"Loading\")"))
#expect(source.contains("self.previewHeader(\"Error\")"))
#expect(source.contains("title: \"Loading cards\""))
#expect(source.contains("title: \"Cards unavailable\""))
#expect(source.contains("IPadWorkboardKanbanColumn("))
#expect(source.contains("#Preview(\"Skill Workshop states\")"))
#expect(source.contains("private struct IPadSkillWorkshopStatesPreview"))
#expect(source.contains("self.previewHeader(\"Offline / Error\")"))
#expect(source.contains("title: \"No proposals\""))
#expect(source.contains("title: \"Workshop offline\""))
#expect(source.contains("title: \"Proposal unavailable\""))
#expect(source.contains("#Preview(\n \"Skill Workshop iPad kanban lanes\""))
#expect(source.contains("private struct IPadSkillWorkshopKanbanPreview"))
#expect(source.contains("\"needs-review\""))
#expect(source.contains("\"manual_QA\""))
}
@Test func activityPreviewMatrixCoversConnectionStates() throws {
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Activity states\")"))
#expect(source.contains("private struct IPadActivityStatesPreview"))
#expect(source.contains("self.previewHeader(\"Connected\")"))
#expect(source.contains("self.previewHeader(\"Loading\")"))
#expect(source.contains("self.previewHeader(\"Empty\")"))
#expect(source.contains("self.previewHeader(\"Error\")"))
#expect(source.contains("title: \"Sessions unavailable\""))
#expect(source.contains("title: \"No recent sessions\""))
#expect(source.contains("title: \"Loading sessions\""))
}
@Test func routedFeatureScreensReuseSharedProComponents() throws {
let source = try Self.iPadTaskFeatureScreensSource()
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
#expect(source.contains("ProMetricGrid(metrics: self.metrics)"))
#expect(source.contains("ProPanelHeader("))
#expect(source.contains("ProStatusRow("))
#expect(!source.contains("private struct ProMetricGrid"))
#expect(!source.contains("private struct ProMetric"))
#expect(!source.contains("private struct ProPanelHeader"))
#expect(!source.contains("private struct ProStatusRow"))
#expect(!channelsSource.contains("private struct SettingsChannelPanelHeader"))
#expect(!channelsSource.contains("private struct SettingsChannelInfoRow"))
#expect(componentsSource.contains("struct ProMetricGrid"))
#expect(componentsSource.contains("struct ProPanelHeader"))
#expect(componentsSource.contains("struct ProStatusRow"))
}
@Test func activityScreenStaysSplitFromTaskFeatureScreens() throws {
let taskSource = try Self.iPadTaskFeatureScreensSource()
let activitySource = try String(contentsOf: Self.iPadActivityScreenSourceURL(), encoding: .utf8)
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
#expect(activitySource.contains("struct IPadActivityScreen: View"))
#expect(activitySource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)"))
#expect(activitySource.contains("IPadSidebarScreenChrome("))
#expect(!taskSource.contains("struct IPadActivityScreen"))
#expect(!taskSource.contains("import OpenClawChatUI"))
#expect(projectSource.contains("IPadActivityScreen.swift in Sources"))
}
@Test func routedFeatureChromeStaysSplitFromTaskFeatureScreens() throws {
let taskSource = try Self.iPadTaskFeatureScreensSource()
let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
#expect(chromeSource.contains("struct IPadSidebarScreenChrome<Content: View>: View"))
#expect(chromeSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
#expect(chromeSource.contains("OpenClawGatewayCompactPill()"))
#expect(!taskSource.contains("struct IPadSidebarScreenChrome"))
#expect(projectSource.contains("IPadSidebarScreenChrome.swift in Sources"))
}
@Test func routedFeatureChromeKeepsGatewayPillActionable() throws {
let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
let featureSource = try Self.iPadTaskFeatureScreensSource()
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
#expect(chromeSource.contains("let gatewayAction: (() -> Void)?"))
#expect(chromeSource.contains("private var gatewayPill: some View"))
#expect(chromeSource.contains("Button(action: gatewayAction)"))
#expect(chromeSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
#expect(featureSource.matches(of: /gatewayAction: self\.openSettings/).count == 2)
#expect(rootSource.contains("IPadActivityScreen("))
#expect(rootSource
.matches(of: /IPadActivityScreen\([\s\S]*?openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/)
.count == 1)
}
@Test func routedGatewayPillsOpenGatewaySettings() throws {
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let agentSource = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8)
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8)
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 1)
#expect(!rootSource.contains("showGatewayActions"))
#expect(!rootSource.contains("gatewayActionsDialog"))
#expect(overviewSource.contains("Button(action: self.openSettings)"))
#expect(overviewSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
#expect(agentSource.contains("let openSettings: (() -> Void)?"))
#expect(agentOverviewSource.contains("OpenClawGatewayCompactPill()"))
#expect(agentOverviewSource.contains("Button(action: openSettings)"))
#expect(rootSource
.matches(of: /AgentProTab\([\s\S]*?openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/)
.count >= 3)
#expect(chatSource.contains("let openSettings: (() -> Void)?"))
#expect(chatSource.contains("private var connectionPillButton: some View"))
#expect(docsSource.contains("let gatewayAction: (() -> Void)?"))
#expect(settingsSource.contains("NavigationLink(value: SettingsRoute.gateway)"))
#expect(rootSource.contains("case .settings:"))
#expect(rootSource.contains("SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)"))
#expect(rootSource.contains("directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway"))
#expect(rootSource.contains("SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)"))
#expect(settingsSource.contains("title: \"Channels / Integrations\""))
#expect(settingsSource.contains("route: .channels"))
#expect(channelsSource.contains("let gatewayAction: (() -> Void)?"))
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
#expect(channelsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
}
@Test func gatewaySettingsKeepsPairingTrustDiagnosticsAndTailscaleActions() throws {
let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
let trustSource = try String(contentsOf: Self.gatewayTrustPromptAlertSourceURL(), encoding: .utf8)
let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8)
#expect(sectionsSource.contains("var gatewayDestination: some View"))
#expect(sectionsSource.contains("self.gatewayActions"))
#expect(sectionsSource.contains("self.manualGatewayCard"))
#expect(sectionsSource.contains("self.gatewaySetupCard"))
#expect(sectionsSource.contains("self.discoveredGatewaysCard"))
#expect(sectionsSource.contains("self.gatewayAdvancedCard"))
#expect(sectionsSource.contains("title: \"Reconnect\""))
#expect(sectionsSource.contains("Task { await self.reconnectGateway() }"))
#expect(sectionsSource.contains("title: \"Diagnose\""))
#expect(sectionsSource.contains("Task { await self.runDiagnostics() }"))
#expect(sectionsSource.contains("title: \"Scan QR\""))
#expect(sectionsSource.contains("self.openGatewayQRScanner()"))
#expect(sectionsSource.contains("title: \"Connect\""))
#expect(sectionsSource.contains("Task { await self.applySetupCodeAndConnect() }"))
#expect(sectionsSource.contains("Task { await self.connect(gateway) }"))
#expect(sectionsSource.contains("tailnetWarningText"))
#expect(sectionsSource.contains("GatewayProblemBanner("))
#expect(sectionsSource.contains("Task { await self.handleGatewayProblemPrimaryAction(problem) }"))
#expect(actionsSource.contains("await self.gatewayController.connectLastKnown()"))
#expect(actionsSource.contains("self.gatewayController.refreshActiveGatewayRegistrationFromSettings()"))
#expect(actionsSource.contains("self.gatewayController.restartDiscovery()"))
#expect(actionsSource.contains("await self.appModel.refreshGatewayOverviewIfConnected()"))
#expect(actionsSource.contains("await TCPProbe.probe(host: trimmed, port: port"))
#expect(actionsSource.contains("Check Tailscale or LAN."))
#expect(actionsSource.contains("Tailscale is off on this device. Turn it on, then try again."))
#expect(actionsSource.contains("Run /pair approve in your OpenClaw chat"))
#expect(actionsSource.contains("self.resetOnboarding()"))
#expect(actionsSource.contains("self.gatewayController.trustRotatedGatewayCertificate(from: problem)"))
#expect(actionsSource.contains("await self.retryGatewayConnectionFromProblem()"))
#expect(settingsSource.contains("GatewayProblemDetailsSheet("))
#expect(settingsSource.contains("QRScannerView("))
#expect(trustSource.contains("Trust this gateway?"))
#expect(trustSource.contains("Trust and connect"))
#expect(controllerSource.contains("acceptPendingTrustPrompt()"))
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
}
@Test func gatewaySettingsPreviewMatrixCoversPrimaryStates() throws {
let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8)
#expect(supportSource.contains("#Preview(\"Gateway settings states\")"))
#expect(supportSource.contains("private struct SettingsGatewayStatesPreview"))
#expect(supportSource.contains("self.stateSection(\"Connected\")"))
#expect(supportSource.contains("self.stateSection(\"Loading\")"))
#expect(supportSource.contains("self.stateSection(\"Empty\")"))
#expect(supportSource.contains("self.stateSection(\"Error\")"))
#expect(supportSource.contains("GatewayProblemBanner("))
#expect(supportSource.contains("kind: .pairingRequired"))
#expect(supportSource.contains("Run /pair approve in your OpenClaw chat"))
#expect(supportSource.contains("Tailscale is off on this device. Turn it on, then try again."))
#expect(supportSource.contains("self.previewButton(\"Scan QR\""))
#expect(supportSource.contains("self.previewButton(\"Connect\""))
#expect(supportSource.contains("self.previewButton(\"Reconnect\""))
#expect(supportSource.contains("self.previewButton(\"Diagnose\""))
}
@Test func nativeChatUsesGatewayTransport() throws {
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
#expect(chatSource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)"))
#expect(channelsSource.contains("Message routing and external channel clients."))
#expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata"))
#expect(channelsSource.contains("label: \"ClickClack\""))
#expect(channelsSource.contains("Self-hosted chat bot routing."))
}
private static func rootTabsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/RootTabs.swift")
}
private static func phoneHubSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/RootTabsPhoneControlHub.swift")
}
private static func proComponentsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/OpenClawProComponents.swift")
}
private static func commandCenterSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/CommandCenterTab.swift")
}
private static func agentProTabSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/AgentProTab.swift")
}
private static func agentProTabOverviewSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/AgentProTab+Overview.swift")
}
private static func agentProTabDestinationsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/AgentProTab+Destinations.swift")
}
private static func agentProNodesDestinationSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/AgentProNodesDestination.swift")
}
private static func agentProDreamingDestinationSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/AgentProDreamingDestination.swift")
}
private static func rootTabsNavigationSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/RootTabsNavigation.swift")
}
private static func iPadSidebarFeatureScreensSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/IPadSidebarFeatureScreens.swift")
}
private static func iPadTaskFeatureScreensSource() throws -> String {
try [
self.iPadWorkboardScreenSourceURL(),
self.iPadSkillWorkshopScreenSourceURL(),
self.iPadSidebarFeatureScreensSourceURL(),
]
.map { try String(contentsOf: $0, encoding: .utf8) }
.joined(separator: "\n")
}
private static func iPadWorkboardScreenSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/IPadWorkboardScreen.swift")
}
private static func iPadSkillWorkshopScreenSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/IPadSkillWorkshopScreen.swift")
}
private static func iPadSidebarFeaturePreviewsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/IPadSidebarFeaturePreviews.swift")
}
private static func iPadActivityScreenSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/IPadActivityScreen.swift")
}
private static func iPadSidebarScreenChromeSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/IPadSidebarScreenChrome.swift")
}
private static func chatProTabSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/ChatProTab.swift")
}
private static func docsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/OpenClawDocsScreen.swift")
}
private static func settingsProTabSectionsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/SettingsProTabSections.swift")
}
private static func settingsProTabSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
}
private static func settingsProTabActionsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/SettingsProTabActions.swift")
}
private static func settingsProTabSupportSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/SettingsProTabSupport.swift")
}
private static func gatewayTrustPromptAlertSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Gateway/GatewayTrustPromptAlert.swift")
}
private static func gatewayConnectionControllerSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Gateway/GatewayConnectionController.swift")
}
private static func channelsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/SettingsChannelsDestination.swift")
}
private static func sharedChatPreviewSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift")
}
private static func xcodeProjectSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("OpenClaw.xcodeproj/project.pbxproj")
}
private static func extract(_ source: String, from start: String, to end: String) throws -> String {
let startRange = try #require(source.range(of: start))
let tail = source[startRange.lowerBound...]
let endRange = try #require(tail.range(of: end))
return String(tail[..<endRange.lowerBound])
}
}

View File

@@ -5,8 +5,9 @@ import UIKit
@testable import OpenClaw
@Suite struct SwiftUIRenderSmokeTests {
@MainActor private static func host(_ view: some View) -> UIWindow {
let window = UIWindow(frame: UIScreen.main.bounds)
@MainActor private static func host(_ view: some View, size: CGSize? = nil) -> UIWindow {
let frame = CGRect(origin: .zero, size: size ?? UIScreen.main.bounds.size)
let window = UIWindow(frame: frame)
window.rootViewController = UIHostingController(rootView: view)
window.makeKeyAndVisible()
window.rootViewController?.view.setNeedsLayout()
@@ -41,18 +42,102 @@ import UIKit
}
}
@Test @MainActor func rootTabsBuildAViewHierarchy() {
let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
@Test @MainActor func rootTabsBuildsDeviceOrientationShellMatrix() {
for scenario in Self.rootTabsShellScenarios() {
let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = RootTabs()
let root = RootTabs()
.environment(appModel)
.environment(appModel.voiceWake)
.environment(gatewayController)
.environment(\.rootTabsUserInterfaceIdiomOverride, scenario.idiom)
.environment(\.horizontalSizeClass, scenario.horizontalSizeClass)
.environment(\.verticalSizeClass, scenario.verticalSizeClass)
_ = Self.host(root, size: scenario.size)
}
}
@Test @MainActor func rootTabsBuildGatewayStateViewHierarchies() {
for appModel in Self.rootTabsGatewayStateModels() {
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = RootTabs()
.environment(appModel)
.environment(appModel.voiceWake)
.environment(gatewayController)
_ = Self.host(root)
}
}
@Test @MainActor func phoneControlHubBuildsGatewayStateViewHierarchies() {
for appModel in Self.rootTabsGatewayStateModels() {
let root = RootTabsPhoneControlHub(
groups: RootTabs.phoneControlGroups,
initialDestination: nil,
openRootDestination: { _ in })
.environment(appModel)
_ = Self.host(root)
}
}
@Test @MainActor func phoneControlHubBuildsLandscapeCompactState() {
let appModel = NodeAppModel()
let root = RootTabsPhoneControlHub(
groups: RootTabs.phoneControlGroups,
initialDestination: nil,
openRootDestination: { _ in })
.environment(appModel)
.environment(appModel.voiceWake)
.environment(gatewayController)
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .compact)
_ = Self.host(root)
}
@Test @MainActor func routedSidebarScreensBuildOfflineStates() {
let appModel = NodeAppModel()
let screens: [AnyView] = [
AnyView(CommandCenterTab(openChat: {}, openSettings: {})),
AnyView(IPadActivityScreen(openChat: {}, openSettings: {})),
AnyView(OpenClawDocsScreen()),
AnyView(SettingsChannelsScreen()),
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
AnyView(AgentProTab(directRoute: .agents)),
AnyView(AgentProTab(directRoute: .instances)),
AnyView(CommandSessionsScreen(openChat: {})),
AnyView(AgentProTab(directRoute: .dreaming)),
AnyView(AgentProTab(directRoute: .usage)),
AnyView(AgentProTab(directRoute: .cron)),
]
for screen in screens {
let root = NavigationStack { screen }
.environment(appModel)
_ = Self.host(root)
}
}
@Test @MainActor func taskScreensBuildPhoneLandscapeCompactStates() {
let appModel = NodeAppModel()
let screens: [AnyView] = [
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
]
for screen in screens {
let root = NavigationStack { screen }
.environment(appModel)
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .compact)
_ = Self.host(root)
}
}
@Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() {
let appModel = NodeAppModel()
let root = NavigationStack { VoiceWakeWordsSettingsView() }
@@ -64,4 +149,51 @@ import UIKit
let root = VoiceWakeToast(command: "openclaw: do something")
_ = Self.host(root)
}
@MainActor private static func rootTabsGatewayStateModels() -> [NodeAppModel] {
let offlineModel = NodeAppModel()
let connectingModel = NodeAppModel()
connectingModel.gatewayStatusText = "Connecting..."
let connectedModel = NodeAppModel()
connectedModel.enterAppleReviewDemoMode()
let errorModel = NodeAppModel()
errorModel.gatewayStatusText = "Gateway error: connection refused"
return [offlineModel, connectingModel, connectedModel, errorModel]
}
private static func rootTabsShellScenarios() -> [RootTabsShellScenario] {
[
RootTabsShellScenario(
idiom: .phone,
size: CGSize(width: 393, height: 852),
horizontalSizeClass: .compact,
verticalSizeClass: .regular),
RootTabsShellScenario(
idiom: .phone,
size: CGSize(width: 852, height: 393),
horizontalSizeClass: .regular,
verticalSizeClass: .compact),
RootTabsShellScenario(
idiom: .pad,
size: CGSize(width: 1024, height: 1366),
horizontalSizeClass: .regular,
verticalSizeClass: .regular),
RootTabsShellScenario(
idiom: .pad,
size: CGSize(width: 1366, height: 1024),
horizontalSizeClass: .regular,
verticalSizeClass: .regular),
]
}
private struct RootTabsShellScenario {
let idiom: UIUserInterfaceIdiom
let size: CGSize
let horizontalSizeClass: UserInterfaceSizeClass
let verticalSizeClass: UserInterfaceSizeClass
}
}

View File

@@ -97,7 +97,7 @@ targets:
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_APP_PROFILE)"
TARGETED_DEVICE_FAMILY: "1"
TARGETED_DEVICE_FAMILY: "1,2"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
SUPPORTS_LIVE_ACTIVITIES: YES
@@ -183,7 +183,7 @@ targets:
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_SHARE_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_SHARE_PROFILE)"
TARGETED_DEVICE_FAMILY: "1"
TARGETED_DEVICE_FAMILY: "1,2"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
info:
@@ -220,7 +220,7 @@ targets:
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID)"
TARGETED_DEVICE_FAMILY: "1"
TARGETED_DEVICE_FAMILY: "1,2"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
SUPPORTS_LIVE_ACTIVITIES: YES

View File

@@ -24,6 +24,15 @@ enum HostEnvSanitizer {
"NO_COLOR",
"FORCE_COLOR",
]
private static let gitAllowProtocolKey = "GIT_ALLOW_PROTOCOL"
private static let gitProtocolFromUserKey = "GIT_PROTOCOL_FROM_USER"
private static let gitProtocolFromUserDisabledValue = "0"
private static let gitDefaultAlwaysAllowedProtocols: Set<String> = [
"git",
"http",
"https",
"ssh",
]
private static func isBlocked(_ upperKey: String) -> Bool {
if self.blockedKeys.contains(upperKey) { return true }
@@ -82,6 +91,25 @@ enum HostEnvSanitizer {
Array(Set(values)).sorted()
}
private static func isPermissiveGitProtocolFromUserValue(_ value: String) -> Bool {
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalized == "true" || normalized == "yes" || normalized == "on" {
return true
}
let isInteger = normalized.range(of: #"^[+-]?[0-9]+$"#, options: .regularExpression) != nil
let isZero = normalized.range(of: #"^[+-]?0+$"#, options: .regularExpression) != nil
return isInteger && !isZero
}
private static func sanitizeInheritedGitAllowProtocolValue(_ value: String) -> String {
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
if normalized.isEmpty { return "" }
let safeProtocols = normalized
.split(separator: ":", omittingEmptySubsequences: false)
.filter { self.gitDefaultAlwaysAllowedProtocols.contains(String($0)) }
return safeProtocols.joined(separator: ":")
}
static func inspectOverrides(
overrides: [String: String]?,
blockPathOverrides: Bool = true) -> HostEnvOverrideDiagnostics
@@ -120,6 +148,22 @@ enum HostEnvSanitizer {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
let upper = key.uppercased()
// Preserve inherited Git allowlists without widening malformed or unsafe entries by
// deletion. Protocols outside Git's safe default set are removed instead.
if upper == self.gitAllowProtocolKey {
merged[key] = self.sanitizeInheritedGitAllowProtocolValue(value)
continue
}
// Preserve non-permissive Git boolean values. Permissive values must become explicit
// `0` because Git's unset default still permits protocols with policy `user`.
if upper == self.gitProtocolFromUserKey {
if !self.isPermissiveGitProtocolFromUserValue(value) {
merged[key] = value
} else {
merged[key] = self.gitProtocolFromUserDisabledValue
}
continue
}
if self.isBlockedInherited(upper) { continue }
merged[key] = value
}

View File

@@ -28,6 +28,7 @@ enum HostEnvSecurityPolicy {
"AWS_SESSION_TOKEN",
"AZURE_CLIENT_ID",
"AZURE_CLIENT_SECRET",
"BASHOPTS",
"BASH_ENV",
"BROWSER",
"BUNDLE_GEMFILE",
@@ -70,12 +71,14 @@ enum HostEnvSecurityPolicy {
"ERL_ZFLAGS",
"EXINIT",
"FCEDIT",
"FPATH",
"GCONV_PATH",
"GEM_HOME",
"GEM_PATH",
"GH_TOKEN",
"GITHUB_TOKEN",
"GITLAB_TOKEN",
"GIT_ALLOW_PROTOCOL",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_ASKPASS",
"GIT_COMMON_DIR",
@@ -87,6 +90,7 @@ enum HostEnvSecurityPolicy {
"GIT_INDEX_FILE",
"GIT_NAMESPACE",
"GIT_OBJECT_DIRECTORY",
"GIT_PROTOCOL_FROM_USER",
"GIT_PROXY_COMMAND",
"GIT_SEQUENCE_EDITOR",
"GIT_SSH",
@@ -116,6 +120,7 @@ enum HostEnvSecurityPolicy {
"JAVA_TOOL_OPTIONS",
"JDK_JAVA_OPTIONS",
"JULIA_EDITOR",
"KSH_ENV",
"LDFLAGS",
"LESSCLOSE",
"LESSOPEN",
@@ -183,6 +188,7 @@ enum HostEnvSecurityPolicy {
"SUDO_EDITOR",
"SVN_EDITOR",
"SVN_SSH",
"TCLLIBPATH",
"TF_CLI_CONFIG_FILE",
"TF_PLUGIN_CACHE_DIR",
"UV_DEFAULT_INDEX",
@@ -207,6 +213,7 @@ enum HostEnvSecurityPolicy {
static let blockedKeys: Set<String> = [
"ANT_OPTS",
"BASHOPTS",
"BASH_ENV",
"BROWSER",
"BZR_EDITOR",
@@ -232,7 +239,9 @@ enum HostEnvSecurityPolicy {
"ERL_FLAGS",
"ERL_ZFLAGS",
"EXINIT",
"FPATH",
"GCONV_PATH",
"GIT_ALLOW_PROTOCOL",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_COMMON_DIR",
"GIT_DIR",
@@ -243,6 +252,7 @@ enum HostEnvSecurityPolicy {
"GIT_INDEX_FILE",
"GIT_NAMESPACE",
"GIT_OBJECT_DIRECTORY",
"GIT_PROTOCOL_FROM_USER",
"GIT_SEQUENCE_EDITOR",
"GIT_SSL_CAINFO",
"GIT_SSL_CAPATH",
@@ -260,6 +270,7 @@ enum HostEnvSecurityPolicy {
"JAVA_TOOL_OPTIONS",
"JDK_JAVA_OPTIONS",
"JULIA_EDITOR",
"KSH_ENV",
"LUA_INIT",
"LUA_INIT_5_1",
"LUA_INIT_5_2",
@@ -297,6 +308,7 @@ enum HostEnvSecurityPolicy {
"SUDO_ASKPASS",
"SVN_EDITOR",
"SVN_SSH",
"TCLLIBPATH",
"VAGRANT_VAGRANTFILE",
"VIMINIT",
"_JAVA_OPTIONS"
@@ -425,6 +437,11 @@ enum HostEnvSecurityPolicy {
"REQUESTS_CA_BUNDLE",
"RUSTC_WRAPPER",
"RUSTFLAGS",
"RUSTUP_DIST_ROOT",
"RUSTUP_DIST_SERVER",
"RUSTUP_HOME",
"RUSTUP_TOOLCHAIN",
"RUSTUP_UPDATE_ROOT",
"R_LIBS_USER",
"SSH_ASKPASS",
"SSH_AUTH_SOCK",

View File

@@ -0,0 +1,256 @@
import Foundation
import OpenClawKit
import SwiftUI
private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
enum Scenario {
case connected
case empty
case loading
case error
}
let scenario: Scenario
init(scenario: Scenario = .connected) {
self.scenario = scenario
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
switch self.scenario {
case .connected:
break
case .empty:
return OpenClawChatHistoryPayload(
sessionKey: sessionKey,
sessionId: "preview-empty-session",
messages: [],
thinkingLevel: "medium")
case .loading:
try await Task.sleep(nanoseconds: 60_000_000_000)
return OpenClawChatHistoryPayload(
sessionKey: sessionKey,
sessionId: "preview-loading-session",
messages: [],
thinkingLevel: "medium")
case .error:
throw NSError(
domain: "OpenClawChatPreviewTransport",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Gateway not connected. Check Tailscale and retry."])
}
return OpenClawChatHistoryPayload(
sessionKey: sessionKey,
sessionId: "preview-session",
messages: [
Self.message(
role: "user",
text: "Can you check the gateway status and summarize anything risky?",
timestamp: 1),
Self.message(
role: "assistant",
text: "Gateway is reachable. The only notable item is that push relay is still using local distribution, so device tests should stay on the local lane.",
timestamp: 2),
Self.toolCall(
id: "tool-preview-1",
name: "gateway.status",
arguments: ["deep": AnyCodable(true)],
timestamp: 3),
Self.toolResult(
toolCallId: "tool-preview-1",
name: "gateway.status",
text: "status=ok, channels=ios,macos, lastHeartbeat=12s",
timestamp: 4),
],
thinkingLevel: "medium")
}
func listModels() async throws -> [OpenClawChatModelChoice] {
[
OpenClawChatModelChoice(
modelID: "gpt-5.5",
name: "GPT-5.5",
provider: "openai",
contextWindow: 400_000),
OpenClawChatModelChoice(
modelID: "sonnet-4.6",
name: "Claude Sonnet 4.6",
provider: "anthropic",
contextWindow: 200_000),
]
}
func sendMessage(
sessionKey _: String,
message _: String,
thinking _: String,
idempotencyKey: String,
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
OpenClawChatSendResponse(runId: idempotencyKey, status: "ok")
}
func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse {
OpenClawChatSessionsListResponse(
ts: 0,
path: nil,
count: 2,
defaults: OpenClawChatSessionsDefaults(
modelProvider: "openai",
model: "gpt-5.5",
contextTokens: 400_000,
thinkingLevels: [
OpenClawChatThinkingLevelOption(id: "off", label: "off"),
OpenClawChatThinkingLevelOption(id: "medium", label: "medium"),
OpenClawChatThinkingLevelOption(id: "high", label: "high"),
],
thinkingDefault: "medium",
mainSessionKey: "main"),
sessions: [
Self.session(key: "main", displayName: "Main", updatedAt: 2),
Self.session(key: "ios-preview", displayName: "iOS preview", updatedAt: 1),
])
}
func requestHealth(timeoutMs _: Int) async throws -> Bool {
switch self.scenario {
case .connected, .empty, .loading:
true
case .error:
false
}
}
func events() -> AsyncStream<OpenClawChatTransportEvent> {
AsyncStream { continuation in
continuation.finish()
}
}
func setActiveSessionKey(_: String) async throws {}
private static func message(role: String, text: String, timestamp: Double) -> AnyCodable {
AnyCodable([
"role": role,
"content": [["type": "text", "text": text]],
"timestamp": timestamp,
])
}
private static func toolCall(
id: String,
name: String,
arguments: [String: AnyCodable],
timestamp: Double) -> AnyCodable
{
AnyCodable([
"role": "assistant",
"content": [
[
"type": "toolCall",
"id": id,
"name": name,
"arguments": AnyCodable(arguments),
],
],
"timestamp": timestamp,
])
}
private static func toolResult(
toolCallId: String,
name: String,
text: String,
timestamp: Double) -> AnyCodable
{
AnyCodable([
"role": "tool",
"content": [["type": "text", "text": text]],
"timestamp": timestamp,
"toolCallId": toolCallId,
"toolName": name,
])
}
private static func session(
key: String,
displayName: String,
updatedAt: Double) -> OpenClawChatSessionEntry
{
OpenClawChatSessionEntry(
key: key,
kind: nil,
displayName: displayName,
surface: "ios",
subject: nil,
room: nil,
space: nil,
updatedAt: updatedAt,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: "medium",
verboseLevel: nil,
inputTokens: 2500,
outputTokens: 900,
totalTokens: 3400,
modelProvider: "openai",
model: "gpt-5.5",
contextTokens: 400_000)
}
}
#Preview("Chat") {
OpenClawChatPreview(scenario: .connected)
}
#Preview("Chat connected") {
OpenClawChatPreview(scenario: .connected)
}
#Preview("Chat empty") {
OpenClawChatPreview(
scenario: .empty,
sessionKey: "empty-preview")
}
#Preview("Chat loading") {
OpenClawChatPreview(
scenario: .loading,
sessionKey: "loading-preview")
}
#Preview("Chat gateway error") {
OpenClawChatPreview(
scenario: .error,
sessionKey: "error-preview")
}
#Preview("Onboarding chat") {
OpenClawChatView(
viewModel: OpenClawChatViewModel(
sessionKey: "ios-preview",
transport: OpenClawChatPreviewTransport()),
showsSessionSwitcher: false,
style: .onboarding,
markdownVariant: .standard,
userAccent: .blue)
}
private struct OpenClawChatPreview: View {
let scenario: OpenClawChatPreviewTransport.Scenario
var sessionKey: String = "main"
var body: some View {
OpenClawChatView(
viewModel: OpenClawChatViewModel(
sessionKey: self.sessionKey,
transport: OpenClawChatPreviewTransport(scenario: self.scenario)),
showsSessionSwitcher: true,
style: .standard,
markdownVariant: .standard,
userAccent: .blue,
showsAssistantTrace: true)
}
}

View File

@@ -1,2 +1,2 @@
1cd5bcc75461c64d39a00918a50d033e66ae7ec199d8029f7cccaaa2eeb16f22 plugin-sdk-api-baseline.json
a5d3b43c3710c4238958b1b3163e652ac34bdc7b82215c6294ce61b72188d75e plugin-sdk-api-baseline.jsonl
4768607253fdc720cb2bc280ac285ccfa7f7057a01659691f5be5b1f58422789 plugin-sdk-api-baseline.json
7901bc511cf6f9628df4cd619035265f48c40939e4e8e51c5c10dc73a263f183 plugin-sdk-api-baseline.jsonl

View File

@@ -42,7 +42,7 @@ Requires OpenClaw 2026.5.29 or above. Run `openclaw --version` to check. Upgrade
Configure `dmPolicy` to control who can DM the bot:
- `"pairing"` - unknown users receive a pairing code; approve via CLI
- `"allowlist"` - only users listed in `allowFrom` can chat (default: bot owner only)
- `"allowlist"` - only users listed in `allowFrom` can chat
- `"open"` - allow public DMs only when `allowFrom` includes `"*"`; with restrictive entries, only matching users can chat
- `"disabled"` - disable all DMs
@@ -567,8 +567,8 @@ Full configuration: [Gateway configuration](/gateway/configuration)
| `channels.feishu.accounts.<id>.appSecret` | App Secret | - |
| `channels.feishu.accounts.<id>.domain` | Per-account domain override | `feishu` |
| `channels.feishu.accounts.<id>.tts` | Per-account TTS override | `messages.tts` |
| `channels.feishu.dmPolicy` | DM policy | `allowlist` |
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | [BotOwnerId] |
| `channels.feishu.dmPolicy` | DM policy | `pairing` |
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - |
| `channels.feishu.groupPolicy` | Group policy | `allowlist` |
| `channels.feishu.groupAllowFrom` | Group allowlist | - |
| `channels.feishu.requireMention` | Require @mention in groups | `true` |

View File

@@ -763,6 +763,31 @@ imessage: suppressed stale inbound backlog account=<id> sent=<iso> recovery=<boo
</Accordion>
<Accordion title="Messages send but inbound iMessages do not arrive">
First prove whether the message reached the local Mac. If `chat.db` does not change, OpenClaw cannot receive the message even when `imsg status --json` reports a healthy bridge.
```bash
imsg chats --limit 10 --json
imsg watch --chat-id <chat-id> --json
sqlite3 ~/Library/Messages/chat.db \
"select datetime(max(date)/1000000000 + 978307200, 'unixepoch', 'localtime'), max(ROWID) from message;"
```
If phone-sent messages create no new rows, repair the macOS Messages and Apple Push layer before changing OpenClaw config. A one-shot service refresh is often enough:
```bash
launchctl kickstart -k system/com.apple.apsd
launchctl kickstart -k gui/$(id -u)/com.apple.CommCenter
launchctl kickstart -k gui/$(id -u)/com.apple.identityservicesd
launchctl kickstart -k gui/$(id -u)/com.apple.imagent
imsg launch
openclaw gateway restart
```
Send a fresh iMessage from the phone and confirm a new `chat.db` row or `imsg watch` event before debugging OpenClaw sessions. Do not run this as a periodic bridge-relaunch loop; repeated `imsg launch` plus gateway restarts during active work can interrupt deliveries and strand in-flight channel runs.
</Accordion>
<Accordion title="Gateway is not running on macOS">
The default `cliPath: "imsg"` must run on the Mac signed into Messages. On Linux or Windows, set `channels.imessage.cliPath` to a wrapper script that SSHes to that Mac and runs `imsg "$@"`.

View File

@@ -17,6 +17,11 @@ For most users, the upgrade is in place:
- runtime state stays under `~/.openclaw/matrix/`
You do not need to rename config keys or reinstall the plugin under a new name.
The root `openclaw` package no longer bundles Matrix runtime code or Matrix SDK
dependencies. If `openclaw channels status` shows Matrix is configured but the
plugin is missing after an update, run `openclaw doctor --fix` or
`openclaw plugins install @openclaw/matrix`; do not install Matrix SDK packages
into the root OpenClaw package.
## What the migration does automatically

View File

@@ -673,7 +673,7 @@ Launches a local child process and communicates over stdin/stdout.
<Warning>
**Stdio env safety filter**
OpenClaw rejects interpreter-startup env keys that can alter how a stdio MCP server starts up before the first RPC, even if they appear in a server's `env` block. Blocked keys include `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHONSTARTUP`, `PYTHONPATH`, `PERL5OPT`, `RUBYOPT`, `SHELLOPTS`, `PS4`, and similar runtime-control variables. Startup rejects these with a configuration error so they cannot inject an implicit prelude, swap the interpreter, enable a debugger, or redirect runtime output against the stdio process. Ordinary credential, proxy, and server-specific env vars (`GITHUB_TOKEN`, `HTTP_PROXY`, custom `*_API_KEY`, etc.) are unaffected.
OpenClaw rejects interpreter-startup env keys that can alter how a stdio MCP server starts up before the first RPC, even if they appear in a server's `env` block. Blocked keys include `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHONSTARTUP`, `PYTHONPATH`, `PERL5OPT`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`, and similar runtime-control variables. Startup rejects these with a configuration error so they cannot inject an implicit prelude, swap the interpreter, enable a debugger, or redirect runtime output against the stdio process. Ordinary credential, proxy, and server-specific env vars (`GITHUB_TOKEN`, `HTTP_PROXY`, custom `*_API_KEY`, etc.) are unaffected.
If your MCP server genuinely needs one of the blocked variables, set it on the gateway host process instead of under the stdio server's `env`.
</Warning>

View File

@@ -417,7 +417,7 @@ openclaw plugins inspect <id> --runtime
openclaw plugins inspect <id> --json
```
Inspect shows identity, load status, source, manifest capabilities, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support without importing plugin runtime by default. Add `--runtime` to load the plugin module and include registered hooks, tools, commands, services, gateway methods, and HTTP routes. Runtime inspection reports missing plugin dependencies directly; installs and repairs stay in `openclaw plugins install`, `openclaw plugins update`, and `openclaw doctor --fix`.
Inspect shows identity, load status, source, manifest capabilities, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support without importing plugin runtime by default. JSON output includes the plugin manifest contracts, such as `contracts.agentToolResultMiddleware` and `contracts.trustedToolPolicies`, so operators can audit trusted-surface declarations before enabling or restarting a plugin. Add `--runtime` to load the plugin module and include registered hooks, tools, commands, services, gateway methods, and HTTP routes. Runtime inspection reports missing plugin dependencies directly; installs and repairs stay in `openclaw plugins install`, `openclaw plugins update`, and `openclaw doctor --fix`.
Plugin-owned CLI commands are usually installed as root `openclaw` command groups, but plugins may also register nested commands under a core parent such as `openclaw nodes`. After `inspect --runtime` shows a command under `cliCommands`, run it at the listed path; for example a plugin that registers `demo-git` can be verified with `openclaw demo-git ping`.

View File

@@ -40,6 +40,7 @@ Notes:
- `--local` cannot be combined with `--url`, `--token`, or `--password`.
- `tui` resolves configured gateway auth SecretRefs for token/password auth when possible (`env`/`file`/`exec` providers).
- When launched from inside a configured agent workspace directory, TUI auto-selects that agent for the session key default (unless `--session` is explicitly `agent:<id>:...`).
- To show the Gateway hostname in the footer for non-local URL-backed connections, run `openclaw config set tui.footer.showRemoteHost true`. The host label is off by default and never appears for loopback or embedded local connections.
- Local mode uses the embedded agent runtime directly. Most local tools work, but Gateway-only features are unavailable.
- Local mode adds `/auth [provider]` inside the TUI command surface.
- Plugin approval gates still apply in local mode. Tools that require approval prompt for a decision in the terminal; nothing is silently auto-approved because the Gateway is not involved.

View File

@@ -309,7 +309,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
| NVIDIA | `nvidia` | `NVIDIA_API_KEY` | `nvidia/nvidia/nemotron-3-ultra-550b-a55b` |
| NovitaAI | `novita` | `NOVITA_API_KEY` | `novita/deepseek/deepseek-v3-0324` |
| [Ollama Cloud](/providers/ollama-cloud) | `ollama-cloud` | `OLLAMA_API_KEY` | `ollama-cloud/kimi-k2.6` |
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | `openrouter/auto` |
| OpenRouter | `openrouter` | OpenRouter OAuth or `OPENROUTER_API_KEY` | `openrouter/auto` |
| Qianfan | `qianfan` | `QIANFAN_API_KEY` | `qianfan/deepseek-v3.2` |
| Qwen Cloud | `qwen` | `QWEN_API_KEY` / `MODELSTUDIO_API_KEY` / `DASHSCOPE_API_KEY` | `qwen/qwen3.5-plus` |
| [Qwen OAuth](/providers/qwen-oauth) | `qwen-oauth` | `QWEN_API_KEY` | `qwen-oauth/qwen3.5-plus` |

View File

@@ -1374,6 +1374,7 @@
"pages": [
"clawhub/cli",
"clawhub/publishing",
"clawhub/plugin-validation-fixes",
"clawhub/skill-format",
"clawhub/soul-format",
"clawhub/auth",

View File

@@ -615,6 +615,7 @@ Before relying on an SSH wrapper for production sends, verify an outbound `imsg
remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"],
mediaMaxMb: 16,
service: "auto",
sendTransport: "auto",
region: "US",
actions: {
reactions: true,
@@ -637,6 +638,7 @@ Before relying on an SSH wrapper for production sends, verify an outbound `imsg
- `attachmentRoots` and `remoteAttachmentRoots` restrict inbound attachment paths (default: `/Users/*/Library/Messages/Attachments`).
- SCP uses strict host-key checking, so ensure the relay host key already exists in `~/.ssh/known_hosts`.
- `channels.imessage.configWrites`: allow or deny iMessage-initiated config writes.
- `channels.imessage.sendTransport`: preferred `imsg` RPC send transport for normal outbound replies. `auto` (default) uses the IMCore bridge for existing chats when it is running, then falls back to AppleScript; `bridge` requires private-API delivery; `applescript` forces the public Messages automation path.
- `channels.imessage.actions.*`: enable private API actions that are also gated by `imsg status` / `openclaw channels status --probe`.
- `channels.imessage.includeAttachments` is off by default; set it to `true` before expecting inbound media in agent turns.
- Inbound recovery after a bridge/gateway restart is automatic (GUID dedupe plus a stale-backlog age fence). Existing `channels.imessage.catchup.enabled: true` configs are still honored as a deprecated compatibility profile.

View File

@@ -161,6 +161,13 @@ When any subkey is enabled, model and tool spans get bounded, redacted
`captureContent: true` only for broad diagnostics captures where OTLP log
message bodies are also approved for export.
`toolInputs`/`toolOutputs` content is captured for the built-in agent runtime's
tool executions (`openclaw.content.tool_input` on completed/error spans,
`openclaw.content.tool_output` on completed spans). External harness tool calls
(Codex, Claude CLI) emit `tool.execution.*` spans without content payloads.
Captured content travels on a trusted, listener-only channel and is never placed
on the public diagnostic event bus.
## Sampling and flushing
- **Traces:** `diagnostics.otel.sampleRate` (root-span only, `0.0` drops all,

View File

@@ -780,16 +780,19 @@ rather than the pre-handshake defaults.
- `gateway.controlUi.allowInsecureAuth=true` for localhost-only insecure HTTP compatibility.
- successful `gateway.auth.mode: "trusted-proxy"` operator Control UI auth.
- `gateway.controlUi.dangerouslyDisableDeviceAuth=true` (break-glass, severe security downgrade).
- direct-loopback `gateway-client` backend RPCs authenticated with the shared
gateway token/password.
- Omitting device identity has scope consequences. When a Control UI connection
lacks device identity, `shouldClearUnboundScopesForMissingDeviceIdentity`
clears self-declared scopes to an empty set for token, password, and
trusted-proxy auth. The connection is allowed on explicit trust paths, but
scope-gated methods fail. The exception is local Control UI token/password
sessions with `allowInsecureAuth`, which preserve scopes. For other cases,
set `gateway.controlUi.dangerouslyDisableDeviceAuth=true` only as a
break-glass scope-preservation path.
- direct-loopback `gateway-client` backend RPCs on the reserved internal
helper path.
- Omitting device identity has scope consequences. When a device-less operator
connection is allowed through an explicit trust path, OpenClaw still clears
self-declared scopes to an empty set unless that path has a named
scope-preservation exception. Scope-gated methods then fail with
`missing scope`.
- `gateway.controlUi.dangerouslyDisableDeviceAuth=true` is a Control UI
break-glass scope-preservation path. It does not grant scopes to arbitrary
custom backend or CLI-shaped WebSocket clients.
- The reserved direct-loopback `gateway-client` backend helper path preserves
scopes only for internal local control-plane RPCs; custom backend IDs do not
receive this exception.
- All connections must sign the server-provided `connect.challenge` nonce.
### Device auth migration diagnostics

View File

@@ -53,24 +53,23 @@ Use `trusted-proxy` auth mode when:
When `gateway.auth.mode = "trusted-proxy"` is active and the request passes trusted-proxy checks, Control UI WebSocket sessions can connect without device pairing identity.
Scope implications:
- Device-less Control UI WebSocket sessions connect but receive no operator scopes by default. OpenClaw clears the requested scope list to `[]` so a session that is not bound to an approved paired device/token cannot self-declare permissions.
- If methods fail with `missing scope` after a successful WebSocket connect, use HTTPS so the browser can generate device identity and complete pairing. See [Control UI insecure HTTP](/web/control-ui#insecure-http).
- Break-glass only: `gateway.controlUi.dangerouslyDisableDeviceAuth=true` preserves requested scopes even without device identity. This is a severe security downgrade; revert quickly. See [Control UI insecure HTTP](/web/control-ui#insecure-http).
Reverse-proxy scope capping:
- If your proxy sends `x-openclaw-scopes` on the Control UI WebSocket upgrade request, OpenClaw caps the session scopes to the intersection of the requested scopes and the declared scopes. This header does not grant scopes; it only narrows what the session can hold.
Implications:
- Pairing is no longer the primary gate for Control UI access in this mode.
- Your reverse proxy auth policy and `allowUsers` become the effective access control.
- Keep gateway ingress locked to trusted proxy IPs only (`gateway.trustedProxies` + firewall).
**Scope clearing without device identity:** Because the browser over plain HTTP
cannot create the device identity that OpenClaw uses to bind operator scopes,
trusted-proxy WebSocket connections that lack device identity have their
self-declared scopes cleared to an empty set. The connection is allowed, but
scope-gated methods (`operator.read`, `operator.write`, etc.) fail with
`missing scope`.
To preserve operator scopes on trusted-proxy WebSocket connections without
device identity, set `gateway.controlUi.dangerouslyDisableDeviceAuth: true`.
This is a break-glass flag (`openclaw security audit` reports it as critical).
Use it only when the reverse proxy is the sole path to the Gateway and device
identity cannot be established.
Custom WebSocket clients are not Control UI sessions. `gateway.controlUi.dangerouslyDisableDeviceAuth` does not grant scopes to arbitrary `client.mode: "backend"` or CLI-shaped clients. Custom automation should use device identity/pairing, the reserved direct-local `client.id: "gateway-client"` backend helper path, or the [admin HTTP RPC plugin](/plugins/admin-http-rpc) when an HTTP request/response surface is a better fit.
## Configuration
@@ -322,12 +321,9 @@ Loopback trusted-proxy identity headers still fail closed: same-host callers are
## Operator scopes header
Trusted-proxy auth is an **identity-bearing** HTTP mode, so callers may optionally declare operator scopes with `x-openclaw-scopes`.
Trusted-proxy auth is an **identity-bearing** HTTP mode, so callers may optionally declare operator scopes with `x-openclaw-scopes` on HTTP API requests.
Note: `x-openclaw-scopes` applies to HTTP endpoints only. WebSocket scopes are
determined by the Gateway protocol handshake and device identity binding. For
WebSocket scope behavior with trusted-proxy, see
[Control UI pairing behavior](#control-ui-pairing-behavior).
Note: WebSocket scopes are determined by the Gateway protocol handshake and device identity binding. On Control UI WebSocket upgrade requests, `x-openclaw-scopes` is only a cap on the negotiated session scopes, not a grant. For WebSocket scope behavior with trusted-proxy, see [Control UI pairing behavior](#control-ui-pairing-behavior).
Examples:
@@ -342,6 +338,7 @@ Behavior:
- When the header is absent, normal identity-bearing HTTP APIs fall back to the standard operator default scope set.
- Gateway-auth **plugin HTTP routes** are narrower by default: when `x-openclaw-scopes` is absent, their runtime scope falls back to `operator.write`.
- Browser-origin HTTP requests still have to pass `gateway.controlUi.allowedOrigins` (or deliberate Host-header fallback mode) even after trusted-proxy auth succeeds.
- For Control UI WebSocket sessions, `x-openclaw-scopes` is a scope cap when present on the upgrade request. An empty value yields no scopes.
Practical rule: send `x-openclaw-scopes` explicitly when you want a trusted-proxy request to be narrower than the defaults, or when a gateway-auth plugin route needs something stronger than write scope.
@@ -427,17 +424,20 @@ The audit checks for:
</Accordion>
<Accordion title="Connection succeeds but methods report missing scope">
The WebSocket connects, but `chat.history` or `sessions.list` fails with
`missing scope: operator.read`.
The WebSocket connects, but `chat.history`, `sessions.list`, or
`models.list` fails with `missing scope: operator.read`.
This is expected for trusted-proxy WebSocket connections without device
identity. Connections lacking device identity have their scopes cleared. The
browser cannot generate device identity over plain HTTP.
Common causes:
- Device-less Control UI session: trusted-proxy auth can admit the WebSocket connection without device identity, but OpenClaw clears scopes on device-less sessions by design.
- Custom backend client: `gateway.controlUi.dangerouslyDisableDeviceAuth` is Control UI scoped and does not grant scopes to arbitrary backend or CLI-shaped WebSocket clients.
- Overly narrow `x-openclaw-scopes`: if your proxy injects this header on the Control UI WebSocket upgrade request, the session scopes are capped to that set. An empty header value yields no scopes.
Fix:
- Set `gateway.controlUi.dangerouslyDisableDeviceAuth: true` to preserve operator scopes on trusted-proxy WebSocket connections, or
- Use device identity pairing so scopes are bound to the device token.
- For Control UI, use HTTPS so the browser can generate device identity and complete pairing.
- For custom automation, use device identity/pairing, the reserved direct-local `gateway-client` backend helper path, or [admin HTTP RPC](/plugins/admin-http-rpc).
- Use `gateway.controlUi.dangerouslyDisableDeviceAuth: true` only as a temporary Control UI break-glass path.
</Accordion>
<Accordion title="WebSocket still failing">

View File

@@ -381,7 +381,7 @@ Notes:
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
- On Windows node hosts in allowlist mode, shell-wrapper runs via `cmd.exe /c` require approval (allowlist entry alone does not auto-allow the wrapper form).
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
- Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`.
- Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`.
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
- On headless node host, `system.run` is gated by exec approvals (`~/.openclaw/exec-approvals.json`).

View File

@@ -107,7 +107,7 @@ Notes:
- `allowlist` entries are glob patterns for resolved binary paths, or bare command names for PATH-invoked commands.
- Raw shell command text that contains shell control or expansion syntax (`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) is treated as an allowlist miss and requires explicit approval (or allowlisting the shell binary).
- Choosing "Always Allow" in the prompt adds that command to the allowlist.
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`) and then merged with the app's environment.
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`) and then merged with the app's environment.
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped environment overrides are reduced to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.

View File

@@ -106,6 +106,14 @@ local proof.
eagerly loading every plugin runtime. Set `activation.onStartup`
intentionally. This example starts on Gateway startup.
Host-trusted plugin surfaces are also manifest-gated and require explicit
enablement for installed plugins. If an installed plugin registers
`api.registerAgentToolResultMiddleware(...)`, declare each target runtime in
`contracts.agentToolResultMiddleware`. If it registers
`api.registerTrustedToolPolicy(...)`, declare each policy id in
`contracts.trustedToolPolicies`. These declarations keep install-time
inspection and runtime registration aligned.
For every manifest field, see [Plugin manifest](/plugins/manifest).
</Step>

View File

@@ -373,7 +373,7 @@ If discovery fails or times out, OpenClaw uses a bundled fallback catalog for:
- GPT-5.4 mini
- GPT-5.2
The current bundled harness is `@openai/codex` `0.137.0`. A `model/list` probe
The current bundled harness is `@openai/codex` `0.139.0`. A `model/list` probe
against that bundled app-server returned:
| Model id | Default | Hidden | Input modalities | Reasoning efforts |

View File

@@ -227,12 +227,17 @@ See [Plugin permission requests](/plugins/plugin-permission-requests) for
approval routing, decision behavior, and when to use `requireApproval` instead
of optional tools or exec approvals.
Bundled plugins that need host-level policy can register trusted tool policies
with `api.registerTrustedToolPolicy(...)`. These run before ordinary
`before_tool_call` hooks and before external plugin decisions. Use them only
Plugins that need host-level policy can register trusted tool policies with
`api.registerTrustedToolPolicy(...)`. These run before ordinary
`before_tool_call` hooks and before normal hook decisions. Bundled trusted
policies run first; installed-plugin trusted policies run next in plugin-load
order; ordinary `before_tool_call` hooks run after them. Bundled plugins keep
the existing trusted-policy path. Installed plugins must be explicitly enabled
and declare every policy id in `contracts.trustedToolPolicies`; undeclared ids
are rejected before registration. Policy ids are scoped to the registering
plugin, so different plugins may reuse the same local id. Use this tier only
for host-trusted gates such as workspace policy, budget enforcement, or
reserved workflow safety. External plugins should use normal `before_tool_call`
hooks.
reserved workflow safety.
### Exec environment hook

View File

@@ -631,6 +631,7 @@ read without importing the plugin runtime.
{
"contracts": {
"agentToolResultMiddleware": ["openclaw", "codex"],
"trustedToolPolicies": ["workflow-budget"],
"externalAuthProviders": ["acme-ai"],
"embeddingProviders": ["openai-compatible"],
"speechProviders": ["openai"],
@@ -651,32 +652,41 @@ read without importing the plugin runtime.
Each list is optional:
| Field | Type | What it means |
| -------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------- |
| `embeddedExtensionFactories` | `string[]` | Codex app-server extension factory ids, currently `codex-app-server`. |
| `agentToolResultMiddleware` | `string[]` | Runtime ids a bundled plugin may register tool-result middleware for. |
| `externalAuthProviders` | `string[]` | Provider ids whose external auth profile hook this plugin owns. |
| `embeddingProviders` | `string[]` | General embedding provider ids this plugin owns for reusable vector embedding use, including memory. |
| `speechProviders` | `string[]` | Speech provider ids this plugin owns. |
| `realtimeTranscriptionProviders` | `string[]` | Realtime-transcription provider ids this plugin owns. |
| `realtimeVoiceProviders` | `string[]` | Realtime-voice provider ids this plugin owns. |
| `memoryEmbeddingProviders` | `string[]` | Deprecated memory-specific embedding provider ids this plugin owns. |
| `mediaUnderstandingProviders` | `string[]` | Media-understanding provider ids this plugin owns. |
| `transcriptSourceProviders` | `string[]` | Transcript source provider ids this plugin owns. |
| `imageGenerationProviders` | `string[]` | Image-generation provider ids this plugin owns. |
| `videoGenerationProviders` | `string[]` | Video-generation provider ids this plugin owns. |
| `webFetchProviders` | `string[]` | Web-fetch provider ids this plugin owns. |
| `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. |
| `migrationProviders` | `string[]` | Import provider ids this plugin owns for `openclaw migrate`. |
| `gatewayMethodDispatch` | `string[]` | Reserved entitlement for authenticated plugin HTTP routes that dispatch Gateway methods in-process. |
| `tools` | `string[]` | Agent tool names this plugin owns. |
| Field | Type | What it means |
| -------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `embeddedExtensionFactories` | `string[]` | Codex app-server extension factory ids, currently `codex-app-server`. |
| `agentToolResultMiddleware` | `string[]` | Runtime ids this plugin may register tool-result middleware for. |
| `trustedToolPolicies` | `string[]` | Plugin-local trusted pre-tool policy ids an installed plugin may register. Bundled plugins may register policies without this field. |
| `externalAuthProviders` | `string[]` | Provider ids whose external auth profile hook this plugin owns. |
| `embeddingProviders` | `string[]` | General embedding provider ids this plugin owns for reusable vector embedding use, including memory. |
| `speechProviders` | `string[]` | Speech provider ids this plugin owns. |
| `realtimeTranscriptionProviders` | `string[]` | Realtime-transcription provider ids this plugin owns. |
| `realtimeVoiceProviders` | `string[]` | Realtime-voice provider ids this plugin owns. |
| `memoryEmbeddingProviders` | `string[]` | Deprecated memory-specific embedding provider ids this plugin owns. |
| `mediaUnderstandingProviders` | `string[]` | Media-understanding provider ids this plugin owns. |
| `transcriptSourceProviders` | `string[]` | Transcript source provider ids this plugin owns. |
| `imageGenerationProviders` | `string[]` | Image-generation provider ids this plugin owns. |
| `videoGenerationProviders` | `string[]` | Video-generation provider ids this plugin owns. |
| `webFetchProviders` | `string[]` | Web-fetch provider ids this plugin owns. |
| `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. |
| `migrationProviders` | `string[]` | Import provider ids this plugin owns for `openclaw migrate`. |
| `gatewayMethodDispatch` | `string[]` | Reserved entitlement for authenticated plugin HTTP routes that dispatch Gateway methods in-process. |
| `tools` | `string[]` | Agent tool names this plugin owns. |
`contracts.embeddedExtensionFactories` is retained for bundled Codex
app-server-only extension factories. Bundled tool-result transforms should
declare `contracts.agentToolResultMiddleware` and register with
`api.registerAgentToolResultMiddleware(...)` instead. External plugins cannot
register tool-result middleware because the seam can rewrite high-trust tool
output before the model sees it.
`api.registerAgentToolResultMiddleware(...)` instead. Installed plugins may use
the same middleware seam only when explicitly enabled and only for runtimes they
declare in `contracts.agentToolResultMiddleware`.
Installed plugins that need the host-trusted pre-tool policy tier must declare
each registered local id in `contracts.trustedToolPolicies` and be explicitly
enabled. Bundled plugins keep the existing trusted-policy path, but installed
plugins with undeclared policy ids are rejected before registration. Policy ids
are scoped to the registering plugin, so two plugins may both declare and
register `workflow-budget`; a single plugin may not register the same local id
twice.
Runtime `api.registerTool(...)` registrations must match `contracts.tools`.
Tool discovery uses this list to load only the plugin runtimes that can own the

View File

@@ -160,11 +160,12 @@ Codex `0.124.0`, while pinning OpenClaw to the newer tested stable line.
### Tool-result middleware
Bundled plugins can attach runtime-neutral tool-result middleware through
Bundled plugins and explicitly enabled installed plugins with matching manifest
contracts can attach runtime-neutral tool-result middleware through
`api.registerAgentToolResultMiddleware(...)` when their manifest declares the
targeted runtime ids in `contracts.agentToolResultMiddleware`. This trusted
seam is for async tool-result transforms that must run before OpenClaw or Codex feeds
tool output back into the model.
seam is for async tool-result transforms that must run before OpenClaw or Codex
feeds tool output back into the model.
Legacy bundled plugins can still use
`api.registerCodexAppServerExtensionFactory(...)` for Codex app-server-only

View File

@@ -92,7 +92,7 @@ Runtime send helpers also live on `channel-outbound`:
- `sendDurableMessageBatch(...)`
- `withDurableMessageSendContext(...)`
- `deliverInboundReplyWithMessageSendContext(...)`
- draft streaming/progress helpers such as `resolveChannelStreamingPreviewChunk(...)`
- draft streaming/progress helpers such as `resolveChannelDraftStreamingChunking(...)`
`sendDurableMessageBatch(...)` returns one explicit outcome:

View File

@@ -318,8 +318,10 @@ releases.
}
```
External plugins cannot register tool-result middleware because it can
rewrite high-trust tool output before the model sees it.
Installed plugins can also register tool-result middleware when they are
explicitly enabled and declare every targeted runtime in
`contracts.agentToolResultMiddleware`. Undeclared installed middleware
registrations are rejected.
</Step>

View File

@@ -187,7 +187,7 @@ plugins.
| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| `api.session.state.registerSessionExtension(...)` | Plugin-owned, JSON-compatible session state projected through Gateway sessions |
| `api.session.workflow.enqueueNextTurnInjection(...)` | Durable exactly-once context injected into the next agent turn for one session |
| `api.registerTrustedToolPolicy(...)` | Bundled/trusted pre-plugin tool policy that can block or rewrite tool params |
| `api.registerTrustedToolPolicy(...)` | Manifest-gated trusted pre-plugin tool policy that can block or rewrite tool params |
| `api.registerToolMetadata(...)` | Tool catalog display metadata without changing the tool implementation |
| `api.registerCommand(...)` | Scoped plugin commands; command results can set `continueAgent: true`; Discord native commands support `descriptionLocalizations` |
| `api.session.controls.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, or settings surfaces |
@@ -235,7 +235,10 @@ The contracts intentionally split authority:
- External plugins can own session extensions, UI descriptors, commands, tool
metadata, next-turn injections, and normal hooks.
- Trusted tool policies run before ordinary `before_tool_call` hooks and are
bundled-only because they participate in host safety policy.
host-trusted. Bundled policies run first; installed-plugin policies require
explicit enablement plus their local ids in
`contracts.trustedToolPolicies`, and run next in plugin-load order. Policy ids
are scoped to the registering plugin.
- Reserved command ownership is bundled-only. External plugins should use their
own command names or aliases.
- `allowPromptInjection=false` disables prompt-mutating hooks including
@@ -260,16 +263,18 @@ Examples of non-Plan consumers:
</Note>
<Accordion title="When to use tool-result middleware">
Bundled plugins can use `api.registerAgentToolResultMiddleware(...)` when
Bundled plugins and explicitly enabled installed plugins with matching
manifest contracts can use `api.registerAgentToolResultMiddleware(...)` when
they need to rewrite a tool result after execution and before the runtime
feeds that result back into the model. This is the trusted runtime-neutral
seam for async output reducers such as tokenjuice.
Bundled plugins must declare `contracts.agentToolResultMiddleware` for each
targeted runtime, for example `["openclaw", "codex"]`. External plugins
cannot register this middleware; keep normal OpenClaw plugin hooks for work
that does not need pre-model tool-result timing. The old embedded-runner-only
extension factory registration path has been removed.
Plugins must declare `contracts.agentToolResultMiddleware` for each targeted
runtime, for example `["openclaw", "codex"]`. Installed plugins without that
contract, or without explicit enablement, cannot register this middleware; keep
normal OpenClaw plugin hooks for work that does not need pre-model tool-result
timing. The old
embedded-runner-only extension factory registration path has been removed.
</Accordion>
### Gateway discovery registration

View File

@@ -246,7 +246,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only |
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), target discovery, legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types |

View File

@@ -14,24 +14,52 @@ endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switc
## Getting started
<Steps>
<Step title="Get your API key">
Create an API key at [openrouter.ai/keys](https://openrouter.ai/keys).
</Step>
<Step title="Run onboarding">
```bash
openclaw onboard --auth-choice openrouter-api-key
```
</Step>
<Step title="(Optional) Switch to a specific model">
Onboarding defaults to `openrouter/auto`. Pick a concrete model later:
<Tabs>
<Tab title="OAuth">
<Steps>
<Step title="Run OAuth onboarding">
```bash
openclaw onboard --auth-choice openrouter-oauth
```
```bash
openclaw models set openrouter/<provider>/<model>
```
OpenClaw opens OpenRouter's browser sign-in flow, exchanges the PKCE
code for an OpenRouter API key, and stores that key in the default
OpenRouter auth profile. On remote/headless hosts, OpenClaw prints the
sign-in URL and asks you to paste the redirect URL after signing in.
</Step>
<Step title="(Optional) Switch to a specific model">
Onboarding defaults to `openrouter/auto`. Pick a concrete model later:
</Step>
</Steps>
```bash
openclaw models set openrouter/<provider>/<model>
```
</Step>
</Steps>
</Tab>
<Tab title="API key">
<Steps>
<Step title="Get your API key">
Create an API key at [openrouter.ai/keys](https://openrouter.ai/keys).
</Step>
<Step title="Run API-key onboarding">
```bash
openclaw onboard --auth-choice openrouter-api-key
```
</Step>
<Step title="(Optional) Switch to a specific model">
Onboarding defaults to `openrouter/auto`. Pick a concrete model later:
```bash
openclaw models set openrouter/<provider>/<model>
```
</Step>
</Steps>
</Tab>
</Tabs>
## Config example
@@ -187,7 +215,20 @@ OpenClaw sends OpenRouter STT requests as JSON with base64 audio under
## Authentication and headers
OpenRouter uses a Bearer token with your API key under the hood.
OpenRouter uses a Bearer token with your API key under the hood. OpenRouter
OAuth is a PKCE login flow that issues an OpenRouter API key, so OpenClaw stores
the result as the same `openrouter:default` API-key auth profile used by the
manual API-key setup path.
For an existing install, sign in or rotate the stored OpenRouter key without
rerunning full onboarding:
```bash
openclaw models auth login --provider openrouter --method oauth
```
Use `openclaw models auth login --provider openrouter --method api-key` when
you want to paste a key you created manually at OpenRouter.
On real OpenRouter requests (`https://openrouter.ai/api/v1`), OpenClaw also adds
OpenRouter's documented app-attribution headers:

View File

@@ -1,316 +0,0 @@
# Tweakcn Custom Theme Import Design
Status: approved in terminal on 2026-04-22
## Summary
Add exactly one browser-local custom Control UI theme slot that can be imported from a tweakcn share link. The existing built-in theme families remain `claw`, `knot`, and `dash`. The new `custom` family behaves like a normal OpenClaw theme family and supports `light`, `dark`, and `system` mode when the imported tweakcn payload includes both light and dark token sets.
The imported theme is stored only in the current browser profile with the rest of the Control UI settings. It is not written to gateway config and does not sync across devices or browsers.
## Problem
The Control UI theme system is currently closed over three hard-coded theme families:
- `ui/src/ui/theme.ts`
- `ui/src/ui/views/config.ts`
- `ui/src/styles/base.css`
Users can switch among built-in families and mode variants, but they cannot bring in a theme from tweakcn without editing repo CSS. The requested outcome is smaller than a general theming system: keep the three built-ins and add one user-controlled imported slot that can be replaced from a tweakcn link.
## Goals
- Keep the existing built-in theme families unchanged.
- Add exactly one imported custom slot, not a theme library.
- Accept a tweakcn share link or a direct `https://tweakcn.com/r/themes/{id}` URL.
- Persist the imported theme in browser local storage only.
- Make the imported slot work with existing `light`, `dark`, and `system` mode controls.
- Keep failure behavior safe: a bad import never breaks the active UI theme.
## Non goals
- No multi-theme library or browser-local list of imports.
- No gateway-side persistence or cross-device sync.
- No arbitrary CSS editor or raw theme JSON editor.
- No automatic loading of remote font assets from tweakcn.
- No attempt to support tweakcn payloads that only expose one mode.
- No repo-wide theming refactor beyond the seams required for the Control UI.
## User decisions already made
- Keep the three built-in themes.
- Add one tweakcn-powered import slot.
- Store the imported theme in the browser, not gateway config.
- Support `light`, `dark`, and `system` for the imported slot.
- Overwriting the custom slot with the next import is the intended behavior.
## Recommended approach
Add a fourth theme family id, `custom`, to the Control UI theme model. The `custom` family becomes selectable only when a valid tweakcn import is present. The imported payload is normalized into an OpenClaw-specific custom theme record and stored in browser local storage with the rest of the UI settings.
At runtime, OpenClaw renders a managed `<style>` tag that defines the resolved custom CSS variable blocks:
```css
:root[data-theme="custom"] { ... }
:root[data-theme="custom-light"] { ... }
```
This keeps custom theme variables scoped to the `custom` family and avoids leaking inline CSS variables into the built-in families.
## Architecture
### Theme model
Update `ui/src/ui/theme.ts`:
- Extend `ThemeName` to include `custom`.
- Extend `ResolvedTheme` to include `custom` and `custom-light`.
- Extend `VALID_THEME_NAMES`.
- Update `resolveTheme()` so `custom` mirrors the existing family behavior:
- `custom + dark` -> `custom`
- `custom + light` -> `custom-light`
- `custom + system` -> `custom` or `custom-light` based on OS preference
No legacy aliases are added for `custom`.
### Persistence model
Extend `UiSettings` persistence in `ui/src/ui/storage.ts` with one optional custom-theme payload:
- `customTheme?: ImportedCustomTheme`
Recommended stored shape:
```ts
type ImportedCustomTheme = {
sourceUrl: string;
themeId: string;
label: string;
importedAt: string;
light: Record<string, string>;
dark: Record<string, string>;
};
```
Notes:
- `sourceUrl` stores the original user input after normalization.
- `themeId` is the tweakcn theme id extracted from the URL.
- `label` is the tweakcn `name` field when present, else `Custom`.
- `light` and `dark` are already normalized OpenClaw token maps, not raw tweakcn payloads.
- The imported payload lives beside other browser-local settings and is serialized in the same local-storage document.
- If stored custom-theme data is missing or invalid on load, ignore the payload and fall back to `theme: "claw"` when the persisted family was `custom`.
### Runtime application
Add a narrow custom-theme stylesheet manager in the Control UI runtime, owned near `ui/src/ui/app-settings.ts` and `ui/src/ui/theme.ts`.
Responsibilities:
- Create or update one stable `<style id="openclaw-custom-theme">` tag in `document.head`.
- Emit CSS only when a valid custom theme payload exists.
- Remove the style tag content when the payload is cleared.
- Keep built-in family CSS in `ui/src/styles/base.css`; do not splice imported tokens into the checked-in stylesheet.
This manager runs whenever settings are loaded, saved, imported, or cleared.
### Light-mode selectors
Implementation should prefer `data-theme-mode="light"` for cross-family light styling rather than special-casing `custom-light`. If an existing selector is pinned to `data-theme="light"` and needs to apply to every light family, broaden it as part of this work.
## Import UX
Update `ui/src/ui/views/config.ts` in the `Appearance` section:
- Add a `Custom` theme card beside `Claw`, `Knot`, and `Dash`.
- Show the card as disabled when no imported custom theme exists.
- Add an import panel under the theme grid with:
- one text input for a tweakcn share link or `/r/themes/{id}` URL
- one `Import` button
- one `Replace` path when a custom payload already exists
- one `Clear` action when a custom payload already exists
- Show the imported theme label and source host when a payload exists.
- If the active theme is `custom`, importing a replacement applies immediately.
- If the active theme is not `custom`, importing only stores the new payload until the user selects the `Custom` card.
The quick settings theme picker in `ui/src/ui/views/config-quick.ts` should also show `Custom` only when a payload exists.
## URL parsing and remote fetch
The browser import path accepts:
- `https://tweakcn.com/themes/{id}`
- `https://tweakcn.com/r/themes/{id}`
Implementation should normalize both forms to:
- `https://tweakcn.com/r/themes/{id}`
The browser then fetches the normalized `/r/themes/{id}` endpoint directly.
Use a narrow schema validator for the external payload. A zod schema is preferred because this is an untrusted external boundary.
Required remote fields:
- top-level `name` as optional string
- `cssVars.theme` as optional object
- `cssVars.light` as object
- `cssVars.dark` as object
If either `cssVars.light` or `cssVars.dark` is missing, reject the import. This is deliberate: the approved product behavior is full mode support, not best-effort synthesis of a missing side.
## Token mapping
Do not mirror tweakcn variables blindly. Normalize a bounded subset into OpenClaw tokens and derive the rest in a helper.
### Tokens imported directly
From each tweakcn mode block:
- `background`
- `foreground`
- `card`
- `card-foreground`
- `popover`
- `popover-foreground`
- `primary`
- `primary-foreground`
- `secondary`
- `secondary-foreground`
- `muted`
- `muted-foreground`
- `accent`
- `accent-foreground`
- `destructive`
- `destructive-foreground`
- `border`
- `input`
- `ring`
- `radius`
From shared `cssVars.theme` when present:
- `font-sans`
- `font-mono`
If a mode block overrides `font-sans`, `font-mono`, or `radius`, the mode-local value wins.
### Tokens derived for OpenClaw
The importer derives OpenClaw-only variables from the imported base colors:
- `--bg-accent`
- `--bg-elevated`
- `--bg-hover`
- `--panel`
- `--panel-strong`
- `--panel-hover`
- `--chrome`
- `--chrome-strong`
- `--text`
- `--text-strong`
- `--chat-text`
- `--muted`
- `--muted-strong`
- `--accent-hover`
- `--accent-muted`
- `--accent-subtle`
- `--accent-glow`
- `--focus`
- `--focus-ring`
- `--focus-glow`
- `--secondary`
- `--secondary-foreground`
- `--danger`
- `--danger-muted`
- `--danger-subtle`
Derivation rules live in a pure helper so they can be tested independently. Exact color-mixing formulas are an implementation detail, but the helper must satisfy two constraints:
- preserve readable contrast close to the imported theme intent
- produce stable output for the same imported payload
### Tokens ignored in v1
These tweakcn tokens are intentionally ignored in the first version:
- `chart-*`
- `sidebar-*`
- `font-serif`
- `shadow-*`
- `tracking-*`
- `letter-spacing`
- `spacing`
This keeps the scope on the tokens the current Control UI actually needs.
### Fonts
Font stack strings are imported if present, but OpenClaw does not load remote font assets in v1. If the imported stack references fonts that are unavailable in the browser, normal fallback behavior applies.
## Failure behavior
Bad imports must fail closed.
- Invalid URL format: show inline validation error, do not fetch.
- Unsupported host or path shape: show inline validation error, do not fetch.
- Network failure, non-OK response, or malformed JSON: show inline error, keep current stored payload untouched.
- Schema failure or missing light/dark blocks: show inline error, keep current stored payload untouched.
- Clear action:
- removes the stored custom payload
- removes the managed custom style tag content
- if `custom` is active, switches theme family back to `claw`
- Invalid stored custom payload on first load:
- ignore the stored payload
- do not emit custom CSS
- if persisted theme family was `custom`, fall back to `claw`
At no point should a failed import leave the active document with partial custom CSS variables applied.
## Files expected to change in implementation
Primary files:
- `ui/src/ui/theme.ts`
- `ui/src/ui/storage.ts`
- `ui/src/ui/app-settings.ts`
- `ui/src/ui/views/config.ts`
- `ui/src/ui/views/config-quick.ts`
- `ui/src/styles/base.css`
Likely new helpers:
- `ui/src/ui/custom-theme.ts`
Tests:
- `ui/src/ui/app-settings.test.ts`
- `ui/src/ui/storage.node.test.ts`
- `ui/src/ui/views/config.browser.test.ts`
- new focused tests for URL parsing and payload normalization
## Testing
Minimum implementation coverage:
- parse share-link URL into tweakcn theme id
- normalize `/themes/{id}` and `/r/themes/{id}` into the fetch URL
- reject unsupported hosts and malformed ids
- validate tweakcn payload shape
- map a valid tweakcn payload into normalized OpenClaw light and dark token maps
- load and save the custom payload in browser-local settings
- resolve `custom` for `light`, `dark`, and `system`
- disable `Custom` selection when no payload exists
- apply imported theme immediately when `custom` is already active
- fall back to `claw` when the active custom theme is cleared
Manual verification target:
- import a known tweakcn theme from Settings
- switch among `light`, `dark`, and `system`
- switch between `custom` and the built-in families
- reload the page and confirm the imported custom theme persists locally
## Rollout notes
This feature is intentionally small. If users later ask for multiple imported themes, rename, export, or cross-device sync, treat that as a follow-on design. Do not pre-build a theme library abstraction in this implementation.

View File

@@ -54,7 +54,7 @@ Notes:
- Header: connection URL, current agent, current session.
- Chat log: user messages, assistant replies, system notices, tool cards.
- Status line: connection/run state (connecting, running, streaming, idle, error).
- Footer: connection state + agent + session + model + goal state + think/fast/verbose/trace/reasoning + token counts + deliver.
- Footer: agent + session + model + goal state + think/fast/verbose/trace/reasoning + token counts + deliver. When `tui.footer.showRemoteHost` is enabled, remote Gateway connections also show the connection host.
- Input: text editor with autocomplete.
## Mental model: agents + sessions
@@ -68,6 +68,14 @@ Notes:
- `per-sender` (default): each agent has many sessions.
- `global`: the TUI always uses the `global` session (the picker may be empty).
- The current agent + session are always visible in the footer.
- To show the Gateway host for non-local URL-backed connections, opt in with:
```bash
openclaw config set tui.footer.showRemoteHost true
```
Loopback and embedded local connections never show a host label.
- If the session has a [goal](/tools/goal), the footer shows its compact state
such as `Pursuing goal`, `Goal paused (/goal resume)`, or
`Goal achieved`.

View File

@@ -1548,9 +1548,9 @@
}
},
"node_modules/hono": {
"version": "4.12.18",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
"version": "4.12.21",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz",
"integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"

View File

@@ -101,8 +101,10 @@ describe("chutes implicit provider auth mode", () => {
const chutesCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes("chutes.ai"));
expect(chutesCalls.length).toBeGreaterThan(0);
const request = chutesCalls[0]?.[1] as { headers?: Record<string, string> } | undefined;
expect(request?.headers?.Authorization).toBe("Bearer my-chutes-access-token");
const request = chutesCalls[0]?.[1] as { headers?: HeadersInit } | undefined;
expect(new Headers(request?.headers).get("authorization")).toBe(
"Bearer my-chutes-access-token",
);
});
});
});

View File

@@ -3,6 +3,8 @@
*/
import type {
AgentHarness,
AgentHarnessCompactParams,
AgentHarnessCompactResult,
ContextEngineHostCapability,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type {
@@ -25,6 +27,12 @@ const CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES = [
/** Public model-listing types exposed for Codex app-server catalog callers. */
export type { CodexAppServerListModelsOptions, CodexAppServerModel, CodexAppServerModelListResult };
type CodexAppServerAgentHarness = AgentHarness & {
compactAfterContextEngine?(
params: AgentHarnessCompactParams,
): Promise<AgentHarnessCompactResult | undefined>;
};
/**
* Creates the Codex app-server harness used for attempts, side questions,
* compaction, reset, and disposal.
@@ -41,7 +49,7 @@ export function createCodexAppServerAgentHarness(options?: {
id.trim().toLowerCase(),
),
);
return {
const harness: CodexAppServerAgentHarness = {
id: options?.id ?? "codex",
label: options?.label ?? "Codex agent harness",
contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES,
@@ -80,6 +88,13 @@ export function createCodexAppServerAgentHarness(options?: {
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
});
},
compactAfterContextEngine: async (params) => {
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
return maybeCompactCodexAppServerSession(params, {
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
allowNonManualNativeRequest: true,
});
},
reset: async (params) => {
if (params.sessionFile) {
const { clearCodexAppServerBinding } = await import("./src/app-server/session-binding.js");
@@ -92,4 +107,5 @@ export function createCodexAppServerAgentHarness(options?: {
await clearSharedCodexAppServerClientAndWait();
},
};
return harness;
}

View File

@@ -8,16 +8,16 @@
"name": "@openclaw/codex",
"version": "2026.6.2",
"dependencies": {
"@openai/codex": "0.137.0",
"@openai/codex": "0.139.0",
"typebox": "1.1.39",
"ws": "8.21.0",
"zod": "4.4.3"
}
},
"node_modules/@openai/codex": {
"version": "0.137.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0.tgz",
"integrity": "sha512-1jUsCnzDBwv7Z4VFZajIlsz41fC18qg6d5qK4PEZhiUk0zJHS90/uGBA70aQPUJLTUZShvyKVAANjw6J/D9eYQ==",
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0.tgz",
"integrity": "sha512-wr2fRE+fzW0CjEbfFsLh1ftarVEcw0CMLWS7QyA0nyOz5qacQPVq3cq2+/U7oEbwm1TOqoi0Fm1nxniB5FkpmA==",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
@@ -26,19 +26,19 @@
"node": ">=16"
},
"optionalDependencies": {
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.137.0-darwin-arm64",
"@openai/codex-darwin-x64": "npm:@openai/codex@0.137.0-darwin-x64",
"@openai/codex-linux-arm64": "npm:@openai/codex@0.137.0-linux-arm64",
"@openai/codex-linux-x64": "npm:@openai/codex@0.137.0-linux-x64",
"@openai/codex-win32-arm64": "npm:@openai/codex@0.137.0-win32-arm64",
"@openai/codex-win32-x64": "npm:@openai/codex@0.137.0-win32-x64"
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.139.0-darwin-arm64",
"@openai/codex-darwin-x64": "npm:@openai/codex@0.139.0-darwin-x64",
"@openai/codex-linux-arm64": "npm:@openai/codex@0.139.0-linux-arm64",
"@openai/codex-linux-x64": "npm:@openai/codex@0.139.0-linux-x64",
"@openai/codex-win32-arm64": "npm:@openai/codex@0.139.0-win32-arm64",
"@openai/codex-win32-x64": "npm:@openai/codex@0.139.0-win32-x64"
}
},
"node_modules/@openai/codex-darwin-arm64": {
"name": "@openai/codex",
"version": "0.137.0-darwin-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-darwin-arm64.tgz",
"integrity": "sha512-YjKmre7DlKslQVhSfocHscgxntZKaZc1LQySKh7q+hNL8jdK+c8nSWSePi583yKFNIxZ8Z/zCkewtjFNvOpQiQ==",
"version": "0.139.0-darwin-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0-darwin-arm64.tgz",
"integrity": "sha512-o+0ZKWwgDFMMLO7rwinzO0PQsgK+Vme1pMN2GeAxsX29ZgGZcyPICfpJbeGSUO1mb2a36Skjx6nfdRnxMY0r7w==",
"cpu": [
"arm64"
],
@@ -53,9 +53,9 @@
},
"node_modules/@openai/codex-darwin-x64": {
"name": "@openai/codex",
"version": "0.137.0-darwin-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-darwin-x64.tgz",
"integrity": "sha512-zjzrFV80LZby9et44dan82e3cwUd46U7u1LSVXTIz5AUcY4y1KZpAeN6cSLVKMZuOHXTDpi15MUQdRwzdeqIOg==",
"version": "0.139.0-darwin-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0-darwin-x64.tgz",
"integrity": "sha512-9gkBWzu6DB2rqU4DbpxD3DE5bofGpsK46Lp0h0I+bKWc2IIcxvSi8K2utKmBLoJCbKrn4JQu7dFNGRqEfENung==",
"cpu": [
"x64"
],
@@ -70,9 +70,9 @@
},
"node_modules/@openai/codex-linux-arm64": {
"name": "@openai/codex",
"version": "0.137.0-linux-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-linux-arm64.tgz",
"integrity": "sha512-R3ZZymQQA1qpp6OpowN49XJ4scHwSckq7CjVvgmLv3bIs3X+F0XXK3xPFkC9vs2mX3wPekPi3ONpxx+yPAsJ6Q==",
"version": "0.139.0-linux-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0-linux-arm64.tgz",
"integrity": "sha512-tBQE5lZciRHeWZGuURgjP9S717MvTIpQMc593+DNxY2LQxozkngOkzFSQd1+/UmQKGrCqdFLu5irIwPXpSZyEw==",
"cpu": [
"arm64"
],
@@ -87,9 +87,9 @@
},
"node_modules/@openai/codex-linux-x64": {
"name": "@openai/codex",
"version": "0.137.0-linux-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-linux-x64.tgz",
"integrity": "sha512-n+26MUj8rekbEDUeYTGoD6HXuGS0MmLHn2LOn0i5qTNYIJvXV82B7cCLSTzVKF/RJxRMRl22se9Q0Z035JIVng==",
"version": "0.139.0-linux-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0-linux-x64.tgz",
"integrity": "sha512-14UgzDS+X4crkvdt6S02A/ZZOrS8ZyWiuTRpguCtnhNamb7unSuDxy86BWgpAl3sqiTaN2CP8VLyp2ohQ8Nbzw==",
"cpu": [
"x64"
],
@@ -104,9 +104,9 @@
},
"node_modules/@openai/codex-win32-arm64": {
"name": "@openai/codex",
"version": "0.137.0-win32-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-win32-arm64.tgz",
"integrity": "sha512-Cofktt213TycdQ/v+nAUuwXUBzjMWfA/ZkXyqefyXxDgw0TMtaiM3cgDna3I8YdXnR0PM9AMbx4t7VloJ3ZZYQ==",
"version": "0.139.0-win32-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0-win32-arm64.tgz",
"integrity": "sha512-nlwRjsYotH1Rtqu/Q0VwQbIeO2UX1mkHK84Ov9qn/hl29QqqoBtno0tRyqIPbkXFIVQuWiAYXlV3ugLwH5fTrQ==",
"cpu": [
"arm64"
],
@@ -121,9 +121,9 @@
},
"node_modules/@openai/codex-win32-x64": {
"name": "@openai/codex",
"version": "0.137.0-win32-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-win32-x64.tgz",
"integrity": "sha512-g9qZ9ERrm5OWXMWJOgojYv1kOc5jajTKq37PBMSe56aJfAr9Jk/qBvIOy7LKq3rABdXuz8k+W65PIt2E1hXilw==",
"version": "0.139.0-win32-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0-win32-x64.tgz",
"integrity": "sha512-lQrVLNz+90wdvWVNFDvCkHQRiAK9ZllmkTka3c8eqSDqdJk35Gpgppfv9Xtw5M2ZBtTq0sBdWBiCMyzGDBSpmQ==",
"cpu": [
"x64"
],

View File

@@ -8,7 +8,7 @@
},
"type": "module",
"dependencies": {
"@openai/codex": "0.137.0",
"@openai/codex": "0.139.0",
"typebox": "1.1.39",
"ws": "8.21.0",
"zod": "4.4.3"

View File

@@ -362,6 +362,101 @@ describe("codex provider", () => {
});
});
it("fetches usage from native Codex app-server rate limits for synthetic auth", async () => {
const readRateLimits = vi.fn(async () => ({
rateLimitsByLimitId: {
codex: {
limitId: "codex",
primary: {
usedPercent: 9,
windowDurationMins: 300,
resetsAt: 1_700_003_600,
},
},
},
}));
const provider = buildCodexProvider({ readRateLimits });
await expect(
provider.fetchUsageSnapshot?.({
provider: "openai",
token: "codex-app-server",
timeoutMs: 3500,
config: {},
env: {},
fetchFn: fetch,
} as never),
).resolves.toEqual({
provider: "openai",
displayName: "OpenAI",
windows: [{ label: "5h", usedPercent: 9, resetAt: 1_700_003_600_000 }],
plan: undefined,
});
expect(readRateLimits).toHaveBeenCalledWith({
timeoutMs: 3500,
agentDir: undefined,
config: {},
startOptions: expect.objectContaining({
command: "codex",
commandSource: "managed",
}),
});
});
it("keeps synthetic usage rate-limit reads on the configured Codex auth bridge", async () => {
const requestCodexAppServerJson = vi.fn(async (_params: unknown) => ({
rateLimitsByLimitId: {},
}));
vi.doMock("./src/app-server/request.js", () => ({
requestCodexAppServerJson,
}));
try {
const provider = buildCodexProvider();
await provider.fetchUsageSnapshot?.({
provider: "openai",
token: "codex-app-server",
authProfileId: "openai:work",
timeoutMs: 3500,
config: {
plugins: {
entries: {
codex: {
config: TEST_CODEX_APP_SERVER_CONFIG,
},
},
},
},
env: {},
fetchFn: fetch,
} as never);
expect(requestCodexAppServerJson).toHaveBeenCalledWith({
method: "account/rateLimits/read",
timeoutMs: 3500,
agentDir: undefined,
authProfileId: "openai:work",
config: {
plugins: {
entries: {
codex: {
config: TEST_CODEX_APP_SERVER_CONFIG,
},
},
},
},
startOptions: expect.objectContaining({
command: "/tmp/openclaw-test-codex",
commandSource: "config",
args: ["app-server", "--listen", "stdio://"],
}),
isolated: true,
});
} finally {
vi.doUnmock("./src/app-server/request.js");
}
});
it("exposes a setup auth choice for installing Codex as an external provider", async () => {
const provider = buildCodexProvider();

View File

@@ -27,6 +27,7 @@ import type {
CodexAppServerModel,
CodexAppServerModelListResult,
} from "./src/app-server/models.js";
import { buildCodexAppServerUsageSnapshot } from "./src/app-server/rate-limits.js";
const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
const LIVE_DISCOVERY_ENV = "OPENCLAW_CODEX_DISCOVERY_LIVE";
@@ -43,9 +44,18 @@ type CodexModelLister = (options: {
sharedClient?: boolean;
}) => Promise<CodexAppServerModelListResult>;
type CodexRateLimitReader = (options: {
timeoutMs: number;
agentDir?: string;
authProfileId?: string;
config?: Parameters<typeof requestCodexAppServerRateLimitsLazy>[0]["config"];
startOptions?: CodexAppServerStartOptions;
}) => Promise<unknown>;
type BuildCodexProviderOptions = {
pluginConfig?: unknown;
listModels?: CodexModelLister;
readRateLimits?: CodexRateLimitReader;
};
type BuildCatalogOptions = {
@@ -107,6 +117,22 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro
source: "codex-app-server",
mode: "token",
}),
fetchUsageSnapshot: async (ctx) => {
if (ctx.token !== CODEX_APP_SERVER_AUTH_MARKER) {
return null;
}
const runtimePluginConfig = resolvePluginConfigObject(ctx.config, CODEX_PROVIDER_ID);
const pluginConfig = runtimePluginConfig ?? (ctx.config ? undefined : options.pluginConfig);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const rateLimits = await (options.readRateLimits ?? requestCodexAppServerRateLimitsLazy)({
timeoutMs: ctx.timeoutMs,
agentDir: ctx.agentDir,
...(ctx.authProfileId ? { authProfileId: ctx.authProfileId } : {}),
config: ctx.config,
startOptions: appServer.start,
});
return buildCodexAppServerUsageSnapshot(rateLimits);
},
resolveThinkingProfile: ({ modelId }) => ({
levels: [
{ id: "off" },
@@ -210,6 +236,27 @@ async function listCodexAppServerModelsLazy(options: {
return listCodexAppServerModels(options);
}
async function requestCodexAppServerRateLimitsLazy(options: {
timeoutMs: number;
agentDir?: string;
authProfileId?: string;
config?: Parameters<
typeof import("./src/app-server/request.js").requestCodexAppServerJson
>[0]["config"];
startOptions?: CodexAppServerStartOptions;
}): Promise<unknown> {
const { requestCodexAppServerJson } = await import("./src/app-server/request.js");
return await requestCodexAppServerJson({
method: "account/rateLimits/read",
timeoutMs: options.timeoutMs,
agentDir: options.agentDir,
...(options.authProfileId ? { authProfileId: options.authProfileId } : {}),
config: options.config,
startOptions: options.startOptions,
isolated: true,
});
}
function normalizeTimeoutMs(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) && value > 0
? value

View File

@@ -1,6 +1,9 @@
// Codex tests cover app server policy plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveCodexAppServerForOpenClawToolPolicy } from "./app-server-policy.js";
import {
resolveCodexAppServerForModelProvider,
resolveCodexAppServerForOpenClawToolPolicy,
} from "./app-server-policy.js";
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
describe("Codex app-server policy", () => {
@@ -66,4 +69,143 @@ describe("Codex app-server policy", () => {
expect(explicitEnv.approvalPolicy).toBe("never");
expect(explicitRequirements.approvalPolicy).toBe("never");
});
it("keeps model-backed reviewers for explicit OpenAI model providers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
modelProvider: "openai",
});
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "codex",
model: "openai/gpt-5.5",
}).approvalsReviewer,
).toBe("auto_review");
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "codex",
model: "gpt-5.5",
}).approvalsReviewer,
).toBe("user");
expect(
resolveCodexAppServerForModelProvider({ appServer, provider: "openai" }).approvalsReviewer,
).toBe("auto_review");
});
it("uses human approval for OpenAI-compatible custom endpoints", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
modelProvider: "openai",
model: "gpt-5.5",
config: {
models: {
providers: {
openai: {
baseUrl: "http://localhost:8080/v1",
models: [],
},
},
},
},
});
expect(appServer.approvalsReviewer).toBe("user");
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "openai",
model: "gpt-5.5",
config: {
models: {
providers: {
openai: {
baseUrl: "http://localhost:8080/v1",
models: [],
},
},
},
},
}).approvalsReviewer,
).toBe("user");
});
it("uses human approval instead of Codex Guardian for custom model providers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
modelProvider: "openai",
});
const resolved = resolveCodexAppServerForModelProvider({
appServer,
provider: "lmstudio",
});
const vendorPrefixedModel = resolveCodexAppServerForModelProvider({
appServer,
provider: "openrouter",
model: "openai/gpt-5.5",
});
expect(appServer.approvalsReviewer).toBe("auto_review");
expect(resolved.approvalPolicy).toBe("on-request");
expect(resolved.sandbox).toBe("workspace-write");
expect(resolved.approvalsReviewer).toBe("user");
expect(vendorPrefixedModel.approvalsReviewer).toBe("user");
});
it("infers custom providers from provider-qualified model refs", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
});
expect(
resolveCodexAppServerForModelProvider({
appServer,
model: "lmstudio/local-model",
}).approvalsReviewer,
).toBe("user");
});
it("uses provider-qualified model refs to override broad native provider wrappers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
});
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "codex",
model: "lmstudio/local-model",
}).approvalsReviewer,
).toBe("user");
});
it("downgrades legacy guardian_subagent for custom model providers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
pluginConfig: {
appServer: {
mode: "guardian",
approvalsReviewer: "guardian_subagent",
},
},
});
expect(
resolveCodexAppServerForModelProvider({ appServer, provider: "local" }).approvalsReviewer,
).toBe("user");
});
});

View File

@@ -2,10 +2,11 @@
* Policy promotion for Codex app-server runs that can safely use OpenClaw tool
* approvals.
*/
import type {
CodexAppServerRuntimeOptions,
CodexPluginConfig,
OpenClawExecPolicyForCodexAppServer,
import {
canUseCodexModelBackedApprovalsReviewerForModel,
type CodexAppServerRuntimeOptions,
type CodexPluginConfig,
type OpenClawExecPolicyForCodexAppServer,
} from "./config.js";
/**
@@ -44,6 +45,35 @@ export function resolveCodexAppServerForOpenClawToolPolicy(params: {
};
}
export function resolveCodexAppServerForModelProvider(params: {
appServer: CodexAppServerRuntimeOptions;
provider?: string;
model?: string;
config?: Parameters<typeof canUseCodexModelBackedApprovalsReviewerForModel>[0]["config"];
env?: NodeJS.ProcessEnv;
agentDir?: string;
codexConfigToml?: string | null;
}): CodexAppServerRuntimeOptions {
const explicitProvider = normalizeModelBackedReviewerProvider(params.provider);
if (
!isCodexModelBackedApprovalsReviewer(params.appServer.approvalsReviewer) ||
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: explicitProvider,
model: params.model,
config: params.config,
env: params.env,
agentDir: params.agentDir,
codexConfigToml: params.codexConfigToml,
})
) {
return params.appServer;
}
return {
...params.appServer,
approvalsReviewer: "user",
};
}
function isCodexAppServerPolicyMode(value: unknown): boolean {
return value === "guardian" || value === "yolo";
}
@@ -53,3 +83,12 @@ function isCodexAppServerApprovalPolicy(value: unknown): boolean {
value === "never" || value === "on-request" || value === "on-failure" || value === "untrusted"
);
}
function isCodexModelBackedApprovalsReviewer(value: string): boolean {
return value === "auto_review" || value === "guardian_subagent";
}
function normalizeModelBackedReviewerProvider(provider: string | undefined): string | undefined {
const normalized = provider?.trim().toLowerCase();
return normalized || undefined;
}

View File

@@ -1,4 +1,8 @@
// Codex tests cover approval bridge plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { reviewExecRequestWithConfiguredModel } from "openclaw/plugin-sdk/agent-harness-exec-review-runtime";
import {
callGatewayTool,
hasNativeHookRelayInvocation,
@@ -22,12 +26,20 @@ vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => (
})),
}));
vi.mock("openclaw/plugin-sdk/agent-harness-exec-review-runtime", async (importOriginal) => ({
...(await importOriginal<
typeof import("openclaw/plugin-sdk/agent-harness-exec-review-runtime")
>()),
reviewExecRequestWithConfiguredModel: vi.fn(),
}));
const mockCallGatewayTool = vi.mocked(callGatewayTool);
const mockHasNativeHookRelayInvocation = vi.mocked(hasNativeHookRelayInvocation);
const mockInvokeNativeHookRelay = vi.mocked(invokeNativeHookRelay);
const mockResolveNativeHookRelayDeferredToolApproval = vi.mocked(
resolveNativeHookRelayDeferredToolApproval,
);
const mockReviewExecRequestWithConfiguredModel = vi.mocked(reviewExecRequestWithConfiguredModel);
const mockRunBeforeToolCallHook = vi.mocked(runBeforeToolCallHook);
function requireRecord(value: unknown, label: string): Record<string, unknown> {
@@ -111,6 +123,12 @@ describe("Codex app-server approval bridge", () => {
mockInvokeNativeHookRelay.mockReset();
mockResolveNativeHookRelayDeferredToolApproval.mockReset();
mockResolveNativeHookRelayDeferredToolApproval.mockResolvedValue(undefined);
mockReviewExecRequestWithConfiguredModel.mockReset();
mockReviewExecRequestWithConfiguredModel.mockResolvedValue({
decision: "ask",
rationale: "test reviewer asks for approval",
risk: "unknown",
});
mockRunBeforeToolCallHook.mockReset();
mockRunBeforeToolCallHook.mockImplementation(async ({ params }) => ({
blocked: false,
@@ -232,6 +250,920 @@ describe("Codex app-server approval bridge", () => {
findApprovalEvent(params, { status: "approved", approvalId: "plugin:approval-1" });
});
it("uses the configured OpenClaw exec auto-review model before plugin approvals", async () => {
const params = createParams();
params.workspaceDir = "/workspace";
params.config = {
tools: {
exec: {
mode: "auto",
reviewer: {
model: "openai/gpt-5.5-mini",
timeoutMs: 12_000,
},
},
},
} as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "allow-once",
rationale: "read-only version check",
risk: "low",
});
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review",
command: "node --version",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
execReviewerAgentId: "main",
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "accept" });
expect(mockCallGatewayTool).not.toHaveBeenCalled();
expect(mockReviewExecRequestWithConfiguredModel).toHaveBeenCalledWith({
cfg: params.config,
agentId: "main",
reviewer: {
model: "openai/gpt-5.5-mini",
timeoutMs: 12_000,
},
input: {
command: "node --version",
argv: ["node", "--version"],
cwd: "/workspace",
envKeys: undefined,
host: "codex-app-server",
reason: "approval-required",
analysis: {
parsed: true,
allowlistMatched: false,
inlineEval: false,
},
agent: {
id: "main",
sessionKey: "agent:main:session-1",
},
},
});
findApprovalEvent(params, {
status: "approved",
message:
"Codex app-server command approval granted by OpenClaw exec auto-reviewer: read-only version check",
});
});
it("falls back to plugin approval when no exec auto-review model is configured", async () => {
const params = createParams();
params.config = {
tools: {
exec: {
mode: "auto",
},
},
} as EmbeddedRunAttemptParams["config"];
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-no-reviewer", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-no-reviewer", decision: "allow-once" });
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-missing",
command: "node --version",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "accept" });
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
});
it("falls back to plugin approval for managed-network command approvals", async () => {
const params = createParams();
params.config = {
tools: {
exec: {
mode: "auto",
reviewer: {
model: "openai/gpt-5.5-mini",
},
},
},
} as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "allow-once",
rationale: "network request looks fine",
risk: "low",
});
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-network", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-network", decision: "allow-once" });
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-network",
command: "curl https://example.test",
networkApprovalContext: {
host: "example.test",
port: 443,
protocol: "https",
},
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "accept" });
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
});
it.each(["lmstudio/local-model", "local-model"])(
"falls back to plugin approval for unsafe exec auto-review model %s",
async (model) => {
const params = createParams();
params.config = {
tools: {
exec: {
mode: "auto",
reviewer: {
model,
},
},
},
} as unknown as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "allow-once",
rationale: "unsafe self review",
risk: "low",
});
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-local-reviewer", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-local-reviewer", decision: "allow-once" });
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-local",
command: "node --version",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "accept" });
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
},
);
it.each([
{
name: "provider base URL",
reviewerModel: "openai/gpt-5.5-mini",
models: {
providers: {
openai: {
baseUrl: "http://127.0.0.1:11434/v1",
models: [],
},
},
},
},
{
name: "provider key casing with custom base URL",
reviewerModel: "openai/gpt-5.5-mini",
models: {
providers: {
OpenAI: {
baseUrl: "http://localhost:8080/v1",
models: [],
},
},
},
},
{
name: "provider local service",
reviewerModel: "openai/gpt-5.5-mini",
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
localService: { command: "local-openai-compatible" },
models: [],
},
},
},
},
{
name: "model base URL",
reviewerModel: "openai/gpt-5.5-mini@work",
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [
{
id: "gpt-5.5-mini",
name: "Local GPT-compatible reviewer",
baseUrl: "http://localhost:8080/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8_192,
},
],
},
},
},
},
])(
"falls back to plugin approval for OpenAI reviewer with custom $name",
async ({ models, reviewerModel }) => {
const params = createParams();
params.config = {
tools: {
exec: {
mode: "auto",
reviewer: {
model: reviewerModel,
},
},
},
models,
} as unknown as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "allow-once",
rationale: "custom endpoint reviewer",
risk: "low",
});
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-custom-openai", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-custom-openai", decision: "allow-once" });
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-custom-openai",
command: "node --version",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "accept" });
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
},
);
it("falls back to plugin approval when an OpenAI-looking reviewer is a configured model alias", async () => {
const params = createParams();
params.config = {
agents: {
defaults: {
models: {
"lmstudio/local-reviewer": {
alias: "OpenAI/Reviewer",
},
},
},
},
tools: {
exec: {
mode: "auto",
reviewer: {
model: "openai/reviewer@work",
},
},
},
} as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "allow-once",
rationale: "aliased local reviewer",
risk: "low",
});
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-aliased-openai", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-aliased-openai", decision: "allow-once" });
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-aliased-openai",
command: "node --version",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "accept" });
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
});
it("keeps exec auto-review when only an agent-specific alias matches the OpenAI reviewer", async () => {
const params = createParams();
params.config = {
agents: {
list: [
{
id: "sidecar",
models: {
"lmstudio/local-reviewer": {
alias: "openai/gpt-5.5-mini",
},
},
},
],
},
tools: {
exec: {
mode: "auto",
reviewer: {
model: "openai/gpt-5.5-mini@work",
},
},
},
} as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "allow-once",
rationale: "real OpenAI reviewer",
risk: "low",
});
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-agent-alias",
command: "node --version",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
execReviewerAgentId: "main",
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "accept" });
expect(mockReviewExecRequestWithConfiguredModel).toHaveBeenCalledWith(
expect.objectContaining({
cfg: params.config,
agentId: "main",
reviewer: {
model: "openai/gpt-5.5-mini@work",
},
}),
);
expect(mockCallGatewayTool).not.toHaveBeenCalled();
});
it("falls back to plugin approval when OpenAI reviewer uses a custom environment base URL", async () => {
const params = createParams();
vi.stubEnv("OPENAI_BASE_URL", "http://127.0.0.1:11434/v1");
params.config = {
tools: {
exec: {
mode: "auto",
reviewer: {
model: "openai/gpt-5.5-mini",
},
},
},
} as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "allow-once",
rationale: "custom env endpoint reviewer",
risk: "low",
});
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-env-openai", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-env-openai", decision: "allow-once" });
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-env-openai",
command: "node --version",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "accept" });
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
});
it("falls back to plugin approval when Codex native OpenAI config uses a local base URL", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-approval-"));
try {
await fs.mkdir(path.join(tempDir, "codex-home"), { recursive: true });
await fs.writeFile(
path.join(tempDir, "codex-home", "config.toml"),
'openai_base_url = "http://127.0.0.1:11434/v1"\n',
);
const params = createParams();
params.agentDir = tempDir;
params.config = {
tools: {
exec: {
mode: "auto",
reviewer: {
model: "openai/gpt-5.5-mini",
},
},
},
} as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "allow-once",
rationale: "custom native endpoint reviewer",
risk: "low",
});
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-native-openai", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-native-openai", decision: "allow-once" });
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-native-openai",
command: "node --version",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "accept" });
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("keeps permission amendment command approvals on the plugin approval route", async () => {
const params = createParams();
params.config = {
tools: {
exec: {
mode: "auto",
reviewer: {
model: "openai/gpt-5.5-mini",
},
},
},
} as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "allow-once",
rationale: "safe command",
risk: "low",
});
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-amendment", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-amendment", decision: "allow-once" });
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-amendment",
command: "node --version",
additionalPermissions: {
network: {
allowHosts: ["example.com"],
},
},
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "accept" });
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
expect(gatewayRequestPayload().description).toContain("Additional permissions: network");
});
it("keeps object-shaped execpolicy amendment command approvals on the plugin approval route", async () => {
const params = createParams();
params.config = {
tools: {
exec: {
mode: "auto",
reviewer: {
model: "openai/gpt-5.5-mini",
},
},
},
} as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "allow-once",
rationale: "safe command",
risk: "low",
});
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-execpolicy-object", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-execpolicy-object", decision: "allow-always" });
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-execpolicy-object",
command: "node --version",
proposedExecpolicyAmendment: {
permissions: [{ permission: "allow", command: ["node"] }],
},
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "acceptForSession" });
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
});
it("keeps unbound shell command approvals on the plugin approval route", async () => {
const params = createParams();
params.config = {
tools: {
exec: {
mode: "auto",
reviewer: {
model: "openai/gpt-5.5-mini",
},
},
},
} as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "allow-once",
rationale: "safe command",
risk: "low",
});
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-unbound", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-unbound", decision: "allow-once" });
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-unbound",
command: "node --version && echo ok",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "accept" });
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
});
it.each([
"/approve abc123 allow-once",
"bash -lc '/approve abc123 allow-once'",
"openclaw channels login --channel whatsapp",
"sudo -EH bash -lc 'openclaw channels login --channel whatsapp'",
])("keeps unsafe control command approvals on the plugin approval route: %s", async (command) => {
const params = createParams();
params.config = {
tools: {
exec: {
mode: "auto",
reviewer: {
model: "openai/gpt-5.5-mini",
},
},
},
} as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "allow-once",
rationale: "unsafe control command",
risk: "low",
});
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-control-command", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-control-command", decision: "allow-once" });
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-control-command",
command,
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "accept" });
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
});
it("keeps security audit suppression edits on the plugin approval route", async () => {
const params = createParams();
params.config = {
tools: {
exec: {
mode: "auto",
reviewer: {
model: "openai/gpt-5.5-mini",
},
},
},
} as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "allow-once",
rationale: "safe command",
risk: "low",
});
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-security-suppression", status: "accepted" })
.mockResolvedValueOnce({
id: "plugin:approval-security-suppression",
decision: "allow-once",
});
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-security-suppression",
command: "openclaw config set security.audit.suppressions '[]'",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "accept" });
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
});
it("keeps amendment-only decision command approvals on the plugin approval route", async () => {
const params = createParams();
params.config = {
tools: {
exec: {
mode: "auto",
reviewer: {
model: "openai/gpt-5.5-mini",
},
},
},
} as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "allow-once",
rationale: "safe command",
risk: "low",
});
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-amendment-only", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-amendment-only", decision: "allow-always" });
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-amendment-only",
command: "node --version",
availableDecisions: [
{
acceptWithExecpolicyAmendment: {
patterns: ["node"],
},
},
],
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
});
expect(result).toEqual({
decision: {
acceptWithExecpolicyAmendment: {
patterns: ["node"],
},
},
});
expect(mockReviewExecRequestWithConfiguredModel).not.toHaveBeenCalled();
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
});
it("falls back to plugin approval when the exec auto-review model asks", async () => {
const params = createParams();
params.config = {
tools: {
exec: {
mode: "auto",
reviewer: {
model: { primary: "openai/gpt-5.5-mini" },
},
},
},
} as EmbeddedRunAttemptParams["config"];
mockReviewExecRequestWithConfiguredModel.mockResolvedValueOnce({
decision: "ask",
rationale: "needs human review",
risk: "medium",
});
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-reviewer-ask", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-reviewer-ask", decision: "allow-once" });
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-ask",
command: "git status",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
});
expect(result).toEqual({ decision: "accept" });
expect(mockReviewExecRequestWithConfiguredModel).toHaveBeenCalledTimes(1);
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
});
it("cancels command approvals when the run aborts during exec auto-review", async () => {
const params = createParams();
params.config = {
tools: {
exec: {
mode: "auto",
reviewer: {
model: "openai/gpt-5.5-mini",
},
},
},
} as EmbeddedRunAttemptParams["config"];
const abortController = new AbortController();
mockReviewExecRequestWithConfiguredModel.mockImplementationOnce(
() =>
new Promise((resolve) => {
setTimeout(
() =>
resolve({
decision: "allow-once",
rationale: "late allow",
risk: "low",
}),
50,
);
}),
);
const resultPromise = handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-auto-review-abort",
command: "node --version",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
execPolicy: { mode: "auto" },
internalExecAutoReview: true,
signal: abortController.signal,
});
abortController.abort(new Error("run stopped"));
await expect(resultPromise).resolves.toEqual({ decision: "cancel" });
expect(mockCallGatewayTool).not.toHaveBeenCalled();
findApprovalEvent(params, {
status: "failed",
message: "Codex app-server approval cancelled because the run stopped.",
});
});
it("normalizes prefixed channel targets for OpenClaw tool policy context", async () => {
const params = createParams();
params.messageChannel = "telegram";

View File

@@ -1,3 +1,7 @@
import {
buildExecAutoReviewInputForShellCommand,
reviewExecRequestWithConfiguredModel,
} from "openclaw/plugin-sdk/agent-harness-exec-review-runtime";
/**
* Bridges Codex app-server approval requests into OpenClaw policy hooks and
* plugin approval UX.
@@ -14,8 +18,13 @@ import {
type NativeHookRelayRegistrationHandle,
runBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
import { formatCodexDisplayText } from "../command-formatters.js";
import {
isTrustedCodexModelBackedOpenAIProvider,
type OpenClawExecPolicyForCodexAppServer,
} from "./config.js";
import {
approvalRequestExplicitlyUnavailable,
mapExecDecisionToOutcome,
@@ -72,6 +81,9 @@ export async function handleCodexAppServerApprovalRequest(params: {
NativeHookRelayRegistrationHandle,
"allowedEvents" | "generation" | "relayId"
>;
execPolicy?: Pick<OpenClawExecPolicyForCodexAppServer, "mode">;
execReviewerAgentId?: string;
internalExecAutoReview?: boolean;
autoApprove?: boolean;
signal?: AbortSignal;
}): Promise<JsonValue | undefined> {
@@ -137,8 +149,29 @@ export async function handleCodexAppServerApprovalRequest(params: {
});
return buildApprovalResponse(params.method, context.requestParams, "approved-session");
}
// Native hook policy did not decide; fall back to the OpenClaw approval
// route so user-facing runs still get an approval prompt.
const autoReviewOutcome = await runInternalExecAutoReviewForApprovalRequest({
enabled: params.internalExecAutoReview === true && params.execPolicy?.mode === "auto",
method: params.method,
requestParams,
paramsForRun: params.paramsForRun,
context,
agentId: params.execReviewerAgentId,
signal: params.signal,
});
if (autoReviewOutcome?.outcome === "approved-once") {
emitApprovalEvent(params.paramsForRun, {
phase: "resolved",
kind: context.kind,
status: "approved",
title: context.title,
...context.eventDetails,
...approvalEventScope(params.method, autoReviewOutcome.outcome),
message: autoReviewOutcome.reason,
});
return buildApprovalResponse(params.method, context.requestParams, autoReviewOutcome.outcome);
}
// Native hook/model policy did not decide; fall back to the OpenClaw
// approval route so user-facing runs still get an approval prompt.
const requestResult = await requestPluginApproval({
paramsForRun: params.paramsForRun,
title: context.title,
@@ -344,6 +377,244 @@ type ApprovalPolicyOutcome =
| { outcome: "approved-once" | "approved-session" }
| { outcome: "no-decision" };
async function runInternalExecAutoReviewForApprovalRequest(params: {
enabled: boolean;
method: string;
requestParams: JsonObject | undefined;
paramsForRun: EmbeddedRunAttemptParams;
context: ApprovalContext;
agentId?: string;
signal?: AbortSignal;
}): Promise<{ outcome: "approved-once"; reason: string } | undefined> {
if (!params.enabled || params.method !== "item/commandExecution/requestApproval") {
return undefined;
}
if (hasCommandApprovalCapabilityAmendments(params.requestParams)) {
return undefined;
}
const input = await buildAppServerExecAutoReviewInput({
requestParams: params.requestParams,
paramsForRun: params.paramsForRun,
});
if (!input) {
return undefined;
}
const reviewerConfig = resolveExecReviewerConfig(params.paramsForRun, params.agentId);
if (
!canUseInternalExecAutoReviewReviewer(
reviewerConfig,
params.paramsForRun.config,
process.env,
params.paramsForRun.agentDir,
)
) {
return undefined;
}
const decision = await waitForInternalExecAutoReviewDecision({
signal: params.signal,
promise: reviewExecRequestWithConfiguredModel({
cfg: params.paramsForRun.config,
agentId: params.agentId ?? params.paramsForRun.agentId,
reviewer: reviewerConfig,
input,
}),
});
if (decision.decision !== "allow-once") {
return undefined;
}
return {
outcome: "approved-once",
reason: `Codex app-server command approval granted by OpenClaw exec auto-reviewer: ${formatCodexDisplayText(
decision.rationale,
)}`,
};
}
async function waitForInternalExecAutoReviewDecision(params: {
signal?: AbortSignal;
promise: Promise<Awaited<ReturnType<typeof reviewExecRequestWithConfiguredModel>>>;
}): Promise<Awaited<ReturnType<typeof reviewExecRequestWithConfiguredModel>>> {
if (!params.signal) {
return params.promise;
}
if (params.signal.aborted) {
throw toCodexAppServerApprovalCancellationError(params.signal.reason);
}
let onAbort: (() => void) | undefined;
const abortPromise = new Promise<never>((_, reject) => {
onAbort = () => reject(toCodexAppServerApprovalCancellationError(params.signal?.reason));
params.signal?.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([params.promise, abortPromise]);
} finally {
if (onAbort) {
params.signal.removeEventListener("abort", onAbort);
}
}
}
function toCodexAppServerApprovalCancellationError(reason: unknown): Error {
if (reason instanceof Error) {
return reason;
}
return new Error(
typeof reason === "string" && reason.trim() ? reason : "Codex app-server approval cancelled.",
);
}
async function buildAppServerExecAutoReviewInput(params: {
requestParams: JsonObject | undefined;
paramsForRun: EmbeddedRunAttemptParams;
}) {
const command = readString(params.requestParams, "command");
if (!command) {
return undefined;
}
return buildExecAutoReviewInputForShellCommand({
command,
cwd: readString(params.requestParams, "cwd") ?? params.paramsForRun.workspaceDir ?? null,
host: "codex-app-server",
agent: {
id: params.paramsForRun.agentId ?? null,
sessionKey: params.paramsForRun.sessionKey ?? null,
},
});
}
function hasCommandApprovalCapabilityAmendments(requestParams: JsonObject | undefined): boolean {
return (
hasNonEmptyJsonObject(requestParams?.additionalPermissions) ||
hasNonEmptyJsonObject(requestParams?.networkApprovalContext) ||
hasNonEmptyJsonObject(requestParams?.proposedExecpolicyAmendment) ||
hasNonEmptyArray(requestParams?.proposedExecpolicyAmendment) ||
hasNonEmptyArray(requestParams?.proposedNetworkPolicyAmendments) ||
findAvailableCommandAmendmentDecision(requestParams) !== undefined ||
commandAcceptDecisionUnavailable(requestParams)
);
}
function commandAcceptDecisionUnavailable(requestParams: JsonObject | undefined): boolean {
const available = requestParams?.availableDecisions;
return Array.isArray(available) && !available.includes("accept");
}
function hasNonEmptyJsonObject(value: unknown): boolean {
return isJsonObject(value) && Object.keys(value).length > 0;
}
function hasNonEmptyArray(value: unknown): boolean {
return Array.isArray(value) && value.length > 0;
}
function resolveExecReviewerConfig(
params: EmbeddedRunAttemptParams,
agentId?: string,
): Record<string, unknown> | undefined {
const configRoot = readUnknownRecord(params.config);
const globalExec = readUnknownRecord(readUnknownRecord(configRoot?.tools)?.exec);
const agentExec = resolveAgentExecConfig(configRoot, agentId ?? params.agentId);
return readUnknownRecord(agentExec?.reviewer) ?? readUnknownRecord(globalExec?.reviewer);
}
function canUseInternalExecAutoReviewReviewer(
reviewerConfig: Record<string, unknown> | undefined,
config: EmbeddedRunAttemptParams["config"] | undefined,
env: NodeJS.ProcessEnv | undefined,
agentDir: string | undefined,
): boolean {
const model = readExecReviewerModelRef(reviewerConfig);
const slashIndex = model?.indexOf("/") ?? -1;
if (!model || slashIndex <= 0) {
return false;
}
if (configuredAgentModelAliasMatches(config, model)) {
return false;
}
const provider = model.slice(0, slashIndex).trim().toLowerCase();
if (provider !== "openai") {
return false;
}
return isTrustedCodexModelBackedOpenAIProvider({
config,
env,
agentDir,
model: model.slice(slashIndex + 1).trim(),
});
}
function readExecReviewerModelRef(
reviewerConfig: Record<string, unknown> | undefined,
): string | undefined {
const model = reviewerConfig?.model;
if (typeof model === "string") {
return model.trim() || undefined;
}
const primary = readUnknownRecord(model)?.primary;
return typeof primary === "string" && primary.trim() ? primary.trim() : undefined;
}
function configuredAgentModelAliasMatches(
config: EmbeddedRunAttemptParams["config"] | undefined,
modelRef: string,
): boolean {
const normalizedModelRef = normalizeExecReviewerAliasRef(modelRef);
const agents = readUnknownRecord(readUnknownRecord(config)?.agents);
return agentModelAliasMatches(readUnknownRecord(agents?.defaults), normalizedModelRef);
}
function agentModelAliasMatches(
agentConfig: Record<string, unknown> | undefined,
normalizedModelRef: string,
): boolean {
const models = readUnknownRecord(agentConfig?.models);
if (!models) {
return false;
}
for (const entry of Object.values(models)) {
const alias = readUnknownRecord(entry)?.alias;
if (typeof alias === "string" && normalizeExecReviewerAliasRef(alias) === normalizedModelRef) {
return true;
}
}
return false;
}
function normalizeExecReviewerAliasRef(modelRef: string): string {
const trimmed = modelRef.trim().toLowerCase();
const slashIndex = trimmed.indexOf("/");
const authProfileIndex = trimmed.indexOf("@", slashIndex + 1);
return authProfileIndex > 0 ? trimmed.slice(0, authProfileIndex) : trimmed;
}
function resolveAgentExecConfig(
configRoot: Record<string, unknown> | undefined,
agentId: string | undefined,
): Record<string, unknown> | undefined {
const normalizedAgentId = agentId ? normalizeAgentId(agentId) : undefined;
if (!normalizedAgentId) {
return undefined;
}
const agentList = readUnknownRecord(configRoot?.agents)?.list;
if (!Array.isArray(agentList)) {
return undefined;
}
for (const entry of agentList) {
const record = readUnknownRecord(entry);
if (typeof record?.id !== "string" || normalizeAgentId(record.id) !== normalizedAgentId) {
continue;
}
return readUnknownRecord(readUnknownRecord(record.tools)?.exec);
}
return undefined;
}
function readUnknownRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
async function runOpenClawToolPolicyForApprovalRequest(params: {
method: string;
requestParams: JsonObject | undefined;

View File

@@ -11,7 +11,11 @@ import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexAppServerClient } from "./client.js";
import { maybeCompactCodexAppServerSession as maybeCompactCodexAppServerSessionImpl } from "./compact.js";
import type { CodexServerNotification } from "./protocol.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import {
clearCodexAppServerBindingForThread,
readCodexAppServerBinding,
writeCodexAppServerBinding,
} from "./session-binding.js";
let tempDir: string;
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
@@ -95,6 +99,26 @@ function compactDetails(result: CompactResult): Record<string, unknown> {
return (result.result?.details ?? {}) as Record<string, unknown>;
}
async function flushAsyncTasks(iterations = 3): Promise<void> {
for (let index = 0; index < iterations; index += 1) {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
}
async function expectExternalMutationBlockedDuringNativeRequest(params: {
releaseExternalMutation: () => void;
isExternalMutationStarted: () => boolean;
isExternalMutationFinished: () => boolean;
}): Promise<Record<string, never>> {
params.releaseExternalMutation();
await flushAsyncTasks();
expect(params.isExternalMutationStarted()).toBe(true);
expect(params.isExternalMutationFinished()).toBe(false);
return {};
}
describe("maybeCompactCodexAppServerSession", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-compact-"));
@@ -156,6 +180,359 @@ describe("maybeCompactCodexAppServerSession", () => {
});
});
it("starts native app-server compaction for post-context-engine budget requests", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding({
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint: "policy-1",
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-1",
fingerprint: "fingerprint-1",
},
},
});
const result = requireCompactResult(
await maybeCompactCodexAppServerSession(
{
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "budget",
currentTokenCount: 456,
},
{ allowNonManualNativeRequest: true },
),
);
expect(fake.request).toHaveBeenCalledWith(
"thread/compact/start",
{ threadId: "thread-1" },
{ timeoutMs: 60_000 },
);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(result.reason).toBeUndefined();
expect(result.result?.tokensBefore).toBe(456);
expect(compactDetails(result)).toMatchObject({
backend: "codex-app-server",
threadId: "thread-1",
signal: "thread/compact/start",
pending: true,
request: "after_context_engine",
trigger: "budget",
});
expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({
threadId: "thread-1",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint: "policy-1",
},
});
expect(
(await readCodexAppServerBinding(sessionFile))?.contextEngine?.projection,
).toBeUndefined();
});
it("preserves projection when aborted before guarded native compaction", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const abortController = new AbortController();
abortController.abort("cancelled");
const sessionFile = await writeTestBinding({
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint: "policy-1",
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-1",
fingerprint: "fingerprint-1",
},
},
});
const result = requireCompactResult(
await maybeCompactCodexAppServerSession(
{
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "budget",
currentTokenCount: 456,
abortSignal: abortController.signal,
},
{ allowNonManualNativeRequest: true },
),
);
expect(fake.request).not.toHaveBeenCalled();
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(result.reason).toBe("codex app-server compaction aborted before native compaction");
expect(compactDetails(result)).toMatchObject({
backend: "codex-app-server",
skipped: true,
reason: "aborted_before_native_compaction",
request: "after_context_engine",
trigger: "budget",
expectedThreadId: "thread-1",
currentThreadId: "thread-1",
});
expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({
threadId: "thread-1",
contextEngine: {
projection: {
epoch: "epoch-1",
fingerprint: "fingerprint-1",
},
},
});
});
it("skips post-context-engine native compaction when the binding changes before projection clear", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const originalContextEngine = {
schemaVersion: 1 as const,
engineId: "lossless-claw",
policyFingerprint: "policy-1",
projection: {
schemaVersion: 1 as const,
mode: "thread_bootstrap" as const,
epoch: "epoch-1",
fingerprint: "fingerprint-1",
},
};
const sessionFile = await writeTestBinding({
contextEngine: originalContextEngine,
});
const actualReadFile = fs.readFile.bind(fs);
const readFileSpy = vi.spyOn(fs, "readFile");
let bindingReads = 0;
readFileSpy.mockImplementation(async (...args: Parameters<typeof fs.readFile>) => {
const result = await actualReadFile(...args);
const readPath =
typeof args[0] === "string"
? args[0]
: args[0] instanceof URL
? args[0].pathname
: Buffer.isBuffer(args[0])
? args[0].toString("utf8")
: "";
if (readPath.endsWith(".codex-app-server.json") && bindingReads++ === 0) {
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-2",
cwd: tempDir,
contextEngine: {
...originalContextEngine,
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-2",
fingerprint: "fingerprint-2",
},
},
});
}
return result;
});
try {
const result = requireCompactResult(
await maybeCompactCodexAppServerSession(
{
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "budget",
currentTokenCount: 456,
},
{ allowNonManualNativeRequest: true },
),
);
expect(fake.request).not.toHaveBeenCalled();
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(result.reason).toBe("codex app-server binding changed before native compaction");
expect(compactDetails(result)).toMatchObject({
backend: "codex-app-server",
skipped: true,
reason: "binding_changed_before_native_compaction",
request: "after_context_engine",
trigger: "budget",
expectedThreadId: "thread-1",
currentThreadId: "thread-2",
});
expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({
threadId: "thread-2",
contextEngine: {
projection: {
epoch: "epoch-2",
fingerprint: "fingerprint-2",
},
},
});
} finally {
readFileSpy.mockRestore();
}
});
it("blocks same-process binding writes until guarded native compaction starts", async () => {
let releaseExternalWrite!: () => void;
const externalWriteGate = new Promise<void>((resolve) => {
releaseExternalWrite = resolve;
});
let externalWriteStarted = false;
let externalWriteFinished = false;
const fake = createFakeCodexClient();
fake.request.mockImplementation(() =>
expectExternalMutationBlockedDuringNativeRequest({
releaseExternalMutation: releaseExternalWrite,
isExternalMutationStarted: () => externalWriteStarted,
isExternalMutationFinished: () => externalWriteFinished,
}),
);
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding({
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint: "policy-1",
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-1",
fingerprint: "fingerprint-1",
},
},
});
const externalWrite = (async () => {
await externalWriteGate;
externalWriteStarted = true;
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-2",
cwd: tempDir,
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint: "policy-2",
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-2",
},
},
});
externalWriteFinished = true;
})();
const result = requireCompactResult(
await maybeCompactCodexAppServerSession(
{
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "budget",
currentTokenCount: 456,
},
{ allowNonManualNativeRequest: true },
),
);
await externalWrite;
expect(fake.request).toHaveBeenCalledWith(
"thread/compact/start",
{ threadId: "thread-1" },
{ timeoutMs: 60_000 },
);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({
threadId: "thread-2",
contextEngine: {
policyFingerprint: "policy-2",
projection: {
epoch: "epoch-2",
},
},
});
});
it("blocks same-process binding clears until guarded native compaction starts", async () => {
let releaseExternalClear!: () => void;
const externalClearGate = new Promise<void>((resolve) => {
releaseExternalClear = resolve;
});
let externalClearStarted = false;
let externalClearFinished = false;
const fake = createFakeCodexClient();
fake.request.mockImplementation(() =>
expectExternalMutationBlockedDuringNativeRequest({
releaseExternalMutation: releaseExternalClear,
isExternalMutationStarted: () => externalClearStarted,
isExternalMutationFinished: () => externalClearFinished,
}),
);
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding({
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint: "policy-1",
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-1",
fingerprint: "fingerprint-1",
},
},
});
const externalClear = (async () => {
await externalClearGate;
externalClearStarted = true;
const cleared = await clearCodexAppServerBindingForThread(sessionFile, "thread-1");
externalClearFinished = true;
expect(cleared).toBe(true);
})();
const result = requireCompactResult(
await maybeCompactCodexAppServerSession(
{
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "budget",
currentTokenCount: 456,
},
{ allowNonManualNativeRequest: true },
),
);
await externalClear;
expect(fake.request).toHaveBeenCalledWith(
"thread/compact/start",
{ threadId: "thread-1" },
{ timeoutMs: 60_000 },
);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
});
it("skips native app-server compaction when trigger is omitted", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
@@ -683,12 +1060,12 @@ describe("maybeCompactCodexAppServerSession", () => {
function createFakeCodexClient(): {
client: CodexAppServerClient;
request: ReturnType<typeof vi.fn>;
request: ReturnType<typeof vi.fn<CodexAppServerClient["request"]>>;
close: ReturnType<typeof vi.fn>;
emit: (notification: CodexServerNotification) => void;
} {
const handlers = new Set<(notification: CodexServerNotification) => void>();
const request = vi.fn(async () => ({}));
const request = vi.fn<CodexAppServerClient["request"]>(async () => ({}));
const close = vi.fn();
const addNotificationHandler = vi.fn(
(handler: (notification: CodexServerNotification) => void) => {

View File

@@ -13,10 +13,21 @@ import {
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import type { JsonObject } from "./protocol.js";
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
import { readCodexAppServerBinding } from "./session-binding.js";
import {
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
readCodexAppServerBinding,
withCodexAppServerBindingLock,
writeCodexAppServerBinding,
type CodexAppServerThreadBinding,
} from "./session-binding.js";
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
const warnedIgnoredCompactionOverrides = new Set<string>();
type CodexAppServerCompactOptions = {
pluginConfig?: unknown;
clientFactory?: CodexAppServerClientFactory;
allowNonManualNativeRequest?: boolean;
};
/**
* Starts native Codex compaction for a manually requested bound session, or
@@ -24,7 +35,7 @@ const warnedIgnoredCompactionOverrides = new Set<string>();
*/
export async function maybeCompactCodexAppServerSession(
params: CompactEmbeddedAgentSessionParams,
options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {},
options: CodexAppServerCompactOptions = {},
): Promise<EmbeddedAgentCompactResult | undefined> {
warnIfIgnoringOpenClawCompactionOverrides(params);
// Codex owns automatic context-pressure compaction for Codex runtime sessions.
@@ -134,9 +145,9 @@ function readRecord(value: unknown): Record<string, unknown> | undefined {
async function compactCodexNativeThread(
params: CompactEmbeddedAgentSessionParams,
options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {},
options: CodexAppServerCompactOptions = {},
): Promise<EmbeddedAgentCompactResult | undefined> {
if (params.trigger !== "manual") {
if (params.trigger !== "manual" && !options.allowNonManualNativeRequest) {
embeddedAgentLog.info("skipping codex app-server compaction for non-manual trigger", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
@@ -169,13 +180,16 @@ async function compactCodexNativeThread(
return { ok: false, compacted: false, reason: nativeExecutionBlock };
}
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const binding = await readCodexAppServerBinding(params.sessionFile, { config: params.config });
if (!binding?.threadId) {
const initialBinding = await readCodexAppServerBinding(params.sessionFile, {
config: params.config,
});
if (!initialBinding?.threadId) {
return failedCodexThreadBindingCompactionResult(params, {
reason: "no codex app-server thread binding",
recovery: "missing_thread_binding",
});
}
let binding = initialBinding;
const requestedAuthProfileId = params.authProfileId?.trim() || undefined;
if (
requestedAuthProfileId &&
@@ -186,7 +200,6 @@ async function compactCodexNativeThread(
// with another profile risks operating on a different Codex account.
return { ok: false, compacted: false, reason: "auth profile mismatch for session binding" };
}
const shouldReleaseDefaultLease = !options.clientFactory;
const clientFactory = options.clientFactory ?? defaultLeasedCodexAppServerClientFactory;
const client = await clientFactory(
@@ -196,9 +209,71 @@ async function compactCodexNativeThread(
params.config,
);
try {
await client.request("thread/compact/start", {
threadId: binding.threadId,
});
if (options.allowNonManualNativeRequest) {
const guardedResult = await withCodexAppServerBindingLock(params.sessionFile, async () => {
const currentBinding = await readCodexAppServerBinding(params.sessionFile, {
config: params.config,
});
if (params.abortSignal?.aborted) {
return {
started: false as const,
result: skippedCodexNativeCompactionResult(params, {
reason: "codex app-server compaction aborted before native compaction",
code: "aborted_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
}),
};
}
if (!currentBinding || !isSameNativeCompactionBinding(currentBinding, binding)) {
embeddedAgentLog.warn(
"skipping codex app-server compaction because the thread binding changed",
{
sessionId: params.sessionId,
sessionKey: params.sessionKey,
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
},
);
return {
started: false as const,
result: skippedCodexNativeCompactionResult(params, {
reason: "codex app-server binding changed before native compaction",
code: "binding_changed_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
}),
};
}
binding = currentBinding;
await clearContextEngineProjectionBeforeNativeCompaction({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
binding,
config: params.config,
});
await client.request(
"thread/compact/start",
{
threadId: binding.threadId,
},
{
timeoutMs: Math.min(
appServer.requestTimeoutMs,
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
),
},
);
return { started: true as const };
});
if (!guardedResult.started) {
return guardedResult.result;
}
} else {
await client.request("thread/compact/start", {
threadId: binding.threadId,
});
}
embeddedAgentLog.info("started codex app-server compaction", {
sessionId: params.sessionId,
threadId: binding.threadId,
@@ -232,6 +307,12 @@ async function compactCodexNativeThread(
threadId: binding.threadId,
signal: "thread/compact/start",
pending: true,
...(options.allowNonManualNativeRequest
? {
request: "after_context_engine",
trigger: params.trigger ?? "unknown",
}
: {}),
};
return {
ok: true,
@@ -245,6 +326,36 @@ async function compactCodexNativeThread(
};
}
function skippedCodexNativeCompactionResult(
params: CompactEmbeddedAgentSessionParams,
skipped: {
reason: string;
code: string;
expectedThreadId?: string;
currentThreadId?: string;
},
): EmbeddedAgentCompactResult {
return {
ok: true,
compacted: false,
reason: skipped.reason,
result: {
summary: "",
firstKeptEntryId: "",
tokensBefore: params.currentTokenCount ?? 0,
details: {
backend: "codex-app-server",
skipped: true,
reason: skipped.code,
request: "after_context_engine",
trigger: params.trigger ?? "unknown",
...(skipped.expectedThreadId ? { expectedThreadId: skipped.expectedThreadId } : {}),
...(skipped.currentThreadId ? { currentThreadId: skipped.currentThreadId } : {}),
},
},
};
}
function failedCodexThreadBindingCompactionResult(
params: CompactEmbeddedAgentSessionParams,
recovery: {
@@ -271,6 +382,54 @@ function failedCodexThreadBindingCompactionResult(
};
}
async function clearContextEngineProjectionBeforeNativeCompaction(params: {
sessionId: string;
sessionFile: string;
binding: CodexAppServerThreadBinding;
config: CompactEmbeddedAgentSessionParams["config"];
}): Promise<void> {
const contextEngineBinding = params.binding.contextEngine;
if (!contextEngineBinding?.projection) {
return;
}
// Native Codex compaction mutates the thread history outside the projection
// guard. Clear only the projection marker so the next turn reprojects context.
await writeCodexAppServerBinding(
params.sessionFile,
{
...params.binding,
contextEngine: {
...contextEngineBinding,
projection: undefined,
},
createdAt: params.binding.createdAt,
},
{ config: params.config },
);
embeddedAgentLog.info("cleared codex context-engine projection before native compaction", {
sessionId: params.sessionId,
threadId: params.binding.threadId,
previousEpoch: contextEngineBinding.projection.epoch,
previousFingerprint: contextEngineBinding.projection.fingerprint,
});
}
function isSameNativeCompactionBinding(
current: CodexAppServerThreadBinding,
expected: CodexAppServerThreadBinding,
): boolean {
return (
current.threadId === expected.threadId &&
current.authProfileId === expected.authProfileId &&
current.contextEngine?.engineId === expected.contextEngine?.engineId &&
current.contextEngine?.policyFingerprint === expected.contextEngine?.policyFingerprint &&
current.contextEngine?.projection?.mode === expected.contextEngine?.projection?.mode &&
current.contextEngine?.projection?.epoch === expected.contextEngine?.projection?.epoch &&
current.contextEngine?.projection?.fingerprint ===
expected.contextEngine?.projection?.fingerprint
);
}
function isCodexThreadNotFoundError(error: unknown): boolean {
return formatCompactionError(error).toLowerCase().includes("thread not found");
}

View File

@@ -8,10 +8,12 @@ import {
CODEX_COMPUTER_USE_CONFIG_KEYS,
CODEX_PLUGIN_ENTRY_CONFIG_KEYS,
CODEX_PLUGINS_CONFIG_KEYS,
canUseCodexModelBackedApprovalsReviewerForModel,
codexAppServerStartOptionsKey,
readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
resolveCodexComputerUseConfig,
resolveCodexModelBackedReviewerPolicyContext,
resolveOpenClawExecModeForCodexAppServer,
resolveOpenClawExecModeFromConfig,
resolveOpenClawExecPolicyForCodexAppServer,
@@ -104,6 +106,7 @@ describe("Codex app-server config", () => {
OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY: "never",
OPENCLAW_CODEX_APP_SERVER_SANDBOX: "read-only",
},
modelProvider: "openai",
});
expectFields(runtime, "runtime", {
@@ -197,6 +200,7 @@ describe("Codex app-server config", () => {
},
},
env: {},
modelProvider: "openai",
});
expectFields(runtime, "runtime", {
@@ -267,12 +271,270 @@ describe("Codex app-server config", () => {
});
});
it("defaults native Codex approvals to guardian when requirements disallow full access", () => {
it("treats only explicit OpenAI model context as safe for Codex-backed auto-review", () => {
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "openai",
model: "gpt-5.5",
}),
).toBe(true);
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "codex",
model: "openai/gpt-5.5",
}),
).toBe(true);
expect(canUseCodexModelBackedApprovalsReviewerForModel({})).toBe(false);
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "codex",
model: "gpt-5.5",
}),
).toBe(false);
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "openrouter",
model: "openai/gpt-5.5",
}),
).toBe(false);
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "openai",
model: "lmstudio/local-model",
}),
).toBe(false);
const switchedLocalModel = resolveCodexModelBackedReviewerPolicyContext({
model: "lmstudio/local-model",
bindingModel: "gpt-5.5",
nativeAuthProfile: true,
});
expect(switchedLocalModel).toEqual({
modelProvider: "lmstudio",
model: "lmstudio/local-model",
});
expect(canUseCodexModelBackedApprovalsReviewerForModel(switchedLocalModel)).toBe(false);
const switchedOpenAIModel = resolveCodexModelBackedReviewerPolicyContext({
provider: "codex",
model: "openai/gpt-5.5",
bindingModel: "local-model",
bindingModelProvider: "lmstudio",
});
expect(switchedOpenAIModel).toEqual({
modelProvider: "openai",
model: "openai/gpt-5.5",
});
expect(canUseCodexModelBackedApprovalsReviewerForModel(switchedOpenAIModel)).toBe(true);
const legacyBindingOpenAIModel = resolveCodexModelBackedReviewerPolicyContext({
provider: "codex",
model: "openai/gpt-5.5",
bindingModelProvider: "lmstudio",
});
expect(legacyBindingOpenAIModel).toEqual({
modelProvider: "openai",
model: "openai/gpt-5.5",
});
expect(canUseCodexModelBackedApprovalsReviewerForModel(legacyBindingOpenAIModel)).toBe(true);
const boundLocalOpenAIName = resolveCodexModelBackedReviewerPolicyContext({
provider: "codex",
model: "openai/gpt-oss-20b",
bindingModel: "openai/gpt-oss-20b",
bindingModelProvider: "lmstudio",
});
expect(boundLocalOpenAIName).toEqual({
modelProvider: "lmstudio",
model: "openai/gpt-oss-20b",
});
expect(canUseCodexModelBackedApprovalsReviewerForModel(boundLocalOpenAIName)).toBe(false);
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "openai",
model: "gpt-5.5",
codexConfigToml: 'openai_base_url = "https://api.openai.com/v1"\n',
}),
).toBe(true);
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "openai",
model: "gpt-5.5",
codexConfigToml: 'openai_base_url = "http://localhost:8080/v1"\n',
}),
).toBe(false);
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "openai",
model: "gpt-5.5",
codexConfigToml: '[model_providers.openai]\nbase_url = "http://localhost:8080/v1"\n',
}),
).toBe(false);
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "openai",
model: "gpt-5.5",
codexConfigToml: 'model_providers.openai.base_url = "http://localhost:8080/v1"\n',
}),
).toBe(false);
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "openai",
model: "gpt-5.5",
codexConfigToml:
'model_providers = { openai = { base_url = "http://localhost:8080/v1" } }\n',
}),
).toBe(false);
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "openai",
model: "gpt-5.5",
codexConfigToml: 'chatgpt_base_url = "https://chatgpt.com/backend-api/"\n',
}),
).toBe(true);
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "openai",
model: "gpt-5.5",
codexConfigToml: 'chatgpt_base_url = "http://localhost:8080/backend-api"\n',
}),
).toBe(false);
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "openai",
model: "gpt-5.5",
config: {
models: {
providers: {
openai: {
baseUrl: "http://localhost:8080/v1",
models: [],
},
},
},
},
}),
).toBe(false);
for (const openAIProvider of [
{
baseUrl: "https://api.openai.com/v1",
request: { proxy: { mode: "explicit-proxy" as const, url: "http://localhost:8080" } },
models: [],
},
{
baseUrl: "https://api.openai.com/v1",
headers: { "x-openclaw-reviewer-proxy": "local" },
models: [],
},
{
baseUrl: "https://api.openai.com/v1",
authHeader: false,
models: [],
},
{
baseUrl: "https://api.openai.com/v1",
models: [
{
id: "gpt-5.5",
name: "GPT with custom headers",
reasoning: true,
input: ["text" as const],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8_192,
headers: { "x-openclaw-reviewer-proxy": "local" },
},
],
},
]) {
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "openai",
model: "gpt-5.5",
config: {
models: {
providers: {
openai: openAIProvider,
},
},
},
}),
).toBe(false);
}
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "openai",
model: "gpt-5.5",
env: {
OPENAI_BASE_URL: "http://localhost:8080/v1",
} as NodeJS.ProcessEnv,
}),
).toBe(false);
expect(
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: "openai",
model: "gpt-5.5",
env: {
OPENAI_BASE_URL: "",
OPENAI_API_BASE: "http://localhost:8080/v1",
} as NodeJS.ProcessEnv,
}),
).toBe(false);
});
it("uses user approvals when Codex native OpenAI config is local", () => {
const runtime = resolveRuntimeForTest({
execMode: "auto",
modelProvider: "openai",
model: "gpt-5.5",
codexConfigToml: 'openai_base_url = "http://localhost:8080/v1"\n',
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "user",
});
});
it("forces prompting when explicit no-prompt config cannot use model-backed review", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {
appServer: {
mode: "guardian",
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "auto_review",
},
},
modelProvider: "lmstudio",
model: "local-model",
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "user",
});
expect(shouldAutoApproveCodexAppServerApprovals(runtime)).toBe(false);
});
it("uses user approvals when requirements force prompting but model provider is unknown", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {},
requirementsToml: 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n',
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "user",
});
});
it("defaults native OpenAI Codex approvals to guardian when requirements disallow full access", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {},
modelProvider: "openai",
requirementsToml: 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n',
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
@@ -283,6 +545,7 @@ describe("Codex app-server config", () => {
it("uses read-only sandbox for guardian defaults when requirements only allow read-only", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {},
modelProvider: "openai",
requirementsToml: 'allowed_sandbox_modes = ["read-only"]\n',
});
@@ -296,6 +559,7 @@ describe("Codex app-server config", () => {
it("defaults native Codex approvals to guardian when requirements disallow never approval", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {},
modelProvider: "openai",
requirementsToml: 'allowed_approval_policies = ["on-request"]\n',
});
@@ -309,6 +573,7 @@ describe("Codex app-server config", () => {
it("selects an allowed guardian approval policy when on-request is unavailable", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {},
modelProvider: "openai",
requirementsToml: 'allowed_approval_policies = ["on-failure"]\n',
});
@@ -335,6 +600,7 @@ describe("Codex app-server config", () => {
it("defaults native Codex approvals to guardian when requirements disallow user reviewer", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {},
modelProvider: "openai",
requirementsToml: 'allowed_approvals_reviewers = ["auto_review"]\n',
});
@@ -362,6 +628,7 @@ describe("Codex app-server config", () => {
it("ignores quoted sandbox modes inside requirements comments", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {},
modelProvider: "openai",
requirementsToml: `allowed_sandbox_modes = [
"read-only",
# "danger-full-access",
@@ -380,6 +647,7 @@ describe("Codex app-server config", () => {
it("applies the first matching remote sandbox requirements before resolving local stdio defaults", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {},
modelProvider: "openai",
hostName: "BUILD-01.EXAMPLE.COM.",
requirementsToml: `[[remote_sandbox_config]]
hostname_patterns = ["build-*.example.com"]
@@ -420,6 +688,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: {},
env: {},
modelProvider: "openai",
requirementsPath: "/custom/codex/requirements.toml",
readRequirementsFile: (path) => {
readPaths.push(path);
@@ -440,6 +709,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: {},
env: { ProgramData: "D:\\ManagedData" },
modelProvider: "openai",
platform: "win32",
readRequirementsFile: (path) => {
readPaths.push(path);
@@ -779,6 +1049,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
mode: "guardian",
},
},
modelProvider: "openai",
env: {},
});
@@ -789,9 +1060,27 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
});
});
it("uses user approvals for explicit guardian mode when model provider is unknown", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {
appServer: {
mode: "guardian",
},
},
env: {},
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "user",
});
});
it("allows environment mode fallback to opt in to guardian-reviewed local execution", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {},
modelProvider: "openai",
env: { OPENCLAW_CODEX_APP_SERVER_MODE: "guardian" },
});
@@ -806,6 +1095,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
const runtime = resolveRuntimeForTest({
pluginConfig: {},
execMode: "auto",
modelProvider: "openai",
});
expectRuntimePolicy(runtime, {
@@ -829,6 +1119,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
OPENCLAW_CODEX_APP_SERVER_SANDBOX: "danger-full-access",
},
execMode: "auto",
modelProvider: "openai",
});
expectRuntimePolicy(runtime, {
@@ -849,11 +1140,13 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
},
},
execMode: "auto",
modelProvider: "openai",
env: {},
});
const envRuntime = resolveRuntimeForTest({
pluginConfig: {},
execMode: "auto",
modelProvider: "openai",
env: {
OPENCLAW_CODEX_APP_SERVER_MODE: "yolo",
OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY: "never",
@@ -1015,6 +1308,17 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
).toThrow("tools.exec.mode=ask requires Codex app-server user approvals");
});
it("fails closed when Guardian local-model fallback needs user approvals but requirements disallow them", () => {
expect(() =>
resolveRuntimeForTest({
pluginConfig: { appServer: { mode: "guardian" } },
modelProvider: "lmstudio",
model: "local-model",
requirementsToml: 'allowed_approvals_reviewers = ["auto_review"]\n',
}),
).toThrow("tools.exec.mode=ask requires Codex app-server user approvals");
});
it.each([
{ execMode: "auto", policies: ["never"] },
{ execMode: "auto", policies: ["on-failure"] },
@@ -1072,16 +1376,45 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
).toThrow("tools.exec.mode=auto requires Codex app-server prompting approvals");
});
it("fails closed when normalized OpenClaw auto mode cannot use an auto reviewer", () => {
expect(() =>
resolveRuntimeForTest({
it("uses user approvals when normalized OpenClaw auto mode cannot use Codex auto-review", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {},
execMode: "auto",
requirementsToml:
'allowed_approval_policies = ["on-request"]\nallowed_approvals_reviewers = ["user"]\n',
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "user",
});
});
it.each([
{ modelProvider: undefined, model: undefined },
{ modelProvider: "lmstudio", model: "local-model" },
{ modelProvider: "codex", model: "gpt-5.5" },
{ modelProvider: "codex", model: "lmstudio/local-model" },
])(
"uses user approvals for local-model auto exec before requirements validation",
({ modelProvider, model }) => {
const runtime = resolveRuntimeForTest({
pluginConfig: {},
execMode: "auto",
modelProvider,
model,
requirementsToml:
'allowed_approval_policies = ["on-request"]\nallowed_approvals_reviewers = ["user"]\n',
}),
).toThrow("tools.exec.mode=auto requires Codex app-server auto approvals");
});
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "user",
});
},
);
it("keeps normalized OpenClaw auto mode when legacy app-server yolo was schema-defaulted", () => {
const runtime = resolveRuntimeForTest({
@@ -1097,6 +1430,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
codexDynamicToolsExclude: [],
},
execMode: "auto",
modelProvider: "openai",
});
expectRuntimePolicy(runtime, {
@@ -1125,6 +1459,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
},
},
execMode: "auto",
modelProvider: "openai",
});
expectRuntimePolicy(runtime, {
@@ -1600,12 +1935,14 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
expect(
resolveRuntimeForTest({
pluginConfig: { appServer: { approvalsReviewer: "auto_review" } },
modelProvider: "openai",
env: {},
}).approvalsReviewer,
).toBe("auto_review");
expect(
resolveRuntimeForTest({
pluginConfig: { appServer: { approvalsReviewer: "guardian_subagent" } },
modelProvider: "openai",
env: {},
}).approvalsReviewer,
).toBe("guardian_subagent");

View File

@@ -2,6 +2,11 @@
import { createHmac, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { hostname as readHostName } from "node:os";
import path from "node:path";
import {
resolveProviderIdForAuth,
type ProviderAuthAliasLookupParams,
} from "openclaw/plugin-sdk/agent-runtime";
import {
resolveExecApprovalsFromFile,
type ExecApprovalsFile,
@@ -17,6 +22,8 @@ const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStart
const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret();
const UNIX_CODEX_REQUIREMENTS_PATH = "/etc/codex/requirements.toml";
const WINDOWS_CODEX_REQUIREMENTS_SUFFIX = "\\OpenAI\\Codex\\requirements.toml";
const CODEX_APP_SERVER_HOME_DIRNAME = "codex-home";
const CODEX_CONFIG_TOML_FILENAME = "config.toml";
const PLAIN_DECIMAL_NUMBER_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))$/;
type CodexAppServerTransportMode = "stdio" | "websocket";
@@ -35,6 +42,7 @@ export type OpenClawExecPolicyForCodexAppServer = {
touched: boolean;
};
type OpenClawExecPolicy = OpenClawExecPolicyForCodexAppServer;
type ProviderAuthAliasConfig = NonNullable<ProviderAuthAliasLookupParams>["config"];
type CodexAppServerDefaultPolicy = {
mode: CodexAppServerPolicyMode;
approvalPolicy?: CodexAppServerApprovalPolicy;
@@ -142,6 +150,15 @@ export type CodexAppServerRuntimeOptions = {
serviceTier?: CodexServiceTier;
};
export type CodexModelBackedReviewerContext = {
modelProvider?: string;
model?: string;
config?: ProviderAuthAliasConfig;
env?: NodeJS.ProcessEnv;
agentDir?: string;
codexConfigToml?: string | null;
};
export type CodexPluginConfig = {
codexDynamicToolsLoading?: CodexDynamicToolsLoading;
codexDynamicToolsExclude?: string[];
@@ -393,7 +410,12 @@ export function resolveCodexAppServerRuntimeOptions(
pluginConfig?: unknown;
execMode?: OpenClawExecMode;
execPolicy?: OpenClawExecPolicyForCodexAppServer;
modelProvider?: string;
model?: string;
config?: ProviderAuthAliasConfig;
env?: NodeJS.ProcessEnv;
agentDir?: string;
codexConfigToml?: string | null;
requirementsToml?: string | null;
requirementsPath?: string;
readRequirementsFile?: (path: string) => string | undefined;
@@ -434,8 +456,29 @@ export function resolveCodexAppServerRuntimeOptions(
const normalizedPolicyMode = resolveCodexPolicyModeForOpenClawExecMode(execMode);
const ignoreLegacyYoloPolicyMode =
normalizedPolicyMode === "guardian" && explicitPolicyMode === "yolo";
const forceUserReviewer = execMode !== undefined && execMode !== "auto" && execMode !== "full";
const forceGuardianReviewer = execMode === "auto";
const canUseModelBackedReviewer = canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: params.modelProvider,
model: params.model,
config: params.config,
env,
agentDir: params.agentDir,
codexConfigToml: params.codexConfigToml,
});
const explicitModelBackedReviewer =
explicitApprovalsReviewer === "auto_review" ||
explicitApprovalsReviewer === "guardian_subagent";
const forceUserReviewerForUnknownModel =
!canUseModelBackedReviewer &&
(explicitModelBackedReviewer ||
(explicitPolicyMode === "guardian" && explicitApprovalsReviewer !== "user"));
const forceUserReviewerForExecMode =
execMode !== undefined &&
execMode !== "full" &&
(execMode !== "auto" || !canUseModelBackedReviewer);
const forceUserReviewer = forceUserReviewerForUnknownModel || forceUserReviewerForExecMode;
const forceGuardianReviewer = execMode === "auto" && canUseModelBackedReviewer;
const execModeRequiringPromptingApprovals: Extract<OpenClawExecMode, "auto" | "ask"> | undefined =
execMode === "auto" || execMode === "ask" ? execMode : forceUserReviewer ? "ask" : undefined;
const forceDangerFullAccessSandbox =
params.execPolicy?.touched === true &&
params.execPolicy.security === "full" &&
@@ -449,14 +492,14 @@ export function resolveCodexAppServerRuntimeOptions(
transport,
env,
forceGuardian: normalizedPolicyMode === "guardian",
forceUserReviewer,
execModeRequiringPromptingApprovals:
execMode === "auto" || execMode === "ask" ? execMode : undefined,
forceUserReviewer: forceUserReviewer || !canUseModelBackedReviewer,
execModeRequiringPromptingApprovals,
requirementsToml: params.requirementsToml,
requirementsPath: params.requirementsPath,
readRequirementsFile: params.readRequirementsFile,
platform: params.platform,
hostName: params.hostName,
execModeRequiringUserReviewer: forceUserReviewer ? execMode : undefined,
});
const preserveExplicitAutoSandbox = forceGuardianReviewer && configuredSandbox === "read-only";
const forcedPolicy = forceRuntimePolicy
@@ -562,6 +605,93 @@ export function isCodexAppServerApprovalPolicyAllowedByRequirements(
return allowedApprovalPolicies === undefined || allowedApprovalPolicies.has(policy);
}
export function canUseCodexModelBackedApprovalsReviewerForModel(
params: CodexModelBackedReviewerContext,
): boolean {
const explicitProvider = params.modelProvider?.trim().toLowerCase();
const inferredProvider = inferProviderFromModelRef(params.model);
if (explicitProvider && explicitProvider !== "codex") {
return (
isTrustedCodexModelBackedApprovalsReviewerProvider(explicitProvider, params) &&
(inferredProvider === undefined ||
isTrustedCodexModelBackedApprovalsReviewerProvider(inferredProvider, params))
);
}
if (inferredProvider !== undefined) {
return isTrustedCodexModelBackedApprovalsReviewerProvider(inferredProvider, params);
}
return isTrustedCodexModelBackedApprovalsReviewerProvider(explicitProvider, params);
}
export function isTrustedCodexModelBackedOpenAIProvider(params: {
config?: ProviderAuthAliasConfig;
env?: NodeJS.ProcessEnv;
model?: string;
agentDir?: string;
codexConfigToml?: string | null;
}): boolean {
if (!openAIBaseUrlEnvOverridesAreTrustedForModelBackedReview(params.env)) {
return false;
}
const codexBaseUrlOverrides = readCodexBaseUrlOverridesForModelBackedReview(params);
if (
codexBaseUrlOverrides === false ||
!codexBaseUrlOverrides.openAI.every(isNativeOpenAIBaseUrl) ||
!codexBaseUrlOverrides.chatGPT.every(isNativeChatGPTBaseUrl)
) {
return false;
}
const openAIProviders = readConfiguredOpenAIProvidersForModelBackedReview(params.config);
if (openAIProviders.length === 0) {
return true;
}
return openAIProviders.every((openAIProvider) =>
configuredOpenAIProviderIsTrustedForModelBackedReview(openAIProvider, params.model),
);
}
export function resolveCodexModelBackedReviewerPolicyContext(params: {
provider?: string;
model?: string;
bindingModelProvider?: string;
bindingModel?: string;
nativeAuthProfile?: boolean;
}): CodexModelBackedReviewerContext {
const provider = params.provider?.trim();
if (provider && provider.toLowerCase() !== "codex") {
return {
modelProvider: normalizeCodexModelBackedReviewerPolicyProvider(provider),
model: params.model,
};
}
const bindingModelProvider = params.bindingModelProvider?.trim();
const currentModel = params.model?.trim();
const bindingModel = params.bindingModel?.trim();
if (bindingModelProvider && currentModel && bindingModel && currentModel === bindingModel) {
return {
modelProvider: normalizeCodexModelBackedReviewerPolicyProvider(bindingModelProvider),
model: params.model ?? params.bindingModel,
};
}
const currentModelProvider = inferProviderFromModelRef(params.model);
if (currentModelProvider) {
return {
modelProvider: normalizeCodexModelBackedReviewerPolicyProvider(currentModelProvider),
model: params.model,
};
}
if (bindingModelProvider) {
return {
modelProvider: normalizeCodexModelBackedReviewerPolicyProvider(bindingModelProvider),
model: params.model ?? params.bindingModel,
};
}
return {
modelProvider: params.nativeAuthProfile === true ? "openai" : undefined,
model: params.model ?? params.bindingModel,
};
}
export function resolveCodexComputerUseConfig(
params: {
pluginConfig?: unknown;
@@ -709,6 +839,7 @@ function resolveDefaultCodexAppServerPolicy(params: {
forceGuardian?: boolean;
forceUserReviewer?: boolean;
execModeRequiringPromptingApprovals?: Extract<OpenClawExecMode, "auto" | "ask">;
execModeRequiringUserReviewer?: OpenClawExecMode;
env?: NodeJS.ProcessEnv;
requirementsToml?: string | null;
requirementsPath?: string;
@@ -732,7 +863,7 @@ function resolveDefaultCodexAppServerPolicy(params: {
params.execModeRequiringPromptingApprovals,
),
approvalsReviewer: params.forceUserReviewer
? selectUserApprovalsReviewer(undefined)
? selectUserApprovalsReviewer(undefined, params.execModeRequiringUserReviewer)
: selectGuardianApprovalsReviewer(
undefined,
params.execModeRequiringPromptingApprovals === "auto" ? "auto" : undefined,
@@ -763,7 +894,7 @@ function resolveDefaultCodexAppServerPolicy(params: {
params.execModeRequiringPromptingApprovals,
),
approvalsReviewer: params.forceUserReviewer
? selectUserApprovalsReviewer(allowedApprovalsReviewers)
? selectUserApprovalsReviewer(allowedApprovalsReviewers, params.execModeRequiringUserReviewer)
: selectGuardianApprovalsReviewer(
allowedApprovalsReviewers,
params.execModeRequiringPromptingApprovals === "auto" ? "auto" : undefined,
@@ -782,14 +913,14 @@ function readCodexRequirementsToml(params: {
if (params.requirementsToml !== undefined) {
return params.requirementsToml ?? undefined;
}
const path =
const requirementsPath =
readNonEmptyString(params.requirementsPath) ??
resolveCodexRequirementsPath(params.env ?? process.env, params.platform ?? process.platform);
try {
if (params.readRequirementsFile) {
return params.readRequirementsFile(path);
return params.readRequirementsFile(requirementsPath);
}
return readFileSync(path, "utf8");
return readFileSync(requirementsPath, "utf8");
} catch {
return undefined;
}
@@ -881,6 +1012,34 @@ function parseTopLevelRequirementsStringArray(content: string, key: string): str
return parseRequirementsStringArray(topLevelContent, key);
}
function parseTomlStringValue(content: string, key: string): string | undefined {
const match = parseTomlStringAssignment(content, tomlDottedKeyPattern(key));
return match ? (match[1] ?? match[2] ?? "") : undefined;
}
function parseInlineOpenAIModelProviderBaseUrl(content: string): string | undefined {
const match = parseTomlStringAssignment(
content,
`${tomlKeyPattern("model_providers")}\\s*=\\s*\\{[\\s\\S]*?${tomlKeyPattern("openai")}\\s*=\\s*\\{[\\s\\S]*?${tomlKeyPattern("base_url")}`,
);
return match ? (match[1] ?? match[2] ?? "") : undefined;
}
function parseTomlStringAssignment(content: string, keyPattern: string): RegExpMatchArray | null {
return content.match(
new RegExp(`(?:^|\\n)\\s*${keyPattern}\\s*=\\s*(?:"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"|'([^']*)')`),
);
}
function tomlDottedKeyPattern(key: string): string {
return key.split(".").map(tomlKeyPattern).join("\\s*\\.\\s*");
}
function tomlKeyPattern(key: string): string {
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return `(?:"${escaped}"|'${escaped}'|${escaped})`;
}
function parseRequirementsStringArray(content: string, key: string): string[] | undefined {
const match = content.match(new RegExp(`(?:^|\\n)\\s*${key}\\s*=\\s*\\[([\\s\\S]*?)\\]`));
if (!match) {
@@ -894,6 +1053,20 @@ function parseRequirementsStringArray(content: string, key: string): string[] |
return stringMatches.map((entry) => entry[1] ?? entry[2] ?? "");
}
function parseTomlTableSection(content: string, table: string): string | undefined {
const strippedContent = stripTomlLineComments(content);
const tablePattern = tomlDottedKeyPattern(table);
const headerPattern = new RegExp(`^\\s*\\[\\s*${tablePattern}\\s*\\]\\s*$`, "m");
const match = headerPattern.exec(strippedContent);
if (!match) {
return undefined;
}
const sectionStart = match.index + match[0].length;
const rest = strippedContent.slice(sectionStart);
const nextTableOffset = rest.search(/^\s*\[/m);
return nextTableOffset === -1 ? rest : rest.slice(0, nextTableOffset);
}
function parseTomlArrayTableSections(content: string, table: string): string[] {
const strippedContent = stripTomlLineComments(content);
const escapedTable = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -1059,11 +1232,214 @@ function selectGuardianApprovalsReviewer(
function selectUserApprovalsReviewer(
allowedApprovalsReviewers: Set<CodexAppServerApprovalsReviewer> | undefined,
execModeRequiringUserReviewer?: OpenClawExecMode,
): CodexAppServerApprovalsReviewer {
if (allowedApprovalsReviewers === undefined || allowedApprovalsReviewers.has("user")) {
return "user";
}
throw new Error("tools.exec.mode=ask requires Codex app-server user approvals");
throw new Error(
`tools.exec.mode=${execModeRequiringUserReviewer ?? "ask"} requires Codex app-server user approvals`,
);
}
function isCodexModelBackedApprovalsReviewerProvider(provider: string | undefined): boolean {
const normalized = provider?.trim().toLowerCase();
return normalized === "openai";
}
function isTrustedCodexModelBackedApprovalsReviewerProvider(
provider: string | undefined,
params: CodexModelBackedReviewerContext,
): boolean {
return (
isCodexModelBackedApprovalsReviewerProvider(provider) &&
isTrustedCodexModelBackedOpenAIProvider({
config: params.config,
env: params.env,
model: params.model,
agentDir: params.agentDir,
codexConfigToml: params.codexConfigToml,
})
);
}
function readCodexBaseUrlOverridesForModelBackedReview(
params: Pick<CodexModelBackedReviewerContext, "agentDir" | "codexConfigToml">,
): { openAI: string[]; chatGPT: string[] } | false {
const configToml = readCodexAppServerConfigToml(params);
if (configToml === false) {
return false;
}
if (configToml === undefined) {
return { openAI: [], chatGPT: [] };
}
const topLevelContent = stripTomlLineComments(configToml).slice(
0,
firstTomlTableOffset(configToml),
);
const modelProviderOpenAISection = parseTomlTableSection(configToml, "model_providers.openai");
return {
openAI: [
parseTomlStringValue(topLevelContent, "openai_base_url"),
parseTomlStringValue(topLevelContent, "model_providers.openai.base_url"),
parseInlineOpenAIModelProviderBaseUrl(topLevelContent),
modelProviderOpenAISection
? parseTomlStringValue(modelProviderOpenAISection, "base_url")
: undefined,
].filter((entry): entry is string => entry !== undefined),
chatGPT: [parseTomlStringValue(topLevelContent, "chatgpt_base_url")].filter(
(entry): entry is string => entry !== undefined,
),
};
}
function readCodexAppServerConfigToml(
params: Pick<CodexModelBackedReviewerContext, "agentDir" | "codexConfigToml">,
): string | undefined | false {
if (params.codexConfigToml !== undefined) {
return params.codexConfigToml ?? undefined;
}
const configPath = resolveCodexAppServerConfigPath(params);
if (!configPath) {
return undefined;
}
try {
return readFileSync(configPath, "utf8");
} catch (error) {
return readErrorCode(error) === "ENOENT" ? undefined : false;
}
}
function resolveCodexAppServerConfigPath(
params: Pick<CodexModelBackedReviewerContext, "agentDir">,
): string | undefined {
const agentDir = readNonEmptyString(params.agentDir);
const codexHome = agentDir
? path.join(path.resolve(agentDir), CODEX_APP_SERVER_HOME_DIRNAME)
: undefined;
return codexHome ? path.join(codexHome, CODEX_CONFIG_TOML_FILENAME) : undefined;
}
function readErrorCode(error: unknown): string | undefined {
return error && typeof error === "object" && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
}
function readConfiguredOpenAIProvidersForModelBackedReview(
config: ProviderAuthAliasConfig | undefined,
): Array<Record<string, unknown>> {
const providerRecords = readRecord(readRecord(readRecord(config)?.models)?.providers);
if (!providerRecords) {
return [];
}
const openAIProviders: Array<Record<string, unknown>> = [];
for (const [providerId, providerConfig] of Object.entries(providerRecords)) {
if (resolveProviderIdForAuth(providerId, { config }) !== "openai") {
continue;
}
const record = readRecord(providerConfig);
if (record) {
openAIProviders.push(record);
}
}
return openAIProviders;
}
function configuredOpenAIProviderIsTrustedForModelBackedReview(
openAIProvider: Record<string, unknown>,
modelInput: string | undefined,
): boolean {
if (
readRecord(openAIProvider.localService) ||
hasNonEmptyRecord(openAIProvider.headers) ||
hasNonEmptyRecord(openAIProvider.request) ||
typeof openAIProvider.authHeader === "boolean" ||
!isNativeOpenAIBaseUrl(openAIProvider.baseUrl)
) {
return false;
}
const models = openAIProvider.models;
if (!Array.isArray(models)) {
return true;
}
const modelId = normalizeOpenAIModelBackedReviewerModelId(modelInput);
if (!modelId) {
return false;
}
for (const entry of models) {
const model = readRecord(entry);
if (typeof model?.id !== "string" || !matchesConfiguredOpenAIModelId(modelId, model.id)) {
continue;
}
if (
hasNonEmptyRecord(model.headers) ||
hasNonEmptyRecord(model.request) ||
!isNativeOpenAIBaseUrl(model.baseUrl)
) {
return false;
}
}
return true;
}
function normalizeOpenAIModelBackedReviewerModelId(modelInput: string | undefined): string {
const normalized = modelInput?.trim() ?? "";
const authProfileIndex = normalized.indexOf("@");
const withoutAuthProfile =
authProfileIndex > 0 ? normalized.slice(0, authProfileIndex) : normalized;
const slashIndex = withoutAuthProfile.indexOf("/");
return slashIndex > 0 ? withoutAuthProfile.slice(slashIndex + 1).trim() : withoutAuthProfile;
}
function matchesConfiguredOpenAIModelId(modelId: string, configuredModelId: string): boolean {
const configured = normalizeOpenAIModelBackedReviewerModelId(configuredModelId);
return Boolean(configured) && (modelId === configured || modelId.startsWith(`${configured}@`));
}
function hasNonEmptyRecord(value: unknown): boolean {
const record = readRecord(value);
return record !== undefined && Object.keys(record).length > 0;
}
function isNativeOpenAIBaseUrl(value: unknown): boolean {
if (typeof value !== "string" || !value.trim()) {
return true;
}
try {
const url = new URL(value);
return url.protocol === "https:" && url.hostname.toLowerCase() === "api.openai.com";
} catch {
return false;
}
}
function openAIBaseUrlEnvOverridesAreTrustedForModelBackedReview(
env: NodeJS.ProcessEnv | undefined,
): boolean {
return [env?.OPENAI_BASE_URL, env?.OPENAI_API_BASE].every(isNativeOpenAIBaseUrl);
}
function isNativeChatGPTBaseUrl(value: unknown): boolean {
if (typeof value !== "string" || !value.trim()) {
return true;
}
try {
const url = new URL(value);
return url.protocol === "https:" && url.hostname.toLowerCase() === "chatgpt.com";
} catch {
return false;
}
}
function normalizeCodexModelBackedReviewerPolicyProvider(provider: string): string {
return provider.toLowerCase() === "openai" ? "openai" : provider;
}
function inferProviderFromModelRef(model: string | undefined): string | undefined {
const normalized = model?.trim().toLowerCase();
const slashIndex = normalized?.indexOf("/") ?? -1;
return slashIndex > 0 ? normalized?.slice(0, slashIndex) : undefined;
}
function selectForcedPromptingSandbox(params: {

View File

@@ -185,15 +185,8 @@
"type": "object"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningEffortOption": {

View File

@@ -718,15 +718,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SandboxPolicy": {

View File

@@ -718,15 +718,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SandboxPolicy": {

View File

@@ -606,15 +606,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"TextElement": {

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