Compare commits

..

253 Commits

Author SHA1 Message Date
Josh Lehman
89c59c50d5 fix: persist migrated tlon install specs 2026-03-11 16:38:37 -07:00
Josh Lehman
e244bf893b fix: auto-migrate legacy tlon config on load 2026-03-11 16:33:18 -07:00
Josh Lehman
584f6bea5b fix: migrate tlon installs to tloncorp package
Switch new Tlon installs to @tloncorp/openclaw, add an on-load config migration for existing plugins.installs.tlon npm records, and document the rollout contract for the package rename. Preserve the tlon plugin/channel id and clear stale npm resolution metadata when rewriting old install specs so future plugin updates resolve cleanly.

Regeneration-Prompt: |
  Migrate the Tlon OpenClaw plugin package from @openclaw/tlon to @tloncorp/openclaw without breaking existing installs. Keep the plugin id and channel id as tlon, switch onboarding and docs for new installs to the new npm package, and add an automatic config migration for existing plugins.installs.tlon npm records that still reference the old package. When rewriting stored install specs, clear old npm resolution metadata such as integrity and resolvedSpec so the next plugin update does not compare the new package against the old package artifact. Also capture the migration contract in repo docs so the companion tloncorp package can preserve version continuity and manifest invariants.
2026-03-11 09:12:08 -07:00
Ayaan Zaidi
daf8afc954 fix(telegram): clear stale retain before transient final fallback (#41763)
Merged via squash.

Prepared head SHA: c0940838bc
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-11 21:36:43 +05:30
Tak Hoffman
87876a3e36 Fix env proxy bootstrap for model traffic (#43248)
* Fix env proxy bootstrap for model traffic

* Address proxy dispatcher review followups

* Fix proxy env precedence for empty lowercase vars
2026-03-11 10:21:35 -05:00
Peter Steinberger
1435fce2de fix: tighten Ollama onboarding cloud handling (#41529) (thanks @BruceMacD) 2026-03-11 14:52:55 +00:00
Bruce MacDonald
d6108a6f72 Onboard: add Ollama auth flow and improve model defaults
Add Ollama as a auth provider in onboarding with Cloud + Local mode
selection, browser-based sign-in via /api/me, smart model suggestions
per mode, and graceful fallback when the default model is unavailable.

- Extract shared ollama-models.ts
- Auto-pull missing models during onboarding
- Non-interactive mode support for CI/automation

Closes #8239
Closes #3494

Co-Authored-By: Jeffrey Morgan <jmorganca@gmail.com>
2026-03-11 14:52:55 +00:00
Robin Waslander
62d5df28dc fix(agents): add nodes to owner-only tool policy fallbacks
The nodes tool was missing from OWNER_ONLY_TOOL_NAME_FALLBACKS in
tool-policy.ts. applyOwnerOnlyToolPolicy() correctly removed gateway
and cron for non-owners but kept nodes, which internally issues
privileged gateway calls: node.pair.approve (operator.pairing) and
node.invoke (operator.write).

A non-owner sender could approve pending node pairings and invoke
arbitrary node commands, extending to system.run on paired nodes.

Add nodes to the fallback owner-only set. Non-owners no longer receive
the nodes tool after policy application; owners retain it.

Fixes GHSA-r26r-9hxr-r792
2026-03-11 14:17:03 +01:00
Robin Waslander
a1520d70ff fix(gateway): propagate real gateway client into plugin subagent runtime
Plugin subagent dispatch used a hardcoded synthetic client carrying
operator.admin, operator.approvals, and operator.pairing for all
runtime.subagent.* calls. Plugin HTTP routes with auth:"plugin" require
no gateway auth by design, so an unauthenticated external request could
drive admin-only gateway methods (sessions.delete, agent.run) through
the subagent runtime.

Propagate the real gateway client into the plugin runtime request scope
when one is available. Plugin HTTP routes now run inside a scoped
runtime client: auth:"plugin" routes receive a non-admin synthetic
operator.write client; gateway-authenticated routes retain admin-capable
scopes. The security boundary is enforced at the HTTP handler level.

Fixes GHSA-xw77-45gv-p728
2026-03-11 14:17:01 +01:00
Robin Waslander
dafd61b5c1 fix(gateway): enforce caller-scope subsetting in device.token.rotate
device.token.rotate accepted attacker-controlled scopes and forwarded
them to rotateDeviceToken without verifying the caller held those
scopes. A pairing-scoped token could rotate up to operator.admin on
any already-paired device whose approvedScopes included admin.

Add a caller-scope subsetting check before rotateDeviceToken: the
requested scopes must be a subset of client.connect.scopes via the
existing roleScopesAllow helper. Reject with missing scope: <scope>
if not.

Also add server.device-token-rotate-authz.test.ts covering both the
priv-esc path and the admin-to-node-invoke chain.

Fixes GHSA-4jpw-hj22-2xmc
2026-03-11 14:16:59 +01:00
Vincent Koc
04e103d10e fix(terminal): stabilize skills table width across Terminal.app and iTerm (#42849)
* Terminal: measure grapheme display width

* Tests: cover grapheme terminal width

* Terminal: wrap table cells by grapheme width

* Tests: cover emoji table alignment

* Terminal: refine table wrapping and width handling

* Terminal: stop shrinking CLI tables by one column

* Skills: use Terminal-safe emoji in list output

* Changelog: note terminal skills table fixes

* Skills: normalize emoji presentation across outputs

* Terminal: consume unsupported escape bytes in tables
2026-03-11 09:13:10 -04:00
Andyliu
10e6e27451 fix(models): guard optional model input capabilities (#42096)
Merged via squash.

Prepared head SHA: d398fa0222
Co-authored-by: andyliu <2377291+andyliu@users.noreply.github.com>
Co-authored-by: hydro13 <6640526+hydro13@users.noreply.github.com>
Reviewed-by: @hydro13
2026-03-11 13:43:59 +01:00
Nimrod Gutman
144c1b802b macOS/onboarding: prompt for remote gateway auth tokens (#43100)
Merged via squash.

Prepared head SHA: 00e2ad847b
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
2026-03-11 13:53:19 +02:00
Luke
f063e57d4b fix(macos): use foundationValue when serializing browser proxy POST body (#43069)
Merged via squash.

Prepared head SHA: 04c33fa061
Co-authored-by: ImLukeF <1272861+Effet@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-11 19:14:01 +08:00
Nimrod Gutman
2d91284fdb feat(ios): add local beta release flow (#42991)
Merged via squash.

Prepared head SHA: 82b38fe93b
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
2026-03-11 12:32:28 +02:00
Frank Yang
665f677265 docs(changelog): update context pruning PR reference 2026-03-11 18:07:37 +08:00
Frank Yang
d68d4362ee fix(context-pruning): cover image-only tool-result pruning 2026-03-11 18:07:37 +08:00
MoerAI
a78674f115 fix(context-pruning): prune image-containing tool results instead of skipping them (#41789) 2026-03-11 18:07:37 +08:00
ademczuk
dc4441322f fix(agents): include azure-openai in Responses API store override (#42934)
Merged via squash.

Prepared head SHA: d3285fef41
Co-authored-by: ademczuk <5212682+ademczuk@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-11 16:16:10 +08:00
Ayaan Zaidi
a2e30824e6 fix(telegram): fall back on ambiguous first preview sends 2026-03-11 11:23:10 +05:30
Wayne
e37e1ed24e fix(telegram): prevent duplicate messages with slow LLM providers (#41932)
Merged via squash.

Prepared head SHA: 2f50c51d5a
Co-authored-by: hougangdev <105773686+hougangdev@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-11 11:19:55 +05:30
Luke
7761e7626f Providers: add Opencode Go support (#42313)
* feat(providers): add opencode-go provider support and onboarding

* Onboard: unify OpenCode auth handling openclaw#42313 thanks @ImLukeF

* Docs: merge OpenCode Zen and Go docs openclaw#42313 thanks @ImLukeF

* Update CHANGELOG.md

---------

Co-authored-by: Ubuntu <ubuntu@vps-90352893.vps.ovh.ca>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-11 01:31:06 -04:00
Vincent Koc
bd33a340fb fix(sandbox): sanitize Docker env before marking OPENCLAW_CLI (#42256)
* Sandbox: sanitize Docker env before exec marker injection

* Sandbox: add regression test for Docker exec marker env

* Sandbox: disable Windows shell fallback for Docker

* Sandbox: cover Windows Docker wrapper rejection

* Sandbox: test strict env sanitization through Docker args
2026-03-11 00:59:36 -04:00
Luke
061b8258bc macOS: add chat model selector and persist thinking (#42314)
* feat(macos): add chat model selector and thinking persistence UX

* Chat UI: carry session model providers

* Docs: add macOS model selector changelog

* macOS: persist extended thinking levels

* Chat UI: keep model picker state in sync

* Chat UI tests: cover model selection races

---------

Co-authored-by: Ubuntu <ubuntu@vps-90352893.vps.ovh.ca>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-11 00:43:04 -04:00
Ayaan Zaidi
bf70a333fa fix: clear pnpm prod audit vulnerabilities 2026-03-11 09:33:45 +05:30
Peter Steinberger
0aa79fc4d3 fix(build): restore full gate 2026-03-11 02:52:55 +00:00
Peter Steinberger
c91d1622d5 fix(gateway): split conversation reset from admin reset 2026-03-11 02:50:44 +00:00
Peter Steinberger
0ab8d20917 docs(changelog): note interpreter approval hardening 2026-03-11 02:45:10 +00:00
Josh Avant
0125ce1f44 Gateway: fail closed unresolved local auth SecretRefs (#42672)
* Gateway: fail closed unresolved local auth SecretRefs

* Docs: align node-host gateway auth precedence

* CI: resolve rebase breakages in checks lanes

* Tests: isolate LOCAL_REMOTE_FALLBACK_TOKEN env state

* Gateway: remove stale remote.enabled auth-surface semantics

* Changelog: note gateway SecretRef fail-closed fix
2026-03-10 21:41:56 -05:00
Peter Steinberger
a52104c235 test: restore fs bridge helper export 2026-03-11 02:38:00 +00:00
Peter Steinberger
a0d5462571 fix(security): pin staged writes and fs mutations 2026-03-11 02:38:00 +00:00
Peter Steinberger
daaf211e20 fix(node-host): fail closed on unbound interpreter approvals 2026-03-11 02:36:38 +00:00
Peter Steinberger
72b0e00eab refactor: unify sandbox fs bridge mutations 2026-03-11 02:10:23 +00:00
Shadow
841f3b4af5 Switch to org-wide funding.yml file 2026-03-10 20:55:08 -05:00
Peter Steinberger
aad014c7c1 fix: harden subagent control boundaries 2026-03-11 01:44:38 +00:00
Peter Steinberger
68c674d37c refactor(security): simplify system.run approval model 2026-03-11 01:43:06 +00:00
Peter Steinberger
5716e52417 refactor: unify gateway credential planning 2026-03-11 01:37:25 +00:00
Peter Steinberger
3a39dc4e18 refactor(security): unify config write target policy 2026-03-11 01:35:04 +00:00
Peter Steinberger
7289c19f1a fix(security): bind system.run approvals to exact argv text 2026-03-11 01:25:31 +00:00
Peter Steinberger
8eac939417 fix(security): enforce target account configWrites 2026-03-11 01:24:36 +00:00
Peter Steinberger
11924a7026 fix(sandbox): pin fs-bridge staged writes 2026-03-11 01:15:47 +00:00
Peter Steinberger
702f6f3305 fix: fail closed for unresolved local gateway auth refs 2026-03-11 01:14:06 +00:00
Peter Steinberger
ecdbd8aa52 fix(security): restrict leaf subagent control scope 2026-03-11 01:12:22 +00:00
Gustavo Madeira Santana
3ba6491659 Infra: extract backup and plugin path helpers 2026-03-10 20:16:35 -04:00
Peter Steinberger
f4a4b50cd5 refactor: compile allowlist matchers 2026-03-11 00:07:47 +00:00
Peter Steinberger
fa0329c340 test: cover cron nested lane selection 2026-03-11 00:02:00 +00:00
Peter Steinberger
f604cbedf3 fix: remove stale allowlist matcher cache 2026-03-11 00:00:04 +00:00
Peter Steinberger
825a435709 fix: avoid cron embedded lane deadlock 2026-03-10 23:56:21 +00:00
Peter Steinberger
8901032007 Merge remote-tracking branch 'origin/main' 2026-03-10 23:55:30 +00:00
Josh Avant
36d2ae2a22 SecretRef: harden custom/provider secret persistence and reuse (#42554)
* Models: gate custom provider keys by usable secret semantics

* Config: project runtime writes onto source snapshot

* Models: prevent stale apiKey preservation for marker-managed providers

* Runner: strip SecretRef marker headers from resolved models

* Secrets: scan active agent models.json path in audit

* Config: guard runtime-source projection for unrelated configs

* Extensions: fix onboarding type errors in CI

* Tests: align setup helper account-enabled expectation

* Secrets audit: harden models.json file reads

* fix: harden SecretRef custom/provider secret persistence (#42554) (thanks @joshavant)
2026-03-10 23:55:10 +00:00
Peter Steinberger
20237358d9 refactor: clarify archive staging intent 2026-03-10 23:54:12 +00:00
Peter Steinberger
0bac47de51 refactor: split tar.bz2 extraction helpers 2026-03-10 23:53:32 +00:00
Peter Steinberger
9c64508822 refactor: rename tar archive preflight checker 2026-03-10 23:52:51 +00:00
Peter Steinberger
6565ae1857 refactor: extract archive staging helpers 2026-03-10 23:52:31 +00:00
Peter Steinberger
658cf4bd94 fix: harden archive extraction destinations 2026-03-10 23:49:35 +00:00
Josh Avant
fbc66324ee SecretRef: harden custom/provider secret persistence and reuse (#42554)
* Models: gate custom provider keys by usable secret semantics

* Config: project runtime writes onto source snapshot

* Models: prevent stale apiKey preservation for marker-managed providers

* Runner: strip SecretRef marker headers from resolved models

* Secrets: scan active agent models.json path in audit

* Config: guard runtime-source projection for unrelated configs

* Extensions: fix onboarding type errors in CI

* Tests: align setup helper account-enabled expectation

* Secrets audit: harden models.json file reads

* fix: harden SecretRef custom/provider secret persistence (#42554) (thanks @joshavant)
2026-03-10 18:46:47 -05:00
Peter Steinberger
201420a7ee fix: harden secret-file readers 2026-03-10 23:40:10 +00:00
Peter Steinberger
208fb1aa35 test: share runtime group policy fallback cases 2026-03-10 22:20:19 +00:00
Peter Steinberger
344b2286aa refactor: share windows command shim resolution 2026-03-10 22:18:04 +00:00
Peter Steinberger
1df78202b9 refactor: share approval gateway client setup 2026-03-10 22:18:04 +00:00
Peter Steinberger
bc1cc2e50f refactor: share telegram payload send flow 2026-03-10 22:18:04 +00:00
Peter Steinberger
a455c0cc3d refactor: share passive account lifecycle helpers 2026-03-10 22:18:04 +00:00
Peter Steinberger
50ded5052f refactor: share channel config schema fragments 2026-03-10 22:18:04 +00:00
Peter Steinberger
4a8e039a5f refactor: share channel config security scaffolding 2026-03-10 22:18:04 +00:00
Peter Steinberger
725958c66f refactor: share onboarding secret prompt flows 2026-03-10 22:18:03 +00:00
Peter Steinberger
00170f8e1a refactor: share scoped account config patching 2026-03-10 22:18:03 +00:00
David Guttman
b517dc089a feat(discord): add autoArchiveDuration config option (#35065)
* feat(discord): add autoArchiveDuration config option

Add config option to control auto-archive duration for auto-created threads:

- autoArchiveDuration: 60 (default), 1440, 4320, or 10080
  - Sets archive duration in minutes (1hr/1day/3days/1week)
  - Accepts both string and numeric values
  - Discord's default was 60 minutes (hardcoded)

Example config:
```yaml
channels:
  discord:
    guilds:
      GUILD_ID:
        channels:
          CHANNEL_ID:
            autoThread: true
            autoArchiveDuration: 10080  # 1 week
```

* feat(discord): add autoArchiveDuration changelog entry (#35065) (thanks @davidguttman)

---------

Co-authored-by: Onur <onur@textcortex.com>
2026-03-10 23:13:24 +01:00
Josh Avant
a76e810193 fix(gateway): harden token fallback/reconnect behavior and docs (#42507)
* fix(gateway): harden token fallback and auth reconnect handling

* docs(gateway): clarify auth retry and token-drift recovery

* fix(gateway): tighten auth reconnect gating across clients

* fix: harden gateway token retry (#42507) (thanks @joshavant)
2026-03-10 17:05:57 -05:00
Rodrigo Uroz
ff2e7a2945 fix(acp): strip provider auth env for child ACP processes (openclaw#42250)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: rodrigouroz <384037+rodrigouroz@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-10 16:50:10 -05:00
Matt Van Horn
5ed96da990 fix(browser): surface 429 rate limit errors with actionable hints (#40491)
Merged via squash.

Prepared head SHA: 13839c2dbd
Co-authored-by: mvanhorn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-11 00:49:31 +03:00
Pejman Pour-Moezzi
7c76acafd6 fix(acp): scope cancellation and event routing by runId (#41331) 2026-03-10 22:37:21 +01:00
Onur
c00117aff2 docs: require codex review in contributing guide (#42503) 2026-03-10 22:15:00 +01:00
PonyX-lab
53374394fb Fix stale runtime model reuse on session reset (#41173)
Merged via squash.

Prepared head SHA: d8a04a466a
Co-authored-by: PonyX-lab <266766228+PonyX-lab@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-10 14:02:43 -07:00
Shadow
0c17e7c225 docs: document r: spam auto-close label 2026-03-10 16:00:34 -05:00
Shadow
b16ee34c34 fix(ci): auto-close and lock r: spam items 2026-03-10 15:58:24 -05:00
David Guttman
9f5dee32f6 fix(acp): implicit streamToParent for mode=run without thread (#42404)
* fix(acp): implicit streamToParent for mode=run without thread

When spawning ACP sessions with mode=run and no thread binding,
automatically route output to parent session instead of Discord.
This enables agent-to-agent supervision patterns where the spawning
agent wants results returned programmatically, not posted as chat.

The change makes sessions_spawn with runtime=acp and thread=false
behave like direct acpx invocation - output goes to the spawning
session, not to Discord.

Fixes the issue where mode=run without thread still posted to Discord
because hasDeliveryTarget was true when called from a Discord context.

* fix: use resolved spawnMode instead of params.mode

Move implicit streamToParent check to after resolveSpawnMode so that
both explicit mode="run" and omitted mode (which defaults to "run"
when thread is false) correctly trigger parent routing.

This fixes the issue where callers that rely on default mode selection
would not get the intended parent streaming behavior.

* fix: tighten implicit ACP parent relay gating (#42404) (thanks @davidguttman)

---------

Co-authored-by: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
2026-03-10 21:42:15 +01:00
Peter Steinberger
f209a9be80 test: extract sendpayload outbound contract suite 2026-03-10 20:35:03 +00:00
Peter Steinberger
158a3b49a7 test: deduplicate cli option collision fixtures 2026-03-10 20:34:54 +00:00
Peter Steinberger
283570de4d fix: normalize stale openai completions transport 2026-03-10 20:23:03 +00:00
Peter Steinberger
0976317f96 test: deduplicate diffs extension fixtures 2026-03-10 20:22:56 +00:00
Peter Steinberger
23cd997526 fix: make install smoke docker-driver safe 2026-03-10 20:02:26 +00:00
Peter Steinberger
6d4241cbd9 fix: wire modelstudio env discovery (#40634) (thanks @pomelo-nwu) 2026-03-10 19:58:43 +00:00
pomelo-nwu
95eaa08781 refactor: rename bailian to modelstudio and fix review issues
- Rename provider ID, constants, functions, CLI flags, and types from
  "bailian" to "modelstudio" to match the official English name
  "Alibaba Cloud Model Studio".
- Fix P2 bug: global endpoint variant now always overwrites baseUrl
  instead of silently preserving a stale CN URL.
- Fix P1 bug: add modelstudio entry to PROVIDER_ENV_VARS so
  secret-input-mode=ref no longer throws.
- Move Model Studio imports to top of onboard-auth.config-core.ts.
- Remove unused BAILIAN_BASE_URL export.

Made-with: Cursor
2026-03-10 19:58:43 +00:00
pomelo-nwu
77a35025e8 feat: integrate Alibaba Bailian Coding Plan into onboarding wizard 2026-03-10 19:58:43 +00:00
Nimrod Gutman
c2e41c57c9 fix(ios): make pairing instructions generic 2026-03-10 21:44:00 +02:00
Nimrod Gutman
6bcf89b09b feat(ios): refresh home canvas toolbar 2026-03-10 21:44:00 +02:00
Mariano Belinky
67746a12de iOS: add welcome home canvas 2026-03-10 21:44:00 +02:00
Onur
8ba1b6eff1 ci: add npm release workflow and CalVer checks (#42414) (thanks @onutc) 2026-03-10 20:09:25 +01:00
Altay
0ff184397d docs(telegram): clarify group and sender allowlists (#42451)
Merged via squash.

Prepared head SHA: f30cacafb3
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 21:56:30 +03:00
Josh Avant
b205de6154 Docs: add changelog entry for SecretRef traversal (#42455) 2026-03-10 13:52:50 -05:00
Josh Avant
d30dc28b8c Secrets: reject exec SecretRef traversal ids across schema/runtime/gateway (#42370)
* Secrets: harden exec SecretRef validation and reload LKG coverage

* Tests: harden exec fast-exit stdin regression case

* Tests: align lifecycle daemon test formatting with oxfmt 0.36
2026-03-10 13:45:37 -05:00
Josh Avant
0687e04760 fix: thread runtime config through Discord/Telegram sends (#42352) (thanks @joshavant) (#42352) 2026-03-10 13:30:57 -05:00
Yufeng He
c2d9386796 fix: log auth profile resolution failures instead of swallowing silently (#41271)
Merged via squash.

Prepared head SHA: 049d1e119a
Co-authored-by: he-yufeng <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 20:38:49 +03:00
JiangNan
e9e8b81939 fix(failover): classify Gemini MALFORMED_RESPONSE as retryable timeout (#42292)
Merged via squash.

Prepared head SHA: 68f106ff49
Co-authored-by: jnMetaCode <12096460+jnMetaCode@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 20:34:32 +03:00
jiarung
bc9b35d6ce fix(logging): include model and provider in overload/error log (#41236)
Merged via squash.

Prepared head SHA: bb16fecbf7
Co-authored-by: jiarung <16461359+jiarung@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 20:32:14 +03:00
Ayaan Zaidi
3b582f1d54 fix(telegram): chunk long html outbound messages (#42240)
Merged via squash.

Prepared head SHA: 4d79c41ddf
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-10 22:53:04 +05:30
CryUshio
8bf64f219a fix: recognize Poe 402 'used up your points' as billing for fallback (#42278)
Merged via squash.

Prepared head SHA: f3cdfa76dd
Co-authored-by: CryUshio <30655354+CryUshio@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 20:17:36 +03:00
Pejman Pour-Moezzi
466cc816a8 docs(acp): document resumeSessionId for session resume (#42280)
* docs(acp): document resumeSessionId for session resume

* docs: clarify ACP resumeSessionId thread/mode behavior (#42280) (thanks @pejmanjohn)

---------

Co-authored-by: Onur <onur@textcortex.com>
2026-03-10 18:06:09 +01:00
sline
bfeea5d23f fix(agents): prevent /v1beta duplication in Gemini PDF URL (#34369)
Strip trailing /v1beta from baseUrl before appending the version
segment, so callers that already include /v1beta in their base URL
(e.g. subagent-registry) no longer produce /v1beta/v1beta/models/…
which results in a 404 from the Gemini API.

Closes #34312

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:52:49 -04:00
Ayaan Zaidi
936607ca22 ci: drop detect-secrets check 2026-03-10 20:35:23 +05:30
Ayaan Zaidi
ac88a39acc fix: align pi-ai 0.57.1 oauth imports and payload hooks 2026-03-10 20:29:03 +05:30
George Zhang
f50fc2966b docs: add #42173 to CHANGELOG — strip leaked model control tokens (#42216)
Thanks @imwyvern.
2026-03-10 07:19:13 -07:00
joshavant
59bc3c6630 Agents: align onPayload callback and OAuth imports 2026-03-10 08:50:30 -05:00
George Zhang
3508b4821b docs: add Tengji (George) Zhang to maintainer table (#42190) 2026-03-10 06:46:12 -07:00
George Zhang
309162f9a2 fix: strip leaked model control tokens from user-facing text (#42173)
Models like GLM-5 and DeepSeek sometimes emit internal delimiter tokens in their responses. Uses generic pattern in the text extraction pipeline, following the same architecture as stripMinimaxToolCallXml.

Closes #40020
Supersedes #40573

Co-authored-by: imwyvern <100903837+imwyvern@users.noreply.github.com>
2026-03-10 06:27:59 -07:00
Vincent Koc
208b636414 Changelog: add unreleased March 9 entries 2026-03-10 08:51:12 -04:00
smysle
d340ea92d1 chore: add .dev-state to .gitignore (#41848)
Merged via squash.

Prepared head SHA: 85c4eb7d26
Co-authored-by: smysle <207193754+smysle@users.noreply.github.com>
Co-authored-by: hydro13 <6640526+hydro13@users.noreply.github.com>
Reviewed-by: @hydro13
2026-03-10 13:35:04 +01:00
Charles Dusek
048e25c2b2 fix(agents): avoid duplicate same-provider cooldown probes in fallback runs (#41711)
Merged via squash.

Prepared head SHA: 8be8967bcb
Co-authored-by: cgdusek <38732970+cgdusek@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 15:26:47 +03:00
Echo
bda63c3c7f fix(mattermost): preserve markdown formatting and native tables (#18655)
Merged via squash.

Prepared head SHA: d30fff1776
Co-authored-by: echo931 <259437483+echo931@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
2026-03-10 17:40:01 +05:30
Pejman Pour-Moezzi
aca216bfcf feat(acp): add resumeSessionId to sessions_spawn for ACP session resume (#41847)
* feat(acp): add resumeSessionId to sessions_spawn for ACP session resume

Thread resumeSessionId through the ACP session spawn pipeline so agents
can resume existing sessions (e.g. a prior Codex conversation) instead
of starting fresh.

Flow: sessions_spawn tool → spawnAcpDirect → initializeSession →
ensureSession → acpx --resume-session flag → agent session/load

- Add resumeSessionId param to sessions-spawn-tool schema with
  description so agents can discover and use it
- Thread through SpawnAcpParams → AcpInitializeSessionInput →
  AcpRuntimeEnsureInput → acpx extension runtime
- Pass as --resume-session flag to acpx CLI
- Error hard (exit 4) on non-existent session, no silent fallback
- All new fields optional for backward compatibility

Depends on acpx >= 0.1.16 (openclaw/acpx#85, merged, pending release).

Tests: 26/26 pass (runtime + tool schema)
Verified e2e: Discord → sessions_spawn(resumeSessionId) → Codex
resumed session and recalled stored secret.

🤖 AI-assisted

* fix: guard resumeSessionId against non-ACP runtime

Add early-return error when resumeSessionId is passed without
runtime="acp" (mirrors existing streamTo guard). Without this,
the parameter is silently ignored and the agent gets a fresh
session instead of resuming.

Also update schema description to note the runtime=acp requirement.

Addresses Greptile review feedback.

* ACP: add changelog entry for session resume (#41847) (thanks @pejmanjohn)

---------

Co-authored-by: Pejman Pour-Moezzi <481729+pejmanjohn@users.noreply.github.com>
Co-authored-by: Onur <onur@textcortex.com>
2026-03-10 10:36:13 +01:00
Bob
c2eb12bbc5 ACPX: bump bundled acpx to 0.1.16 (#41975)
* ACPX: bump bundled acpx to 0.1.16

* fix: bump acpx pin to 0.1.16 (#41975) (thanks @dutifulbob)

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
2026-03-10 10:18:09 +01:00
Teconomix
6d0547dc2e mattermost: fix DM media upload for unprefixed user IDs (#29925)
Merged via squash.

Prepared head SHA: 5cffcb072c
Co-authored-by: teconomix <6959299+teconomix@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
2026-03-10 14:22:24 +05:30
Brad Groux
568b0a22bb fix(msteams): use General channel conversation ID as team key for Bot Framework compatibility (#41838)
* fix(msteams): use General channel conversation ID as team key for Bot Framework compatibility

Bot Framework sends `activity.channelData.team.id` as the General channel's
conversation ID (e.g. `19:abc@thread.tacv2`), not the Graph API group GUID
(e.g. `fa101332-cf00-431b-b0ea-f701a85fde81`). The startup resolver was
storing the Graph GUID as the team config key, so runtime matching always
failed and every channel message was silently dropped.

Fix: always call `listChannelsForTeam` during resolution to find the General
channel, then use its conversation ID as the stored `teamId`. When a specific
channel is also configured, reuse the same channel list rather than issuing a
second API call. Falls back to the Graph GUID if the General channel cannot
be found (renamed/deleted edge case).

Fixes #41390

* fix(msteams): handle listChannelsForTeam failure gracefully

* fix(msteams): trim General channel ID and guard against empty string

* fix: document MS Teams allowlist team-key fix (#41838) (thanks @BradGroux)

---------

Co-authored-by: bradgroux <bradgroux@users.noreply.github.com>
Co-authored-by: Onur <onur@textcortex.com>
2026-03-10 09:13:41 +01:00
Daniel Hnyk
450d49ea52 fix(mattermost): read replyTo param in plugin handleAction send (#41176)
Merged via squash.

Prepared head SHA: 33cac4c33f
Co-authored-by: hnykda <2741256+hnykda@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
2026-03-10 13:19:54 +05:30
Daniel Reis
3495563cfe fix(sandbox): pass real workspace to sessions_spawn when workspaceAccess is ro (#40757)
Merged via squash.

Prepared head SHA: 0e8b27bf80
Co-authored-by: dsantoreis <66363641+dsantoreis@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-03-10 04:12:50 -03:00
Austin
9d403fd415 fix(ui): replace Manual RPC text input with sorted method dropdown (#14967)
Merged via squash.

Prepared head SHA: 1bb49b2e64
Co-authored-by: rixau <112558420+rixau@users.noreply.github.com>
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Reviewed-by: @BunsDev
2026-03-10 01:30:31 -05:00
Val Alexander
5296147c20 CI: select Swift 6.2 toolchain for CodeQL (#41787)
Merged via squash.

Prepared head SHA: 8abc6c1657
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Reviewed-by: @BunsDev
2026-03-10 01:22:41 -05:00
Frank Yang
8306eabf85 fix(agents): forward memory flush write path (#41761)
Merged via squash.

Prepared head SHA: 0a8ebf8e5b
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-10 14:18:41 +08:00
Eugene
45b74fb56c fix(telegram): move network fallback to resolver-scoped dispatchers (#40740)
Merged via squash.

Prepared head SHA: a4456d48b4
Co-authored-by: sircrumpet <4436535+sircrumpet@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-10 11:28:51 +05:30
Urian Paul Danut
d1a59557b5 fix(security): harden replaceMarkers() to catch space/underscore boundary marker variants (#35983)
Merged via squash.

Prepared head SHA: ff07dc45a9
Co-authored-by: urianpaul94 <33277984+urianpaul94@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-10 13:54:23 +08:00
Laurie Luo
cf9db91b61 fix(web-search): recover OpenRouter Perplexity citations from message annotations (#40881)
Merged via squash.

Prepared head SHA: 66c8bb2c6a
Co-authored-by: laurieluo <89195476+laurieluo@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-10 10:37:44 +05:30
futuremind2026
382287026b cron: record lastErrorReason in job state (#14382)
Merged via squash.

Prepared head SHA: baa6b5d566
Co-authored-by: futuremind2026 <258860756+futuremind2026@users.noreply.github.com>
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Reviewed-by: @BunsDev
2026-03-10 00:01:45 -05:00
Wayne
da4fec6641 fix(telegram): prevent duplicate messages when preview edit times out (#41662)
Merged via squash.

Prepared head SHA: 2780e62d07
Co-authored-by: hougangdev <105773686+hougangdev@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-10 10:17:39 +05:30
Frank Yang
96e4975922 fix: protect bootstrap files during memory flush (#38574)
Merged via squash.

Prepared head SHA: a0b9a02e2e
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-10 12:44:33 +08:00
Benji Peng
989ee21b24 ui: fix sessions table collapse on narrow widths (#12175)
Merged via squash.

Prepared head SHA: b1fcfba868
Co-authored-by: benjipeng <11394934+benjipeng@users.noreply.github.com>
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Reviewed-by: @BunsDev
2026-03-09 23:14:07 -05:00
Tak Hoffman
705c6a422d Add provider routing details to bug report form (#41712) 2026-03-09 23:01:55 -05:00
Josh Avant
f0eb67923c fix(secrets): resolve web tool SecretRefs atomically at runtime 2026-03-09 22:57:03 -05:00
Ayaan Zaidi
93c44e3dad ci: drop gha cache from docker release (#41692) 2026-03-10 09:14:57 +05:30
Shadow
e42c4f4513 docs: harden PR review gates against unsubstantiated fixes 2026-03-09 22:43:56 -05:00
Ayane
391f9430ca fix(feishu): pass mediaLocalRoots in sendText local-image auto-convert shim (openclaw#40623)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: ayanesakura <40628300+ayanesakura@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-09 22:26:06 -05:00
Ayaan Zaidi
e74666cd0a build: raise extension openclaw peer floor 2026-03-10 08:47:56 +05:30
Ayaan Zaidi
731f1aa906 test: avoid detect-secrets churn in observation fixtures 2026-03-10 08:43:19 +05:30
Harold Hunt
de49a8b72c Telegram: exec approvals for OpenCode/Codex (#37233)
Merged via squash.

Prepared head SHA: f243379094
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
2026-03-09 23:04:35 -04:00
Ayaan Zaidi
9432a8bb3f test: allowlist detect-secrets fixture strings 2026-03-10 08:14:35 +05:30
Zhe Liu
25c2facc2b fix(agents): fix Brave llm-context empty snippets (#41387)
Merged via squash.

Prepared head SHA: 1e6f1d9d51
Co-authored-by: zheliu2 <15888718+zheliu2@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-10 08:09:57 +05:30
Shadow
1720174757 fix: auto-close no-ci PR label and document triage labels 2026-03-09 21:30:47 -05:00
Neerav Makwana
5decb00e9d fix(swiftformat): sync GatewayModels exclusions with OpenClawProtocol (#41242)
Co-authored-by: Shadow <shadow@openclaw.ai>
2026-03-09 20:42:54 -05:00
Val Alexander
6b87489890 Revert "feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2)"
This reverts commit 5a659b0b61.
2026-03-09 18:47:44 -05:00
Val Alexander
9f0a64f855 Revert "Update ui/src/ui/chat/export.ts"
This reverts commit d648dd7643.
2026-03-09 18:47:40 -05:00
Val Alexander
8e412bad0e Revert "fix(ui): address review feedback on chat infra slice"
This reverts commit 8a6cd808a1.
2026-03-09 18:47:37 -05:00
Val Alexander
8a6cd808a1 fix(ui): address review feedback on chat infra slice
- export.ts: handle array content blocks (Claude API format) instead
  of silently exporting empty strings
- slash-command-executor.ts: restrict /kill all to current session's
  subagent subtree instead of all sessions globally
- slash-command-executor.ts: only count truly aborted runs (check
  aborted !== false) in /kill summary
2026-03-09 18:34:47 -05:00
Val Alexander
d648dd7643 Update ui/src/ui/chat/export.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-09 18:34:47 -05:00
Val Alexander
5a659b0b61 feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2)
New self-contained chat modules extracted from dashboard-v2-structure:

- chat/slash-commands.ts: slash command definitions and completions
- chat/slash-command-executor.ts: execute slash commands via gateway RPC
- chat/slash-command-executor.node.test.ts: test coverage
- chat/speech.ts: speech-to-text (STT) support
- chat/input-history.ts: per-session input history navigation
- chat/pinned-messages.ts: pinned message management
- chat/deleted-messages.ts: deleted message tracking
- chat/export.ts: shared exportChatMarkdown helper
- chat-export.ts: re-export shim for backwards compat

Gateway fix:
- Restore usage/cost stripping in chat.history sanitization
- Add test coverage for sanitization behavior

These modules are additive and tree-shaken — no existing code
imports them yet. They will be wired in subsequent slices.
2026-03-09 18:34:47 -05:00
Julia Barth
c0cba7fb72 Fix one-shot exit hangs by tearing down cached memory managers (#40389)
Merged via squash.

Prepared head SHA: 0e600e89cf
Co-authored-by: Julbarth <72460857+Julbarth@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-10 07:34:46 +08:00
Vincent Koc
b48291e01e Exec: mark child command env with OPENCLAW_CLI (#41411) 2026-03-09 19:14:08 -04:00
Xinhua Gu
4790e40ac6 fix(plugins): expose model auth API to context-engine plugins (#41090)
Merged via squash.

Prepared head SHA: ee96e96bb9
Co-authored-by: xinhuagu <562450+xinhuagu@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-09 16:07:26 -07:00
alan blount
c9a6c542ef Add HTTP 499 to transient error codes for model fallback (#41468)
Merged via squash.

Prepared head SHA: 0053bae140
Co-authored-by: zeroasterisk <23422+zeroasterisk@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 01:55:10 +03:00
Altay
de4c3db3e3 Logging: harden probe suppression for observations (#41338)
Merged via squash.

Prepared head SHA: d18356cb80
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 01:40:15 +03:00
Hermione
64746c150c fix(discord): apply effective maxLinesPerMessage in live replies (#40133)
Merged via squash.

Prepared head SHA: 031d032534
Co-authored-by: rbutera <6047293+rbutera@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 01:30:24 +03:00
Mariano
56f787e3c0 build(protocol): regenerate Swift models after pending node work schemas (#41477)
Merged via squash.

Prepared head SHA: cae0aaf1c2
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 23:22:09 +01:00
Altay
531e8362b1 Agents: add fallback error observations (#41337)
Merged via squash.

Prepared head SHA: 852469c82f
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 01:12:10 +03:00
Mariano
3c3474360b acp: harden follow-up reliability and attachments (#41464)
Merged via squash.

Prepared head SHA: 7d167dff54
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 23:03:50 +01:00
Altay
0669b0ddc2 fix(agents): probe single-provider billing cooldowns (#41422)
Merged via squash.

Prepared head SHA: bbc4254b94
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-10 00:58:51 +03:00
Mariano
0c7f07818f acp: add regression coverage and smoke-test docs (#41456)
Merged via squash.

Prepared head SHA: 514d587352
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 22:40:14 +01:00
Mariano
4aebff78bc acp: forward attachments into ACP runtime sessions (#41427)
Merged via squash.

Prepared head SHA: f2ac51df2c
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 22:32:32 +01:00
Mariano
8e3f3bc3cf acp: enrich streaming updates for ide clients (#41442)
Merged via squash.

Prepared head SHA: 0764368e80
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 22:26:46 +01:00
Altay
30340d6835 Sandbox: import STATE_DIR from paths directly (#41439) 2026-03-10 00:18:41 +03:00
Mariano
d346f2d9ce acp: restore session context and controls (#41425)
Merged via squash.

Prepared head SHA: fcabdf7c31
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 22:17:19 +01:00
Mariano
e6e4169e82 acp: fail honestly in bridge mode (#41424)
Merged via squash.

Prepared head SHA: b5e6e13afe
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 22:01:30 +01:00
Mariano
1bc59cc09d Gateway: tighten node pending drain semantics (#41429)
Merged via squash.

Prepared head SHA: 361c2eb5c8
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 21:56:00 +01:00
Mariano
ef95975411 Gateway: add pending node work primitives (#41409)
Merged via squash.

Prepared head SHA: a6d7ca90d7
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 21:42:57 +01:00
zerone0x
5f90883ad3 fix(auth): reset cooldown error counters on expiry to prevent infinite escalation (#41028)
Merged via squash.

Prepared head SHA: 89bd83f09a
Co-authored-by: zerone0x <39543393+zerone0x@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-09 23:40:11 +03:00
Robin Waslander
2b2e5e2038 fix(cron): do not misclassify empty/NO_REPLY as interim acknowledgement (#41401)
* fix(cron): do not misclassify empty/NO_REPLY as interim acknowledgement

When a cron task's agent returns NO_REPLY, the payload filter strips the
silent token, leaving an empty text string. isLikelyInterimCronMessage()
previously returned true for empty input, causing the cron runner to
inject a forced rerun prompt ('Your previous response was only an
acknowledgement...').

Change the empty-string branch to return false: empty text after payload
filtering means the agent deliberately chose silent completion, not that
it sent an interim 'on it' message.

Fixes #41246

* fix(cron): do not misclassify empty/NO_REPLY as interim acknowledgement

Fixes #41246. (#41383) thanks @jackal092927.

---------

Co-authored-by: xaeon2026 <xaeon2026@gmail.com>
2026-03-09 21:16:28 +01:00
Mariano
0bcddb3d4f iOS: reconnect gateway on foreground return (#41384)
Merged via squash.

Prepared head SHA: 0e2e0dcc36
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 21:12:23 +01:00
Vincent Koc
d86647d7db Doctor: fix non-interactive cron repair gating (#41386) 2026-03-09 12:35:31 -07:00
Altay
87d939be79 Agents: add embedded error observations (#41336)
Merged via squash.

Prepared head SHA: 4900042298
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-09 22:27:05 +03:00
Mariano
d4e59a3666 Cron: enforce cron-owned delivery contract (#40998)
Merged via squash.

Prepared head SHA: 5877389e33
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 20:12:37 +01:00
Vincent Koc
7b88249c9e fix(telegram): bridge direct delivery to internal message:sent hooks (#40185)
* telegram: bridge direct delivery message hooks

* telegram: align sent hooks with command session
2026-03-09 11:21:19 -07:00
Vincent Koc
12702e11a5 plugins: harden global hook runner state (#40184) 2026-03-09 11:20:33 -07:00
Pejman Pour-Moezzi
14bbcad169 fix(acp): propagate setSessionMode gateway errors to client (#41185)
* fix(acp): propagate setSessionMode gateway errors to client

* fix: add changelog entry for ACP setSessionMode propagation (#41185) (thanks @pejmanjohn)

---------

Co-authored-by: Pejman Pour-Moezzi <481729+pejmanjohn@users.noreply.github.com>
Co-authored-by: Onur <onur@textcortex.com>
2026-03-09 17:50:38 +01:00
Pejman Pour-Moezzi
eab39c721b fix(acp): map error states to end_turn instead of unconditional refusal (#41187)
* fix(acp): map error states to end_turn instead of unconditional refusal

* fix: map ACP error stop reason to end_turn (#41187) (thanks @pejmanjohn)

---------

Co-authored-by: Pejman Pour-Moezzi <481729+pejmanjohn@users.noreply.github.com>
Co-authored-by: Onur <onur@textcortex.com>
2026-03-09 17:37:33 +01:00
Radek Sienkiewicz
4815dc0603 Update CONTRIBUTING.md 2026-03-09 17:27:29 +01:00
Robin Waslander
2cce45962f Add Robin Waslander to maintainers 2026-03-09 17:23:56 +01:00
Radek Sienkiewicz
258b7902a4 Update CONTRIBUTING.md 2026-03-09 17:13:16 +01:00
xaeon2026
425bd89b48 Allow ACP sessions.patch lineage fields on ACP session keys (#40995)
Merged via squash.

Prepared head SHA: c1191edc08
Co-authored-by: xaeon2026 <264572156+xaeon2026@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 17:08:11 +01:00
Charles Dusek
54be30ef89 fix(agents): bound compaction retry wait and drain embedded runs on restart (#40324)
Merged via squash.

Prepared head SHA: cfd99562d6
Co-authored-by: cgdusek <38732970+cgdusek@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-09 08:27:29 -07:00
Daniel Reis
fbf5d56366 test(context-engine): add bundle chunk isolation tests for registry (#40460)
Merged via squash.

Prepared head SHA: 44622abfbc
Co-authored-by: dsantoreis <220753637+dsantoreis@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-09 08:15:35 -07:00
Joshua Lelon Mitchell
98ea71aca5 fix(swiftformat): exclude HostEnvSecurityPolicy.generated.swift from formatters (#39969) 2026-03-09 07:30:43 -07:00
opriz
51bae75120 fix(kimi-coding): fix kimi tool format: use native Anthropic tool schema instead of OpenAI … (openclaw#40008)
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: opriz <51957849+opriz@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-09 08:28:47 -05:00
Radek Sienkiewicz
f2f561fab1 fix(ui): preserve control-ui auth across refresh (#40892)
Merged via squash.

Prepared head SHA: f9b2375892
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-09 12:50:47 +01:00
Peter Steinberger
f6d0712f50 build: sync plugin versions for 2026.3.9 2026-03-09 08:39:52 +00:00
Peter Steinberger
6c579d7842 fix: stabilize launchd paths and appcast secret scan 2026-03-09 08:37:37 +00:00
Peter Steinberger
f9706fde6a build: bump unreleased version to 2026.3.9 2026-03-09 08:33:58 +00:00
Peter Steinberger
7217b97658 fix(onboard): avoid persisting talk fallback on fresh setup 2026-03-09 08:33:58 +00:00
Peter Steinberger
ce9e91fdfc fix(launchd): harden macOS launchagent install permissions 2026-03-09 08:14:46 +00:00
Peter Steinberger
3caab9260c test: narrow gateway loop signal harness 2026-03-09 07:42:15 +00:00
Peter Steinberger
d0847ee322 chore: prepare 2026.3.8 npm release 2026-03-09 07:37:50 +00:00
Peter Steinberger
1d3dde8d21 fix(update): re-enable launchd service before updater bootstrap 2026-03-09 07:27:11 +00:00
Peter Steinberger
cc0f30f5fb test: fix windows runtime and restart loop harnesses 2026-03-09 07:22:23 +00:00
Peter Steinberger
250d3c949e chore: update appcast for 2026.3.8-beta.1 2026-03-09 07:20:08 +00:00
Peter Steinberger
5fca4c0de0 chore: prepare 2026.3.8-beta.1 release 2026-03-09 07:09:37 +00:00
Peter Steinberger
66c581c64c fix: normalize windows runtime shim executables 2026-03-09 07:01:42 +00:00
Peter Steinberger
912aa8744a test: fix Windows fake runtime bin fixtures 2026-03-09 06:50:52 +00:00
Peter Steinberger
8d2d6db9ad test: fix Node 24+ test runner and subagent registry mocks 2026-03-09 06:45:13 +00:00
Peter Steinberger
2d55ad05f3 docs: move 2026.3.8 entries back to unreleased 2026-03-09 06:34:53 +00:00
Peter Steinberger
9631f4665c chore: refresh secrets baseline 2026-03-09 06:31:35 +00:00
Peter Steinberger
e2a1a4a3db build: sync pnpm lockfile 2026-03-09 06:25:01 +00:00
Peter Steinberger
f82931ba8b docs: reorder 2026.3.8 changelog by impact 2026-03-09 06:24:29 +00:00
Peter Steinberger
17599a8ea2 refactor: flatten supervisor marker hints 2026-03-09 06:19:30 +00:00
Peter Steinberger
e86b38f09d refactor: split cron startup catch-up flow 2026-03-09 06:19:10 +00:00
Peter Steinberger
1d301f74a6 refactor: extract telegram polling session 2026-03-09 06:18:07 +00:00
Peter Steinberger
2e79d82198 build: update app deps except carbon 2026-03-09 06:09:33 +00:00
Peter Steinberger
96d17f3cb1 fix: stagger missed cron jobs on restart (#18925) (thanks @rexlunae) 2026-03-09 06:07:43 +00:00
rexlunae
79853aca9c fix(cron): stagger missed jobs on restart to prevent gateway overload
When the gateway restarts with many overdue cron jobs, they are now
executed with staggered delays to prevent overwhelming the gateway.

- Add missedJobStaggerMs config (default 5s between jobs)
- Add maxMissedJobsPerRestart limit (default 5 jobs immediately)
- Prioritize most overdue jobs by sorting by nextRunAtMs
- Reschedule deferred jobs to fire gradually via normal timer

Fixes #18892
2026-03-09 06:07:43 +00:00
Peter Steinberger
2d5e70f3e7 fix: abort telegram getupdates on shutdown (#23950) (thanks @Gkinthecodeland) 2026-03-09 06:03:46 +00:00
George Kalogirou
6186f620d2 fix(telegram): use manual signal forwarding to avoid cross-realm AbortSignal
AbortSignal.any() fails in Node.js when signals come from different module
contexts (grammY's internal signal vs local AbortController), producing:
"The signals[0] argument must be an instance of AbortSignal. Received an
instance of AbortSignal".

Replace with manual event forwarding that works across all realms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 06:03:46 +00:00
George Kalogirou
2767907abf fix(telegram): abort in-flight getUpdates fetch on shutdown
When the gateway receives SIGTERM, runner.stop() stops the grammY polling
loop but does not abort the in-flight getUpdates HTTP request. That request
hangs for up to 30 seconds (the Telegram API timeout). If a new gateway
instance starts polling during that window, Telegram returns a 409 Conflict
error, causing message loss and requiring exponential backoff recovery.

This is especially problematic with service managers (launchd, systemd)
that restart the process immediately after SIGTERM.

Wire an AbortController into the fetch layer so every Telegram API request
(especially the long-polling getUpdates) aborts immediately on shutdown:

- bot.ts: Accept optional fetchAbortSignal in TelegramBotOptions; wrap
  the grammY fetch with AbortSignal.any() to merge the shutdown signal.
- monitor.ts: Create a per-iteration AbortController, pass its signal to
  createTelegramBot, and abort it from the SIGTERM handler, force-restart
  path, and finally block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 06:03:46 +00:00
Peter Steinberger
9abf014f35 fix(skills): pin validated download roots 2026-03-09 06:00:50 +00:00
Peter Steinberger
cf3a479bd1 fix(node-host): bind bun and deno approval scripts 2026-03-09 05:59:32 +00:00
Peter Steinberger
fd902b0651 fix: detect launchd supervision via xpc service name (#20555) (thanks @dimat) 2026-03-09 05:57:35 +00:00
dimatu
cf796e2a22 fix(gateway): detect launchd supervision via XPC_SERVICE_NAME
On macOS, launchd sets XPC_SERVICE_NAME on managed processes but does
not set LAUNCH_JOB_LABEL or LAUNCH_JOB_NAME. Without checking
XPC_SERVICE_NAME, isLikelySupervisedProcess() returns false for
launchd-managed gateways, causing restartGatewayProcessWithFreshPid()
to fork a detached child instead of returning "supervised". The
detached child holds the gateway lock while launchd simultaneously
respawns the original process (KeepAlive=true), leading to an infinite
lock-timeout / restart loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 05:57:35 +00:00
merlin
f84adcbe88 fix: release gateway lock on restart failure + reply to Codex reviews
- Release gateway lock when in-process restart fails, so daemon
  restart/stop can still manage the process (Codex P2)
- P1 (env mismatch) already addressed: best-effort by design, documented
  in JSDoc
2026-03-09 05:53:52 +00:00
merlin
f184e7811c fix: move config pre-flight before onNotLoaded in runServiceRestart (Codex P2)
The config check was positioned after onNotLoaded, which could send
SIGUSR1 to an unmanaged process before config was validated.
2026-03-09 05:53:52 +00:00
merlin
c79a0dbdb4 fix: address bot review feedback on #35862
- Remove dead 'return false' in runServiceStart (Greptile)
- Include stack trace in run-loop crash guard error log (Greptile)
- Only catch startup errors on subsequent restarts, not initial start (Codex P1)
- Add JSDoc note about env var false positive edge case (Codex P1)
2026-03-09 05:53:52 +00:00
merlin
335223af32 test: add runServiceStart config pre-flight tests (#35862)
Address Greptile review: add test coverage for runServiceStart path.
The error message copy-paste issue was already fixed in the DRY refactor
(uses params.serviceNoun instead of hardcoded 'restart').
2026-03-09 05:53:52 +00:00
merlin
6740cdf160 fix(gateway): catch startup failure in run loop to prevent process exit (#35862)
When an in-process restart (SIGUSR1) triggers a config-triggered restart
and the new config is invalid, params.start() throws and the while loop
exits, killing the process. On macOS this loses TCC permissions.

Wrap params.start() in try/catch: on failure, set server=null, log the
error, and wait for the next SIGUSR1 instead of crashing.
2026-03-09 05:53:52 +00:00
merlin
eea925b12b fix(gateway): validate config before restart to prevent crash + macOS permission loss (#35862)
When 'openclaw gateway restart' is run with an invalid config, the new
process crashes on startup due to config validation failure. On macOS,
this causes Full Disk Access (TCC) permissions to be lost because the
respawned process has a different PID.

Add getConfigValidationError() helper and pre-flight config validation
in both runServiceRestart() and runServiceStart(). If config is invalid,
abort with a clear error message instead of crashing.

The config watcher's hot-reload path already had this guard
(handleInvalidSnapshot), but the CLI restart/start commands did not.

AI-assisted (OpenClaw agent, fully tested)
2026-03-09 05:53:52 +00:00
Peter Steinberger
88aee9161e fix(msteams): enforce sender allowlists with route allowlists 2026-03-09 05:52:19 +00:00
Peter Steinberger
03a6e3b460 test(cron): cover owner-only tool availability 2026-03-09 05:52:04 +00:00
Peter Steinberger
41e023a80b fix(cron): restore owner-only tools for isolated runs 2026-03-09 05:49:20 +00:00
Peter Steinberger
93775ef6a4 fix(browser): enforce redirect-hop SSRF checks 2026-03-09 05:41:36 +00:00
Peter Steinberger
31402b8542 fix: add changelog for restart timeout recovery (#40380) (thanks @dsantoreis) 2026-03-09 05:38:54 +00:00
DevMac
4bb8104810 test(secrets): skip ACL-dependent runtime snapshot tests on windows 2026-03-09 05:38:54 +00:00
Daniel dos Santos Reis
1d6a2d0165 fix(gateway): exit non-zero on restart shutdown timeout
When a config-change restart hits the force-exit timeout, exit with
code 1 instead of 0 so launchd/systemd treats it as a failure and
triggers a clean process restart. Stop-timeout stays at exit(0)
since graceful stops should not cause supervisor recovery.

Closes #36822
2026-03-09 05:38:54 +00:00
scoootscooob
44beb7be1f fix(daemon): also enable LaunchAgent in repairLaunchAgentBootstrap
The repair/recovery path had the same missing `enable` guard as
`restartLaunchAgent`.  If launchd persists a "disabled" state after a
previous `bootout`, the `bootstrap` call in `repairLaunchAgentBootstrap`
fails silently, leaving the gateway unloaded in the recovery flow.

Add the same `enable` guard before `bootstrap` that was already applied
to `installLaunchAgent` and (in this PR) `restartLaunchAgent`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 05:36:27 +00:00
scoootscooob
69cd376e3b fix(daemon): enable LaunchAgent before bootstrap on restart
restartLaunchAgent was missing the launchctl enable call that
installLaunchAgent already performs. launchd can persist a "disabled"
state after bootout, causing bootstrap to silently fail and leaving the
gateway unloaded until a manual reinstall.

Fixes #39211

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 05:36:27 +00:00
Peter Steinberger
41eef15cdc test: fix windows secrets runtime ci 2026-03-09 05:24:09 +00:00
GazeKingNuWu
41450187dd fix: clear plugin discovery cache after plugin installation (openclaw#39752)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: GazeKingNuWu <264914544+GazeKingNuWu@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-09 00:16:25 -05:00
Ayaan Zaidi
a40c29b11a Fix cron text announce delivery for Telegram targets (#40575)
Merged via squash.

Prepared head SHA: 54b1513c78
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 10:26:17 +05:30
Bronko
d4a960fcca fix(matrix): restore robust DM routing without the memberCount heuristic (#19736)
* fix(matrix): remove memberCount heuristic from DM detection

The memberCount === 2 check in isDirectMessage() misclassifies 2-person
group rooms (admin channels, monitoring rooms) as DMs, routing them to
the main session instead of their room-specific session.

Matrix already distinguishes DMs from groups at the protocol level via
m.direct account data and is_direct member state flags. Both are already
checked by client.dms.isDm() and hasDirectFlag(). The memberCount
heuristic only adds false positives for 2-person groups.

Move resolveMemberCount() below the protocol-level checks so it is only
reached for rooms not matched by m.direct or is_direct. This narrows its
role to diagnostic logging for confirmed group rooms.

Refs: #19739

* fix(matrix): add conservative fallback for broken DM flags

Some homeservers (notably Continuwuity) have broken m.direct account
data or never set is_direct on invite events. With the memberCount
heuristic removed, these DMs are no longer detected.

Add a conservative fallback that requires two signals before classifying
as DM: memberCount === 2 AND no explicit m.room.name. Group rooms almost
always have explicit names; DMs almost never do.

Error handling distinguishes M_NOT_FOUND (missing state event, expected
for unnamed rooms) from network/auth errors. Non-404 errors fall through
to group classification rather than guessing.

This is independently revertable — removing this commit restores pure
protocol-based detection without any heuristic fallback.

* fix(matrix): add parentPeer for DM room binding support

Add parentPeer to DM routes so conversations are bindable by room ID
while preserving DM trust semantics (secure 1:1, no group restrictions).

Suggested by @KirillShchetinin.

* fix(matrix): override DM detection for explicitly configured rooms

Builds on @robertcorreiro's config-driven approach from #9106.

Move resolveMatrixRoomConfig() before the DM check. If a room matches
a non-wildcard config entry (matchSource === "direct") and was
classified as DM, override the classification to group. This gives users
a deterministic escape hatch for misclassified rooms.

Wildcards are excluded from the override to avoid breaking DM routing
when a "*" catch-all exists. roomConfig is gated behind isRoom so DMs
never inherit group settings (skills, systemPrompt, autoReply).

This commit is independently droppable if the scope is too broad.

* test(matrix): add DM detection and config override tests

- 15 unit tests for direct.ts: all detection paths, priority order,
  M_NOT_FOUND vs network error handling, edge cases (whitespace names,
  API failures)
- 8 unit tests for rooms.ts: matchSource classification, wildcard
  safety for DM override, direct match priority over wildcard

* Changelog: note matrix DM routing follow-up

* fix(matrix): preserve DM fallback and room bindings

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-08 23:26:48 -05:00
Ayaan Zaidi
26e76f9a61 fix: dedupe inbound Telegram DM replies per agent (#40519)
Merged via squash.

Prepared head SHA: 6e235e7d1f
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 09:31:05 +05:30
Peter Steinberger
8befd88119 build(protocol): sync generated swift models 2026-03-09 03:49:50 +00:00
Peter Steinberger
99cbda83a2 fix(media): accept reader read result type 2026-03-09 03:49:50 +00:00
Peter Steinberger
e8775cda93 fix(agents): re-expose configured tools under restrictive profiles 2026-03-09 03:49:50 +00:00
Tak Hoffman
ef36cb8cbc chore(acpx): move runtime test fixtures to test-utils (openclaw#40548)
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini
2026-03-08 22:47:04 -05:00
Ayaan Zaidi
f114a5c638 test: fix android talk config contract fixture 2026-03-09 09:15:49 +05:30
Kyle
a438ff4397 fix(plugin-sdk): remove remaining bundled plugin src imports (openclaw#39638)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Kyle <3477429+kyledh@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-08 22:32:45 -05:00
Kesku
adec8b28bb alphabetize web search providers (#40259)
Merged via squash.

Prepared head SHA: be6350e5ae
Co-authored-by: kesku <62210496+kesku@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 08:54:54 +05:30
Mariano
e3df94365b ACP: add optional ingress provenance receipts (#40473)
Merged via squash.

Prepared head SHA: b63e46dd94
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 04:19:03 +01:00
Tyson Cung
4d501e4ccf fix(telegram): add download timeout to prevent polling loop hang (#40098)
Merged via squash.

Prepared head SHA: abdfa1a35f
Co-authored-by: tysoncung <45380903+tysoncung@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 08:29:21 +05:30
yuweuii
f6243916b5 fix(models): use 1M context for openai-codex gpt-5.4 (#37876)
Merged via squash.

Prepared head SHA: c41020779e
Co-authored-by: yuweuii <82372187+yuweuii@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-08 18:23:49 -07:00
Radek Sienkiewicz
b34158086a docs(changelog): correct Control UI contributor credit (#40420)
Merged via squash.

Prepared head SHA: e4295fe18b
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-09 02:18:30 +01:00
Vincent Koc
eabda6e3a4 fix(tests): correct security check failure 2026-03-08 18:13:35 -07:00
Vincent Koc
6d5e142b93 Docker: improve build cache reuse (#40351)
* Docker: improve build cache reuse

* Tests: cover Docker build cache layout

* Docker: fix sandbox cache mount continuations

* Docker: document qr-import manifest scope

* Docker: narrow e2e install inputs

* CI: cache Docker builds in workflows

* CI: route sandbox smoke through setup script

* CI: keep sandbox smoke on script path
2026-03-08 17:57:46 -07:00
Radek Sienkiewicz
4f42c03a49 gateway: fix global Control UI 404s for symlinked wrappers and bundled package roots (#40385)
Merged via squash.

Prepared head SHA: 567b3ed684
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-09 01:50:42 +01:00
Peter Steinberger
13bd3db307 chore(docs): drop refactor cleanup tracker 2026-03-09 00:26:20 +00:00
Peter Steinberger
ff4745fc3f refactor(models): split provider discovery helpers 2026-03-09 00:26:20 +00:00
Peter Steinberger
c29b098744 refactor(models): split models.json planning from writes 2026-03-09 00:26:20 +00:00
Peter Steinberger
24b53fcf47 refactor(agents): extract provider model normalization 2026-03-09 00:26:20 +00:00
Peter Steinberger
dfc18b7a2b refactor(models): extract list row builders 2026-03-09 00:26:20 +00:00
Peter Steinberger
141738f717 refactor: harden browser runtime profile handling 2026-03-09 00:25:43 +00:00
bbblending
4ff4ed7ec9 fix(config): refresh runtime snapshot from disk after write. Fixes #37175 (#37313)
Merged via squash.

Prepared head SHA: 69e1861abf
Co-authored-by: bbblending <122739024+bbblending@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-08 19:49:15 -04:00
Peter Steinberger
362248e559 refactor: harden browser relay CDP flows 2026-03-08 23:46:10 +00:00
813 changed files with 40130 additions and 8906 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
custom: ["https://github.com/sponsors/steipete"]

View File

@@ -76,6 +76,37 @@ body:
label: Install method
description: How OpenClaw was installed or launched.
placeholder: npm global / pnpm dev / docker / mac app
- type: input
id: model
attributes:
label: Model
description: Effective model under test.
placeholder: minimax/text-01 / openrouter/anthropic/claude-opus-4.1 / anthropic/claude-sonnet-4.5
validations:
required: true
- type: input
id: provider_chain
attributes:
label: Provider / routing chain
description: Effective request path through gateways, proxies, providers, or model routers.
placeholder: openclaw -> cloudflare-ai-gateway -> minimax
validations:
required: true
- type: input
id: config_location
attributes:
label: Config file / key location
description: Optional. Relevant config source or key path if this bug depends on overrides or custom provider setup. Redact secrets.
placeholder: ~/.openclaw/openclaw.json ; models.providers.cloudflare-ai-gateway.baseUrl ; ~/.openclaw/agents/<agentId>/agent/models.json
- type: textarea
id: provider_setup_details
attributes:
label: Additional provider/model setup details
description: Optional. Include redacted routing details, per-agent overrides, auth-profile interactions, env/config context, or anything else needed to explain the effective provider/model setup. Do not include API keys, tokens, or passwords.
placeholder: |
Default route is openclaw -> cloudflare-ai-gateway -> minimax.
Previous setup was openclaw -> cloudflare-ai-gateway -> openrouter -> minimax.
Relevant config lives in ~/.openclaw/openclaw.json under models.providers.minimax and models.providers.cloudflare-ai-gateway.
- type: textarea
id: logs
attributes:

View File

@@ -51,6 +51,7 @@ jobs:
},
{
label: "r: no-ci-pr",
close: true,
message:
"Please don't make PRs for test failures on main.\n\n" +
"The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" +
@@ -392,6 +393,7 @@ jobs:
}
const invalidLabel = "invalid";
const spamLabel = "r: spam";
const dirtyLabel = "dirty";
const noisyPrMessage =
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
@@ -428,6 +430,21 @@ jobs:
});
return;
}
if (labelSet.has(spamLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
state: "closed",
});
await github.rest.issues.lock({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
lock_reason: "spam",
});
return;
}
if (labelSet.has(invalidLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,
@@ -439,6 +456,23 @@ jobs:
}
}
if (issue && labelSet.has(spamLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: "closed",
state_reason: "not_planned",
});
await github.rest.issues.lock({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
lock_reason: "spam",
});
return;
}
if (issue && labelSet.has(invalidLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,

View File

@@ -302,34 +302,6 @@ jobs:
python -m pip install --upgrade pip
python -m pip install pre-commit
- name: Detect secrets
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
echo "Running full detect-secrets scan on push."
pre-commit run --all-files detect-secrets
exit 0
fi
BASE="${{ github.event.pull_request.base.sha }}"
changed_files=()
if git rev-parse --verify "$BASE^{commit}" >/dev/null 2>&1; then
while IFS= read -r path; do
[ -n "$path" ] || continue
[ -f "$path" ] || continue
changed_files+=("$path")
done < <(git diff --name-only --diff-filter=ACMR "$BASE" HEAD)
fi
if [ "${#changed_files[@]}" -gt 0 ]; then
echo "Running detect-secrets on ${#changed_files[@]} changed file(s)."
pre-commit run detect-secrets --files "${changed_files[@]}"
else
echo "Falling back to full detect-secrets scan."
pre-commit run --all-files detect-secrets
fi
- name: Detect committed private keys
run: pre-commit run --all-files detect-private-key

View File

@@ -93,7 +93,11 @@ jobs:
- name: Setup Swift build tools
if: matrix.needs_swift_tools
run: brew install xcodegen swiftlint swiftformat
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
brew install xcodegen swiftlint swiftformat
swift --version
- name: Initialize CodeQL
uses: github/codeql-action/init@v4

View File

@@ -109,8 +109,6 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-amd64
cache-to: type=gha,mode=max,scope=docker-release-amd64
- name: Build and push amd64 slim image
id: build-slim
@@ -124,8 +122,6 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-amd64
cache-to: type=gha,mode=max,scope=docker-release-amd64
# Build arm64 images (default + slim share the build stage cache)
build-arm64:
@@ -214,8 +210,6 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-arm64
cache-to: type=gha,mode=max,scope=docker-release-arm64
- name: Build and push arm64 slim image
id: build-slim
@@ -229,8 +223,6 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-arm64
cache-to: type=gha,mode=max,scope=docker-release-arm64
# Create multi-platform manifests
create-manifest:

View File

@@ -43,6 +43,8 @@ jobs:
- name: Set up Docker Builder
uses: useblacksmith/setup-docker-builder@v1
# Blacksmith can fall back to the local docker driver, which rejects gha
# cache export/import. Keep smoke builds driver-agnostic.
- name: Build root Dockerfile smoke image
uses: useblacksmith/build-push-action@v2
with:
@@ -52,8 +54,6 @@ jobs:
load: true
push: false
provenance: false
cache-from: type=gha,scope=install-smoke-root-dockerfile
cache-to: type=gha,mode=max,scope=install-smoke-root-dockerfile
- name: Run root Dockerfile CLI smoke
run: |
@@ -73,8 +73,6 @@ jobs:
load: true
push: false
provenance: false
cache-from: type=gha,scope=install-smoke-root-dockerfile-ext
cache-to: type=gha,mode=max,scope=install-smoke-root-dockerfile-ext
- name: Smoke test Dockerfile with extension build arg
run: |
@@ -89,8 +87,6 @@ jobs:
load: true
push: false
provenance: false
cache-from: type=gha,scope=install-smoke-installer-root
cache-to: type=gha,mode=max,scope=install-smoke-installer-root
- name: Build installer non-root image
if: github.event_name != 'pull_request'
@@ -102,8 +98,6 @@ jobs:
load: true
push: false
provenance: false
cache-from: type=gha,scope=install-smoke-installer-nonroot
cache-to: type=gha,mode=max,scope=install-smoke-installer-nonroot
- name: Run installer docker tests
env:

View File

@@ -0,0 +1,79 @@
name: OpenClaw NPM Release
on:
push:
tags:
- "v*"
concurrency:
group: openclaw-npm-release-${{ github.ref }}
cancel-in-progress: false
env:
NODE_VERSION: "22.x"
PNPM_VERSION: "10.23.0"
jobs:
publish_openclaw_npm:
# npm trusted publishing + provenance requires a GitHub-hosted runner.
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "false"
use-sticky-disk: "false"
- name: Validate release tag and package metadata
env:
RELEASE_SHA: ${{ github.sha }}
RELEASE_TAG: ${{ github.ref_name }}
RELEASE_MAIN_REF: origin/main
run: |
set -euo pipefail
# Fetch the full main ref so merge-base ancestry checks keep working
# for older tagged commits that are still contained in main.
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
pnpm release:openclaw:npm:check
- name: Ensure version is not already published
run: |
set -euo pipefail
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
exit 1
fi
echo "Publishing openclaw@${PACKAGE_VERSION}"
- name: Check
run: pnpm check
- name: Build
run: pnpm build
- name: Verify release contents
run: pnpm release:check
- name: Publish
run: |
set -euo pipefail
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if [[ "$PACKAGE_VERSION" == *-beta.* ]]; then
npm publish --access public --tag beta --provenance
else
npm publish --access public --provenance
fi

2
.gitignore vendored
View File

@@ -81,6 +81,7 @@ apps/ios/*.mobileprovision
# Local untracked files
.local/
docs/.local/
tmp/
IDENTITY.md
USER.md
.tgz
@@ -121,3 +122,4 @@ dist/protocol.schema.json
# Synthing
**/.stfolder/
.dev-state

View File

@@ -9,7 +9,19 @@ Input
- If ambiguous: ask.
Do (review-only)
Goal: produce a thorough review and a clear recommendation (READY for /landpr vs NEEDS WORK). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
Goal: produce a thorough review and a clear recommendation (READY FOR /landpr vs NEEDS WORK vs INVALID CLAIM). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
0. Truthfulness + reality gate (required for bug-fix claims)
- Do not trust the issue text or PR summary by default; verify in code and evidence.
- If the PR claims to fix a bug linked to an issue, confirm the bug exists now (repro steps, logs, failing test, or clear code-path proof).
- Prove root cause with exact location (`path/file.ts:line` + explanation of why behavior is wrong).
- Verify fix targets the same code path as the root cause.
- Require a regression test when feasible (fails before fix, passes after fix). If not feasible, require explicit justification + manual verification evidence.
- Hallucination/BS red flags (treat as BLOCKER until disproven):
- claimed behavior not present in repo,
- issue/PR says "fixes #..." but changed files do not touch implicated path,
- only docs/comments changed for a runtime bug claim,
- vague AI-generated rationale without concrete evidence.
1. Identify PR meta + context
@@ -56,6 +68,7 @@ Goal: produce a thorough review and a clear recommendation (READY for /landpr vs
- Any deprecations, docs, types, or lint rules we should adjust?
8. Key questions to answer explicitly
- Is the core claim substantiated by evidence, or is it likely invalid/hallucinated?
- Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR?
- Any blocking concerns (must-fix before merge)?
- Is this PR ready to land, or does it need work?
@@ -65,18 +78,32 @@ Goal: produce a thorough review and a clear recommendation (READY for /landpr vs
A) TL;DR recommendation
- One of: READY FOR /landpr | NEEDS WORK | NEEDS DISCUSSION
- One of: READY FOR /landpr | NEEDS WORK | INVALID CLAIM (issue/bug not substantiated) | NEEDS DISCUSSION
- 13 sentence rationale.
B) What changed
B) Claim verification matrix (required)
- Fill this table:
| Field | Evidence |
| ----------------------------------------------- | -------- |
| Claimed problem | ... |
| Evidence observed (repro/log/test/code) | ... |
| Root cause location (`path:line`) | ... |
| Why this fix addresses that root cause | ... |
| Regression coverage (test name or manual proof) | ... |
- If any row is missing/weak, default to `NEEDS WORK` or `INVALID CLAIM`.
C) What changed
- Brief bullet summary of the diff/behavioral changes.
C) What's good
D) What's good
- Bullets: correctness, simplicity, tests, docs, ergonomics, etc.
D) Concerns / questions (actionable)
E) Concerns / questions (actionable)
- Numbered list.
- Mark each item as:
@@ -84,17 +111,19 @@ D) Concerns / questions (actionable)
- IMPORTANT (should fix before merge)
- NIT (optional)
- For each: point to the file/area and propose a concrete fix or alternative.
- If evidence for the core bug claim is missing, add a `BLOCKER` explicitly.
E) Tests
F) Tests
- What exists.
- What's missing (specific scenarios).
- State clearly whether there is a regression test for the claimed bug.
F) Follow-ups (optional)
G) Follow-ups (optional)
- Non-blocking refactors/tickets to open later.
G) Suggested PR comment (optional)
H) Suggested PR comment (optional)
- Offer: "Want me to draft a PR comment to the author?"
- If yes, provide a ready-to-paste comment summarizing the above, with clear asks.

View File

@@ -205,7 +205,7 @@
"filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift",
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
"is_verified": false,
"line_number": 1763
"line_number": 1859
}
],
"apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [
@@ -266,7 +266,7 @@
"filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
"is_verified": false,
"line_number": 1763
"line_number": 1859
}
],
"docs/.i18n/zh-CN.tm.jsonl": [
@@ -11659,7 +11659,7 @@
"filename": "src/agents/tools/web-search.ts",
"hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b",
"is_verified": false,
"line_number": 292
"line_number": 291
}
],
"src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [
@@ -13013,5 +13013,5 @@
}
]
},
"generated_at": "2026-03-09T08:37:13Z"
"generated_at": "2026-03-10T03:11:06Z"
}

View File

@@ -48,4 +48,4 @@
--allman false
# Exclusions
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/OpenClawProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift

View File

@@ -18,7 +18,7 @@ excluded:
- coverage
- "*.playground"
# Generated (protocol-gen-swift.ts)
- apps/macos/Sources/MoltbotProtocol/GatewayModels.swift
- apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
# Generated (generate-host-env-security-policy-swift.mjs)
- apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift

View File

@@ -10,6 +10,36 @@
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
## Auto-close labels (issues and PRs)
- If an issue/PR matches one of the reasons below, apply the label and let `.github/workflows/auto-response.yml` handle comment/close/lock.
- Do not manually close + manually comment for these reasons.
- Why: keeps wording consistent, preserves automation behavior (`state_reason`, locking), and keeps triage/reporting searchable by label.
- `r:*` labels can be used on both issues and PRs.
- `r: skill`: close with guidance to publish skills on Clawhub.
- `r: support`: close with redirect to Discord support + stuck FAQ.
- `r: no-ci-pr`: close test-fix-only PRs for failing `main` CI and post the standard explanation.
- `r: too-many-prs`: close when author exceeds active PR limit.
- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies.
- `r: third-party-extension`: close with guidance to ship as third-party plugin.
- `r: moltbook`: close + lock as off-topic (not affiliated).
- `r: spam`: close + lock as spam (`lock_reason: spam`).
- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed).
- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label).
## PR truthfulness and bug-fix validation
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
- Before `/landpr`, run `/reviewpr` and require explicit evidence for bug-fix claims.
- Minimum merge gate for bug-fix PRs:
1. symptom evidence (repro/log/failing test),
2. verified root cause in code with file/line,
3. fix touches the implicated code path,
4. regression test (fail before/pass after) when feasible; if not feasible, include manual verification proof and why no test was added.
- If claim is unsubstantiated or likely hallucinated/BS: do not merge. Request evidence/changes, or close with `invalid` when appropriate.
- If linked issue appears wrong/outdated, correct triage first; do not merge speculative fixes.
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).

View File

@@ -6,18 +6,102 @@ Docs: https://docs.openclaw.ai
### Changes
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
- ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn.
- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc.
- iOS/Home canvas: add a bundled welcome screen with a live agent overview that refreshes on connect, reconnect, and foreground return, and move the compact connection pill off the top-left canvas overlay. (#42456) Thanks @ngutman.
- iOS/Home canvas: replace floating controls with a docked toolbar, make the bundled home scaffold adapt to smaller phones, and open chat in the resolved main session instead of a synthetic `ios` session. (#42456) Thanks @ngutman.
- Discord/auto threads: add `autoArchiveDuration` channel config for auto-created threads so Discord thread archiving can stay at 1 hour, 1 day, 3 days, or 1 week instead of always using the 1-hour default. (#35065) Thanks @davidguttman.
- OpenCode/onboarding: add new OpenCode Go provider, treat Zen and Go as one OpenCode setup in the wizard/docs while keeping the runtime providers split, store one shared OpenCode key for both profiles, and stop overriding the built-in `opencode-go` catalog routing. (#42313) Thanks @ImLukeF and @vincentkoc.
- macOS/chat UI: add a chat model picker, persist explicit thinking-level selections across relaunch, and harden provider-aware session model sync for the shared chat composer. (#42314) Thanks @ImLukeF.
- iOS/TestFlight: add a local beta release flow with Fastlane prepare/archive/upload support, canonical beta bundle IDs, and watch-app archive fixes. (#42991) Thanks @ngutman.
- macOS/onboarding: detect when remote gateways need a shared auth token, explain where to find it on the gateway host, and clarify when a successful check used paired-device auth instead. (#43100) Thanks @ngutman.
- Onboarding/Ollama: add first-class Ollama setup with Local or Cloud + Local modes, browser-based cloud sign-in, curated model suggestions, and cloud-model handling that skips unnecessary local pulls. (#41529) Thanks @BruceMacD.
### Breaking
- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky.
### Fixes
- Agents/text sanitization: strip leaked model control tokens (`<|...|>` and full-width `<...>` variants) from user-facing assistant text, preventing GLM-5 and DeepSeek internal delimiters from reaching end users. (#42173) Thanks @imwyvern.
- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant.
- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura.
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob.
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.
- Secret files: harden CLI and channel credential file reads against path-swap races by requiring direct regular files for `*File` secret inputs and rejecting symlink-backed secret files.
- Archive extraction: harden TAR and external `tar.bz2` installs against destination symlink and pre-existing child-symlink escapes by extracting into staging first and merging into the canonical destination with safe file opens.
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
- Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis.
- Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek.
- ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026.
- ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn.
- ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn.
- Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf.
- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky.
- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927.
- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x.
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
- ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky.
- ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky.
- ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky.
- ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky.
- ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky.
- Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf.
- ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky.
- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf.
- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky.
- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera.
- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf.
- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk.
- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu.
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2.
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng.
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026.
- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo.
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet.
- Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn.
- CI/CodeQL Swift toolchain: select Xcode 26.1 before installing Swift build tools so the CodeQL Swift job uses Swift tools 6.2 on `macos-latest`. (#41787) thanks @BunsDev.
- Sandbox/subagents: pass the real configured workspace through `sessions_spawn` inheritance when a parent agent runs in a copied-workspace sandbox, so child `/agent` mounts point at the configured workspace instead of the parent sandbox copy. (#40757) Thanks @dsantoreis.
- Mattermost/plugin send actions: normalize direct `replyTo` fallback handling so threaded plugin sends trim blank IDs and reuse the correct reply target again. (#41176) Thanks @hnykda.
- MS Teams/allowlist resolution: use the General channel conversation ID as the resolved team key (with Graph GUID fallback) so Bot Framework runtime `channelData.team.id` matching works for team and team/channel allowlist entries. (#41838) Thanks @BradGroux.
- Mattermost/Markdown formatting: preserve first-line indentation when stripping bot mentions so nested list items and indented code blocks keep their structure, and render Mattermost tables natively by default instead of fenced-code fallback. (#18655) thanks @echo931.
- Agents/fallback cooldown probing: cap cooldown-bypass probing to one attempt per provider per fallback run so multi-model same-provider cooldown chains can continue to cross-provider fallbacks instead of repeatedly stalling on duplicate cooldown probes. (#41711) Thanks @cgdusek.
- Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc.
- Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc.
- Agents/fallback: recognize Poe `402 You've used up your points!` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#42278) Thanks @CryUshio.
- Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus.
- Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung.
- Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode.
- Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant.
- Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant.
- Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf.
- Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu.
- Subagents/authority: persist leaf vs orchestrator control scope at spawn time and route tool plus slash-command control through shared ownership checks, so leaf sessions cannot regain orchestration privileges after restore or flat-key lookups. Thanks @tdjackey.
- ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman.
- Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab.
- Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn.
- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant.
- Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases.
- Gateway/auth: fail closed when local `gateway.auth.*` SecretRefs are configured but unavailable, instead of silently falling back to `gateway.remote.*` credentials in local mode. (#42672) Thanks @joshavant.
- Sandbox/fs bridge: pin staged writes to verified parent directories so temporary write files cannot materialize outside the allowed mount before atomic replace. Thanks @tdjackey.
- Commands/config writes: enforce `configWrites` against both the originating account and the targeted account scope for `/config` and config-backed `/allowlist` edits, blocking sibling-account mutations while preserving gateway `operator.admin` flows. Thanks @tdjackey for reporting.
- Security/system.run: fail closed for approval-backed interpreter/runtime commands when OpenClaw cannot bind exactly one concrete local file operand, while extending best-effort direct-file binding to additional runtime forms. Thanks @tdjackey for reporting.
- Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting.
- Telegram/final preview delivery followup: keep ambiguous missing-`message_id` finals only when a preview was already visible, while first-preview/no-id cases still fall back so Telegram users do not lose the final reply. (#41932) thanks @hougangdev.
- Agents/Azure OpenAI Responses: include the `azure-openai` provider in the Responses API store override so Azure OpenAI multi-turn cron jobs and embedded agent runs no longer fail with HTTP 400 "store is set to false". (#42934, fixes #42800) Thanks @ademczuk.
- Agents/context pruning: prune image-only tool results during soft-trim, align context-pruning coverage with the new tool-result contract, and extend historical image cleanup to the same screenshot-heavy session path. (#43045) Thanks @MoerAI.
- fix(models): guard optional model.input capability checks (#42096) thanks @andyliu
- Security/plugin runtime: stop unauthenticated plugin HTTP routes from inheriting synthetic admin gateway scopes when they call `runtime.subagent.*`, so admin-only methods like `sessions.delete` stay blocked without gateway auth.
- Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`.
- Security/nodes: treat the `nodes` agent tool as owner-only fallback policy so non-owner senders cannot reach paired-node approval or invoke paths through the shared tool set.
- Telegram/final preview cleanup follow-up: clear stale cleanup-retain state only for transient preview finals so archived-preview retains no longer leave a stale partial bubble beside a later fallback-sent final. (#41763) Thanks @obviyus.
## 2026.3.8
@@ -73,6 +157,7 @@ Docs: https://docs.openclaw.ai
- Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark.
- Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung.
- Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc.
- Subagents/sandboxing: restrict leaf subagents to their own spawned runs and remove leaf `subagents` control access so sandboxed leaf workers can no longer steer sibling sessions. Thanks @tdjackey.
- Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis.
- Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468.
- Gateway/launchd respawn detection: treat `XPC_SERVICE_NAME` as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat.
@@ -81,8 +166,15 @@ Docs: https://docs.openclaw.ai
- Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so `cron`/`gateway` tooling remains available after the owner-auth hardening narrowed direct-message ownership inference.
- Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent.
- MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.
- Security/Gateway: block `device.token.rotate` from minting operator scopes broader than the caller session already holds, closing the critical paired-device token privilege escalation reported as GHSA-4jpw-hj22-2xmc.
- Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution.
- Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey.
- Control UI/Debug: replace the Manual RPC free-text method field with a sorted dropdown sourced from gateway-advertised methods, and stack the form vertically for narrower layouts. (#14967) thanks @rixau.
- Auth/profile resolution: log debug details when auto-discovered auth profiles fail during provider API-key resolution, so `--debug` output surfaces the real refresh/keychain/credential-store failure instead of only the generic missing-key message. (#41271) thanks @he-yufeng.
- ACP/cancel scoping: scope `chat.abort` and shared-session ACP event routing by `runId` so one session cannot cancel or consume another session's run when they share the same gateway session key. (#41331) Thanks @pejmanjohn.
- SecretRef/models: harden custom/provider secret persistence and reuse across models.json snapshots, merge behavior, runtime headers, and secret audits. (#42554) Thanks @joshavant.
- macOS/browser proxy: serialize non-GET browser proxy request bodies through `AnyCodable.foundationValue` so nested JSON bodies no longer crash the macOS app with `Invalid type in JSON write (__SwiftValue)`. (#43069) Thanks @Effet.
- CLI/skills tables: keep terminal table borders aligned for wide graphemes, use full reported terminal width, and switch a few ambiguous skill icons to Terminal-safe emoji so `openclaw skills` renders more consistently in Terminal.app and iTerm. Thanks @vincentkoc.
## 2026.3.7
@@ -149,6 +241,7 @@ Docs: https://docs.openclaw.ai
- Onboarding/API key input hardening: strip non-Latin1 Unicode artifacts from normalized secret input (while preserving Latin-1 content and internal spaces) so malformed copied API keys cannot trigger HTTP header `ByteString` construction crashes; adds regression coverage for shared normalization and MiniMax auth header usage. (#24496) Thanks @fa6maalassaf.
- Kimi Coding/Anthropic tools compatibility: normalize `anthropic-messages` tool payloads to OpenAI-style `tools[].function` + compatible `tool_choice` when targeting Kimi Coding endpoints, restoring tool-call workflows that regressed after v2026.3.2. (#37038) Thanks @mochimochimochi-hub.
- Heartbeat/workspace-path guardrails: append explicit workspace `HEARTBEAT.md` path guidance (and `docs/heartbeat.md` avoidance) to heartbeat prompts so heartbeat runs target workspace checklists reliably across packaged install layouts. (#37037) Thanks @stofancy.
- Node/system.run approvals: bind approval prompts to the exact executed argv text and show shell payload only as a secondary preview, closing basename-spoofed wrapper approval mismatches. Thanks @tdjackey.
- Subagents/kill-complete announce race: when a late `subagent-complete` lifecycle event arrives after an earlier kill marker, clear stale kill suppression/cleanup flags and re-run announce cleanup so finished runs no longer get silently swallowed. (#37024) Thanks @cmfinlan.
- Agents/tool-result cleanup timeout hardening: on embedded runner teardown idle timeouts, clear pending tool-call state without persisting synthetic `missing tool result` entries, preventing timeout cleanups from poisoning follow-up turns; adds regression coverage for timeout clear-vs-flush behavior. (#37081) Thanks @Coyote-Den.
- Agents/openai-completions stream timeout hardening: ensure runtime undici global dispatchers use extended streaming body/header timeouts (including env-proxy dispatcher mode) before embedded runs, reducing forced mid-stream `terminated` failures on long generations; adds regression coverage for dispatcher selection and idempotent reconfiguration. (#9708) Thanks @scottchguard.
@@ -440,6 +533,8 @@ Docs: https://docs.openclaw.ai
- Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of `You`. (#39414) Thanks @obviyus.
- Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee.
- Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh.
- Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn.
- Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix.
## 2026.3.2

View File

@@ -73,6 +73,9 @@ Welcome to the lobster tank! 🦞
- **Robin Waslander** - Security, PR triage, bug fixes
- GitHub: [@hydro13](https://github.com/hydro13) · X: [@Robin_waslander](https://x.com/Robin_waslander)
- **Tengji (George) Zhang** - Chinese model APIs, cloud, pi
- GitHub: [@odysseus0](https://github.com/odysseus0) · X: [@odysseus0z](https://x.com/odysseus0z)
## How to Contribute
1. **Bugs & small fixes** → Open a PR!
@@ -83,6 +86,7 @@ Welcome to the lobster tank! 🦞
- Test locally with your OpenClaw instance
- Run tests: `pnpm build && pnpm check && pnpm test`
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
- Ensure CI checks pass
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
- Describe what & why
@@ -96,6 +100,8 @@ If a review bot leaves review conversations on your PR, you are expected to hand
- Resolve the conversation yourself once the code or explanation fully addresses the bot's concern
- Reply and leave it open only when you need maintainer or reviewer judgment
- Do not leave "fixed" bot review conversations for maintainers to clean up for you
- If Codex leaves comments, address every relevant one or resolve it with a short explanation when it is not applicable to your change
- If GitHub Codex review does not trigger for some reason, run `codex review --base origin/main` locally anyway and treat that output as required review work
This applies to both human-authored and AI-assisted PRs.
@@ -124,6 +130,7 @@ Please include in your PR:
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
- [ ] Include prompts or session logs if possible (super helpful!)
- [ ] Confirm you understand what the code does
- [ ] If you have access to Codex, run `codex review --base origin/main` locally and address the findings before asking for review
- [ ] Resolve or reply to bot review conversations after you address them
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. If you are using an LLM coding agent, instruct it to resolve bot review conversations it has addressed instead of leaving them for maintainers.

View File

@@ -125,6 +125,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses.
- Reports whose only claim is heuristic/parity drift in command-risk detection (for example obfuscation-pattern checks) across exec surfaces, without a demonstrated trust-boundary bypass. These are hardening-only findings and are not vulnerabilities; triage may close them as `invalid`/`no-action` or track them separately as low/informational hardening.
- Reports whose only claim is that exec approvals do not semantically model every interpreter/runtime loader form, subcommand, flag combination, package script, or transitive module/config import. Exec approvals bind exact request context and best-effort direct local file operands; they are not a complete semantic model of everything a runtime may load.
- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact
- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass.
- Reports whose only claim is that a platform-provided upload destination URL is untrusted (for example Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl`) without proving attacker control in an authenticated production flow.
@@ -165,6 +166,7 @@ OpenClaw separates routing from execution, but both remain inside the same opera
- **Gateway** is the control plane. If a caller passes Gateway auth, they are treated as a trusted operator for that Gateway.
- **Node** is an execution extension of the Gateway. Pairing a node grants operator-level remote capability on that node.
- **Exec approvals** (allowlist/ask UI) are operator guardrails to reduce accidental command execution, not a multi-tenant authorization boundary.
- Exec approvals bind exact command/cwd/env context and, when OpenClaw can identify one concrete local script/file operand, that file snapshot too. This is best-effort integrity hardening, not a complete semantic model of every interpreter/runtime loader path.
- Differences in command-risk warning heuristics between exec surfaces (`gateway`, `node`, `sandbox`) do not, by themselves, constitute a security-boundary bypass.
- For untrusted-user isolation, split by trust boundary: separate gateways and separate OS users/hosts per boundary.

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.9</string>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>20260308</string>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@@ -1,10 +1,12 @@
// Shared iOS signing defaults for local development + CI.
#include "Version.xcconfig"
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.ios.activitywidget
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
// Local contributors can override this by running scripts/ios-configure-signing.sh.
// Keep include after defaults: xcconfig is evaluated top-to-bottom.

View File

@@ -0,0 +1,8 @@
// Shared iOS version defaults.
// Generated overrides live in build/Version.xcconfig (git-ignored).
OPENCLAW_GATEWAY_VERSION = 0.0.0
OPENCLAW_MARKETING_VERSION = 0.0.0
OPENCLAW_BUILD_VERSION = 0
#include? "../build/Version.xcconfig"

View File

@@ -1,15 +1,12 @@
# OpenClaw iOS (Super Alpha)
NO TEST FLIGHT AVAILABLE AT THIS POINT
This iPhone app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node`.
## Distribution Status
NO TEST FLIGHT AVAILABLE AT THIS POINT
- Current distribution: local/manual deploy from source via Xcode.
- App Store flow is not part of the current internal development path.
- Public distribution: not available.
- Internal beta distribution: local archive + TestFlight upload via Fastlane.
- Local/manual deploy from source via Xcode remains the default development path.
## Super-Alpha Disclaimer
@@ -50,6 +47,45 @@ Shortcut command (same flow + open project):
pnpm ios:open
```
## Local Beta Release Flow
Prereqs:
- Xcode 16+
- `pnpm`
- `xcodegen`
- `fastlane`
- Apple account signed into Xcode for automatic signing/provisioning
- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh` when auto-resolving a beta build number or uploading to TestFlight
Release behavior:
- Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`.
- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- Root `package.json.version` is the only version source for iOS.
- A root version like `2026.3.9-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.9`
- `CFBundleVersion = next TestFlight build number for 2026.3.9`
Archive without upload:
```bash
pnpm ios:beta:archive
```
Archive and upload to TestFlight:
```bash
pnpm ios:beta
```
If you need to force a specific build number:
```bash
pnpm ios:beta -- --build-number 7
```
## APNs Expectations For Local/Manual Builds
- The app calls `registerForRemoteNotifications()` at launch.

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.9</string>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>20260308</string>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

View File

@@ -2,6 +2,8 @@
// Auto-selected local team overrides live in .local-signing.xcconfig (git-ignored).
// Manual local overrides can go in LocalSigning.xcconfig (git-ignored).
#include "Config/Version.xcconfig"
OPENCLAW_CODE_SIGN_STYLE = Manual
OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ

View File

@@ -0,0 +1,223 @@
import SwiftUI
struct HomeToolbar: View {
var gateway: StatusPill.GatewayState
var voiceWakeEnabled: Bool
var activity: StatusPill.Activity?
var brighten: Bool
var talkButtonEnabled: Bool
var talkActive: Bool
var talkTint: Color
var onStatusTap: () -> Void
var onChatTap: () -> Void
var onTalkTap: () -> Void
var onSettingsTap: () -> Void
@Environment(\.colorSchemeContrast) private var contrast
var body: some View {
VStack(spacing: 0) {
Rectangle()
.fill(.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.18 : 0.12)))
.frame(height: self.contrast == .increased ? 1.0 : 0.6)
.allowsHitTesting(false)
HStack(spacing: 12) {
HomeToolbarStatusButton(
gateway: self.gateway,
voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.activity,
brighten: self.brighten,
onTap: self.onStatusTap)
Spacer(minLength: 0)
HStack(spacing: 8) {
HomeToolbarActionButton(
systemImage: "text.bubble.fill",
accessibilityLabel: "Chat",
brighten: self.brighten,
action: self.onChatTap)
if self.talkButtonEnabled {
HomeToolbarActionButton(
systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle",
accessibilityLabel: self.talkActive ? "Talk Mode On" : "Talk Mode Off",
brighten: self.brighten,
tint: self.talkTint,
isActive: self.talkActive,
action: self.onTalkTap)
}
HomeToolbarActionButton(
systemImage: "gearshape.fill",
accessibilityLabel: "Settings",
brighten: self.brighten,
action: self.onSettingsTap)
}
}
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 8)
}
.frame(maxWidth: .infinity)
.background(.ultraThinMaterial)
.overlay(alignment: .top) {
LinearGradient(
colors: [
.white.opacity(self.brighten ? 0.10 : 0.06),
.clear,
],
startPoint: .top,
endPoint: .bottom)
.allowsHitTesting(false)
}
}
}
private struct HomeToolbarStatusButton: View {
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorSchemeContrast) private var contrast
var gateway: StatusPill.GatewayState
var voiceWakeEnabled: Bool
var activity: StatusPill.Activity?
var brighten: Bool
var onTap: () -> Void
@State private var pulse: Bool = false
var body: some View {
Button(action: self.onTap) {
HStack(spacing: 8) {
HStack(spacing: 6) {
Circle()
.fill(self.gateway.color)
.frame(width: 8, height: 8)
.scaleEffect(
self.gateway == .connecting && !self.reduceMotion
? (self.pulse ? 1.15 : 0.85)
: 1.0
)
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.gateway.title)
.font(.footnote.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
}
if let activity {
Image(systemName: activity.systemImage)
.font(.footnote.weight(.semibold))
.foregroundStyle(activity.tint ?? .primary)
.transition(.opacity.combined(with: .move(edge: .top)))
} else {
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
.font(.footnote.weight(.semibold))
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.black.opacity(self.brighten ? 0.12 : 0.18))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(
.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.22 : 0.16)),
lineWidth: self.contrast == .increased ? 1.0 : 0.6)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel("Connection Status")
.accessibilityValue(self.accessibilityValue)
.accessibilityHint(self.gateway == .connected ? "Double tap for gateway actions" : "Double tap to open settings")
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) }
.onDisappear { self.pulse = false }
.onChange(of: self.gateway) { _, newValue in
self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion)
}
.onChange(of: self.scenePhase) { _, newValue in
self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion)
}
.onChange(of: self.reduceMotion) { _, newValue in
self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue)
}
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
}
private var accessibilityValue: String {
if let activity {
return "\(self.gateway.title), \(activity.title)"
}
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
}
private func updatePulse(for gateway: StatusPill.GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) {
guard gateway == .connecting, scenePhase == .active, !reduceMotion else {
withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false }
return
}
guard !self.pulse else { return }
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
self.pulse = true
}
}
}
private struct HomeToolbarActionButton: View {
@Environment(\.colorSchemeContrast) private var contrast
let systemImage: String
let accessibilityLabel: String
let brighten: Bool
var tint: Color?
var isActive: Bool = false
let action: () -> Void
var body: some View {
Button(action: self.action) {
Image(systemName: self.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary)
.frame(width: 40, height: 40)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.black.opacity(self.brighten ? 0.12 : 0.18))
.overlay {
if let tint {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [
tint.opacity(self.isActive ? 0.22 : 0.14),
tint.opacity(self.isActive ? 0.08 : 0.04),
.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blendMode(.overlay)
}
}
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(
(self.tint ?? .white).opacity(
self.isActive
? 0.34
: (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16))
),
lineWidth: self.contrast == .increased ? 1.0 : (self.isActive ? 0.8 : 0.6))
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(self.accessibilityLabel)
}
}

View File

@@ -23,7 +23,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.9</string>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -36,7 +36,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>20260308</string>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>

View File

@@ -34,18 +34,11 @@ extension NodeAppModel {
}
func showA2UIOnConnectIfNeeded() async {
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if current.isEmpty || current == self.lastAutoA2uiURL {
if let canvasUrl = await self.resolveCanvasHostURLWithCapabilityRefresh(),
let url = URL(string: canvasUrl),
await Self.probeTCP(url: url, timeoutSeconds: 2.5)
{
self.screen.navigate(to: canvasUrl)
self.lastAutoA2uiURL = canvasUrl
} else {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
await MainActor.run {
// Keep the bundled home canvas as the default connected view.
// Agents can still explicitly present a remote or local canvas later.
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
}

View File

@@ -88,6 +88,7 @@ final class NodeAppModel {
var selectedAgentId: String?
var gatewayDefaultAgentId: String?
var gatewayAgents: [AgentSummary] = []
var homeCanvasRevision: Int = 0
var lastShareEventText: String = "No share events yet."
var openChatRequestID: Int = 0
private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt?
@@ -362,7 +363,14 @@ final class NodeAppModel {
await MainActor.run {
self.operatorConnected = false
self.gatewayConnected = false
// Foreground recovery must actively restart the saved gateway config.
// Disconnecting stale sockets alone can leave us idle if the old
// reconnect tasks were suppressed or otherwise got stuck in background.
self.gatewayStatusText = "Reconnecting…"
self.talkMode.updateGatewayConnected(false)
if let cfg = self.activeGatewayConnectConfig {
self.applyGatewayConnectConfig(cfg)
}
}
}
}
@@ -541,6 +549,7 @@ final class NodeAppModel {
self.seamColorHex = raw.isEmpty ? nil : raw
self.mainSessionBaseKey = mainKey
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
}
} catch {
if let gatewayError = error as? GatewayResponseError {
@@ -567,12 +576,19 @@ final class NodeAppModel {
self.selectedAgentId = nil
}
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
}
} catch {
// Best-effort only.
}
}
func refreshGatewayOverviewIfConnected() async {
guard await self.isOperatorConnected() else { return }
await self.refreshBrandingFromGateway()
await self.refreshAgentsFromGateway()
}
func setSelectedAgentId(_ agentId: String?) {
let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
@@ -583,6 +599,7 @@ final class NodeAppModel {
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId)
}
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
if let relay = ShareGatewayRelaySettings.loadConfig() {
ShareGatewayRelaySettings.saveConfig(
ShareGatewayRelayConfig(
@@ -1622,11 +1639,9 @@ extension NodeAppModel {
}
var chatSessionKey: String {
let base = "ios"
let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base }
return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base)
// Keep chat aligned with the gateway's resolved main session key.
// A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI.
self.mainSessionKey
}
var activeAgentName: String {
@@ -1742,6 +1757,7 @@ private extension NodeAppModel {
self.gatewayDefaultAgentId = nil
self.gatewayAgents = []
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
self.homeCanvasRevision &+= 1
self.apnsLastRegisteredTokenHex = nil
}
@@ -2239,8 +2255,7 @@ extension NodeAppModel {
from: payload)
guard !decoded.actions.isEmpty else { return }
self.pendingActionLogger.info(
"Pending actions pulled trigger=\(trigger, privacy: .public) "
+ "count=\(decoded.actions.count, privacy: .public)")
"Pending actions pulled trigger=\(trigger, privacy: .public) count=\(decoded.actions.count, privacy: .public)")
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
} catch {
// Best-effort only.
@@ -2263,9 +2278,7 @@ extension NodeAppModel {
paramsJSON: action.paramsJSON)
let result = await self.handleInvoke(req)
self.pendingActionLogger.info(
"Pending action replay trigger=\(trigger, privacy: .public) "
+ "id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) "
+ "ok=\(result.ok, privacy: .public)")
"Pending action replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) ok=\(result.ok, privacy: .public)")
guard result.ok else { return }
let acked = await self.ackPendingForegroundNodeAction(
id: action.id,
@@ -2290,9 +2303,7 @@ extension NodeAppModel {
return true
} catch {
self.pendingActionLogger.error(
"Pending action ack failed trigger=\(trigger, privacy: .public) "
+ "id=\(id, privacy: .public) command=\(command, privacy: .public) "
+ "error=\(String(describing: error), privacy: .public)")
"Pending action ack failed trigger=\(trigger, privacy: .public) id=\(id, privacy: .public) command=\(command, privacy: .public) error=\(String(describing: error), privacy: .public)")
return false
}
}

View File

@@ -536,7 +536,7 @@ struct OnboardingWizardView: View {
Text(
"Approve this device on the gateway.\n"
+ "1) `openclaw devices approve` (or `openclaw devices approve <requestId>`)\n"
+ "2) `/pair approve` in Telegram\n"
+ "2) `/pair approve` in your OpenClaw chat\n"
+ "\(requestLine)\n"
+ "OpenClaw will also retry automatically when you return to this app.")
}

View File

@@ -1,5 +1,6 @@
import SwiftUI
import UIKit
import OpenClawProtocol
struct RootCanvas: View {
@Environment(NodeAppModel.self) private var appModel
@@ -137,16 +138,33 @@ struct RootCanvas: View {
.environment(self.gatewayController)
}
.onAppear { self.updateIdleTimer() }
.onAppear { self.updateHomeCanvasState() }
.onAppear { self.evaluateOnboardingPresentation(force: false) }
.onAppear { self.maybeAutoOpenSettings() }
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, newValue in
self.updateIdleTimer()
self.updateHomeCanvasState()
guard newValue == .active else { return }
Task {
await self.appModel.refreshGatewayOverviewIfConnected()
await MainActor.run {
self.updateHomeCanvasState()
}
}
}
.onAppear { self.maybeShowQuickSetup() }
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
.onAppear { self.updateCanvasDebugStatus() }
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayStatusText) { _, _ in
self.updateCanvasDebugStatus()
self.updateHomeCanvasState()
}
.onChange(of: self.appModel.gatewayServerName) { _, _ in
self.updateCanvasDebugStatus()
self.updateHomeCanvasState()
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
if newValue != nil {
self.showOnboarding = false
@@ -155,7 +173,13 @@ struct RootCanvas: View {
.onChange(of: self.onboardingRequestID) { _, _ in
self.evaluateOnboardingPresentation(force: true)
}
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in
self.updateCanvasDebugStatus()
self.updateHomeCanvasState()
}
.onChange(of: self.appModel.homeCanvasRevision) { _, _ in
self.updateHomeCanvasState()
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
if newValue != nil {
self.onboardingComplete = true
@@ -209,6 +233,134 @@ struct RootCanvas: View {
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
private func updateHomeCanvasState() {
let payload = self.makeHomeCanvasPayload()
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else {
self.appModel.screen.updateHomeCanvasState(json: nil)
return
}
self.appModel.screen.updateHomeCanvasState(json: json)
}
private func makeHomeCanvasPayload() -> HomeCanvasPayload {
let gatewayName = self.normalized(self.appModel.gatewayServerName)
let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress)
let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway"
let activeAgentID = self.resolveActiveAgentID()
let agents = self.homeCanvasAgents(activeAgentID: activeAgentID)
switch self.gatewayStatus {
case .connected:
return HomeCanvasPayload(
gatewayState: "connected",
eyebrow: "Connected to \(gatewayLabel)",
title: "Your agents are ready",
subtitle:
"This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.",
gatewayLabel: gatewayLabel,
activeAgentName: self.appModel.activeAgentName,
activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC",
activeAgentCaption: "Selected on this phone",
agentCount: agents.count,
agents: Array(agents.prefix(6)),
footer: "The overview refreshes on reconnect and when the app returns to foreground.")
case .connecting:
return HomeCanvasPayload(
gatewayState: "connecting",
eyebrow: "Reconnecting",
title: "OpenClaw is syncing back up",
subtitle:
"The gateway session is coming back online. "
+ "Agent shortcuts should settle automatically in a moment.",
gatewayLabel: gatewayLabel,
activeAgentName: self.appModel.activeAgentName,
activeAgentBadge: "OC",
activeAgentCaption: "Gateway session in progress",
agentCount: agents.count,
agents: Array(agents.prefix(4)),
footer: "If the gateway is reachable, reconnect should complete without intervention.")
case .error, .disconnected:
return HomeCanvasPayload(
gatewayState: self.gatewayStatus == .error ? "error" : "offline",
eyebrow: "Welcome to OpenClaw",
title: "Your phone stays quiet until it is needed",
subtitle:
"Pair this device to your gateway to wake it only for real work, "
+ "keep a live agent overview handy, and avoid battery-draining background loops.",
gatewayLabel: gatewayLabel,
activeAgentName: "Main",
activeAgentBadge: "OC",
activeAgentCaption: "Connect to load your agents",
agentCount: agents.count,
agents: Array(agents.prefix(4)),
footer:
"When connected, the gateway can wake the phone with a silent push "
+ "instead of holding an always-on session.")
}
}
private func resolveActiveAgentID() -> String {
let selected = self.normalized(self.appModel.selectedAgentId) ?? ""
if !selected.isEmpty {
return selected
}
return self.resolveDefaultAgentID()
}
private func resolveDefaultAgentID() -> String {
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
}
private func homeCanvasAgents(activeAgentID: String) -> [HomeCanvasAgentCard] {
let defaultAgentID = self.resolveDefaultAgentID()
let cards = self.appModel.gatewayAgents.map { agent -> HomeCanvasAgentCard in
let isActive = !activeAgentID.isEmpty && agent.id == activeAgentID
let isDefault = !defaultAgentID.isEmpty && agent.id == defaultAgentID
return HomeCanvasAgentCard(
id: agent.id,
name: self.homeCanvasName(for: agent),
badge: self.homeCanvasBadge(for: agent),
caption: isActive ? "Active on this phone" : (isDefault ? "Default agent" : "Ready"),
isActive: isActive)
}
return cards.sorted { lhs, rhs in
if lhs.isActive != rhs.isActive {
return lhs.isActive
}
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
}
private func homeCanvasName(for agent: AgentSummary) -> String {
self.normalized(agent.name) ?? agent.id
}
private func homeCanvasBadge(for agent: AgentSummary) -> String {
if let identity = agent.identity,
let emoji = identity["emoji"]?.value as? String,
let normalizedEmoji = self.normalized(emoji)
{
return normalizedEmoji
}
let words = self.homeCanvasName(for: agent)
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
.prefix(2)
let initials = words.compactMap { $0.first }.map(String.init).joined()
if !initials.isEmpty {
return initials.uppercased()
}
return "OC"
}
private func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func evaluateOnboardingPresentation(force: Bool) {
if force {
self.onboardingAllowSkip = true
@@ -274,6 +426,28 @@ struct RootCanvas: View {
}
}
private struct HomeCanvasPayload: Codable {
var gatewayState: String
var eyebrow: String
var title: String
var subtitle: String
var gatewayLabel: String
var activeAgentName: String
var activeAgentBadge: String
var activeAgentCaption: String
var agentCount: Int
var agents: [HomeCanvasAgentCard]
var footer: String
}
private struct HomeCanvasAgentCard: Codable {
var id: String
var name: String
var badge: String
var caption: String
var isActive: Bool
}
private struct CanvasContent: View {
@Environment(NodeAppModel.self) private var appModel
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
@@ -301,53 +475,33 @@ private struct CanvasContent: View {
.transition(.opacity)
}
}
.overlay(alignment: .topLeading) {
HStack(alignment: .top, spacing: 8) {
StatusPill(
gateway: self.gatewayStatus,
voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.statusActivity,
brighten: self.brightenButtons,
onTap: {
if self.gatewayStatus == .connected {
self.showGatewayActions = true
} else {
self.openSettings()
}
})
.layoutPriority(1)
Spacer(minLength: 8)
HStack(spacing: 8) {
OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) {
self.openChat()
}
.accessibilityLabel("Chat")
if self.talkButtonEnabled {
// Keep Talk mode near status controls while freeing right-side screen real estate.
OverlayButton(
systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle",
brighten: self.brightenButtons,
tint: self.appModel.seamColor,
isActive: self.talkActive)
{
let next = !self.talkActive
self.talkEnabled = next
self.appModel.setTalkEnabled(next)
}
.accessibilityLabel("Talk Mode")
}
OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) {
.safeAreaInset(edge: .bottom, spacing: 0) {
HomeToolbar(
gateway: self.gatewayStatus,
voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.statusActivity,
brighten: self.brightenButtons,
talkButtonEnabled: self.talkButtonEnabled,
talkActive: self.talkActive,
talkTint: self.appModel.seamColor,
onStatusTap: {
if self.gatewayStatus == .connected {
self.showGatewayActions = true
} else {
self.openSettings()
}
.accessibilityLabel("Settings")
}
}
.padding(.horizontal, 10)
.safeAreaPadding(.top, 10)
},
onChatTap: {
self.openChat()
},
onTalkTap: {
let next = !self.talkActive
self.talkEnabled = next
self.appModel.setTalkEnabled(next)
},
onSettingsTap: {
self.openSettings()
})
}
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
@@ -380,63 +534,6 @@ private struct CanvasContent: View {
}
}
private struct OverlayButton: View {
let systemImage: String
let brighten: Bool
var tint: Color?
var isActive: Bool = false
let action: () -> Void
var body: some View {
Button(action: self.action) {
Image(systemName: self.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary)
.padding(10)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [
.white.opacity(self.brighten ? 0.26 : 0.18),
.white.opacity(self.brighten ? 0.08 : 0.04),
.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blendMode(.overlay)
}
.overlay {
if let tint {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [
tint.opacity(self.isActive ? 0.22 : 0.14),
tint.opacity(self.isActive ? 0.10 : 0.06),
.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blendMode(.overlay)
}
}
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(
(self.tint ?? .white).opacity(self.isActive ? 0.34 : (self.brighten ? 0.24 : 0.18)),
lineWidth: self.isActive ? 0.7 : 0.5)
}
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
}
}
.buttonStyle(.plain)
}
}
private struct CameraFlashOverlay: View {
var nonce: Int

View File

@@ -20,6 +20,7 @@ final class ScreenController {
private var debugStatusEnabled: Bool = false
private var debugStatusTitle: String?
private var debugStatusSubtitle: String?
private var homeCanvasStateJSON: String?
init() {
self.reload()
@@ -94,6 +95,26 @@ final class ScreenController {
subtitle: self.debugStatusSubtitle)
}
func updateHomeCanvasState(json: String?) {
self.homeCanvasStateJSON = json
self.applyHomeCanvasStateIfNeeded()
}
func applyHomeCanvasStateIfNeeded() {
guard let webView = self.activeWebView else { return }
let payload = self.homeCanvasStateJSON ?? "null"
let js = """
(() => {
try {
const api = globalThis.__openclaw;
if (!api || typeof api.renderHome !== 'function') return;
api.renderHome(\(payload));
} catch (_) {}
})()
"""
webView.evaluateJavaScript(js) { _, _ in }
}
func waitForA2UIReady(timeoutMs: Int) async -> Bool {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .milliseconds(timeoutMs))
@@ -191,6 +212,7 @@ final class ScreenController {
self.activeWebView = webView
self.reload()
self.applyDebugStatusIfNeeded()
self.applyHomeCanvasStateIfNeeded()
}
func detachWebView(_ webView: WKWebView) {

View File

@@ -7,7 +7,7 @@ struct ScreenTab: View {
var body: some View {
ZStack(alignment: .top) {
ScreenWebView(controller: self.appModel.screen)
.ignoresSafeArea()
.ignoresSafeArea(.container, edges: [.top, .leading, .trailing])
.overlay(alignment: .top) {
if let errorText = self.appModel.screen.errorText,
self.appModel.gatewayServerName == nil

View File

@@ -161,6 +161,7 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
func webView(_: WKWebView, didFinish _: WKNavigation?) {
self.controller?.errorText = nil
self.controller?.applyDebugStatusIfNeeded()
self.controller?.applyHomeCanvasStateIfNeeded()
}
func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) {

View File

@@ -65,10 +65,10 @@ struct SettingsTab: View {
DisclosureGroup(isExpanded: self.$gatewayExpanded) {
if !self.isGatewayConnected {
Text(
"1. Open Telegram and message your bot: /pair\n"
"1. Open a chat with your OpenClaw agent and send /pair\n"
+ "2. Copy the setup code it returns\n"
+ "3. Paste here and tap Connect\n"
+ "4. Back in Telegram, run /pair approve")
+ "4. Back in that chat, run /pair approve")
.font(.footnote)
.foregroundStyle(.secondary)
@@ -340,9 +340,9 @@ struct SettingsTab: View {
.foregroundStyle(.secondary)
}
self.featureToggle(
"Show Talk Button",
"Show Talk Control",
isOn: self.$talkButtonEnabled,
help: "Shows the floating Talk button in the main interface.")
help: "Shows the Talk control in the main toolbar.")
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
.lineLimit(2 ... 6)
.textInputAutocapitalization(.sentences)
@@ -896,7 +896,7 @@ struct SettingsTab: View {
guard !trimmed.isEmpty else { return nil }
let lower = trimmed.lowercased()
if lower.contains("pairing required") {
return "Pairing required. Go back to Telegram and run /pair approve, then tap Connect again."
return "Pairing required. Go back to your OpenClaw chat and run /pair approve, then tap Connect again."
}
if lower.contains("device nonce required") || lower.contains("device nonce mismatch") {
return "Secure handshake failed. Make sure Tailscale is connected, then tap Connect again."

View File

@@ -38,6 +38,7 @@ struct StatusPill: View {
var gateway: GatewayState
var voiceWakeEnabled: Bool
var activity: Activity?
var compact: Bool = false
var brighten: Bool = false
var onTap: () -> Void
@@ -45,11 +46,11 @@ struct StatusPill: View {
var body: some View {
Button(action: self.onTap) {
HStack(spacing: 10) {
HStack(spacing: 8) {
HStack(spacing: self.compact ? 8 : 10) {
HStack(spacing: self.compact ? 6 : 8) {
Circle()
.fill(self.gateway.color)
.frame(width: 9, height: 9)
.frame(width: self.compact ? 8 : 9, height: self.compact ? 8 : 9)
.scaleEffect(
self.gateway == .connecting && !self.reduceMotion
? (self.pulse ? 1.15 : 0.85)
@@ -58,34 +59,38 @@ struct StatusPill: View {
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.gateway.title)
.font(.subheadline.weight(.semibold))
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
.foregroundStyle(.primary)
}
Divider()
.frame(height: 14)
.opacity(0.35)
if let activity {
HStack(spacing: 6) {
if !self.compact {
Divider()
.frame(height: 14)
.opacity(0.35)
}
HStack(spacing: self.compact ? 4 : 6) {
Image(systemName: activity.systemImage)
.font(.subheadline.weight(.semibold))
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
.foregroundStyle(activity.tint ?? .primary)
Text(activity.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
if !self.compact {
Text(activity.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
}
}
.transition(.opacity.combined(with: .move(edge: .top)))
} else {
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
.font(.subheadline.weight(.semibold))
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.statusGlassCard(brighten: self.brighten, verticalPadding: 8)
.statusGlassCard(brighten: self.brighten, verticalPadding: self.compact ? 6 : 8)
}
.buttonStyle(.plain)
.accessibilityLabel("Connection Status")

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.9</string>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>20260308</string>
<string>$(OPENCLAW_BUILD_VERSION)</string>
</dict>
</plist>

View File

@@ -83,16 +83,16 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
#expect(json.contains("\"value\""))
}
@Test @MainActor func chatSessionKeyDefaultsToIOSBase() {
@Test @MainActor func chatSessionKeyDefaultsToMainBase() {
let appModel = NodeAppModel()
#expect(appModel.chatSessionKey == "ios")
#expect(appModel.chatSessionKey == "main")
}
@Test @MainActor func chatSessionKeyUsesAgentScopedKeyForNonDefaultAgent() {
let appModel = NodeAppModel()
appModel.gatewayDefaultAgentId = "main"
appModel.setSelectedAgentId("agent-123")
#expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "ios"))
#expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "main"))
#expect(appModel.mainSessionKey == "agent:agent-123:main")
}

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.9</string>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>20260308</string>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>WKCompanionAppBundleIdentifier</key>
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
<key>WKWatchKitApp</key>

View File

@@ -15,9 +15,9 @@
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.9</string>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>20260308</string>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

View File

@@ -1,8 +1,11 @@
require "shellwords"
require "open3"
require "json"
default_platform(:ios)
BETA_APP_IDENTIFIER = "ai.openclaw.client"
def load_env_file(path)
return unless File.exist?(path)
@@ -84,6 +87,111 @@ def read_asc_key_content_from_keychain
end
end
def repo_root
File.expand_path("../../..", __dir__)
end
def ios_root
File.expand_path("..", __dir__)
end
def normalize_release_version(raw_value)
version = raw_value.to_s.strip.sub(/\Av/, "")
UI.user_error!("Missing root package.json version.") unless env_present?(version)
unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i)
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.9 or 2026.3.9-beta.1.")
end
version
end
def read_root_package_version
package_json_path = File.join(repo_root, "package.json")
UI.user_error!("Missing package.json at #{package_json_path}.") unless File.exist?(package_json_path)
parsed = JSON.parse(File.read(package_json_path))
normalize_release_version(parsed["version"])
rescue JSON::ParserError => e
UI.user_error!("Invalid package.json at #{package_json_path}: #{e.message}")
end
def short_release_version(version)
normalize_release_version(version).sub(/([.-]?beta[.-]\d+)\z/i, "")
end
def shell_join(parts)
Shellwords.join(parts.compact)
end
def resolve_beta_build_number(api_key:, version:)
explicit = ENV["IOS_BETA_BUILD_NUMBER"]
if env_present?(explicit)
UI.user_error!("Invalid IOS_BETA_BUILD_NUMBER '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/)
UI.message("Using explicit iOS beta build number #{explicit}.")
return explicit
end
short_version = short_release_version(version)
latest_build = latest_testflight_build_number(
api_key: api_key,
app_identifier: BETA_APP_IDENTIFIER,
version: short_version,
initial_build_number: 0
)
next_build = latest_build.to_i + 1
UI.message("Resolved iOS beta build number #{next_build} for #{short_version} (latest TestFlight build: #{latest_build}).")
next_build.to_s
end
def beta_build_number_needs_asc_auth?
explicit = ENV["IOS_BETA_BUILD_NUMBER"]
!env_present?(explicit)
end
def prepare_beta_release!(version:, build_number:)
script_path = File.join(repo_root, "scripts", "ios-beta-prepare.sh")
UI.message("Preparing iOS beta release #{version} (build #{build_number}).")
sh(shell_join(["bash", script_path, "--build-number", build_number]))
beta_xcconfig = File.join(ios_root, "build", "BetaRelease.xcconfig")
UI.user_error!("Missing beta xcconfig at #{beta_xcconfig}.") unless File.exist?(beta_xcconfig)
ENV["XCODE_XCCONFIG_FILE"] = beta_xcconfig
beta_xcconfig
end
def build_beta_release(context)
version = context[:version]
output_directory = File.join("build", "beta")
archive_path = File.join(output_directory, "OpenClaw-#{version}.xcarchive")
build_app(
project: "OpenClaw.xcodeproj",
scheme: "OpenClaw",
configuration: "Release",
export_method: "app-store",
clean: true,
skip_profile_detection: true,
build_path: "build",
archive_path: archive_path,
output_directory: output_directory,
output_name: "OpenClaw-#{version}.ipa",
xcargs: "-allowProvisioningUpdates",
export_xcargs: "-allowProvisioningUpdates",
export_options: {
signingStyle: "automatic"
}
)
{
archive_path: archive_path,
build_number: context[:build_number],
ipa_path: lane_context[SharedValues::IPA_OUTPUT_PATH],
short_version: context[:short_version],
version: version
}
end
platform :ios do
private_lane :asc_api_key do
load_env_file(File.join(__dir__, ".env"))
@@ -132,38 +240,48 @@ platform :ios do
api_key
end
desc "Build + upload to TestFlight"
private_lane :prepare_beta_context do |options|
require_api_key = options[:require_api_key] == true
needs_api_key = require_api_key || beta_build_number_needs_asc_auth?
api_key = needs_api_key ? asc_api_key : nil
version = read_root_package_version
build_number = resolve_beta_build_number(api_key: api_key, version: version)
beta_xcconfig = prepare_beta_release!(version: version, build_number: build_number)
{
api_key: api_key,
beta_xcconfig: beta_xcconfig,
build_number: build_number,
short_version: short_release_version(version),
version: version
}
end
desc "Build a beta archive locally without uploading"
lane :beta_archive do
context = prepare_beta_context(require_api_key: false)
build = build_beta_release(context)
UI.success("Built iOS beta archive: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
build
ensure
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Build + upload a beta to TestFlight"
lane :beta do
api_key = asc_api_key
team_id = ENV["IOS_DEVELOPMENT_TEAM"]
if team_id.nil? || team_id.strip.empty?
helper_path = File.expand_path("../../../scripts/ios-team-id.sh", __dir__)
if File.exist?(helper_path)
# Keep CI/local compatibility where teams are present in keychain but not Xcode account metadata.
team_id = sh("IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash #{helper_path.shellescape}").strip
end
end
UI.user_error!("Missing IOS_DEVELOPMENT_TEAM (Apple Team ID). Add it to fastlane/.env or export it in your shell.") if team_id.nil? || team_id.strip.empty?
build_app(
project: "OpenClaw.xcodeproj",
scheme: "OpenClaw",
export_method: "app-store",
clean: true,
skip_profile_detection: true,
xcargs: "DEVELOPMENT_TEAM=#{team_id} -allowProvisioningUpdates",
export_xcargs: "-allowProvisioningUpdates",
export_options: {
signingStyle: "automatic"
}
)
context = prepare_beta_context(require_api_key: true)
build = build_beta_release(context)
upload_to_testflight(
api_key: api_key,
api_key: context[:api_key],
ipa: build[:ipa_path],
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
UI.success("Uploaded iOS beta: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
ensure
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Upload App Store metadata (and optionally screenshots)"

View File

@@ -32,9 +32,9 @@ ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle):
```bash
ASC_APP_IDENTIFIER=ai.openclaw.ios
ASC_APP_IDENTIFIER=ai.openclaw.client
# or
ASC_APP_ID=6760218713
ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID
```
File-based fallback (CI/non-macOS):
@@ -60,9 +60,37 @@ cd apps/ios
fastlane ios auth_check
```
Run:
ASC auth is only required when:
- uploading to TestFlight
- auto-resolving the next build number from App Store Connect
If you pass `--build-number` to `pnpm ios:beta:archive`, the local archive path does not need ASC auth.
Archive locally without upload:
```bash
pnpm ios:beta:archive
```
Upload to TestFlight:
```bash
pnpm ios:beta
```
Direct Fastlane entry point:
```bash
cd apps/ios
fastlane beta
fastlane ios beta
```
Versioning rules:
- Root `package.json.version` is the single source of truth for iOS
- Use `YYYY.M.D` for stable versions and `YYYY.M.D-beta.N` for beta versions
- Fastlane stamps `CFBundleShortVersionString` to `YYYY.M.D`
- Fastlane resolves `CFBundleVersion` as the next integer TestFlight build number for that short version
- The beta flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
- Local beta signing uses a temporary generated xcconfig and leaves local development signing overrides untouched

View File

@@ -6,7 +6,7 @@ This directory is used by `fastlane deliver` for App Store Connect text metadata
```bash
cd apps/ios
ASC_APP_ID=6760218713 \
ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
DELIVER_METADATA=1 fastlane ios metadata
```

View File

@@ -107,8 +107,8 @@ targets:
- CFBundleURLName: ai.openclaw.ios
CFBundleURLSchemes:
- openclaw
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -168,8 +168,8 @@ targets:
path: ShareExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw Share
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
@@ -205,8 +205,8 @@ targets:
path: ActivityWidget/Info.plist
properties:
CFBundleDisplayName: OpenClaw Activity
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSSupportsLiveActivities: true
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
@@ -224,6 +224,7 @@ targets:
Release: Config/Signing.xcconfig
settings:
base:
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ENABLE_APPINTENTS_METADATA: NO
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
@@ -231,8 +232,8 @@ targets:
path: WatchApp/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
WKWatchKitApp: true
@@ -256,8 +257,8 @@ targets:
path: WatchExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionAttributes:
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
@@ -293,8 +294,8 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
OpenClawLogicTests:
type: bundle.unit-test
@@ -319,5 +320,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawLogicTests
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"

View File

@@ -600,30 +600,29 @@ final class AppState {
private func syncGatewayConfigIfNeeded() {
guard !self.isPreview, !self.isInitializing else { return }
let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity
let remoteTransport = self.remoteTransport
let remoteUrl = self.remoteUrl
let remoteToken = self.remoteToken
let remoteTokenDirty = self.remoteTokenDirty
Task { @MainActor in
// Keep app-only connection settings local to avoid overwriting remote gateway config.
let synced = Self.syncedGatewayRoot(
currentRoot: OpenClawConfigFile.loadDict(),
connectionMode: connectionMode,
remoteTransport: remoteTransport,
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity,
remoteUrl: remoteUrl,
remoteToken: remoteToken,
remoteTokenDirty: remoteTokenDirty)
guard synced.changed else { return }
OpenClawConfigFile.saveDict(synced.root)
self.syncGatewayConfigNow()
}
}
@MainActor
func syncGatewayConfigNow() {
guard !self.isPreview, !self.isInitializing else { return }
// Keep app-only connection settings local to avoid overwriting remote gateway config.
let synced = Self.syncedGatewayRoot(
currentRoot: OpenClawConfigFile.loadDict(),
connectionMode: self.connectionMode,
remoteTransport: self.remoteTransport,
remoteTarget: self.remoteTarget,
remoteIdentity: self.remoteIdentity,
remoteUrl: self.remoteUrl,
remoteToken: self.remoteToken,
remoteTokenDirty: self.remoteTokenDirty)
guard synced.changed else { return }
OpenClawConfigFile.saveDict(synced.root)
}
func triggerVoiceEars(ttl: TimeInterval? = 5) {
self.earBoostTask?.cancel()
self.earBoostActive = true

View File

@@ -188,6 +188,10 @@ final class ControlChannel {
return desc
}
if let authIssue = RemoteGatewayAuthIssue(error: error) {
return authIssue.statusMessage
}
// If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it.
if let urlErr = error as? URLError,
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures

View File

@@ -348,10 +348,18 @@ struct GeneralSettings: View {
Text("Testing…")
.font(.caption)
.foregroundStyle(.secondary)
case .ok:
Label("Ready", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
case let .ok(success):
VStack(alignment: .leading, spacing: 2) {
Label(success.title, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
if let detail = success.detail {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
case let .failed(message):
Text(message)
.font(.caption)
@@ -518,7 +526,7 @@ struct GeneralSettings: View {
private enum RemoteStatus: Equatable {
case idle
case checking
case ok
case ok(RemoteGatewayProbeSuccess)
case failed(String)
}
@@ -558,114 +566,14 @@ extension GeneralSettings {
@MainActor
func testRemote() async {
self.remoteStatus = .checking
let settings = CommandResolver.connectionSettings()
if self.state.remoteTransport == .direct {
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedUrl.isEmpty else {
self.remoteStatus = .failed("Set a gateway URL first")
return
}
guard Self.isValidWsUrl(trimmedUrl) else {
self.remoteStatus = .failed(
"Gateway URL must use wss:// for remote hosts (ws:// only for localhost)")
return
}
} else {
guard !settings.target.isEmpty else {
self.remoteStatus = .failed("Set an SSH target first")
return
}
// Step 1: basic SSH reachability check
guard let sshCommand = Self.sshCheckCommand(
target: settings.target,
identity: settings.identity)
else {
self.remoteStatus = .failed("SSH target is invalid")
return
}
let sshResult = await ShellExecutor.run(
command: sshCommand,
cwd: nil,
env: nil,
timeout: 8)
guard sshResult.ok else {
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
return
}
switch await RemoteGatewayProbe.run() {
case let .ready(success):
self.remoteStatus = .ok(success)
case let .authIssue(issue):
self.remoteStatus = .failed(issue.statusMessage)
case let .failed(message):
self.remoteStatus = .failed(message)
}
// Step 2: control channel health check
let originalMode = AppStateStore.shared.connectionMode
do {
try await ControlChannel.shared.configure(mode: .remote(
target: settings.target,
identity: settings.identity))
let data = try await ControlChannel.shared.health(timeout: 10)
if decodeHealthSnapshot(from: data) != nil {
self.remoteStatus = .ok
} else {
self.remoteStatus = .failed("Control channel returned invalid health JSON")
}
} catch {
self.remoteStatus = .failed(error.localizedDescription)
}
// Restore original mode if we temporarily switched
switch originalMode {
case .remote:
break
case .local:
try? await ControlChannel.shared.configure(mode: .local)
case .unconfigured:
await ControlChannel.shared.disconnect()
}
}
private static func isValidWsUrl(_ raw: String) -> Bool {
GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil
}
private static func sshCheckCommand(target: String, identity: String) -> [String]? {
guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
let options = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
let args = CommandResolver.sshArguments(
target: parsed,
identity: identity,
options: options,
remoteCommand: ["echo", "ok"])
return ["/usr/bin/ssh"] + args
}
private func formatSSHFailure(_ response: Response, target: String) -> String {
let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) }
let trimmed = payload?
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(whereSeparator: \.isNewline)
.joined(separator: " ")
if let trimmed,
trimmed.localizedCaseInsensitiveContains("host key verification failed")
{
let host = CommandResolver.parseSSHTarget(target)?.host ?? target
return "SSH check failed: Host key verification failed. Remove the old key with " +
"`ssh-keygen -R \(host)` and try again."
}
if let trimmed, !trimmed.isEmpty {
if let message = response.message, message.hasPrefix("exit ") {
return "SSH check failed: \(trimmed) (\(message))"
}
return "SSH check failed: \(trimmed)"
}
if let message = response.message {
return "SSH check failed (\(message))"
}
return "SSH check failed"
}
private func revealLogs() {

View File

@@ -146,8 +146,8 @@ actor MacNodeBrowserProxy {
request.setValue(password, forHTTPHeaderField: "x-openclaw-password")
}
if method != "GET", let body = params.body?.value {
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed])
if method != "GET", let body = params.body {
request.httpBody = try JSONSerialization.data(withJSONObject: body.foundationValue, options: [.fragmentsAllowed])
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}

View File

@@ -9,6 +9,13 @@ enum UIStrings {
static let welcomeTitle = "Welcome to OpenClaw"
}
enum RemoteOnboardingProbeState: Equatable {
case idle
case checking
case ok(RemoteGatewayProbeSuccess)
case failed(String)
}
@MainActor
final class OnboardingController {
static let shared = OnboardingController()
@@ -72,6 +79,9 @@ struct OnboardingView: View {
@State var didAutoKickoff = false
@State var showAdvancedConnection = false
@State var preferredGatewayID: String?
@State var remoteProbeState: RemoteOnboardingProbeState = .idle
@State var remoteAuthIssue: RemoteGatewayAuthIssue?
@State var suppressRemoteProbeReset = false
@State var gatewayDiscovery: GatewayDiscoveryModel
@State var onboardingChatModel: OpenClawChatViewModel
@State var onboardingSkillsModel = SkillsSettingsModel()

View File

@@ -2,6 +2,7 @@ import AppKit
import OpenClawChatUI
import OpenClawDiscovery
import OpenClawIPC
import OpenClawKit
import SwiftUI
extension OnboardingView {
@@ -97,6 +98,11 @@ extension OnboardingView {
self.gatewayDiscoverySection()
if self.shouldShowRemoteConnectionSection {
Divider().padding(.vertical, 4)
self.remoteConnectionSection()
}
self.connectionChoiceButton(
title: "Configure later",
subtitle: "Dont start the Gateway yet.",
@@ -109,6 +115,22 @@ extension OnboardingView {
}
}
}
.onChange(of: self.state.connectionMode) { _, newValue in
guard Self.shouldResetRemoteProbeFeedback(
for: newValue,
suppressReset: self.suppressRemoteProbeReset)
else { return }
self.resetRemoteProbeFeedback()
}
.onChange(of: self.state.remoteTransport) { _, _ in
self.resetRemoteProbeFeedback()
}
.onChange(of: self.state.remoteTarget) { _, _ in
self.resetRemoteProbeFeedback()
}
.onChange(of: self.state.remoteUrl) { _, _ in
self.resetRemoteProbeFeedback()
}
}
private var localGatewaySubtitle: String {
@@ -199,25 +221,6 @@ extension OnboardingView {
.pickerStyle(.segmented)
.frame(width: fieldWidth)
}
GridRow {
Text("Gateway token")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
if self.state.remoteTokenUnsupported {
GridRow {
Text("")
.frame(width: labelWidth, alignment: .leading)
Text(
"The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.")
.font(.caption)
.foregroundStyle(.orange)
.frame(width: fieldWidth, alignment: .leading)
}
}
if self.state.remoteTransport == .direct {
GridRow {
Text("Gateway URL")
@@ -289,6 +292,248 @@ extension OnboardingView {
}
}
private var shouldShowRemoteConnectionSection: Bool {
self.state.connectionMode == .remote ||
self.showAdvancedConnection ||
self.remoteProbeState != .idle ||
self.remoteAuthIssue != nil ||
Self.shouldShowRemoteTokenField(
showAdvancedConnection: self.showAdvancedConnection,
remoteToken: self.state.remoteToken,
remoteTokenUnsupported: self.state.remoteTokenUnsupported,
authIssue: self.remoteAuthIssue)
}
private var shouldShowRemoteTokenField: Bool {
guard self.shouldShowRemoteConnectionSection else { return false }
return Self.shouldShowRemoteTokenField(
showAdvancedConnection: self.showAdvancedConnection,
remoteToken: self.state.remoteToken,
remoteTokenUnsupported: self.state.remoteTokenUnsupported,
authIssue: self.remoteAuthIssue)
}
private var remoteProbePreflightMessage: String? {
switch self.state.remoteTransport {
case .direct:
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedUrl.isEmpty {
return "Select a nearby gateway or open Advanced to enter a gateway URL."
}
if GatewayRemoteConfig.normalizeGatewayUrl(trimmedUrl) == nil {
return "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)."
}
return nil
case .ssh:
let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedTarget.isEmpty {
return "Select a nearby gateway or open Advanced to enter an SSH target."
}
return CommandResolver.sshTargetValidationMessage(trimmedTarget)
}
}
private var canProbeRemoteConnection: Bool {
self.remoteProbePreflightMessage == nil && self.remoteProbeState != .checking
}
@ViewBuilder
private func remoteConnectionSection() -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text("Remote connection")
.font(.callout.weight(.semibold))
Text("Checks the real remote websocket and auth handshake.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
Button {
Task { await self.probeRemoteConnection() }
} label: {
if self.remoteProbeState == .checking {
ProgressView()
.controlSize(.small)
.frame(minWidth: 120)
} else {
Text("Check connection")
.frame(minWidth: 120)
}
}
.buttonStyle(.borderedProminent)
.disabled(!self.canProbeRemoteConnection)
}
if self.shouldShowRemoteTokenField {
self.remoteTokenField()
}
if let message = self.remoteProbePreflightMessage, self.remoteProbeState != .checking {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
self.remoteProbeStatusView()
if let issue = self.remoteAuthIssue {
self.remoteAuthPromptView(issue: issue)
}
}
}
private func remoteTokenField() -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .center, spacing: 12) {
Text("Gateway token")
.font(.callout.weight(.semibold))
.frame(width: 110, alignment: .leading)
SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 320)
}
Text("Used when the remote gateway requires token auth.")
.font(.caption)
.foregroundStyle(.secondary)
if self.state.remoteTokenUnsupported {
Text(
"The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.")
.font(.caption)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
}
}
@ViewBuilder
private func remoteProbeStatusView() -> some View {
switch self.remoteProbeState {
case .idle:
EmptyView()
case .checking:
Text("Checking remote gateway…")
.font(.caption)
.foregroundStyle(.secondary)
case let .ok(success):
VStack(alignment: .leading, spacing: 2) {
Label(success.title, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
if let detail = success.detail {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
case let .failed(message):
if self.remoteAuthIssue == nil {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private func remoteAuthPromptView(issue: RemoteGatewayAuthIssue) -> some View {
let promptStyle = Self.remoteAuthPromptStyle(for: issue)
return HStack(alignment: .top, spacing: 10) {
Image(systemName: promptStyle.systemImage)
.font(.caption.weight(.semibold))
.foregroundStyle(promptStyle.tint)
.frame(width: 16, alignment: .center)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 4) {
Text(issue.title)
.font(.caption.weight(.semibold))
Text(.init(issue.body))
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let footnote = issue.footnote {
Text(.init(footnote))
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
@MainActor
private func probeRemoteConnection() async {
let originalMode = self.state.connectionMode
let shouldRestoreMode = originalMode != .remote
if shouldRestoreMode {
// Reuse the shared remote endpoint stack for probing without committing the user's mode choice.
self.state.connectionMode = .remote
}
self.remoteProbeState = .checking
self.remoteAuthIssue = nil
defer {
if shouldRestoreMode {
self.suppressRemoteProbeReset = true
self.state.connectionMode = originalMode
self.suppressRemoteProbeReset = false
}
}
switch await RemoteGatewayProbe.run() {
case let .ready(success):
self.remoteProbeState = .ok(success)
case let .authIssue(issue):
self.remoteAuthIssue = issue
self.remoteProbeState = .failed(issue.statusMessage)
case let .failed(message):
self.remoteProbeState = .failed(message)
}
}
private func resetRemoteProbeFeedback() {
self.remoteProbeState = .idle
self.remoteAuthIssue = nil
}
static func remoteAuthPromptStyle(
for issue: RemoteGatewayAuthIssue)
-> (systemImage: String, tint: Color)
{
switch issue {
case .tokenRequired:
return ("key.fill", .orange)
case .tokenMismatch:
return ("exclamationmark.triangle.fill", .orange)
case .gatewayTokenNotConfigured:
return ("wrench.and.screwdriver.fill", .orange)
case .passwordRequired:
return ("lock.slash.fill", .orange)
case .pairingRequired:
return ("link.badge.plus", .orange)
}
}
static func shouldShowRemoteTokenField(
showAdvancedConnection: Bool,
remoteToken: String,
remoteTokenUnsupported: Bool,
authIssue: RemoteGatewayAuthIssue?) -> Bool
{
showAdvancedConnection ||
remoteTokenUnsupported ||
!remoteToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
authIssue?.showsTokenField == true
}
static func shouldResetRemoteProbeFeedback(
for connectionMode: AppState.ConnectionMode,
suppressReset: Bool) -> Bool
{
!suppressReset && connectionMode != .remote
}
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
if self.state.remoteTransport == .direct {
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"

View File

@@ -0,0 +1,222 @@
import Foundation
import OpenClawIPC
import OpenClawKit
enum RemoteGatewayAuthIssue: Equatable {
case tokenRequired
case tokenMismatch
case gatewayTokenNotConfigured
case passwordRequired
case pairingRequired
init?(error: Error) {
guard let authError = error as? GatewayConnectAuthError else {
return nil
}
switch authError.detail {
case .authTokenMissing:
self = .tokenRequired
case .authTokenMismatch:
self = .tokenMismatch
case .authTokenNotConfigured:
self = .gatewayTokenNotConfigured
case .authPasswordMissing, .authPasswordMismatch, .authPasswordNotConfigured:
self = .passwordRequired
case .pairingRequired:
self = .pairingRequired
default:
return nil
}
}
var showsTokenField: Bool {
switch self {
case .tokenRequired, .tokenMismatch:
true
case .gatewayTokenNotConfigured, .passwordRequired, .pairingRequired:
false
}
}
var title: String {
switch self {
case .tokenRequired:
"This gateway requires an auth token"
case .tokenMismatch:
"That token did not match the gateway"
case .gatewayTokenNotConfigured:
"This gateway host needs token setup"
case .passwordRequired:
"This gateway is using unsupported auth"
case .pairingRequired:
"This device needs pairing approval"
}
}
var body: String {
switch self {
case .tokenRequired:
"Paste the token configured on the gateway host. On the gateway host, run `openclaw config get gateway.auth.token`. If the gateway uses an environment variable instead, use `OPENCLAW_GATEWAY_TOKEN`."
case .tokenMismatch:
"Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again."
case .gatewayTokenNotConfigured:
"This gateway is set to token auth, but no `gateway.auth.token` is configured on the gateway host. If the gateway uses an environment variable instead, set `OPENCLAW_GATEWAY_TOKEN` before starting the gateway."
case .passwordRequired:
"This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry."
case .pairingRequired:
"Approve this device from an already-paired OpenClaw client. In your OpenClaw chat, run `/pair approve`, then click **Check connection** again."
}
}
var footnote: String? {
switch self {
case .tokenRequired, .gatewayTokenNotConfigured:
"No token yet? Generate one on the gateway host with `openclaw doctor --generate-gateway-token`, then set it as `gateway.auth.token`."
case .pairingRequired:
"If you do not have another paired OpenClaw client yet, approve the pending request on the gateway host with `openclaw devices approve`."
case .tokenMismatch, .passwordRequired:
nil
}
}
var statusMessage: String {
switch self {
case .tokenRequired:
"This gateway requires an auth token from the gateway host."
case .tokenMismatch:
"Gateway token mismatch. Check gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host."
case .gatewayTokenNotConfigured:
"This gateway has token auth enabled, but no gateway.auth.token is configured on the host."
case .passwordRequired:
"This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet."
case .pairingRequired:
"Pairing required. In an already-paired OpenClaw client, run /pair approve, then check the connection again."
}
}
}
enum RemoteGatewayProbeResult: Equatable {
case ready(RemoteGatewayProbeSuccess)
case authIssue(RemoteGatewayAuthIssue)
case failed(String)
}
struct RemoteGatewayProbeSuccess: Equatable {
let authSource: GatewayAuthSource?
var title: String {
switch self.authSource {
case .some(.deviceToken):
"Connected via paired device"
case .some(.sharedToken):
"Connected with gateway token"
case .some(.password):
"Connected with password"
case .some(GatewayAuthSource.none), nil:
"Remote gateway ready"
}
}
var detail: String? {
switch self.authSource {
case .some(.deviceToken):
"This Mac used a stored device token. New or unpaired devices may still need the gateway token."
case .some(.sharedToken), .some(.password), .some(GatewayAuthSource.none), nil:
nil
}
}
}
enum RemoteGatewayProbe {
@MainActor
static func run() async -> RemoteGatewayProbeResult {
AppStateStore.shared.syncGatewayConfigNow()
let settings = CommandResolver.connectionSettings()
let transport = AppStateStore.shared.remoteTransport
if transport == .direct {
let trimmedUrl = AppStateStore.shared.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedUrl.isEmpty else {
return .failed("Set a gateway URL first")
}
guard self.isValidWsUrl(trimmedUrl) else {
return .failed("Gateway URL must use wss:// for remote hosts (ws:// only for localhost)")
}
} else {
let trimmedTarget = settings.target.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTarget.isEmpty else {
return .failed("Set an SSH target first")
}
if let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget) {
return .failed(validationMessage)
}
guard let sshCommand = self.sshCheckCommand(target: settings.target, identity: settings.identity) else {
return .failed("SSH target is invalid")
}
let sshResult = await ShellExecutor.run(
command: sshCommand,
cwd: nil,
env: nil,
timeout: 8)
guard sshResult.ok else {
return .failed(self.formatSSHFailure(sshResult, target: settings.target))
}
}
do {
_ = try await GatewayConnection.shared.healthSnapshot(timeoutMs: 10_000)
let authSource = await GatewayConnection.shared.authSource()
return .ready(RemoteGatewayProbeSuccess(authSource: authSource))
} catch {
if let authIssue = RemoteGatewayAuthIssue(error: error) {
return .authIssue(authIssue)
}
return .failed(error.localizedDescription)
}
}
private static func isValidWsUrl(_ raw: String) -> Bool {
GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil
}
private static func sshCheckCommand(target: String, identity: String) -> [String]? {
guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
let options = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
let args = CommandResolver.sshArguments(
target: parsed,
identity: identity,
options: options,
remoteCommand: ["echo", "ok"])
return ["/usr/bin/ssh"] + args
}
private static func formatSSHFailure(_ response: Response, target: String) -> String {
let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) }
let trimmed = payload?
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(whereSeparator: \.isNewline)
.joined(separator: " ")
if let trimmed,
trimmed.localizedCaseInsensitiveContains("host key verification failed")
{
let host = CommandResolver.parseSSHTarget(target)?.host ?? target
return "SSH check failed: Host key verification failed. Remove the old key with ssh-keygen -R \(host) and try again."
}
if let trimmed, !trimmed.isEmpty {
if let message = response.message, message.hasPrefix("exit ") {
return "SSH check failed: \(trimmed) (\(message))"
}
return "SSH check failed: \(trimmed)"
}
if let message = response.message {
return "SSH check failed (\(message))"
}
return "SSH check failed"
}
}

View File

@@ -8,6 +8,7 @@ import QuartzCore
import SwiftUI
private let webChatSwiftLogger = Logger(subsystem: "ai.openclaw", category: "WebChatSwiftUI")
private let webChatThinkingLevelDefaultsKey = "openclaw.webchat.thinkingLevel"
private enum WebChatSwiftUILayout {
static let windowSize = NSSize(width: 500, height: 840)
@@ -21,6 +22,21 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey)
}
func listModels() async throws -> [OpenClawChatModelChoice] {
do {
let data = try await GatewayConnection.shared.request(
method: "models.list",
params: [:],
timeoutMs: 15000)
let result = try JSONDecoder().decode(ModelsListResult.self, from: data)
return result.models.map(Self.mapModelChoice)
} catch {
webChatSwiftLogger.warning(
"models.list failed; hiding model picker: \(error.localizedDescription, privacy: .public)")
return []
}
}
func abortRun(sessionKey: String, runId: String) async throws {
_ = try await GatewayConnection.shared.request(
method: "chat.abort",
@@ -46,6 +62,28 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data)
}
func setSessionModel(sessionKey: String, model: String?) async throws {
var params: [String: AnyCodable] = [
"key": AnyCodable(sessionKey),
]
params["model"] = model.map(AnyCodable.init) ?? AnyCodable(NSNull())
_ = try await GatewayConnection.shared.request(
method: "sessions.patch",
params: params,
timeoutMs: 15000)
}
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws {
let params: [String: AnyCodable] = [
"key": AnyCodable(sessionKey),
"thinkingLevel": AnyCodable(thinkingLevel),
]
_ = try await GatewayConnection.shared.request(
method: "sessions.patch",
params: params,
timeoutMs: 15000)
}
func sendMessage(
sessionKey: String,
message: String,
@@ -133,6 +171,14 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
return .seqGap
}
}
private static func mapModelChoice(_ model: OpenClawProtocol.ModelChoice) -> OpenClawChatModelChoice {
OpenClawChatModelChoice(
modelID: model.id,
name: model.name,
provider: model.provider,
contextWindow: model.contextwindow)
}
}
// MARK: - Window controller
@@ -155,7 +201,13 @@ final class WebChatSwiftUIWindowController {
init(sessionKey: String, presentation: WebChatPresentation, transport: any OpenClawChatTransport) {
self.sessionKey = sessionKey
self.presentation = presentation
let vm = OpenClawChatViewModel(sessionKey: sessionKey, transport: transport)
let vm = OpenClawChatViewModel(
sessionKey: sessionKey,
transport: transport,
initialThinkingLevel: Self.persistedThinkingLevel(),
onThinkingLevelChanged: { level in
UserDefaults.standard.set(level, forKey: webChatThinkingLevelDefaultsKey)
})
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
self.hosting = NSHostingController(rootView: OpenClawChatView(
viewModel: vm,
@@ -254,6 +306,16 @@ final class WebChatSwiftUIWindowController {
OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor)
}
private static func persistedThinkingLevel() -> String? {
let stored = UserDefaults.standard.string(forKey: webChatThinkingLevelDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard let stored, ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"].contains(stored) else {
return nil
}
return stored
}
private static func makeWindow(
for presentation: WebChatPresentation,
contentViewController: NSViewController) -> NSWindow

View File

@@ -950,6 +950,102 @@ public struct NodeEventParams: Codable, Sendable {
}
}
public struct NodePendingDrainParams: Codable, Sendable {
public let maxitems: Int?
public init(
maxitems: Int?)
{
self.maxitems = maxitems
}
private enum CodingKeys: String, CodingKey {
case maxitems = "maxItems"
}
}
public struct NodePendingDrainResult: Codable, Sendable {
public let nodeid: String
public let revision: Int
public let items: [[String: AnyCodable]]
public let hasmore: Bool
public init(
nodeid: String,
revision: Int,
items: [[String: AnyCodable]],
hasmore: Bool)
{
self.nodeid = nodeid
self.revision = revision
self.items = items
self.hasmore = hasmore
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case revision
case items
case hasmore = "hasMore"
}
}
public struct NodePendingEnqueueParams: Codable, Sendable {
public let nodeid: String
public let type: String
public let priority: String?
public let expiresinms: Int?
public let wake: Bool?
public init(
nodeid: String,
type: String,
priority: String?,
expiresinms: Int?,
wake: Bool?)
{
self.nodeid = nodeid
self.type = type
self.priority = priority
self.expiresinms = expiresinms
self.wake = wake
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case type
case priority
case expiresinms = "expiresInMs"
case wake
}
}
public struct NodePendingEnqueueResult: Codable, Sendable {
public let nodeid: String
public let revision: Int
public let queued: [String: AnyCodable]
public let waketriggered: Bool
public init(
nodeid: String,
revision: Int,
queued: [String: AnyCodable],
waketriggered: Bool)
{
self.nodeid = nodeid
self.revision = revision
self.queued = queued
self.waketriggered = waketriggered
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case revision
case queued
case waketriggered = "wakeTriggered"
}
}
public struct NodeInvokeRequestEvent: Codable, Sendable {
public let id: String
public let nodeid: String
@@ -1241,6 +1337,8 @@ public struct SessionsPatchParams: Codable, Sendable {
public let model: AnyCodable?
public let spawnedby: AnyCodable?
public let spawndepth: AnyCodable?
public let subagentrole: AnyCodable?
public let subagentcontrolscope: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable?
@@ -1259,6 +1357,8 @@ public struct SessionsPatchParams: Codable, Sendable {
model: AnyCodable?,
spawnedby: AnyCodable?,
spawndepth: AnyCodable?,
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?)
{
@@ -1276,6 +1376,8 @@ public struct SessionsPatchParams: Codable, Sendable {
self.model = model
self.spawnedby = spawnedby
self.spawndepth = spawndepth
self.subagentrole = subagentrole
self.subagentcontrolscope = subagentcontrolscope
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation
}
@@ -1295,6 +1397,8 @@ public struct SessionsPatchParams: Codable, Sendable {
case model
case spawnedby = "spawnedBy"
case spawndepth = "spawnDepth"
case subagentrole = "subagentRole"
case subagentcontrolscope = "subagentControlScope"
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation"
}
@@ -2950,7 +3054,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String
public let command: String?
public let commandargv: [String]?
public let systemrunplan: [String: AnyCodable]?
public let env: [String: AnyCodable]?
@@ -2971,7 +3075,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public init(
id: String?,
command: String,
command: String?,
commandargv: [String]?,
systemrunplan: [String: AnyCodable]?,
env: [String: AnyCodable]?,

View File

@@ -7,6 +7,11 @@ struct GatewayChannelConnectTests {
private enum FakeResponse {
case helloOk(delayMs: Int)
case invalid(delayMs: Int)
case authFailed(
delayMs: Int,
detailCode: String,
canRetryWithDeviceToken: Bool,
recommendedNextStep: String?)
}
private func makeSession(response: FakeResponse) -> GatewayTestWebSocketSession {
@@ -27,6 +32,14 @@ struct GatewayChannelConnectTests {
case let .invalid(ms):
delayMs = ms
message = .string("not json")
case let .authFailed(ms, detailCode, canRetryWithDeviceToken, recommendedNextStep):
delayMs = ms
let id = task.snapshotConnectRequestID() ?? "connect"
message = .data(GatewayWebSocketTestSupport.connectAuthFailureData(
id: id,
detailCode: detailCode,
canRetryWithDeviceToken: canRetryWithDeviceToken,
recommendedNextStep: recommendedNextStep))
}
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
return message
@@ -71,4 +84,29 @@ struct GatewayChannelConnectTests {
}())
#expect(session.snapshotMakeCount() == 1)
}
@Test func `connect surfaces structured auth failure`() async throws {
let session = self.makeSession(response: .authFailed(
delayMs: 0,
detailCode: GatewayConnectAuthDetailCode.authTokenMissing.rawValue,
canRetryWithDeviceToken: true,
recommendedNextStep: GatewayConnectRecoveryNextStep.updateAuthConfiguration.rawValue))
let channel = try GatewayChannelActor(
url: #require(URL(string: "ws://example.invalid")),
token: nil,
session: WebSocketSessionBox(session: session))
do {
try await channel.connect()
Issue.record("expected GatewayConnectAuthError")
} catch let error as GatewayConnectAuthError {
#expect(error.detail == .authTokenMissing)
#expect(error.detailCode == GatewayConnectAuthDetailCode.authTokenMissing.rawValue)
#expect(error.canRetryWithDeviceToken)
#expect(error.recommendedNextStep == .updateAuthConfiguration)
#expect(error.recommendedNextStepCode == GatewayConnectRecoveryNextStep.updateAuthConfiguration.rawValue)
} catch {
Issue.record("unexpected error: \(error)")
}
}
}

View File

@@ -52,6 +52,40 @@ enum GatewayWebSocketTestSupport {
return Data(json.utf8)
}
static func connectAuthFailureData(
id: String,
detailCode: String,
message: String = "gateway auth rejected",
canRetryWithDeviceToken: Bool = false,
recommendedNextStep: String? = nil) -> Data
{
let recommendedNextStepJson: String
if let recommendedNextStep {
recommendedNextStepJson = """
,
"recommendedNextStep": "\(recommendedNextStep)"
"""
} else {
recommendedNextStepJson = ""
}
let json = """
{
"type": "res",
"id": "\(id)",
"ok": false,
"error": {
"message": "\(message)",
"details": {
"code": "\(detailCode)",
"canRetryWithDeviceToken": \(canRetryWithDeviceToken ? "true" : "false")
\(recommendedNextStepJson)
}
}
}
"""
return Data(json.utf8)
}
static func requestID(from message: URLSessionWebSocketTask.Message) -> String? {
guard let obj = self.requestFrameObject(from: message) else { return nil }
guard (obj["type"] as? String) == "req" else {

View File

@@ -38,4 +38,49 @@ struct MacNodeBrowserProxyTests {
#expect(tabs.count == 1)
#expect(tabs[0]["id"] as? String == "tab-1")
}
// Regression test: nested POST bodies must serialize without __SwiftValue crashes.
@Test func postRequestSerializesNestedBodyWithoutCrash() async throws {
actor BodyCapture {
private var body: Data?
func set(_ body: Data?) {
self.body = body
}
func get() -> Data? {
self.body
}
}
let capturedBody = BodyCapture()
let proxy = MacNodeBrowserProxy(
endpointProvider: {
MacNodeBrowserProxy.Endpoint(
baseURL: URL(string: "http://127.0.0.1:18791")!,
token: nil,
password: nil)
},
performRequest: { request in
await capturedBody.set(request.httpBody)
let url = try #require(request.url)
let response = try #require(
HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: nil))
return (Data(#"{"ok":true}"#.utf8), response)
})
_ = try await proxy.request(
paramsJSON: #"{"method":"POST","path":"/action","body":{"nested":{"key":"val"},"arr":[1,2]}}"#)
let bodyData = try #require(await capturedBody.get())
let parsed = try #require(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
let nested = try #require(parsed["nested"] as? [String: Any])
#expect(nested["key"] as? String == "val")
let arr = try #require(parsed["arr"] as? [Any])
#expect(arr.count == 2)
}
}

View File

@@ -0,0 +1,126 @@
import OpenClawKit
import Testing
@testable import OpenClaw
@MainActor
struct OnboardingRemoteAuthPromptTests {
@Test func `auth detail codes map to remote auth issues`() {
let tokenMissing = GatewayConnectAuthError(
message: "token missing",
detailCode: GatewayConnectAuthDetailCode.authTokenMissing.rawValue,
canRetryWithDeviceToken: false)
let tokenMismatch = GatewayConnectAuthError(
message: "token mismatch",
detailCode: GatewayConnectAuthDetailCode.authTokenMismatch.rawValue,
canRetryWithDeviceToken: false)
let tokenNotConfigured = GatewayConnectAuthError(
message: "token not configured",
detailCode: GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue,
canRetryWithDeviceToken: false)
let passwordMissing = GatewayConnectAuthError(
message: "password missing",
detailCode: GatewayConnectAuthDetailCode.authPasswordMissing.rawValue,
canRetryWithDeviceToken: false)
let pairingRequired = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false)
let unknown = GatewayConnectAuthError(
message: "other",
detailCode: "SOMETHING_ELSE",
canRetryWithDeviceToken: false)
#expect(RemoteGatewayAuthIssue(error: tokenMissing) == .tokenRequired)
#expect(RemoteGatewayAuthIssue(error: tokenMismatch) == .tokenMismatch)
#expect(RemoteGatewayAuthIssue(error: tokenNotConfigured) == .gatewayTokenNotConfigured)
#expect(RemoteGatewayAuthIssue(error: passwordMissing) == .passwordRequired)
#expect(RemoteGatewayAuthIssue(error: pairingRequired) == .pairingRequired)
#expect(RemoteGatewayAuthIssue(error: unknown) == nil)
}
@Test func `password detail family maps to password required issue`() {
let mismatch = GatewayConnectAuthError(
message: "password mismatch",
detailCode: GatewayConnectAuthDetailCode.authPasswordMismatch.rawValue,
canRetryWithDeviceToken: false)
let notConfigured = GatewayConnectAuthError(
message: "password not configured",
detailCode: GatewayConnectAuthDetailCode.authPasswordNotConfigured.rawValue,
canRetryWithDeviceToken: false)
#expect(RemoteGatewayAuthIssue(error: mismatch) == .passwordRequired)
#expect(RemoteGatewayAuthIssue(error: notConfigured) == .passwordRequired)
}
@Test func `token field visibility follows onboarding rules`() {
#expect(OnboardingView.shouldShowRemoteTokenField(
showAdvancedConnection: false,
remoteToken: "",
remoteTokenUnsupported: false,
authIssue: nil) == false)
#expect(OnboardingView.shouldShowRemoteTokenField(
showAdvancedConnection: true,
remoteToken: "",
remoteTokenUnsupported: false,
authIssue: nil))
#expect(OnboardingView.shouldShowRemoteTokenField(
showAdvancedConnection: false,
remoteToken: "secret",
remoteTokenUnsupported: false,
authIssue: nil))
#expect(OnboardingView.shouldShowRemoteTokenField(
showAdvancedConnection: false,
remoteToken: "",
remoteTokenUnsupported: true,
authIssue: nil))
#expect(OnboardingView.shouldShowRemoteTokenField(
showAdvancedConnection: false,
remoteToken: "",
remoteTokenUnsupported: false,
authIssue: .tokenRequired))
#expect(OnboardingView.shouldShowRemoteTokenField(
showAdvancedConnection: false,
remoteToken: "",
remoteTokenUnsupported: false,
authIssue: .tokenMismatch))
#expect(OnboardingView.shouldShowRemoteTokenField(
showAdvancedConnection: false,
remoteToken: "",
remoteTokenUnsupported: false,
authIssue: .gatewayTokenNotConfigured) == false)
#expect(OnboardingView.shouldShowRemoteTokenField(
showAdvancedConnection: false,
remoteToken: "",
remoteTokenUnsupported: false,
authIssue: .pairingRequired) == false)
}
@Test func `pairing required copy points users to pair approve`() {
let issue = RemoteGatewayAuthIssue.pairingRequired
#expect(issue.title == "This device needs pairing approval")
#expect(issue.body.contains("`/pair approve`"))
#expect(issue.statusMessage.contains("/pair approve"))
#expect(issue.footnote?.contains("`openclaw devices approve`") == true)
}
@Test func `paired device success copy explains auth source`() {
let pairedDevice = RemoteGatewayProbeSuccess(authSource: .deviceToken)
let sharedToken = RemoteGatewayProbeSuccess(authSource: .sharedToken)
let noAuth = RemoteGatewayProbeSuccess(authSource: GatewayAuthSource.none)
#expect(pairedDevice.title == "Connected via paired device")
#expect(pairedDevice.detail == "This Mac used a stored device token. New or unpaired devices may still need the gateway token.")
#expect(sharedToken.title == "Connected with gateway token")
#expect(sharedToken.detail == nil)
#expect(noAuth.title == "Remote gateway ready")
#expect(noAuth.detail == nil)
}
@Test func `transient probe mode restore does not clear probe feedback`() {
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .local, suppressReset: false))
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .unconfigured, suppressReset: false))
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .remote, suppressReset: false) == false)
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .local, suppressReset: true) == false)
}
}

View File

@@ -9,6 +9,8 @@ import UniformTypeIdentifiers
@MainActor
struct OpenClawChatComposer: View {
private static let menuThinkingLevels = ["off", "low", "medium", "high"]
@Bindable var viewModel: OpenClawChatViewModel
let style: OpenClawChatView.Style
let showsSessionSwitcher: Bool
@@ -27,11 +29,15 @@ struct OpenClawChatComposer: View {
if self.showsSessionSwitcher {
self.sessionPicker
}
if self.viewModel.showsModelPicker {
self.modelPicker
}
self.thinkingPicker
Spacer()
self.refreshButton
self.attachmentPicker
}
.padding(.horizontal, 10)
}
if self.showsAttachments, !self.viewModel.attachments.isEmpty {
@@ -83,11 +89,19 @@ struct OpenClawChatComposer: View {
}
private var thinkingPicker: some View {
Picker("Thinking", selection: self.$viewModel.thinkingLevel) {
Picker(
"Thinking",
selection: Binding(
get: { self.viewModel.thinkingLevel },
set: { next in self.viewModel.selectThinkingLevel(next) }))
{
Text("Off").tag("off")
Text("Low").tag("low")
Text("Medium").tag("medium")
Text("High").tag("high")
if !Self.menuThinkingLevels.contains(self.viewModel.thinkingLevel) {
Text(self.viewModel.thinkingLevel.capitalized).tag(self.viewModel.thinkingLevel)
}
}
.labelsHidden()
.pickerStyle(.menu)
@@ -95,6 +109,25 @@ struct OpenClawChatComposer: View {
.frame(maxWidth: 140, alignment: .leading)
}
private var modelPicker: some View {
Picker(
"Model",
selection: Binding(
get: { self.viewModel.modelSelectionID },
set: { next in self.viewModel.selectModel(next) }))
{
Text(self.viewModel.defaultModelLabel).tag(OpenClawChatViewModel.defaultModelSelectionID)
ForEach(self.viewModel.modelChoices) { model in
Text(model.displayLabel).tag(model.selectionID)
}
}
.labelsHidden()
.pickerStyle(.menu)
.controlSize(.small)
.frame(maxWidth: 240, alignment: .leading)
.help("Model")
}
private var sessionPicker: some View {
Picker(
"Session",

View File

@@ -1,5 +1,36 @@
import Foundation
public struct OpenClawChatModelChoice: Identifiable, Codable, Sendable, Hashable {
public var id: String { self.selectionID }
public let modelID: String
public let name: String
public let provider: String
public let contextWindow: Int?
public init(modelID: String, name: String, provider: String, contextWindow: Int?) {
self.modelID = modelID
self.name = name
self.provider = provider
self.contextWindow = contextWindow
}
/// Provider-qualified model ref used for picker identity and selection tags.
public var selectionID: String {
let trimmedProvider = self.provider.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedProvider.isEmpty else { return self.modelID }
let providerPrefix = "\(trimmedProvider)/"
if self.modelID.hasPrefix(providerPrefix) {
return self.modelID
}
return "\(trimmedProvider)/\(self.modelID)"
}
public var displayLabel: String {
self.selectionID
}
}
public struct OpenClawChatSessionsDefaults: Codable, Sendable {
public let model: String?
public let contextTokens: Int?
@@ -27,6 +58,7 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
public let outputTokens: Int?
public let totalTokens: Int?
public let modelProvider: String?
public let model: String?
public let contextTokens: Int?
}

View File

@@ -10,6 +10,7 @@ public enum OpenClawChatTransportEvent: Sendable {
public protocol OpenClawChatTransport: Sendable {
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload
func listModels() async throws -> [OpenClawChatModelChoice]
func sendMessage(
sessionKey: String,
message: String,
@@ -19,6 +20,8 @@ public protocol OpenClawChatTransport: Sendable {
func abortRun(sessionKey: String, runId: String) async throws
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse
func setSessionModel(sessionKey: String, model: String?) async throws
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws
func requestHealth(timeoutMs: Int) async throws -> Bool
func events() -> AsyncStream<OpenClawChatTransportEvent>
@@ -42,4 +45,25 @@ extension OpenClawChatTransport {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.list not supported by this transport"])
}
public func listModels() async throws -> [OpenClawChatModelChoice] {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "models.list not supported by this transport"])
}
public func setSessionModel(sessionKey _: String, model _: String?) async throws {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.patch(model) not supported by this transport"])
}
public func setSessionThinking(sessionKey _: String, thinkingLevel _: String) async throws {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.patch(thinkingLevel) not supported by this transport"])
}
}

View File

@@ -15,9 +15,13 @@ private let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawC
@MainActor
@Observable
public final class OpenClawChatViewModel {
public static let defaultModelSelectionID = "__default__"
public private(set) var messages: [OpenClawChatMessage] = []
public var input: String = ""
public var thinkingLevel: String = "off"
public private(set) var thinkingLevel: String
public private(set) var modelSelectionID: String = "__default__"
public private(set) var modelChoices: [OpenClawChatModelChoice] = []
public private(set) var isLoading = false
public private(set) var isSending = false
public private(set) var isAborting = false
@@ -32,6 +36,9 @@ public final class OpenClawChatViewModel {
public private(set) var pendingToolCalls: [OpenClawChatPendingToolCall] = []
public private(set) var sessions: [OpenClawChatSessionEntry] = []
private let transport: any OpenClawChatTransport
private var sessionDefaults: OpenClawChatSessionsDefaults?
private let prefersExplicitThinkingLevel: Bool
private let onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)?
@ObservationIgnored
private nonisolated(unsafe) var eventTask: Task<Void, Never>?
@@ -42,6 +49,17 @@ public final class OpenClawChatViewModel {
@ObservationIgnored
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
private let pendingRunTimeoutMs: UInt64 = 120_000
// Session switches can overlap in-flight picker patches, so stale completions
// must compare against the latest request and latest desired value for that session.
private var nextModelSelectionRequestID: UInt64 = 0
private var latestModelSelectionRequestIDsBySession: [String: UInt64] = [:]
private var latestModelSelectionIDsBySession: [String: String] = [:]
private var lastSuccessfulModelSelectionIDsBySession: [String: String] = [:]
private var inFlightModelPatchCountsBySession: [String: Int] = [:]
private var modelPatchWaitersBySession: [String: [CheckedContinuation<Void, Never>]] = [:]
private var nextThinkingSelectionRequestID: UInt64 = 0
private var latestThinkingSelectionRequestIDsBySession: [String: UInt64] = [:]
private var latestThinkingLevelsBySession: [String: String] = [:]
private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] {
didSet {
@@ -52,9 +70,18 @@ public final class OpenClawChatViewModel {
private var lastHealthPollAt: Date?
public init(sessionKey: String, transport: any OpenClawChatTransport) {
public init(
sessionKey: String,
transport: any OpenClawChatTransport,
initialThinkingLevel: String? = nil,
onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)? = nil)
{
self.sessionKey = sessionKey
self.transport = transport
let normalizedThinkingLevel = Self.normalizedThinkingLevel(initialThinkingLevel)
self.thinkingLevel = normalizedThinkingLevel ?? "off"
self.prefersExplicitThinkingLevel = normalizedThinkingLevel != nil
self.onThinkingLevelChanged = onThinkingLevelChanged
self.eventTask = Task { [weak self] in
guard let self else { return }
@@ -99,6 +126,14 @@ public final class OpenClawChatViewModel {
Task { await self.performSwitchSession(to: sessionKey) }
}
public func selectThinkingLevel(_ level: String) {
Task { await self.performSelectThinkingLevel(level) }
}
public func selectModel(_ selectionID: String) {
Task { await self.performSelectModel(selectionID) }
}
public var sessionChoices: [OpenClawChatSessionEntry] {
let now = Date().timeIntervalSince1970 * 1000
let cutoff = now - (24 * 60 * 60 * 1000)
@@ -134,6 +169,17 @@ public final class OpenClawChatViewModel {
return result
}
public var showsModelPicker: Bool {
!self.modelChoices.isEmpty
}
public var defaultModelLabel: String {
guard let defaultModelID = self.normalizedModelSelectionID(self.sessionDefaults?.model) else {
return "Default"
}
return "Default: \(self.modelLabel(for: defaultModelID))"
}
public func addAttachments(urls: [URL]) {
Task { await self.loadAttachments(urls: urls) }
}
@@ -174,11 +220,14 @@ public final class OpenClawChatViewModel {
previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId
if let level = payload.thinkingLevel, !level.isEmpty {
if !self.prefersExplicitThinkingLevel,
let level = Self.normalizedThinkingLevel(payload.thinkingLevel)
{
self.thinkingLevel = level
}
await self.pollHealthIfNeeded(force: true)
await self.fetchSessions(limit: 50)
await self.fetchModels()
self.errorText = nil
} catch {
self.errorText = error.localizedDescription
@@ -320,6 +369,7 @@ public final class OpenClawChatViewModel {
guard !self.isSending else { return }
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty || !self.attachments.isEmpty else { return }
let sessionKey = self.sessionKey
guard self.healthOK else {
self.errorText = "Gateway health not OK; cannot send"
@@ -330,6 +380,7 @@ public final class OpenClawChatViewModel {
self.errorText = nil
let runId = UUID().uuidString
let messageText = trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : trimmed
let thinkingLevel = self.thinkingLevel
self.pendingRuns.insert(runId)
self.armPendingRunTimeout(runId: runId)
self.pendingToolCallsById = [:]
@@ -382,10 +433,11 @@ public final class OpenClawChatViewModel {
self.attachments = []
do {
await self.waitForPendingModelPatches(in: sessionKey)
let response = try await self.transport.sendMessage(
sessionKey: self.sessionKey,
sessionKey: sessionKey,
message: messageText,
thinking: self.thinkingLevel,
thinking: thinkingLevel,
idempotencyKey: runId,
attachments: encodedAttachments)
if response.runId != runId {
@@ -422,6 +474,17 @@ public final class OpenClawChatViewModel {
do {
let res = try await self.transport.listSessions(limit: limit)
self.sessions = res.sessions
self.sessionDefaults = res.defaults
self.syncSelectedModel()
} catch {
// Best-effort.
}
}
private func fetchModels() async {
do {
self.modelChoices = try await self.transport.listModels()
self.syncSelectedModel()
} catch {
// Best-effort.
}
@@ -432,9 +495,106 @@ public final class OpenClawChatViewModel {
guard !next.isEmpty else { return }
guard next != self.sessionKey else { return }
self.sessionKey = next
self.modelSelectionID = Self.defaultModelSelectionID
await self.bootstrap()
}
private func performSelectThinkingLevel(_ level: String) async {
let next = Self.normalizedThinkingLevel(level) ?? "off"
guard next != self.thinkingLevel else { return }
let sessionKey = self.sessionKey
self.thinkingLevel = next
self.onThinkingLevelChanged?(next)
self.nextThinkingSelectionRequestID &+= 1
let requestID = self.nextThinkingSelectionRequestID
self.latestThinkingSelectionRequestIDsBySession[sessionKey] = requestID
self.latestThinkingLevelsBySession[sessionKey] = next
do {
try await self.transport.setSessionThinking(sessionKey: sessionKey, thinkingLevel: next)
guard requestID == self.latestThinkingSelectionRequestIDsBySession[sessionKey] else {
let latest = self.latestThinkingLevelsBySession[sessionKey] ?? next
guard latest != next else { return }
try? await self.transport.setSessionThinking(sessionKey: sessionKey, thinkingLevel: latest)
return
}
} catch {
guard sessionKey == self.sessionKey,
requestID == self.latestThinkingSelectionRequestIDsBySession[sessionKey]
else { return }
// Best-effort. Persisting the user's local preference matters more than a patch error here.
}
}
private func performSelectModel(_ selectionID: String) async {
let next = self.normalizedSelectionID(selectionID)
guard next != self.modelSelectionID else { return }
let sessionKey = self.sessionKey
let previous = self.modelSelectionID
let previousRequestID = self.latestModelSelectionRequestIDsBySession[sessionKey]
self.nextModelSelectionRequestID &+= 1
let requestID = self.nextModelSelectionRequestID
let nextModelRef = self.modelRef(forSelectionID: next)
self.latestModelSelectionRequestIDsBySession[sessionKey] = requestID
self.latestModelSelectionIDsBySession[sessionKey] = next
self.beginModelPatch(for: sessionKey)
self.modelSelectionID = next
self.errorText = nil
defer { self.endModelPatch(for: sessionKey) }
do {
try await self.transport.setSessionModel(
sessionKey: sessionKey,
model: nextModelRef)
guard requestID == self.latestModelSelectionRequestIDsBySession[sessionKey] else {
self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: false)
return
}
self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: true)
} catch {
guard requestID == self.latestModelSelectionRequestIDsBySession[sessionKey] else { return }
self.latestModelSelectionIDsBySession[sessionKey] = previous
if let previousRequestID {
self.latestModelSelectionRequestIDsBySession[sessionKey] = previousRequestID
} else {
self.latestModelSelectionRequestIDsBySession.removeValue(forKey: sessionKey)
}
if self.lastSuccessfulModelSelectionIDsBySession[sessionKey] == previous {
self.applySuccessfulModelSelection(previous, sessionKey: sessionKey, syncSelection: sessionKey == self.sessionKey)
}
guard sessionKey == self.sessionKey else { return }
self.modelSelectionID = previous
self.errorText = error.localizedDescription
chatUILogger.error("sessions.patch(model) failed \(error.localizedDescription, privacy: .public)")
}
}
private func beginModelPatch(for sessionKey: String) {
self.inFlightModelPatchCountsBySession[sessionKey, default: 0] += 1
}
private func endModelPatch(for sessionKey: String) {
let remaining = max(0, (self.inFlightModelPatchCountsBySession[sessionKey] ?? 0) - 1)
if remaining == 0 {
self.inFlightModelPatchCountsBySession.removeValue(forKey: sessionKey)
let waiters = self.modelPatchWaitersBySession.removeValue(forKey: sessionKey) ?? []
for waiter in waiters {
waiter.resume()
}
return
}
self.inFlightModelPatchCountsBySession[sessionKey] = remaining
}
private func waitForPendingModelPatches(in sessionKey: String) async {
guard (self.inFlightModelPatchCountsBySession[sessionKey] ?? 0) > 0 else { return }
await withCheckedContinuation { continuation in
self.modelPatchWaitersBySession[sessionKey, default: []].append(continuation)
}
}
private func placeholderSession(key: String) -> OpenClawChatSessionEntry {
OpenClawChatSessionEntry(
key: key,
@@ -453,10 +613,159 @@ public final class OpenClawChatViewModel {
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil)
}
private func syncSelectedModel() {
let currentSession = self.sessions.first(where: { $0.key == self.sessionKey })
let explicitModelID = self.normalizedModelSelectionID(
currentSession?.model,
provider: currentSession?.modelProvider)
if let explicitModelID {
self.lastSuccessfulModelSelectionIDsBySession[self.sessionKey] = explicitModelID
self.modelSelectionID = explicitModelID
return
}
self.lastSuccessfulModelSelectionIDsBySession[self.sessionKey] = Self.defaultModelSelectionID
self.modelSelectionID = Self.defaultModelSelectionID
}
private func normalizedSelectionID(_ selectionID: String) -> String {
let trimmed = selectionID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return Self.defaultModelSelectionID }
return trimmed
}
private func normalizedModelSelectionID(_ modelID: String?, provider: String? = nil) -> String? {
guard let modelID else { return nil }
let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if let provider = Self.normalizedProvider(provider) {
let providerQualified = Self.providerQualifiedModelSelectionID(modelID: trimmed, provider: provider)
if let match = self.modelChoices.first(where: {
$0.selectionID == providerQualified ||
($0.modelID == trimmed && Self.normalizedProvider($0.provider) == provider)
}) {
return match.selectionID
}
return providerQualified
}
if self.modelChoices.contains(where: { $0.selectionID == trimmed }) {
return trimmed
}
let matches = self.modelChoices.filter { $0.modelID == trimmed || $0.selectionID == trimmed }
if matches.count == 1 {
return matches[0].selectionID
}
return trimmed
}
private func modelRef(forSelectionID selectionID: String) -> String? {
let normalized = self.normalizedSelectionID(selectionID)
if normalized == Self.defaultModelSelectionID {
return nil
}
return normalized
}
private func modelLabel(for modelID: String) -> String {
self.modelChoices.first(where: { $0.selectionID == modelID || $0.modelID == modelID })?.displayLabel ??
modelID
}
private func applySuccessfulModelSelection(_ selectionID: String, sessionKey: String, syncSelection: Bool) {
self.lastSuccessfulModelSelectionIDsBySession[sessionKey] = selectionID
let resolved = self.resolvedSessionModelIdentity(forSelectionID: selectionID)
self.updateCurrentSessionModel(
modelID: resolved.modelID,
modelProvider: resolved.modelProvider,
sessionKey: sessionKey,
syncSelection: syncSelection)
}
private func resolvedSessionModelIdentity(forSelectionID selectionID: String) -> (modelID: String?, modelProvider: String?) {
guard let modelRef = self.modelRef(forSelectionID: selectionID) else {
return (nil, nil)
}
if let choice = self.modelChoices.first(where: { $0.selectionID == modelRef }) {
return (choice.modelID, Self.normalizedProvider(choice.provider))
}
return (modelRef, nil)
}
private static func normalizedProvider(_ provider: String?) -> String? {
let trimmed = provider?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let trimmed, !trimmed.isEmpty else { return nil }
return trimmed
}
private static func providerQualifiedModelSelectionID(modelID: String, provider: String) -> String {
let providerPrefix = "\(provider)/"
if modelID.hasPrefix(providerPrefix) {
return modelID
}
return "\(provider)/\(modelID)"
}
private func updateCurrentSessionModel(
modelID: String?,
modelProvider: String?,
sessionKey: String,
syncSelection: Bool)
{
if let index = self.sessions.firstIndex(where: { $0.key == sessionKey }) {
let current = self.sessions[index]
self.sessions[index] = OpenClawChatSessionEntry(
key: current.key,
kind: current.kind,
displayName: current.displayName,
surface: current.surface,
subject: current.subject,
room: current.room,
space: current.space,
updatedAt: current.updatedAt,
sessionId: current.sessionId,
systemSent: current.systemSent,
abortedLastRun: current.abortedLastRun,
thinkingLevel: current.thinkingLevel,
verboseLevel: current.verboseLevel,
inputTokens: current.inputTokens,
outputTokens: current.outputTokens,
totalTokens: current.totalTokens,
modelProvider: modelProvider,
model: modelID,
contextTokens: current.contextTokens)
} else {
let placeholder = self.placeholderSession(key: sessionKey)
self.sessions.append(
OpenClawChatSessionEntry(
key: placeholder.key,
kind: placeholder.kind,
displayName: placeholder.displayName,
surface: placeholder.surface,
subject: placeholder.subject,
room: placeholder.room,
space: placeholder.space,
updatedAt: placeholder.updatedAt,
sessionId: placeholder.sessionId,
systemSent: placeholder.systemSent,
abortedLastRun: placeholder.abortedLastRun,
thinkingLevel: placeholder.thinkingLevel,
verboseLevel: placeholder.verboseLevel,
inputTokens: placeholder.inputTokens,
outputTokens: placeholder.outputTokens,
totalTokens: placeholder.totalTokens,
modelProvider: modelProvider,
model: modelID,
contextTokens: placeholder.contextTokens))
}
if syncSelection {
self.syncSelectedModel()
}
}
private func handleTransportEvent(_ evt: OpenClawChatTransportEvent) {
switch evt {
case let .health(ok):
@@ -573,7 +882,9 @@ public final class OpenClawChatViewModel {
previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId
if let level = payload.thinkingLevel, !level.isEmpty {
if !self.prefersExplicitThinkingLevel,
let level = Self.normalizedThinkingLevel(payload.thinkingLevel)
{
self.thinkingLevel = level
}
} catch {
@@ -682,4 +993,13 @@ public final class OpenClawChatViewModel {
nil
#endif
}
private static func normalizedThinkingLevel(_ level: String?) -> String? {
guard let level else { return nil }
let trimmed = level.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"].contains(trimmed) else {
return nil
}
return trimmed
}
}

View File

@@ -131,6 +131,20 @@ private let defaultOperatorConnectScopes: [String] = [
"operator.pairing",
]
private enum GatewayConnectErrorCodes {
static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue
static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue
static let authTokenMissing = GatewayConnectAuthDetailCode.authTokenMissing.rawValue
static let authTokenNotConfigured = GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue
static let authPasswordMissing = GatewayConnectAuthDetailCode.authPasswordMissing.rawValue
static let authPasswordMismatch = GatewayConnectAuthDetailCode.authPasswordMismatch.rawValue
static let authPasswordNotConfigured = GatewayConnectAuthDetailCode.authPasswordNotConfigured.rawValue
static let authRateLimited = GatewayConnectAuthDetailCode.authRateLimited.rawValue
static let pairingRequired = GatewayConnectAuthDetailCode.pairingRequired.rawValue
static let controlUiDeviceIdentityRequired = GatewayConnectAuthDetailCode.controlUiDeviceIdentityRequired.rawValue
static let deviceIdentityRequired = GatewayConnectAuthDetailCode.deviceIdentityRequired.rawValue
}
public actor GatewayChannelActor {
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway")
private var task: WebSocketTaskBox?
@@ -160,6 +174,9 @@ public actor GatewayChannelActor {
private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>?
private var keepaliveTask: Task<Void, Never>?
private var pendingDeviceTokenRetry = false
private var deviceTokenRetryBudgetUsed = false
private var reconnectPausedForAuthFailure = false
private let defaultRequestTimeoutMs: Double = 15000
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
private let connectOptions: GatewayConnectOptions?
@@ -232,10 +249,18 @@ public actor GatewayChannelActor {
while self.shouldReconnect {
guard await self.sleepUnlessCancelled(nanoseconds: 30 * 1_000_000_000) else { return } // 30s cadence
guard self.shouldReconnect else { return }
if self.reconnectPausedForAuthFailure { continue }
if self.connected { continue }
do {
try await self.connect()
} catch {
if self.shouldPauseReconnectAfterAuthFailure(error) {
self.reconnectPausedForAuthFailure = true
self.logger.error(
"gateway watchdog reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)"
)
continue
}
let wrapped = self.wrap(error, context: "gateway watchdog reconnect")
self.logger.error("gateway watchdog reconnect failed \(wrapped.localizedDescription, privacy: .public)")
}
@@ -267,7 +292,12 @@ public actor GatewayChannelActor {
},
operation: { try await self.sendConnect() })
} catch {
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
let wrapped: Error
if let authError = error as? GatewayConnectAuthError {
wrapped = authError
} else {
wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
}
self.connected = false
self.task?.cancel(with: .goingAway, reason: nil)
await self.disconnectHandler?("connect failed: \(wrapped.localizedDescription)")
@@ -281,6 +311,7 @@ public actor GatewayChannelActor {
}
self.listen()
self.connected = true
self.reconnectPausedForAuthFailure = false
self.backoffMs = 500
self.lastSeq = nil
self.startKeepalive()
@@ -371,11 +402,18 @@ public actor GatewayChannelActor {
(includeDeviceIdentity && identity != nil)
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
: nil
// If we're not sending a device identity, a device token can't be validated server-side.
// In that mode we always use the shared gateway token/password.
let authToken = includeDeviceIdentity ? (storedToken ?? self.token) : self.token
let shouldUseDeviceRetryToken =
includeDeviceIdentity && self.pendingDeviceTokenRetry &&
storedToken != nil && self.token != nil && self.isTrustedDeviceRetryEndpoint()
if shouldUseDeviceRetryToken {
self.pendingDeviceTokenRetry = false
}
// Keep shared credentials explicit when provided. Device token retry is attached
// only on a bounded second attempt after token mismatch.
let authToken = self.token ?? (includeDeviceIdentity ? storedToken : nil)
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
let authSource: GatewayAuthSource
if storedToken != nil {
if authDeviceToken != nil || (self.token == nil && storedToken != nil) {
authSource = .deviceToken
} else if authToken != nil {
authSource = .sharedToken
@@ -386,9 +424,12 @@ public actor GatewayChannelActor {
}
self.lastAuthSource = authSource
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
let canFallbackToShared = includeDeviceIdentity && storedToken != nil && self.token != nil
if let authToken {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
var auth: [String: ProtoAnyCodable] = ["token": ProtoAnyCodable(authToken)]
if let authDeviceToken {
auth["deviceToken"] = ProtoAnyCodable(authDeviceToken)
}
params["auth"] = ProtoAnyCodable(auth)
} else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
@@ -426,11 +467,24 @@ public actor GatewayChannelActor {
do {
let response = try await self.waitForConnectResponse(reqId: reqId)
try await self.handleConnectResponse(response, identity: identity, role: role)
self.pendingDeviceTokenRetry = false
self.deviceTokenRetryBudgetUsed = false
} catch {
if canFallbackToShared {
if let identity {
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
}
let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken(
error: error,
explicitGatewayToken: self.token,
storedToken: storedToken,
attemptedDeviceTokenRetry: authDeviceToken != nil)
if shouldRetryWithDeviceToken {
self.pendingDeviceTokenRetry = true
self.deviceTokenRetryBudgetUsed = true
self.backoffMs = min(self.backoffMs, 250)
} else if authDeviceToken != nil,
let identity,
self.shouldClearStoredDeviceTokenAfterRetry(error)
{
// Retry failed with an explicit device-token mismatch; clear stale local token.
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
}
throw error
}
@@ -443,7 +497,15 @@ public actor GatewayChannelActor {
) async throws {
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
let details = res.error?["details"]?.value as? [String: ProtoAnyCodable]
let detailCode = details?["code"]?.value as? String
let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false
let recommendedNextStep = details?["recommendedNextStep"]?.value as? String
throw GatewayConnectAuthError(
message: msg,
detailCodeRaw: detailCode,
canRetryWithDeviceToken: canRetryWithDeviceToken,
recommendedNextStepRaw: recommendedNextStep)
}
guard let payload = res.payload else {
throw NSError(
@@ -616,19 +678,90 @@ public actor GatewayChannelActor {
private func scheduleReconnect() async {
guard self.shouldReconnect else { return }
guard !self.reconnectPausedForAuthFailure else { return }
let delay = self.backoffMs / 1000
self.backoffMs = min(self.backoffMs * 2, 30000)
guard await self.sleepUnlessCancelled(nanoseconds: UInt64(delay * 1_000_000_000)) else { return }
guard self.shouldReconnect else { return }
guard !self.reconnectPausedForAuthFailure else { return }
do {
try await self.connect()
} catch {
if self.shouldPauseReconnectAfterAuthFailure(error) {
self.reconnectPausedForAuthFailure = true
self.logger.error(
"gateway reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)"
)
return
}
let wrapped = self.wrap(error, context: "gateway reconnect")
self.logger.error("gateway reconnect failed \(wrapped.localizedDescription, privacy: .public)")
await self.scheduleReconnect()
}
}
private func shouldRetryWithStoredDeviceToken(
error: Error,
explicitGatewayToken: String?,
storedToken: String?,
attemptedDeviceTokenRetry: Bool
) -> Bool {
if self.deviceTokenRetryBudgetUsed {
return false
}
if attemptedDeviceTokenRetry {
return false
}
guard explicitGatewayToken != nil, storedToken != nil else {
return false
}
guard self.isTrustedDeviceRetryEndpoint() else {
return false
}
guard let authError = error as? GatewayConnectAuthError else {
return false
}
return authError.canRetryWithDeviceToken ||
authError.detail == .authTokenMismatch
}
private func shouldPauseReconnectAfterAuthFailure(_ error: Error) -> Bool {
guard let authError = error as? GatewayConnectAuthError else {
return false
}
if authError.isNonRecoverable {
return true
}
if authError.detail == .authTokenMismatch &&
self.deviceTokenRetryBudgetUsed && !self.pendingDeviceTokenRetry
{
return true
}
return false
}
private func shouldClearStoredDeviceTokenAfterRetry(_ error: Error) -> Bool {
guard let authError = error as? GatewayConnectAuthError else {
return false
}
return authError.detail == .authDeviceTokenMismatch
}
private func isTrustedDeviceRetryEndpoint() -> Bool {
// This client currently treats loopback as the only trusted retry target.
// Unlike the Node gateway client, it does not yet expose a pinned TLS-fingerprint
// trust path for remote retry, so remote fallback remains disabled by default.
guard let host = self.url.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
!host.isEmpty
else {
return false
}
if host == "localhost" || host == "::1" || host == "127.0.0.1" || host.hasPrefix("127.") {
return true
}
return false
}
private nonisolated func sleepUnlessCancelled(nanoseconds: UInt64) async -> Bool {
do {
try await Task.sleep(nanoseconds: nanoseconds)
@@ -713,6 +846,9 @@ public actor GatewayChannelActor {
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
private func wrap(_ error: Error, context: String) -> Error {
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError {
return error
}
if let urlError = error as? URLError {
let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription
return NSError(

View File

@@ -1,6 +1,112 @@
import OpenClawProtocol
import Foundation
public enum GatewayConnectAuthDetailCode: String, Sendable {
case authRequired = "AUTH_REQUIRED"
case authUnauthorized = "AUTH_UNAUTHORIZED"
case authTokenMismatch = "AUTH_TOKEN_MISMATCH"
case authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH"
case authTokenMissing = "AUTH_TOKEN_MISSING"
case authTokenNotConfigured = "AUTH_TOKEN_NOT_CONFIGURED"
case authPasswordMissing = "AUTH_PASSWORD_MISSING"
case authPasswordMismatch = "AUTH_PASSWORD_MISMATCH"
case authPasswordNotConfigured = "AUTH_PASSWORD_NOT_CONFIGURED"
case authRateLimited = "AUTH_RATE_LIMITED"
case authTailscaleIdentityMissing = "AUTH_TAILSCALE_IDENTITY_MISSING"
case authTailscaleProxyMissing = "AUTH_TAILSCALE_PROXY_MISSING"
case authTailscaleWhoisFailed = "AUTH_TAILSCALE_WHOIS_FAILED"
case authTailscaleIdentityMismatch = "AUTH_TAILSCALE_IDENTITY_MISMATCH"
case pairingRequired = "PAIRING_REQUIRED"
case controlUiDeviceIdentityRequired = "CONTROL_UI_DEVICE_IDENTITY_REQUIRED"
case deviceIdentityRequired = "DEVICE_IDENTITY_REQUIRED"
case deviceAuthInvalid = "DEVICE_AUTH_INVALID"
case deviceAuthDeviceIdMismatch = "DEVICE_AUTH_DEVICE_ID_MISMATCH"
case deviceAuthSignatureExpired = "DEVICE_AUTH_SIGNATURE_EXPIRED"
case deviceAuthNonceRequired = "DEVICE_AUTH_NONCE_REQUIRED"
case deviceAuthNonceMismatch = "DEVICE_AUTH_NONCE_MISMATCH"
case deviceAuthSignatureInvalid = "DEVICE_AUTH_SIGNATURE_INVALID"
case deviceAuthPublicKeyInvalid = "DEVICE_AUTH_PUBLIC_KEY_INVALID"
}
public enum GatewayConnectRecoveryNextStep: String, Sendable {
case retryWithDeviceToken = "retry_with_device_token"
case updateAuthConfiguration = "update_auth_configuration"
case updateAuthCredentials = "update_auth_credentials"
case waitThenRetry = "wait_then_retry"
case reviewAuthConfiguration = "review_auth_configuration"
}
/// Structured websocket connect-auth rejection surfaced before the channel is usable.
public struct GatewayConnectAuthError: LocalizedError, Sendable {
public let message: String
public let detailCodeRaw: String?
public let recommendedNextStepRaw: String?
public let canRetryWithDeviceToken: Bool
public init(
message: String,
detailCodeRaw: String?,
canRetryWithDeviceToken: Bool,
recommendedNextStepRaw: String? = nil)
{
let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDetailCode = detailCodeRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedRecommendedNextStep =
recommendedNextStepRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
self.message = trimmedMessage.isEmpty ? "gateway connect failed" : trimmedMessage
self.detailCodeRaw = trimmedDetailCode?.isEmpty == false ? trimmedDetailCode : nil
self.canRetryWithDeviceToken = canRetryWithDeviceToken
self.recommendedNextStepRaw =
trimmedRecommendedNextStep?.isEmpty == false ? trimmedRecommendedNextStep : nil
}
public init(
message: String,
detailCode: String?,
canRetryWithDeviceToken: Bool,
recommendedNextStep: String? = nil)
{
self.init(
message: message,
detailCodeRaw: detailCode,
canRetryWithDeviceToken: canRetryWithDeviceToken,
recommendedNextStepRaw: recommendedNextStep)
}
public var detailCode: String? { self.detailCodeRaw }
public var recommendedNextStepCode: String? { self.recommendedNextStepRaw }
public var detail: GatewayConnectAuthDetailCode? {
guard let detailCodeRaw else { return nil }
return GatewayConnectAuthDetailCode(rawValue: detailCodeRaw)
}
public var recommendedNextStep: GatewayConnectRecoveryNextStep? {
guard let recommendedNextStepRaw else { return nil }
return GatewayConnectRecoveryNextStep(rawValue: recommendedNextStepRaw)
}
public var errorDescription: String? { self.message }
public var isNonRecoverable: Bool {
switch self.detail {
case .authTokenMissing,
.authTokenNotConfigured,
.authPasswordMissing,
.authPasswordMismatch,
.authPasswordNotConfigured,
.authRateLimited,
.pairingRequired,
.controlUiDeviceIdentityRequired,
.deviceIdentityRequired:
return true
default:
return false
}
}
}
/// Structured error surfaced when the gateway responds with `{ ok: false }`.
public struct GatewayResponseError: LocalizedError, @unchecked Sendable {
public let method: String

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Canvas</title>
<title>OpenClaw</title>
<script>
(() => {
try {
@@ -15,99 +15,358 @@
}
if (/android/i.test(navigator.userAgent || '')) {
document.documentElement.dataset.platform = 'android';
} else {
document.documentElement.dataset.platform = 'ios';
}
} catch (_) {}
})();
</script>
<style>
:root { color-scheme: dark; }
@media (prefers-reduced-motion: reduce) {
body::before, body::after { animation: none !important; }
:root {
color-scheme: dark;
--bg: #06070b;
--panel: rgba(14, 17, 24, 0.74);
--panel-strong: rgba(18, 23, 32, 0.86);
--line: rgba(255, 255, 255, 0.1);
--line-strong: rgba(255, 255, 255, 0.18);
--text: rgba(255, 255, 255, 0.96);
--muted: rgba(222, 229, 239, 0.72);
--soft: rgba(222, 229, 239, 0.5);
--accent: #8ec5ff;
--accent-strong: #5b9dff;
--accent-warm: #ff9159;
--accent-rose: #ff5fa2;
--state: #7d8ca3;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
html,body { height:100%; margin:0; }
html, body {
height: 100%;
margin: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
background:
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
#000;
radial-gradient(900px 640px at 12% 10%, rgba(91, 157, 255, 0.36), rgba(0, 0, 0, 0) 58%),
radial-gradient(840px 600px at 88% 16%, rgba(255, 95, 162, 0.24), rgba(0, 0, 0, 0) 62%),
radial-gradient(960px 720px at 50% 100%, rgba(255, 145, 89, 0.18), rgba(0, 0, 0, 0) 60%),
linear-gradient(180deg, #090b11 0%, #05060a 100%);
color: var(--text);
overflow: hidden;
}
:root[data-platform="android"] body {
background:
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.62), rgba(0,0,0,0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.52), rgba(0,0,0,0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.48), rgba(0,0,0,0) 60%),
#0b1328;
}
body::before {
content:"";
position: fixed;
inset: -20%;
background:
repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
transparent 1px, transparent 48px),
repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
transparent 1px, transparent 48px);
transform: translate3d(0,0,0) rotate(-7deg);
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
opacity: 0.45;
pointer-events: none;
animation: openclaw-grid-drift 140s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::before { opacity: 0.80; }
body::before,
body::after {
content:"";
content: "";
position: fixed;
inset: -35%;
background:
radial-gradient(900px 700px at 30% 30%, rgba(42,113,255,0.16), rgba(0,0,0,0) 60%),
radial-gradient(800px 650px at 70% 35%, rgba(255,0,138,0.12), rgba(0,0,0,0) 62%),
radial-gradient(900px 800px at 55% 75%, rgba(0,209,255,0.10), rgba(0,0,0,0) 62%);
filter: blur(28px);
opacity: 0.52;
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transform: translate3d(0,0,0);
inset: -10%;
pointer-events: none;
animation: openclaw-glow-drift 110s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::after { opacity: 0.85; }
@supports (mix-blend-mode: screen) {
body::after { mix-blend-mode: screen; }
body::before {
background:
repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.025) 0,
rgba(255, 255, 255, 0.025) 1px,
transparent 1px,
transparent 52px
),
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.025) 0,
rgba(255, 255, 255, 0.025) 1px,
transparent 1px,
transparent 52px
);
opacity: 0.42;
transform: rotate(-7deg);
}
@supports not (mix-blend-mode: screen) {
body::after { opacity: 0.70; }
}
@keyframes openclaw-grid-drift {
0% { transform: translate3d(-12px, 8px, 0) rotate(-7deg); opacity: 0.40; }
50% { transform: translate3d( 10px,-7px, 0) rotate(-6.6deg); opacity: 0.56; }
100% { transform: translate3d(-8px, 6px, 0) rotate(-7.2deg); opacity: 0.42; }
}
@keyframes openclaw-glow-drift {
0% { transform: translate3d(-18px, 12px, 0) scale(1.02); opacity: 0.40; }
50% { transform: translate3d( 14px,-10px, 0) scale(1.05); opacity: 0.52; }
100% { transform: translate3d(-10px, 8px, 0) scale(1.03); opacity: 0.43; }
body::after {
background:
radial-gradient(700px 460px at 20% 18%, rgba(142, 197, 255, 0.18), rgba(0, 0, 0, 0) 62%),
radial-gradient(720px 520px at 84% 20%, rgba(255, 95, 162, 0.14), rgba(0, 0, 0, 0) 66%),
radial-gradient(860px 620px at 52% 88%, rgba(255, 145, 89, 0.14), rgba(0, 0, 0, 0) 64%);
filter: blur(28px);
opacity: 0.95;
}
body[data-state="connected"] { --state: #61d58b; }
body[data-state="connecting"] { --state: #ffd05f; }
body[data-state="error"] { --state: #ff6d6d; }
body[data-state="offline"] { --state: #95a3b9; }
canvas {
position: fixed;
inset: 0;
display:block;
width:100vw;
height:100vh;
touch-action: none;
width: 100vw;
height: 100vh;
display: block;
z-index: 1;
}
:root[data-platform="android"] #openclaw-canvas {
background:
radial-gradient(1100px 800px at 20% 15%, rgba(42, 113, 255, 0.78), rgba(0,0,0,0) 58%),
radial-gradient(900px 650px at 82% 28%, rgba(255, 0, 138, 0.66), rgba(0,0,0,0) 62%),
radial-gradient(1000px 900px at 60% 88%, rgba(0, 209, 255, 0.58), rgba(0,0,0,0) 62%),
#141c33;
#openclaw-home {
position: fixed;
inset: 0;
z-index: 2;
display: flex;
align-items: flex-start;
justify-content: center;
padding: calc(var(--safe-top) + 18px) 16px calc(var(--safe-bottom) + 18px);
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.shell {
width: min(100%, 760px);
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 16px;
min-height: 100%;
box-sizing: border-box;
}
.hero {
position: relative;
overflow: hidden;
border-radius: 28px;
background: linear-gradient(180deg, rgba(18, 24, 34, 0.86), rgba(10, 13, 19, 0.94));
border: 1px solid var(--line);
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.42);
padding: 22px 22px 18px;
}
.hero::before {
content: "";
position: absolute;
inset: -30% auto auto -20%;
width: 240px;
height: 240px;
border-radius: 999px;
background: radial-gradient(circle, rgba(142, 197, 255, 0.18), rgba(0, 0, 0, 0));
pointer-events: none;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
max-width: 100%;
box-sizing: border-box;
}
.eyebrow-dot {
flex: 0 0 auto;
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--state);
box-shadow: 0 0 18px color-mix(in srgb, var(--state) 68%, transparent);
}
#openclaw-home-eyebrow {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hero h1 {
margin: 18px 0 0;
font-size: clamp(32px, 7vw, 52px);
line-height: 0.98;
letter-spacing: -0.04em;
}
.hero p {
margin: 14px 0 0;
font-size: 16px;
line-height: 1.5;
color: var(--muted);
max-width: 32rem;
}
.hero-grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 12px;
margin-top: 22px;
}
.meta-card,
.agent-card {
border-radius: 22px;
background: var(--panel);
border: 1px solid var(--line);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.meta-card {
padding: 16px 16px 15px;
}
.meta-label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--soft);
}
.meta-value {
margin-top: 8px;
font-size: 24px;
font-weight: 700;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
}
.meta-subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
line-height: 1.4;
}
.agent-focus {
display: flex;
align-items: flex-start;
gap: 14px;
margin-top: 8px;
}
.agent-badge {
width: 56px;
height: 56px;
border-radius: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
background:
linear-gradient(135deg, rgba(142, 197, 255, 0.22), rgba(91, 157, 255, 0.1)),
rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
font-size: 24px;
font-weight: 700;
}
.agent-focus .name {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
}
.agent-focus .caption {
margin-top: 4px;
font-size: 13px;
color: var(--muted);
}
.section {
padding: 16px 16px 14px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-title {
font-size: 14px;
font-weight: 700;
color: var(--muted);
}
.section-count {
font-size: 12px;
font-weight: 700;
color: var(--soft);
}
.agent-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.agent-card {
padding: 13px 13px 12px;
}
.agent-card.active {
background: var(--panel-strong);
border-color: var(--line-strong);
box-shadow: inset 0 0 0 1px rgba(142, 197, 255, 0.12);
}
.agent-row {
display: flex;
align-items: center;
gap: 10px;
}
.agent-row .badge {
width: 38px;
height: 38px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
font-size: 16px;
font-weight: 700;
}
.agent-row .name {
font-size: 15px;
font-weight: 700;
line-height: 1.2;
overflow-wrap: anywhere;
}
.agent-row .caption {
margin-top: 3px;
font-size: 12px;
color: var(--muted);
}
.empty-state {
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.12);
color: var(--muted);
font-size: 14px;
line-height: 1.45;
}
.footer-note {
margin-top: 12px;
color: var(--soft);
font-size: 12px;
line-height: 1.45;
}
#openclaw-status {
position: fixed;
inset: 0;
@@ -115,41 +374,174 @@
align-items: center;
justify-content: flex-start;
flex-direction: column;
padding-top: calc(20px + env(safe-area-inset-top, 0px));
padding-top: calc(var(--safe-top) + 18px);
pointer-events: none;
z-index: 3;
}
#openclaw-status .card {
text-align: center;
padding: 16px 18px;
border-radius: 14px;
background: rgba(18, 18, 22, 0.42);
border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
background: rgba(18, 18, 22, 0.46);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.55);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
}
#openclaw-status .title {
font: 600 20px -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
letter-spacing: 0.2px;
color: rgba(255,255,255,0.92);
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
color: rgba(255, 255, 255, 0.92);
}
#openclaw-status .subtitle {
margin-top: 6px;
font: 500 12px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
color: rgba(255,255,255,0.58);
color: rgba(255, 255, 255, 0.58);
}
@media (max-width: 640px) {
#openclaw-home {
padding-left: 12px;
padding-right: 12px;
}
.hero {
border-radius: 24px;
padding: 18px 16px 16px;
}
.hero-grid,
.agent-grid {
grid-template-columns: 1fr;
}
.hero h1 {
font-size: 34px;
}
}
@media (max-height: 760px) {
#openclaw-home {
padding-top: calc(var(--safe-top) + 14px);
padding-bottom: calc(var(--safe-bottom) + 12px);
}
.shell {
gap: 12px;
}
.hero {
border-radius: 24px;
padding: 16px 15px 15px;
}
.hero h1 {
margin-top: 14px;
font-size: clamp(28px, 8vw, 38px);
}
.hero p {
margin-top: 10px;
font-size: 15px;
line-height: 1.42;
}
.hero-grid {
margin-top: 18px;
}
.meta-card {
padding: 14px 14px 13px;
}
.meta-value {
font-size: 22px;
}
.agent-badge {
width: 50px;
height: 50px;
border-radius: 16px;
font-size: 22px;
}
.agent-focus .name {
font-size: 20px;
}
.section {
padding: 14px 14px 12px;
}
.section-header {
margin-bottom: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
body::before,
body::after {
animation: none !important;
}
}
</style>
</head>
<body>
<body data-state="offline">
<canvas id="openclaw-canvas"></canvas>
<div id="openclaw-home">
<div class="shell">
<div class="hero">
<div class="eyebrow">
<span class="eyebrow-dot"></span>
<span id="openclaw-home-eyebrow">Welcome to OpenClaw</span>
</div>
<h1 id="openclaw-home-title">Your phone stays quiet until it is needed</h1>
<p id="openclaw-home-subtitle">
Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops.
</p>
<div class="hero-grid">
<div class="meta-card">
<div class="meta-label">Gateway</div>
<div class="meta-value" id="openclaw-home-gateway">Gateway</div>
<div class="meta-subtitle" id="openclaw-home-gateway-caption">Connect to load your agents</div>
</div>
<div class="meta-card">
<div class="meta-label">Active Agent</div>
<div class="agent-focus">
<div class="agent-badge" id="openclaw-home-active-badge">OC</div>
<div>
<div class="name" id="openclaw-home-active-name">Main</div>
<div class="caption" id="openclaw-home-active-caption">Connect to load your agents</div>
</div>
</div>
</div>
</div>
</div>
<div class="meta-card section">
<div class="section-header">
<div class="section-title">Live agents</div>
<div class="section-count" id="openclaw-home-agent-count">0 agents</div>
</div>
<div class="agent-grid" id="openclaw-home-agent-grid"></div>
<div class="footer-note" id="openclaw-home-footer">
When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.
</div>
</div>
</div>
</div>
<div id="openclaw-status">
<div class="card">
<div class="title" id="openclaw-status-title">Ready</div>
<div class="subtitle" id="openclaw-status-subtitle">Waiting for agent</div>
</div>
</div>
<script>
(() => {
const canvas = document.getElementById('openclaw-canvas');
@@ -157,6 +549,20 @@
const statusEl = document.getElementById('openclaw-status');
const titleEl = document.getElementById('openclaw-status-title');
const subtitleEl = document.getElementById('openclaw-status-subtitle');
const home = {
root: document.getElementById('openclaw-home'),
eyebrow: document.getElementById('openclaw-home-eyebrow'),
title: document.getElementById('openclaw-home-title'),
subtitle: document.getElementById('openclaw-home-subtitle'),
gateway: document.getElementById('openclaw-home-gateway'),
gatewayCaption: document.getElementById('openclaw-home-gateway-caption'),
activeBadge: document.getElementById('openclaw-home-active-badge'),
activeName: document.getElementById('openclaw-home-active-name'),
activeCaption: document.getElementById('openclaw-home-active-caption'),
agentCount: document.getElementById('openclaw-home-agent-count'),
agentGrid: document.getElementById('openclaw-home-agent-grid'),
footer: document.getElementById('openclaw-home-footer')
};
const debugStatusEnabledByQuery = (() => {
try {
const params = new URLSearchParams(window.location.search);
@@ -172,54 +578,114 @@
function resize() {
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
canvas.width = w;
canvas.height = h;
const width = Math.max(1, Math.floor(window.innerWidth * dpr));
const height = Math.max(1, Math.floor(window.innerHeight * dpr));
canvas.width = width;
canvas.height = height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function setDebugStatusEnabled(enabled) {
debugStatusEnabled = !!enabled;
if (!debugStatusEnabled) {
statusEl.style.display = 'none';
}
}
function setStatus(title, subtitle) {
if (!debugStatusEnabled) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
return;
}
statusEl.style.display = 'flex';
if (typeof title === 'string') titleEl.textContent = title;
if (typeof subtitle === 'string') subtitleEl.textContent = subtitle;
}
function clearChildren(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
function createAgentCard(agent) {
const card = document.createElement('div');
card.className = `agent-card${agent.isActive ? ' active' : ''}`;
const row = document.createElement('div');
row.className = 'agent-row';
const badge = document.createElement('div');
badge.className = 'badge';
badge.textContent = agent.badge || 'OC';
const text = document.createElement('div');
const name = document.createElement('div');
name.className = 'name';
name.textContent = agent.name || agent.id || 'Agent';
const caption = document.createElement('div');
caption.className = 'caption';
caption.textContent = agent.caption || 'Ready';
text.appendChild(name);
text.appendChild(caption);
row.appendChild(badge);
row.appendChild(text);
card.appendChild(row);
return card;
}
function renderHome(state) {
if (!state || typeof state !== 'object') return;
document.body.dataset.state = state.gatewayState || 'offline';
home.root.style.display = 'flex';
home.eyebrow.textContent = state.eyebrow || 'Welcome to OpenClaw';
home.title.textContent = state.title || 'OpenClaw';
home.subtitle.textContent = state.subtitle || '';
home.gateway.textContent = state.gatewayLabel || 'Gateway';
home.gatewayCaption.textContent = state.gatewayState === 'connected'
? `${state.agentCount || 0} agent${state.agentCount === 1 ? '' : 's'} available`
: (state.activeAgentCaption || 'Connect to load your agents');
home.activeBadge.textContent = state.activeAgentBadge || 'OC';
home.activeName.textContent = state.activeAgentName || 'Main';
home.activeCaption.textContent = state.activeAgentCaption || '';
home.agentCount.textContent = `${state.agentCount || 0} agent${state.agentCount === 1 ? '' : 's'}`;
home.footer.textContent = state.footer || '';
clearChildren(home.agentGrid);
const agents = Array.isArray(state.agents) ? state.agents : [];
if (!agents.length) {
const empty = document.createElement('div');
empty.className = 'empty-state';
empty.textContent = state.gatewayState === 'connected'
? 'Your gateway is online. Agents will appear here as soon as the current scope reports them.'
: 'Connect this phone to your gateway and the live agent overview will appear here.';
home.agentGrid.appendChild(empty);
return;
}
agents.forEach((agent) => {
home.agentGrid.appendChild(createAgentCard(agent));
});
}
window.addEventListener('resize', resize);
resize();
const setDebugStatusEnabled = (enabled) => {
debugStatusEnabled = !!enabled;
if (!statusEl) return;
if (!debugStatusEnabled) {
statusEl.style.display = 'none';
}
};
if (statusEl && !debugStatusEnabled) {
if (!debugStatusEnabled) {
statusEl.style.display = 'none';
}
const api = {
window.__openclaw = {
canvas,
ctx,
setDebugStatusEnabled,
setStatus: (title, subtitle) => {
if (!statusEl || !debugStatusEnabled) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
return;
}
statusEl.style.display = 'flex';
if (titleEl && typeof title === 'string') titleEl.textContent = title;
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
if (!debugStatusEnabled) {
clearTimeout(window.__statusTimeout);
window.__statusTimeout = setTimeout(() => {
statusEl.style.display = 'none';
}, 3000);
} else {
clearTimeout(window.__statusTimeout);
}
}
setStatus,
renderHome
};
window.__openclaw = api;
})();
</script>
</body>
</html>

View File

@@ -950,6 +950,102 @@ public struct NodeEventParams: Codable, Sendable {
}
}
public struct NodePendingDrainParams: Codable, Sendable {
public let maxitems: Int?
public init(
maxitems: Int?)
{
self.maxitems = maxitems
}
private enum CodingKeys: String, CodingKey {
case maxitems = "maxItems"
}
}
public struct NodePendingDrainResult: Codable, Sendable {
public let nodeid: String
public let revision: Int
public let items: [[String: AnyCodable]]
public let hasmore: Bool
public init(
nodeid: String,
revision: Int,
items: [[String: AnyCodable]],
hasmore: Bool)
{
self.nodeid = nodeid
self.revision = revision
self.items = items
self.hasmore = hasmore
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case revision
case items
case hasmore = "hasMore"
}
}
public struct NodePendingEnqueueParams: Codable, Sendable {
public let nodeid: String
public let type: String
public let priority: String?
public let expiresinms: Int?
public let wake: Bool?
public init(
nodeid: String,
type: String,
priority: String?,
expiresinms: Int?,
wake: Bool?)
{
self.nodeid = nodeid
self.type = type
self.priority = priority
self.expiresinms = expiresinms
self.wake = wake
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case type
case priority
case expiresinms = "expiresInMs"
case wake
}
}
public struct NodePendingEnqueueResult: Codable, Sendable {
public let nodeid: String
public let revision: Int
public let queued: [String: AnyCodable]
public let waketriggered: Bool
public init(
nodeid: String,
revision: Int,
queued: [String: AnyCodable],
waketriggered: Bool)
{
self.nodeid = nodeid
self.revision = revision
self.queued = queued
self.waketriggered = waketriggered
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case revision
case queued
case waketriggered = "wakeTriggered"
}
}
public struct NodeInvokeRequestEvent: Codable, Sendable {
public let id: String
public let nodeid: String
@@ -1241,6 +1337,8 @@ public struct SessionsPatchParams: Codable, Sendable {
public let model: AnyCodable?
public let spawnedby: AnyCodable?
public let spawndepth: AnyCodable?
public let subagentrole: AnyCodable?
public let subagentcontrolscope: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable?
@@ -1259,6 +1357,8 @@ public struct SessionsPatchParams: Codable, Sendable {
model: AnyCodable?,
spawnedby: AnyCodable?,
spawndepth: AnyCodable?,
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?)
{
@@ -1276,6 +1376,8 @@ public struct SessionsPatchParams: Codable, Sendable {
self.model = model
self.spawnedby = spawnedby
self.spawndepth = spawndepth
self.subagentrole = subagentrole
self.subagentcontrolscope = subagentcontrolscope
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation
}
@@ -1295,6 +1397,8 @@ public struct SessionsPatchParams: Codable, Sendable {
case model
case spawnedby = "spawnedBy"
case spawndepth = "spawnDepth"
case subagentrole = "subagentRole"
case subagentcontrolscope = "subagentControlScope"
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation"
}
@@ -2950,7 +3054,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String
public let command: String?
public let commandargv: [String]?
public let systemrunplan: [String: AnyCodable]?
public let env: [String: AnyCodable]?
@@ -2971,7 +3075,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public init(
id: String?,
command: String,
command: String?,
commandargv: [String]?,
systemrunplan: [String: AnyCodable]?,
env: [String: AnyCodable]?,

View File

@@ -41,17 +41,67 @@ private func sessionEntry(key: String, updatedAt: Double) -> OpenClawChatSession
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil)
}
private func sessionEntry(
key: String,
updatedAt: Double,
model: String?,
modelProvider: String? = nil) -> OpenClawChatSessionEntry
{
OpenClawChatSessionEntry(
key: key,
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: updatedAt,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: modelProvider,
model: model,
contextTokens: nil)
}
private func modelChoice(id: String, name: String, provider: String = "anthropic") -> OpenClawChatModelChoice {
OpenClawChatModelChoice(modelID: id, name: name, provider: provider, contextWindow: nil)
}
private func makeViewModel(
sessionKey: String = "main",
historyResponses: [OpenClawChatHistoryPayload],
sessionsResponses: [OpenClawChatSessionsListResponse] = []) async -> (TestChatTransport, OpenClawChatViewModel)
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
initialThinkingLevel: String? = nil,
onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)? = nil) async
-> (TestChatTransport, OpenClawChatViewModel)
{
let transport = TestChatTransport(historyResponses: historyResponses, sessionsResponses: sessionsResponses)
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: sessionKey, transport: transport) }
let transport = TestChatTransport(
historyResponses: historyResponses,
sessionsResponses: sessionsResponses,
modelResponses: modelResponses,
setSessionModelHook: setSessionModelHook,
setSessionThinkingHook: setSessionThinkingHook)
let vm = await MainActor.run {
OpenClawChatViewModel(
sessionKey: sessionKey,
transport: transport,
initialThinkingLevel: initialThinkingLevel,
onThinkingLevelChanged: onThinkingLevelChanged)
}
return (transport, vm)
}
@@ -125,27 +175,60 @@ private func emitExternalFinal(
errorMessage: nil)))
}
@MainActor
private final class CallbackBox {
var values: [String] = []
}
private actor AsyncGate {
private var continuation: CheckedContinuation<Void, Never>?
func wait() async {
await withCheckedContinuation { continuation in
self.continuation = continuation
}
}
func open() {
self.continuation?.resume()
self.continuation = nil
}
}
private actor TestChatTransportState {
var historyCallCount: Int = 0
var sessionsCallCount: Int = 0
var modelsCallCount: Int = 0
var sentRunIds: [String] = []
var sentThinkingLevels: [String] = []
var abortedRunIds: [String] = []
var patchedModels: [String?] = []
var patchedThinkingLevels: [String] = []
}
private final class TestChatTransport: @unchecked Sendable, OpenClawChatTransport {
private let state = TestChatTransportState()
private let historyResponses: [OpenClawChatHistoryPayload]
private let sessionsResponses: [OpenClawChatSessionsListResponse]
private let modelResponses: [[OpenClawChatModelChoice]]
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
private let stream: AsyncStream<OpenClawChatTransportEvent>
private let continuation: AsyncStream<OpenClawChatTransportEvent>.Continuation
init(
historyResponses: [OpenClawChatHistoryPayload],
sessionsResponses: [OpenClawChatSessionsListResponse] = [])
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil)
{
self.historyResponses = historyResponses
self.sessionsResponses = sessionsResponses
self.modelResponses = modelResponses
self.setSessionModelHook = setSessionModelHook
self.setSessionThinkingHook = setSessionThinkingHook
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
self.stream = AsyncStream { c in
cont = c
@@ -175,11 +258,12 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
func sendMessage(
sessionKey _: String,
message _: String,
thinking _: String,
thinking: String,
idempotencyKey: String,
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
await self.state.sentRunIdsAppend(idempotencyKey)
await self.state.sentThinkingLevelsAppend(thinking)
return OpenClawChatSendResponse(runId: idempotencyKey, status: "ok")
}
@@ -201,6 +285,29 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
sessions: [])
}
func listModels() async throws -> [OpenClawChatModelChoice] {
let idx = await self.state.modelsCallCount
await self.state.setModelsCallCount(idx + 1)
if idx < self.modelResponses.count {
return self.modelResponses[idx]
}
return self.modelResponses.last ?? []
}
func setSessionModel(sessionKey _: String, model: String?) async throws {
await self.state.patchedModelsAppend(model)
if let setSessionModelHook = self.setSessionModelHook {
try await setSessionModelHook(model)
}
}
func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws {
await self.state.patchedThinkingLevelsAppend(thinkingLevel)
if let setSessionThinkingHook = self.setSessionThinkingHook {
try await setSessionThinkingHook(thinkingLevel)
}
}
func requestHealth(timeoutMs _: Int) async throws -> Bool {
true
}
@@ -217,6 +324,18 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
func abortedRunIds() async -> [String] {
await self.state.abortedRunIds
}
func sentThinkingLevels() async -> [String] {
await self.state.sentThinkingLevels
}
func patchedModels() async -> [String?] {
await self.state.patchedModels
}
func patchedThinkingLevels() async -> [String] {
await self.state.patchedThinkingLevels
}
}
extension TestChatTransportState {
@@ -228,6 +347,10 @@ extension TestChatTransportState {
self.sessionsCallCount = v
}
fileprivate func setModelsCallCount(_ v: Int) {
self.modelsCallCount = v
}
fileprivate func sentRunIdsAppend(_ v: String) {
self.sentRunIds.append(v)
}
@@ -235,6 +358,18 @@ extension TestChatTransportState {
fileprivate func abortedRunIdsAppend(_ v: String) {
self.abortedRunIds.append(v)
}
fileprivate func sentThinkingLevelsAppend(_ v: String) {
self.sentThinkingLevels.append(v)
}
fileprivate func patchedModelsAppend(_ v: String?) {
self.patchedModels.append(v)
}
fileprivate func patchedThinkingLevelsAppend(_ v: String) {
self.patchedThinkingLevels.append(v)
}
}
@Suite struct ChatViewModelTests {
@@ -457,6 +592,512 @@ extension TestChatTransportState {
#expect(keys == ["main", "custom"])
}
@Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openai/gpt-4.1-mini", contextTokens: nil),
sessions: [
sessionEntry(key: "main", updatedAt: now, model: "anthropic/claude-opus-4-6"),
])
let models = [
modelChoice(id: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6"),
modelChoice(id: "openai/gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openai"),
]
let (_, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
#expect(await MainActor.run { vm.showsModelPicker })
#expect(await MainActor.run { vm.modelSelectionID } == "anthropic/claude-opus-4-6")
#expect(await MainActor.run { vm.defaultModelLabel } == "Default: openai/gpt-4.1-mini")
}
@Test func selectingDefaultModelPatchesNilAndUpdatesSelection() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openai/gpt-4.1-mini", contextTokens: nil),
sessions: [
sessionEntry(key: "main", updatedAt: now, model: "anthropic/claude-opus-4-6"),
])
let models = [
modelChoice(id: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6"),
modelChoice(id: "openai/gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.selectModel(OpenClawChatViewModel.defaultModelSelectionID) }
try await waitUntil("session model patched") {
let patched = await transport.patchedModels()
return patched == [nil]
}
#expect(await MainActor.run { vm.modelSelectionID } == OpenClawChatViewModel.defaultModelSelectionID)
}
@Test func selectingProviderQualifiedModelDisambiguatesDuplicateModelIDs() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openrouter/gpt-4.1-mini", contextTokens: nil),
sessions: [
sessionEntry(key: "main", updatedAt: now, model: "gpt-4.1-mini", modelProvider: "openrouter"),
])
let models = [
modelChoice(id: "gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openai"),
modelChoice(id: "gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openrouter"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
#expect(await MainActor.run { vm.modelSelectionID } == "openrouter/gpt-4.1-mini")
await MainActor.run { vm.selectModel("openai/gpt-4.1-mini") }
try await waitUntil("provider-qualified model patched") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-4.1-mini"]
}
}
@Test func slashModelIDsStayProviderQualifiedInSelectionAndPatch() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(
id: "openai/gpt-5.4",
name: "GPT-5.4 via Vercel AI Gateway",
provider: "vercel-ai-gateway"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.selectModel("vercel-ai-gateway/openai/gpt-5.4") }
try await waitUntil("slash model patched with provider-qualified ref") {
let patched = await transport.patchedModels()
return patched == ["vercel-ai-gateway/openai/gpt-5.4"]
}
}
@Test func staleModelPatchCompletionsDoNotOverwriteNewerSelection() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.selectModel("openai/gpt-5.4")
vm.selectModel("openai/gpt-5.4-pro")
}
try await waitUntil("two model patches complete") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4-pro")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4-pro")
}
@Test func sendWaitsForInFlightModelPatchToFinish() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
]
let gate = AsyncGate()
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
await gate.wait()
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.selectModel("openai/gpt-5.4") }
try await waitUntil("model patch started") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4"]
}
await sendUserMessage(vm, text: "hello")
try await waitUntil("send entered waiting state") {
await MainActor.run { vm.isSending }
}
#expect(await transport.lastSentRunId() == nil)
await MainActor.run { vm.selectThinkingLevel("high") }
try await waitUntil("thinking level changed while send is blocked") {
await MainActor.run { vm.thinkingLevel == "high" }
}
await gate.open()
try await waitUntil("send released after model patch") {
await transport.lastSentRunId() != nil
}
#expect(await transport.sentThinkingLevels() == ["off"])
}
@Test func failedLatestModelSelectionDoesNotReplayAfterOlderCompletionFinishes() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
return
}
if model == "openai/gpt-5.4-pro" {
throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"])
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.selectModel("openai/gpt-5.4")
vm.selectModel("openai/gpt-5.4-pro")
}
try await waitUntil("older model completion wins after latest failure") {
await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model == "openai/gpt-5.4" }
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4")
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
@Test func failedLatestModelSelectionRestoresEarlierSuccessWithoutReplay() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(100))
return
}
if model == "openai/gpt-5.4-pro" {
try await Task.sleep(for: .milliseconds(200))
throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"])
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.selectModel("openai/gpt-5.4")
vm.selectModel("openai/gpt-5.4-pro")
}
try await waitUntil("latest failure restores prior successful model") {
await MainActor.run {
vm.modelSelectionID == "openai/gpt-5.4" &&
vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" &&
vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai"
}
}
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
@Test func switchingSessionsIgnoresLateModelPatchCompletionFromPreviousSession() async throws {
let now = Date().timeIntervalSince1970 * 1000
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
sessionEntry(key: "other", updatedAt: now - 1000, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "other", sessionId: "sess-other"),
],
sessionsResponses: [sessions, sessions],
modelResponses: [models, models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
await MainActor.run { vm.selectModel("openai/gpt-5.4") }
await MainActor.run { vm.switchSession(to: "other") }
try await waitUntil("switched sessions") {
await MainActor.run { vm.sessionKey == "other" && vm.sessionId == "sess-other" }
}
try await waitUntil("late model patch finished") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4"]
}
#expect(await MainActor.run { vm.modelSelectionID } == OpenClawChatViewModel.defaultModelSelectionID)
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == nil)
}
@Test func lateModelCompletionDoesNotReplayCurrentSessionSelectionIntoPreviousSession() async throws {
let now = Date().timeIntervalSince1970 * 1000
let initialSessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
sessionEntry(key: "other", updatedAt: now - 1000, model: nil),
])
let sessionsAfterOtherSelection = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
sessionEntry(key: "other", updatedAt: now - 1000, model: "openai/gpt-5.4-pro"),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "other", sessionId: "sess-other"),
historyPayload(sessionKey: "main", sessionId: "sess-main"),
],
sessionsResponses: [initialSessions, initialSessions, sessionsAfterOtherSelection],
modelResponses: [models, models, models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
await MainActor.run { vm.selectModel("openai/gpt-5.4") }
await MainActor.run { vm.switchSession(to: "other") }
try await waitUntil("switched to other session") {
await MainActor.run { vm.sessionKey == "other" && vm.sessionId == "sess-other" }
}
await MainActor.run { vm.selectModel("openai/gpt-5.4-pro") }
try await waitUntil("both model patches issued") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
}
await MainActor.run { vm.switchSession(to: "main") }
try await waitUntil("switched back to main session") {
await MainActor.run { vm.sessionKey == "main" && vm.sessionId == "sess-main" }
}
try await waitUntil("late model completion updates only the original session") {
await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model == "openai/gpt-5.4" }
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == "openai/gpt-5.4-pro")
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
@Test func explicitThinkingLevelWinsOverHistoryAndPersistsChanges() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "off")
let callbackState = await MainActor.run { CallbackBox() }
let (transport, vm) = await makeViewModel(
historyResponses: [history],
initialThinkingLevel: "high",
onThinkingLevelChanged: { level in
callbackState.values.append(level)
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { vm.thinkingLevel } == "high")
await MainActor.run { vm.selectThinkingLevel("medium") }
try await waitUntil("thinking level patched") {
let patched = await transport.patchedThinkingLevels()
return patched == ["medium"]
}
#expect(await MainActor.run { vm.thinkingLevel } == "medium")
#expect(await MainActor.run { callbackState.values } == ["medium"])
}
@Test func serverProvidedThinkingLevelsOutsideMenuArePreservedForSend() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "xhigh")
let (transport, vm) = await makeViewModel(historyResponses: [history])
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { vm.thinkingLevel } == "xhigh")
await sendUserMessage(vm, text: "hello")
try await waitUntil("send uses preserved thinking level") {
await transport.sentThinkingLevels() == ["xhigh"]
}
}
@Test func staleThinkingPatchCompletionReappliesLatestSelection() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "off")
let (transport, vm) = await makeViewModel(
historyResponses: [history],
setSessionThinkingHook: { level in
if level == "medium" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
await MainActor.run {
vm.selectThinkingLevel("medium")
vm.selectThinkingLevel("high")
}
try await waitUntil("thinking patch replayed latest selection") {
let patched = await transport.patchedThinkingLevels()
return patched == ["medium", "high", "high"]
}
#expect(await MainActor.run { vm.thinkingLevel } == "high")
}
@Test func clearsStreamingOnExternalErrorEvent() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId)

View File

@@ -17,6 +17,51 @@ Key goals:
- Works with existing Gateway session store (list/resolve/reset).
- Safe defaults (isolated ACP session keys by default).
## Bridge Scope
`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor
runtime. It is designed to route IDE prompts into an existing OpenClaw Gateway
session with predictable session mapping and basic streaming updates.
## Compatibility Matrix
| ACP area | Status | Notes |
| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. |
| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. |
| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. |
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. |
| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. |
| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. |
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. |
## Known Limitations
- `loadSession` replays stored user and assistant text history, but it does not
reconstruct historic tool calls, system notices, or richer ACP-native event
types.
- If multiple ACP clients share the same Gateway session key, event and cancel
routing are best-effort rather than strictly isolated per client. Prefer the
default isolated `acp:<uuid>` sessions when you need clean editor-local
turns.
- Gateway stop states are translated into ACP stop reasons, but that mapping is
less expressive than a fully ACP-native runtime.
- Initial session controls currently surface a focused subset of Gateway knobs:
thought level, tool verbosity, reasoning, usage detail, and elevated
actions. Model selection and exec-host controls are not yet exposed as ACP
config options.
- `session_info_update` and `usage_update` are derived from Gateway session
snapshots, not live ACP-native runtime accounting. Usage is approximate,
carries no cost data, and is only emitted when the Gateway marks total token
data as fresh.
- Tool follow-along data is best-effort. The bridge can surface file paths that
appear in known tool args/results, but it does not yet emit ACP terminals or
structured file diffs.
## How can I use this
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
@@ -181,9 +226,11 @@ updates. Terminal Gateway states map to ACP `done` with stop reasons:
## Compatibility
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x).
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.15.x).
- Works with ACP clients that implement `initialize`, `newSession`,
`loadSession`, `prompt`, `cancel`, and `listSessions`.
- Bridge mode rejects per-session `mcpServers` instead of silently ignoring
them. Configure MCP at the Gateway or agent layer.
## Testing

View File

@@ -29,6 +29,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = "<url>"`.
- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode.
- For upgrades, `openclaw doctor --fix` can normalize legacy cron store fields before the scheduler touches them.
## Quick start (actionable)
@@ -261,6 +262,7 @@ If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the mai
Target format reminders:
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
Mattermost bare 26-char IDs are resolved **user-first** (DM if user exists, channel otherwise) — use `user:<id>` or `channel:<id>` for deterministic routing.
- Telegram topics should use the `:topic:` form (see below).
#### Telegram delivery targets (topics / forum threads)

View File

@@ -168,6 +168,7 @@ openclaw pairing approve discord <CODE>
<Note>
Token resolution is account-aware. Config token values win over env fallback. `DISCORD_BOT_TOKEN` is only used for the default account.
For advanced outbound calls (message tool/channel actions), an explicit per-call `token` is used for that call. Account policy/retry settings still come from the selected account in the active runtime snapshot.
</Note>
## Recommended: Set up a guild workspace
@@ -945,7 +946,7 @@ Default slash command settings:
Gateway auth for this handler uses the same shared credential resolution contract as other Gateway clients:
- env-first local auth (`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` then `gateway.auth.*`)
- in local mode, `gateway.remote.*` can be used as fallback when `gateway.auth.*` is unset
- in local mode, `gateway.remote.*` can be used as fallback only when `gateway.auth.*` is unset; configured-but-unresolved local SecretRefs fail closed
- remote-mode support via `gateway.remote.*` when applicable
- URL overrides are override-safe: CLI overrides do not reuse implicit credentials, and env overrides use env credentials only

View File

@@ -87,6 +87,8 @@ Token/secret files:
}
```
`tokenFile` and `secretFile` must point to regular files. Symlinks are rejected.
Multiple accounts:
```json5

View File

@@ -153,7 +153,14 @@ Use these target formats with `openclaw message send` or cron/webhooks:
- `user:<id>` for a DM
- `@username` for a DM (resolved via the Mattermost API)
Bare IDs are treated as channels.
Bare opaque IDs (like `64ifufp...`) are **ambiguous** in Mattermost (user ID vs channel ID).
OpenClaw resolves them **user-first**:
- If the ID exists as a user (`GET /api/v4/users/<id>` succeeds), OpenClaw sends a **DM** by resolving the direct channel via `/api/v4/channels/direct`.
- Otherwise the ID is treated as a **channel ID**.
If you need deterministic behavior, always use the explicit prefixes (`user:<id>` / `channel:<id>`).
## Reactions (message tool)

View File

@@ -115,7 +115,7 @@ Provider options:
- `channels.nextcloud-talk.enabled`: enable/disable channel startup.
- `channels.nextcloud-talk.baseUrl`: Nextcloud instance URL.
- `channels.nextcloud-talk.botSecret`: bot shared secret.
- `channels.nextcloud-talk.botSecretFile`: secret file path.
- `channels.nextcloud-talk.botSecretFile`: regular-file secret path. Symlinks are rejected.
- `channels.nextcloud-talk.apiUser`: API user for room lookups (DM detection).
- `channels.nextcloud-talk.apiPassword`: API/app password for room lookups.
- `channels.nextcloud-talk.apiPasswordFile`: API password file path.

View File

@@ -155,6 +155,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
`groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`.
`groupAllowFrom` entries should be numeric Telegram user IDs (`telegram:` / `tg:` prefixes are normalized).
Do not put Telegram group or supergroup chat IDs in `groupAllowFrom`. Negative chat IDs belong under `channels.telegram.groups`.
Non-numeric entries are ignored for sender authorization.
Security boundary (`2026.2.25+`): group sender auth does **not** inherit DM pairing-store approvals.
Pairing stays DM-only. For groups, set `groupAllowFrom` or per-group/per-topic `allowFrom`.
@@ -177,6 +178,31 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
}
```
Example: allow only specific users inside one specific group:
```json5
{
channels: {
telegram: {
groups: {
"-1001234567890": {
requireMention: true,
allowFrom: ["8734062810", "745123456"],
},
},
},
},
}
```
<Warning>
Common mistake: `groupAllowFrom` is not a Telegram group allowlist.
- Put negative Telegram group or supergroup chat IDs like `-1001234567890` under `channels.telegram.groups`.
- Put Telegram user IDs like `8734062810` under `groupAllowFrom` when you want to limit which people inside an allowed group can trigger the bot.
- Use `groupAllowFrom: ["*"]` only when you want any member of an allowed group to be able to talk to the bot.
</Warning>
</Tab>
<Tab title="Mention behavior">
@@ -410,6 +436,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `channels.telegram.actions.sticker` (default: disabled)
Note: `edit` and `topic-create` are currently enabled by default and do not have separate `channels.telegram.actions.*` toggles.
Runtime sends use the active config/secrets snapshot (startup/reload), so action paths do not perform ad-hoc SecretRef re-resolution per send.
Reaction removal semantics: [/tools/reactions](/tools/reactions)
@@ -760,6 +787,34 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
- `channels.telegram.actions.poll=false` disables Telegram poll creation while leaving regular sends enabled
</Accordion>
<Accordion title="Exec approvals in Telegram">
Telegram supports exec approvals in approver DMs and can optionally post approval prompts in the originating chat or topic.
Config path:
- `channels.telegram.execApprovals.enabled`
- `channels.telegram.execApprovals.approvers`
- `channels.telegram.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`)
- `agentFilter`, `sessionFilter`
Approvers must be numeric Telegram user IDs. When `enabled` is false or `approvers` is empty, Telegram does not act as an exec approval client. Approval requests fall back to other configured approval routes or the exec approval fallback policy.
Delivery rules:
- `target: "dm"` sends approval prompts only to configured approver DMs
- `target: "channel"` sends the prompt back to the originating Telegram chat/topic
- `target: "both"` sends to approver DMs and the originating chat/topic
Only configured approvers can approve or deny. Non-approvers cannot use `/approve` and cannot use Telegram approval buttons.
Channel delivery shows the command text in the chat, so only enable `channel` or `both` in trusted groups/topics. When the prompt lands in a forum topic, OpenClaw preserves the topic for both the approval prompt and the post-approval follow-up.
Inline approval buttons also depend on `channels.telegram.capabilities.inlineButtons` allowing the target surface (`dm`, `group`, or `all`).
Related docs: [Exec approvals](/tools/exec-approvals)
</Accordion>
</AccordionGroup>
## Troubleshooting
@@ -837,7 +892,7 @@ Primary reference:
- `channels.telegram.enabled`: enable/disable channel startup.
- `channels.telegram.botToken`: bot token (BotFather).
- `channels.telegram.tokenFile`: read token from file path.
- `channels.telegram.tokenFile`: read token from a regular file path. Symlinks are rejected.
- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `allowlist` requires at least one sender ID. `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs and can recover allowlist entries from pairing-store files in allowlist migration flows.
- `channels.telegram.actions.poll`: enable or disable Telegram poll creation (default: enabled; still requires `sendMessage`).
@@ -859,10 +914,16 @@ Primary reference:
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (group fields + topic-only `agentId`).
- `channels.telegram.groups.<id>.topics.<threadId>.agentId`: route this topic to a specific agent (overrides group-level and binding routing).
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
- `channels.telegram.execApprovals.enabled`: enable Telegram as a chat-based exec approval client for this account.
- `channels.telegram.execApprovals.approvers`: Telegram user IDs allowed to approve or deny exec requests. Required when exec approvals are enabled.
- `channels.telegram.execApprovals.target`: `dm | channel | both` (default: `dm`). `channel` and `both` preserve the originating Telegram topic when present.
- `channels.telegram.execApprovals.agentFilter`: optional agent ID filter for forwarded approval prompts.
- `channels.telegram.execApprovals.sessionFilter`: optional session key filter (substring or regex) for forwarded approval prompts.
- `channels.telegram.accounts.<account>.execApprovals`: per-account override for Telegram exec approval routing and approver authorization.
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
- `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands.
@@ -892,8 +953,9 @@ Primary reference:
Telegram-specific high-signal fields:
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*` (`tokenFile` must point to a regular file; symlinks are rejected)
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`)
- exec approvals: `execApprovals`, `accounts.*.execApprovals`
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
- threading/replies: `replyToMode`
- streaming: `streaming` (preview), `blockStreaming`

View File

@@ -21,7 +21,7 @@ Tlon ships as a plugin and is not bundled with the core install.
Install via CLI (npm registry):
```bash
openclaw plugins install @openclaw/tlon
openclaw plugins install @tloncorp/openclaw
```
Local checkout (when running from a git repo):
@@ -32,6 +32,9 @@ openclaw plugins install ./extensions/tlon
Details: [Plugins](/tools/plugin)
Existing installs from `@openclaw/tlon` are migrated to the new package name on config load so
future `openclaw plugins update` runs resolve from `@tloncorp/openclaw`.
## Setup
1. Install the Tlon plugin.

View File

@@ -179,7 +179,7 @@ Provider options:
- `channels.zalo.enabled`: enable/disable channel startup.
- `channels.zalo.botToken`: bot token from Zalo Bot Platform.
- `channels.zalo.tokenFile`: read token from file path.
- `channels.zalo.tokenFile`: read token from a regular file path. Symlinks are rejected.
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs.
- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
@@ -193,7 +193,7 @@ Provider options:
Multi-account options:
- `channels.zalo.accounts.<id>.botToken`: per-account token.
- `channels.zalo.accounts.<id>.tokenFile`: per-account token file.
- `channels.zalo.accounts.<id>.tokenFile`: per-account regular token file. Symlinks are rejected.
- `channels.zalo.accounts.<id>.name`: display name.
- `channels.zalo.accounts.<id>.enabled`: enable/disable account.
- `channels.zalo.accounts.<id>.dmPolicy`: per-account DM policy.

View File

@@ -13,6 +13,49 @@ Run the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) bridge t
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor
runtime. It focuses on session routing, prompt delivery, and basic streaming
updates.
## Compatibility Matrix
| ACP area | Status | Notes |
| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. |
| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. |
| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. |
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. |
| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. |
| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. |
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. |
## Known Limitations
- `loadSession` replays stored user and assistant text history, but it does not
reconstruct historic tool calls, system notices, or richer ACP-native event
types.
- If multiple ACP clients share the same Gateway session key, event and cancel
routing are best-effort rather than strictly isolated per client. Prefer the
default isolated `acp:<uuid>` sessions when you need clean editor-local
turns.
- Gateway stop states are translated into ACP stop reasons, but that mapping is
less expressive than a fully ACP-native runtime.
- Initial session controls currently surface a focused subset of Gateway knobs:
thought level, tool verbosity, reasoning, usage detail, and elevated
actions. Model selection and exec-host controls are not yet exposed as ACP
config options.
- `session_info_update` and `usage_update` are derived from Gateway session
snapshots, not live ACP-native runtime accounting. Usage is approximate,
carries no cost data, and is only emitted when the Gateway marks total token
data as fresh.
- Tool follow-along data is best-effort. The bridge can surface file paths that
appear in known tool args/results, but it does not yet emit ACP terminals or
structured file diffs.
## Usage
```bash
@@ -96,6 +139,10 @@ Each ACP session maps to a single Gateway session key. One agent can have many
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
the key or label.
Per-session `mcpServers` are not supported in bridge mode. If an ACP client
sends them during `newSession` or `loadSession`, the bridge returns a clear
error instead of silently ignoring them.
## Use from `acpx` (Codex, Claude, other ACP clients)
If you want a coding agent such as Codex or Claude Code to talk to your
@@ -226,7 +273,7 @@ Security note:
- `--token` and `--password` can be visible in local process listings on some systems.
- Prefer `--token-file`/`--password-file` or environment variables (`OPENCLAW_GATEWAY_TOKEN`, `OPENCLAW_GATEWAY_PASSWORD`).
- Gateway auth resolution follows the shared contract used by other Gateway clients:
- local mode: env (`OPENCLAW_GATEWAY_*`) -> `gateway.auth.*` -> `gateway.remote.*` fallback when `gateway.auth.*` is unset
- local mode: env (`OPENCLAW_GATEWAY_*`) -> `gateway.auth.*` -> `gateway.remote.*` fallback only when `gateway.auth.*` is unset (configured-but-unresolved local SecretRefs fail closed)
- remote mode: `gateway.remote.*` with env/config fallback per remote precedence rules
- `--url` is override-safe and does not reuse implicit config/env credentials; pass explicit `--token`/`--password` (or file variants)
- ACP runtime backend child processes receive `OPENCLAW_SHELL=acp`, which can be used for context-specific shell/profile rules.

View File

@@ -30,6 +30,12 @@ Note: retention/pruning is controlled in config:
- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions.
- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/<jobId>.jsonl`.
Upgrade note: if you have older cron jobs from before the current delivery/store format, run
`openclaw doctor --fix`. Doctor now normalizes legacy cron fields (`jobId`, `schedule.cron`,
top-level delivery fields, payload `provider` delivery aliases) and migrates simple
`notify: true` webhook fallback jobs to explicit webhook delivery when `cron.webhook` is
configured.
## Common edits
Update delivery settings without changing the message:

View File

@@ -92,3 +92,40 @@ Pass `--token` or `--password` explicitly. Missing explicit credentials is an er
- These commands require `operator.pairing` (or `operator.admin`) scope.
- `devices clear` is intentionally gated by `--yes`.
- If pairing scope is unavailable on local loopback (and no explicit `--url` is passed), list/approve can use a local pairing fallback.
## Token drift recovery checklist
Use this when Control UI or other clients keep failing with `AUTH_TOKEN_MISMATCH` or `AUTH_DEVICE_TOKEN_MISMATCH`.
1. Confirm current gateway token source:
```bash
openclaw config get gateway.auth.token
```
2. List paired devices and identify the affected device id:
```bash
openclaw devices list
```
3. Rotate operator token for the affected device:
```bash
openclaw devices rotate --device <deviceId> --role operator
```
4. If rotation is not enough, remove stale pairing and approve again:
```bash
openclaw devices remove <deviceId>
openclaw devices list
openclaw devices approve <requestId>
```
5. Retry client connection with the current shared token/password.
Related:
- [Dashboard auth troubleshooting](/web/dashboard#if-you-see-unauthorized-1008)
- [Gateway troubleshooting](/gateway/troubleshooting#dashboard-control-ui-connectivity)

View File

@@ -28,6 +28,7 @@ Notes:
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
- State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.<timestamp>` to reclaim space safely.
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing.
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).

View File

@@ -337,7 +337,7 @@ Options:
- `--non-interactive`
- `--mode <local|remote>`
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|mistral-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|custom-api-key|skip>`
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|mistral-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|opencode-go|custom-api-key|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
@@ -354,6 +354,7 @@ Options:
- `--zai-api-key <key>`
- `--minimax-api-key <key>`
- `--opencode-zen-api-key <key>`
- `--opencode-go-api-key <key>`
- `--custom-base-url <url>` (non-interactive; used with `--auth-choice custom-api-key`)
- `--custom-model-id <id>` (non-interactive; used with `--auth-choice custom-api-key`)
- `--custom-api-key <key>` (non-interactive; optional; used with `--auth-choice custom-api-key`; falls back to `CUSTOM_API_KEY` when omitted)
@@ -1018,7 +1019,7 @@ Subcommands:
Auth notes:
- `node` resolves gateway auth from env/config (no `--token`/`--password` flags): `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`, then `gateway.auth.*`, with remote-mode support via `gateway.remote.*`.
- `node` resolves gateway auth from env/config (no `--token`/`--password` flags): `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`, then `gateway.auth.*`. In local mode, node host intentionally ignores `gateway.remote.*`; in `gateway.mode=remote`, `gateway.remote.*` participates per remote precedence rules.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored for node-host auth resolution.
## Nodes

View File

@@ -64,7 +64,8 @@ Options:
- `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` are checked first.
- Then local config fallback: `gateway.auth.token` / `gateway.auth.password`.
- In local mode, `gateway.remote.token` / `gateway.remote.password` are also eligible as fallback when `gateway.auth.*` is unset.
- In local mode, node host intentionally does not inherit `gateway.remote.token` / `gateway.remote.password`.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, node auth resolution fails closed (no remote fallback masking).
- In `gateway.mode=remote`, remote client fields (`gateway.remote.token` / `gateway.remote.password`) are also eligible per remote precedence rules.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are ignored for node host auth resolution.

View File

@@ -86,12 +86,13 @@ OpenClaw ships with the piai catalog. These providers require **no**
}
```
### OpenCode Zen
### OpenCode
- Provider: `opencode`
- Auth: `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`)
- Example model: `opencode/claude-opus-4-6`
- CLI: `openclaw onboard --auth-choice opencode-zen`
- Zen runtime provider: `opencode`
- Go runtime provider: `opencode-go`
- Example models: `opencode/claude-opus-4-6`, `opencode-go/kimi-k2.5`
- CLI: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go`
```json5
{
@@ -104,8 +105,8 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Provider: `google`
- Auth: `GEMINI_API_KEY`
- Optional rotation: `GEMINI_API_KEYS`, `GEMINI_API_KEY_1`, `GEMINI_API_KEY_2`, `GOOGLE_API_KEY` fallback, and `OPENCLAW_LIVE_GEMINI_KEY` (single override)
- Example models: `google/gemini-3.1-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-3.1-flash-lite-preview`
- Compatibility: legacy OpenClaw config using `google/gemini-3.1-flash-preview` is normalized to `google/gemini-3-flash-preview`, and bare `google/gemini-3.1-flash-lite` is normalized to `google/gemini-3.1-flash-lite-preview`
- Example models: `google/gemini-3.1-pro-preview`, `google/gemini-3-flash-preview`
- Compatibility: legacy OpenClaw config using `google/gemini-3.1-flash-preview` is normalized to `google/gemini-3-flash-preview`
- CLI: `openclaw onboard --auth-choice gemini-api-key`
### Google Vertex, Antigravity, and Gemini CLI

View File

@@ -55,8 +55,8 @@ subscription** (OAuth) and **Anthropic** (API key or `claude setup-token`).
Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize
to `zai/*`.
Provider configuration examples (including OpenCode Zen) live in
[/gateway/configuration](/gateway/configuration#opencode-zen-multi-model-proxy).
Provider configuration examples (including OpenCode) live in
[/gateway/configuration](/gateway/configuration#opencode).
## “Model is not allowed” (and why replies stop)

View File

@@ -103,6 +103,10 @@
"source": "/opencode",
"destination": "/providers/opencode"
},
{
"source": "/opencode-go",
"destination": "/providers/opencode-go"
},
{
"source": "/qianfan",
"destination": "/providers/qianfan"
@@ -1013,8 +1017,7 @@
"tools/browser",
"tools/browser-login",
"tools/chrome-extension",
"tools/browser-linux-troubleshooting",
"tools/browser-wsl2-windows-remote-cdp-troubleshooting"
"tools/browser-linux-troubleshooting"
]
},
{
@@ -1112,6 +1115,7 @@
"providers/nvidia",
"providers/ollama",
"providers/openai",
"providers/opencode-go",
"providers/opencode",
"providers/openrouter",
"providers/qianfan",

View File

@@ -203,7 +203,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
}
```
- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile`, with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile` (regular file only; symlinks rejected), with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
- Optional `channels.telegram.defaultAccount` overrides default account selection when it matches a configured account id.
- In multi-account setups (2+ account ids), set an explicit default (`channels.telegram.defaultAccount` or `channels.telegram.accounts.default`) to avoid fallback routing; `openclaw doctor` warns when this is missing or invalid.
- `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`).
@@ -304,6 +304,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
```
- Token: `channels.discord.token`, with `DISCORD_BOT_TOKEN` as fallback for the default account.
- Direct outbound calls that provide an explicit Discord `token` use that token for the call; account retry/policy settings still come from the selected account in the active runtime snapshot.
- Optional `channels.discord.defaultAccount` overrides default account selection when it matches a configured account id.
- Use `user:<id>` (DM) or `channel:<id>` (guild channel) for delivery targets; bare numeric IDs are rejected.
- Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs.
@@ -747,6 +748,7 @@ Include your own number in `allowFrom` to enable self-chat mode (ignores native
- `bash: true` enables `! <cmd>` for host shell. Requires `tools.elevated.enabled` and sender in `tools.elevated.allowFrom.<channel>`.
- `config: true` enables `/config` (reads/writes `openclaw.json`). For gateway `chat.send` clients, persistent `/config set|unset` writes also require `operator.admin`; read-only `/config show` stays available to normal write-scoped operator clients.
- `channels.<provider>.configWrites` gates config mutations per channel (default: true).
- For multi-account channels, `channels.<provider>.accounts.<id>.configWrites` also gates writes that target that account (for example `/allowlist --config --account <id>` or `/config set channels.<provider>.accounts.<id>...`).
- `allowFrom` is per-provider. When set, it is the **only** authorization source (channel allowlists/pairing and `useAccessGroups` are ignored).
- `useAccessGroups: false` allows commands to bypass access-group policies when `allowFrom` is not set.
@@ -2077,7 +2079,7 @@ Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct.
</Accordion>
<Accordion title="OpenCode Zen">
<Accordion title="OpenCode">
```json5
{
@@ -2090,7 +2092,7 @@ Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct.
}
```
Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Shortcut: `openclaw onboard --auth-choice opencode-zen`.
Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for the Zen catalog or `opencode-go/...` refs for the Go catalog. Shortcut: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go`.
</Accordion>
@@ -2468,7 +2470,8 @@ See [Plugins](/tools/plugin).
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`: client-side break-glass override that allows plaintext `ws://` to trusted private-network IPs; default remains loopback-only for plaintext.
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
- Local gateway call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
- Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.
- `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior.
- `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).
@@ -2712,6 +2715,7 @@ Validation:
- `source: "env"` id pattern: `^[A-Z][A-Z0-9_]{0,127}$`
- `source: "file"` id: absolute JSON pointer (for example `"/providers/openai/apiKey"`)
- `source: "exec"` id pattern: `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
- `source: "exec"` ids must not contain `.` or `..` slash-delimited path segments (for example `a/../b` is rejected)
### Supported credential surface

View File

@@ -63,8 +63,9 @@ cat ~/.openclaw/openclaw.json
- Health check + restart prompt.
- Skills status summary (eligible/missing/blocked).
- Config normalization for legacy values.
- OpenCode Zen provider override warnings (`models.providers.opencode`).
- OpenCode provider override warnings (`models.providers.opencode` / `models.providers.opencode-go`).
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
- Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, simple `notify: true` webhook fallback jobs).
- State integrity and permissions checks (sessions, transcripts, state dir).
- Config file permission checks (chmod 600) when running locally.
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.
@@ -133,12 +134,12 @@ Doctor warnings also include account-default guidance for multi-account channels
- If two or more `channels.<channel>.accounts` entries are configured without `channels.<channel>.defaultAccount` or `accounts.default`, doctor warns that fallback routing can pick an unexpected account.
- If `channels.<channel>.defaultAccount` is set to an unknown account ID, doctor warns and lists configured account IDs.
### 2b) OpenCode Zen provider overrides
### 2b) OpenCode provider overrides
If youve added `models.providers.opencode` (or `opencode-zen`) manually, it
overrides the built-in OpenCode Zen catalog from `@mariozechner/pi-ai`. That can
force every model onto a single API or zero out costs. Doctor warns so you can
remove the override and restore per-model API routing + costs.
If youve added `models.providers.opencode`, `opencode-zen`, or `opencode-go`
manually, it overrides the built-in OpenCode catalog from `@mariozechner/pi-ai`.
That can force models onto the wrong API or zero out costs. Doctor warns so you
can remove the override and restore per-model API routing + costs.
### 3) Legacy state migrations (disk layout)
@@ -158,6 +159,25 @@ the legacy sessions + agent dir on startup so history/auth/models land in the
per-agent path without a manual doctor run. WhatsApp auth is intentionally only
migrated via `openclaw doctor`.
### 3b) Legacy cron store migrations
Doctor also checks the cron job store (`~/.openclaw/cron/jobs.json` by default,
or `cron.store` when overridden) for old job shapes that the scheduler still
accepts for compatibility.
Current cron cleanups include:
- `jobId``id`
- `schedule.cron``schedule.expr`
- top-level payload fields (`message`, `model`, `thinking`, ...) → `payload`
- top-level delivery fields (`deliver`, `channel`, `to`, `provider`, ...) → `delivery`
- payload `provider` delivery aliases → explicit `delivery.channel`
- simple legacy `notify: true` webhook fallback jobs → explicit `delivery.mode="webhook"` with `delivery.to=cron.webhook`
Doctor only auto-migrates `notify: true` jobs when it can do so without
changing behavior. If a job combines legacy notify fallback with an existing
non-webhook delivery mode, doctor warns and leaves that job for manual review.
### 4) State integrity checks (session persistence, routing, and safety)
The state directory is the operational brainstem. If it vanishes, you lose

View File

@@ -206,6 +206,12 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
persisted by the client for future connects.
- Device tokens can be rotated/revoked via `device.token.rotate` and
`device.token.revoke` (requires `operator.pairing` scope).
- Auth failures include `error.details.code` plus recovery hints:
- `error.details.canRetryWithDeviceToken` (boolean)
- `error.details.recommendedNextStep` (`retry_with_device_token`, `update_auth_configuration`, `update_auth_credentials`, `wait_then_retry`, `review_auth_configuration`)
- Client behavior for `AUTH_TOKEN_MISMATCH`:
- Trusted clients may attempt one bounded retry with a cached per-device token.
- If that retry fails, clients should stop automatic reconnect loops and surface operator action guidance.
## Device identity + pairing
@@ -217,8 +223,9 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- **Local** connects include loopback and the gateway hosts own tailnet address
(so samehost tailnet binds can still autoapprove).
- All WS clients must include `device` identity during `connect` (operator + node).
Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
is enabled for break-glass use.
Control UI can omit it only in these modes:
- `gateway.controlUi.allowInsecureAuth=true` for localhost-only insecure HTTP compatibility.
- `gateway.controlUi.dangerouslyDisableDeviceAuth=true` (break-glass, severe security downgrade).
- All connections must sign the server-provided `connect.challenge` nonce.
### Device auth migration diagnostics

View File

@@ -103,18 +103,19 @@ When the gateway is loopback-only, keep the URL at `ws://127.0.0.1:18789` and op
## Credential precedence
Gateway credential resolution follows one shared contract across call/probe/status paths, Discord exec-approval monitoring, and node-host connections:
Gateway credential resolution follows one shared contract across call/probe/status paths and Discord exec-approval monitoring. Node-host uses the same base contract with one local-mode exception (it intentionally ignores `gateway.remote.*`):
- Explicit credentials (`--token`, `--password`, or tool `gatewayToken`) always win on call paths that accept explicit auth.
- URL override safety:
- CLI URL overrides (`--url`) never reuse implicit config/env credentials.
- Env URL overrides (`OPENCLAW_GATEWAY_URL`) may use env credentials only (`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`).
- Local mode defaults:
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password`
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token` (remote fallback applies only when local auth token input is unset)
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password` (remote fallback applies only when local auth password input is unset)
- Remote mode defaults:
- token: `gateway.remote.token` -> `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.remote.password` -> `gateway.auth.password`
- Node-host local-mode exception: `gateway.remote.token` / `gateway.remote.password` are ignored.
- Remote probe/status token checks are strict by default: they use `gateway.remote.token` only (no local token fallback) when targeting remote mode.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are only used by compatibility call paths; probe/status/auth resolution uses `OPENCLAW_GATEWAY_*` only.
@@ -140,7 +141,8 @@ Short version: **keep the Gateway loopback-only** unless youre sure you need
set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass.
- **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords.
- `gateway.remote.token` / `.password` are client credential sources. They do **not** configure server auth by themselves.
- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
- Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
- **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity
headers when `gateway.auth.allowTailscale: true`; HTTP API endpoints still

View File

@@ -21,6 +21,7 @@ Secrets are resolved into an in-memory runtime snapshot.
- Startup fails fast when an effectively active SecretRef cannot be resolved.
- Reload uses atomic swap: full success, or keep the last-known-good snapshot.
- Runtime requests read from the active in-memory snapshot only.
- Outbound delivery paths also read from that active snapshot (for example Discord reply/thread delivery and Telegram action sends); they do not re-resolve SecretRefs on each send.
This keeps secret-provider outages off hot request paths.
@@ -38,14 +39,15 @@ Examples of inactive surfaces:
- Top-level channel credentials that no enabled account inherits.
- Disabled tool/feature surfaces.
- Web search provider-specific keys that are not selected by `tools.web.search.provider`.
In auto mode (provider unset), provider-specific keys are also active for provider auto-detection.
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active (when `gateway.remote.enabled` is not `false`) if one of these is true:
In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves.
After selection, non-selected provider keys are treated as inactive until selected.
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active if one of these is true:
- `gateway.mode=remote`
- `gateway.remote.url` is configured
- `gateway.tailscale.mode` is `serve` or `funnel`
In local mode without those remote surfaces:
- `gateway.remote.token` is active when token auth can win and no env/auth token is configured.
- `gateway.remote.password` is active only when password auth can win and no env/auth password is configured.
- In local mode without those remote surfaces:
- `gateway.remote.token` is active when token auth can win and no env/auth token is configured.
- `gateway.remote.password` is active only when password auth can win and no env/auth password is configured.
- `gateway.auth.token` SecretRef is inactive for startup auth resolution when `OPENCLAW_GATEWAY_TOKEN` (or `CLAWDBOT_GATEWAY_TOKEN`) is set, because env token input wins for that runtime.
## Gateway auth surface diagnostics
@@ -112,6 +114,7 @@ Validation:
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
- `id` must match `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
- `id` must not contain `.` or `..` as slash-delimited path segments (for example `a/../b` is rejected)
## Provider config
@@ -320,6 +323,7 @@ Activation contract:
- Success swaps the snapshot atomically.
- Startup failure aborts gateway startup.
- Runtime reload failure keeps the last-known-good snapshot.
- Providing an explicit per-call channel token to an outbound helper/tool call does not trigger SecretRef activation; activation points remain startup, reload, and explicit `secrets.reload`.
## Degraded and recovered signals

View File

@@ -104,6 +104,7 @@ Treat Gateway and node as one operator trust domain, with different roles:
- A caller authenticated to the Gateway is trusted at Gateway scope. After pairing, node actions are trusted operator actions on that node.
- `sessionKey` is routing/context selection, not per-user auth.
- Exec approvals (allowlist + ask) are guardrails for operator intent, not hostile multi-tenant isolation.
- Exec approvals bind exact request context and best-effort direct local file operands; they do not semantically model every runtime/interpreter loader path. Use sandboxing and host isolation for strong boundaries.
If you need hostile-user isolation, split trust boundaries by OS user/host and run separate gateways.
@@ -199,7 +200,7 @@ If you run `--deep`, OpenClaw also attempts a best-effort live Gateway probe.
Use this when auditing access or deciding what to back up:
- **WhatsApp**: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
- **Telegram bot token**: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected)
- **Discord bot token**: config/env or SecretRef (env/file/exec providers)
- **Slack tokens**: config/env (`channels.slack.*`)
- **Pairing allowlists**:
@@ -262,9 +263,14 @@ High-signal `checkId` values you will most likely see in real deployments (not e
## Control UI over HTTP
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
identity. `gateway.controlUi.allowInsecureAuth` does **not** bypass secure-context,
device-identity, or device-pairing checks. Prefer HTTPS (Tailscale Serve) or open
the UI on `127.0.0.1`.
identity. `gateway.controlUi.allowInsecureAuth` is a local compatibility toggle:
- On localhost, it allows Control UI auth without device identity when the page
is loaded over non-secure HTTP.
- It does not bypass pairing checks.
- It does not relax remote (non-localhost) device identity requirements.
Prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
For break-glass scenarios only, `gateway.controlUi.dangerouslyDisableDeviceAuth`
disables device identity checks entirely. This is a severe security downgrade;
@@ -365,6 +371,7 @@ If a macOS node is paired, the Gateway can invoke `system.run` on that node. Thi
- Requires node pairing (approval + token).
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
- Approval mode binds exact request context and, when possible, one concrete local script/file operand. If OpenClaw cannot identify exactly one direct local file for an interpreter/runtime command, approval-backed execution is denied rather than promising full semantic coverage.
- If you dont want remote execution, set security to **deny** and remove node pairing for that Mac.
## Dynamic skills (watcher / remote nodes)
@@ -747,8 +754,10 @@ Doctor can generate one for you: `openclaw doctor --generate-gateway-token`.
Note: `gateway.remote.token` / `.password` are client credential sources. They
do **not** protect local WS access by themselves.
Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*`
Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*`
is unset.
If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via
SecretRef and unresolved, resolution fails closed (no remote fallback masking).
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
Plaintext `ws://` is loopback-only by default. For trusted private-network
paths, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass.

View File

@@ -113,9 +113,21 @@ Common signatures:
challenge-based device auth flow (`connect.challenge` + `device.nonce`).
- `device signature invalid` / `device signature expired` → client signed the wrong
payload (or stale timestamp) for the current handshake.
- `unauthorized` / reconnect loop → token/password mismatch.
- `AUTH_TOKEN_MISMATCH` with `canRetryWithDeviceToken=true` → client can do one trusted retry with cached device token.
- repeated `unauthorized` after that retry → shared token/device token drift; refresh token config and re-approve/rotate device token if needed.
- `gateway connect failed:` → wrong host/port/url target.
### Auth detail codes quick map
Use `error.details.code` from the failed `connect` response to pick the next action:
| Detail code | Meaning | Recommended action |
| ---------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AUTH_TOKEN_MISSING` | Client did not send a required shared token. | Paste/set token in the client and retry. For dashboard paths: `openclaw config get gateway.auth.token` then paste into Control UI settings. |
| `AUTH_TOKEN_MISMATCH` | Shared token did not match gateway auth token. | If `canRetryWithDeviceToken=true`, allow one trusted retry. If still failing, run the [token drift recovery checklist](/cli/devices#token-drift-recovery-checklist). |
| `AUTH_DEVICE_TOKEN_MISMATCH` | Cached per-device token is stale or revoked. | Rotate/re-approve device token using [devices CLI](/cli/devices), then reconnect. |
| `PAIRING_REQUIRED` | Device identity is known but not approved for this role. | Approve pending request: `openclaw devices list` then `openclaw devices approve <requestId>`. |
Device auth v2 migration check:
```bash
@@ -135,6 +147,7 @@ Related:
- [/web/control-ui](/web/control-ui)
- [/gateway/authentication](/gateway/authentication)
- [/gateway/remote](/gateway/remote)
- [/cli/devices](/cli/devices)
## Gateway service not running

View File

@@ -1452,7 +1452,8 @@ Non-loopback binds **require auth**. Configure `gateway.auth.mode` + `gateway.au
Notes:
- `gateway.remote.token` / `.password` do **not** enable local gateway auth by themselves.
- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
- Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- The Control UI authenticates via `connect.params.auth.token` (stored in app/UI settings). Avoid putting tokens in URLs.
### Why do I need a token on localhost now
@@ -1489,10 +1490,16 @@ Set `cli.banner.taglineMode` in config:
### How do I enable web search and web fetch
`web_fetch` works without an API key. `web_search` requires a Brave Search API
key. **Recommended:** run `openclaw configure --section web` to store it in
`tools.web.search.apiKey`. Environment alternative: set `BRAVE_API_KEY` for the
Gateway process.
`web_fetch` works without an API key. `web_search` requires a key for your
selected provider (Brave, Gemini, Grok, Kimi, or Perplexity).
**Recommended:** run `openclaw configure --section web` and choose a provider.
Environment alternatives:
- Brave: `BRAVE_API_KEY`
- Gemini: `GEMINI_API_KEY`
- Grok: `XAI_API_KEY`
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
```json5
{
@@ -1500,6 +1507,7 @@ Gateway process.
web: {
search: {
enabled: true,
provider: "brave",
apiKey: "BRAVE_API_KEY_HERE",
maxResults: 5,
},
@@ -2505,6 +2513,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not
Facts (from code):
- The Control UI keeps the token in `sessionStorage` for the current browser tab session and selected gateway URL, so same-tab refreshes keep working without restoring long-lived localStorage token persistence.
- On `AUTH_TOKEN_MISMATCH`, trusted clients can attempt one bounded retry with a cached device token when the gateway returns retry hints (`canRetryWithDeviceToken=true`, `recommendedNextStep=retry_with_device_token`).
Fix:
@@ -2513,6 +2522,9 @@ Fix:
- If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`.
- Set `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`) on the gateway host.
- In the Control UI settings, paste the same token.
- If mismatch persists after the one retry, rotate/re-approve the paired device token:
- `openclaw devices list`
- `openclaw devices rotate --device <id> --role operator`
- Still stuck? Run `openclaw status --all` and follow [Troubleshooting](/gateway/troubleshooting). See [Dashboard](/web/dashboard) for auth details.
### I set gatewaybind tailnet but it can't bind nothing listens

View File

@@ -311,11 +311,11 @@ Include at least one image-capable model in `OPENCLAW_LIVE_GATEWAY_MODELS` (Clau
If you have keys enabled, we also support testing via:
- OpenRouter: `openrouter/...` (hundreds of models; use `openclaw models scan` to find tool+image capable candidates)
- OpenCode Zen: `opencode/...` (auth via `OPENCODE_API_KEY` / `OPENCODE_ZEN_API_KEY`)
- OpenCode: `opencode/...` for Zen and `opencode-go/...` for Go (auth via `OPENCODE_API_KEY` / `OPENCODE_ZEN_API_KEY`)
More providers you can include in the live matrix (if you have creds/config):
- Built-in: `openai`, `openai-codex`, `anthropic`, `google`, `google-vertex`, `google-antigravity`, `google-gemini-cli`, `zai`, `openrouter`, `opencode`, `xai`, `groq`, `cerebras`, `mistral`, `github-copilot`
- Built-in: `openai`, `openai-codex`, `anthropic`, `google`, `google-vertex`, `google-antigravity`, `google-gemini-cli`, `zai`, `openrouter`, `opencode`, `opencode-go`, `xai`, `groq`, `cerebras`, `mistral`, `github-copilot`
- Via `models.providers` (custom endpoints): `minimax` (cloud/API), plus any OpenAI/Anthropic-compatible proxy (LM Studio, vLLM, LiteLLM, etc.)
Tip: dont try to hardcode “all models” in docs. The authoritative list is whatever `discoverModels(...)` returns on your machine + whatever keys are available.
@@ -409,3 +409,6 @@ When you fix a provider/model issue discovered in live:
- Prefer targeting the smallest layer that catches the bug:
- provider request conversion/replay bug → direct models test
- gateway session/history/tool pipeline bug → gateway live smoke or CI-safe gateway mock test
- SecretRef traversal guardrail:
- `src/secrets/exec-secret-ref-id-parity.test.ts` derives one sampled target per SecretRef class from registry metadata (`listSecretTargetRegistryEntries()`), then asserts traversal-segment exec ids are rejected.
- If you add a new `includeInPlan` SecretRef target family in `src/secrets/target-registry-data.ts`, update `classifyTargetClass` in that test. The test intentionally fails on unclassified target ids so new classes cannot be skipped silently.

View File

@@ -136,7 +136,8 @@ flowchart TD
Common log signatures:
- `device identity required` → HTTP/non-secure context cannot complete device auth.
- `unauthorized` / reconnect loopwrong token/password or auth mode mismatch.
- `AUTH_TOKEN_MISMATCH` with retry hints (`canRetryWithDeviceToken=true`) → one trusted device-token retry may occur automatically.
- repeated `unauthorized` after that retry → wrong token/password, auth mode mismatch, or stale paired device token.
- `gateway connect failed:` → UI is targeting the wrong URL/port or unreachable gateway.
Deep pages:

View File

@@ -54,6 +54,15 @@ forwards `exec` calls to the **node host** when `host=node` is selected.
- **Node host**: executes `system.run`/`system.which` on the node machine.
- **Approvals**: enforced on the node host via `~/.openclaw/exec-approvals.json`.
Approval note:
- Approval-backed node runs bind exact request context.
- For direct shell/runtime file executions, OpenClaw also best-effort binds one concrete local
file operand and denies the run if that file changes before execution.
- If OpenClaw cannot identify exactly one concrete local file for an interpreter/runtime command,
approval-backed execution is denied instead of pretending full runtime coverage. Use sandboxing,
separate hosts, or an explicit trusted allowlist/full workflow for broader interpreter semantics.
### Start a node host (foreground)
On the node machine:
@@ -83,7 +92,10 @@ Notes:
- `openclaw node run` supports token or password auth.
- Env vars are preferred: `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`.
- Config fallback is `gateway.auth.token` / `gateway.auth.password`; in remote mode, `gateway.remote.token` / `gateway.remote.password` are also eligible.
- Config fallback is `gateway.auth.token` / `gateway.auth.password`.
- In local mode, node host intentionally ignores `gateway.remote.token` / `gateway.remote.password`.
- In remote mode, `gateway.remote.token` / `gateway.remote.password` are eligible per remote precedence rules.
- If active local `gateway.auth.*` SecretRefs are configured but unresolved, node-host auth fails closed.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored by node-host auth resolution.
### Start a node host (service)

View File

@@ -71,11 +71,14 @@ Optional legacy controls:
**Via config:** run `openclaw configure --section web`. It stores the key in
`~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`.
That field also accepts SecretRef objects.
**Via environment:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
in the Gateway process environment. For a gateway install, put it in
`~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
If `provider: "perplexity"` is configured and the Perplexity key SecretRef is unresolved with no env fallback, startup/reload fails fast.
## Tool parameters
These parameters apply to the native Perplexity Search API path.

View File

@@ -39,7 +39,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [NVIDIA](/providers/nvidia)
- [Ollama (local models)](/providers/ollama)
- [OpenAI (API + Codex)](/providers/openai)
- [OpenCode Zen](/providers/opencode)
- [OpenCode (Zen + Go)](/providers/opencode)
- [OpenRouter](/providers/openrouter)
- [Qianfan](/providers/qianfan)
- [Qwen (OAuth)](/providers/qwen)

View File

@@ -32,7 +32,7 @@ model as `provider/model`.
- [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
- [Mistral](/providers/mistral)
- [Synthetic](/providers/synthetic)
- [OpenCode Zen](/providers/opencode)
- [OpenCode (Zen + Go)](/providers/opencode)
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)

View File

@@ -0,0 +1,45 @@
---
summary: "Use the OpenCode Go catalog with the shared OpenCode setup"
read_when:
- You want the OpenCode Go catalog
- You need the runtime model refs for Go-hosted models
title: "OpenCode Go"
---
# OpenCode Go
OpenCode Go is the Go catalog within [OpenCode](/providers/opencode).
It uses the same `OPENCODE_API_KEY` as the Zen catalog, but keeps the runtime
provider id `opencode-go` so upstream per-model routing stays correct.
## Supported models
- `opencode-go/kimi-k2.5`
- `opencode-go/glm-5`
- `opencode-go/minimax-m2.5`
## CLI setup
```bash
openclaw onboard --auth-choice opencode-go
# or non-interactive
openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY"
```
## Config snippet
```json5
{
env: { OPENCODE_API_KEY: "YOUR_API_KEY_HERE" }, // pragma: allowlist secret
agents: { defaults: { model: { primary: "opencode-go/kimi-k2.5" } } },
}
```
## Routing behavior
OpenClaw handles per-model routing automatically when the model ref uses `opencode-go/...`.
## Notes
- Use [OpenCode](/providers/opencode) for the shared onboarding and catalog overview.
- Runtime refs stay explicit: `opencode/...` for Zen, `opencode-go/...` for Go.

View File

@@ -1,25 +1,38 @@
---
summary: "Use OpenCode Zen (curated models) with OpenClaw"
summary: "Use OpenCode Zen and Go catalogs with OpenClaw"
read_when:
- You want OpenCode Zen for model access
- You want a curated list of coding-friendly models
title: "OpenCode Zen"
- You want OpenCode-hosted model access
- You want to pick between the Zen and Go catalogs
title: "OpenCode"
---
# OpenCode Zen
# OpenCode
OpenCode Zen is a **curated list of models** recommended by the OpenCode team for coding agents.
It is an optional, hosted model access path that uses an API key and the `opencode` provider.
Zen is currently in beta.
OpenCode exposes two hosted catalogs in OpenClaw:
- `opencode/...` for the **Zen** catalog
- `opencode-go/...` for the **Go** catalog
Both catalogs use the same OpenCode API key. OpenClaw keeps the runtime provider ids
split so upstream per-model routing stays correct, but onboarding and docs treat them
as one OpenCode setup.
## CLI setup
### Zen catalog
```bash
openclaw onboard --auth-choice opencode-zen
# or non-interactive
openclaw onboard --opencode-zen-api-key "$OPENCODE_API_KEY"
```
### Go catalog
```bash
openclaw onboard --auth-choice opencode-go
openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY"
```
## Config snippet
```json5
@@ -29,8 +42,23 @@ openclaw onboard --opencode-zen-api-key "$OPENCODE_API_KEY"
}
```
## Catalogs
### Zen
- Runtime provider: `opencode`
- Example models: `opencode/claude-opus-4-6`, `opencode/gpt-5.2`, `opencode/gemini-3-pro`
- Best when you want the curated OpenCode multi-model proxy
### Go
- Runtime provider: `opencode-go`
- Example models: `opencode-go/kimi-k2.5`, `opencode-go/glm-5`, `opencode-go/minimax-m2.5`
- Best when you want the OpenCode-hosted Kimi/GLM/MiniMax lineup
## Notes
- `OPENCODE_ZEN_API_KEY` is also supported.
- You sign in to Zen, add billing details, and copy your API key.
- OpenCode Zen bills per request; check the OpenCode dashboard for details.
- Entering one OpenCode key during onboarding stores credentials for both runtime providers.
- You sign in to OpenCode, add billing details, and copy your API key.
- Billing and catalog availability are managed from the OpenCode dashboard.

View File

@@ -0,0 +1,73 @@
---
title: "Tlon Package Migration"
summary: "Plan for migrating the Tlon plugin package from @openclaw/tlon to @tloncorp/openclaw"
read_when:
- Updating Tlon onboarding/install metadata
- Migrating existing Tlon plugin installs
---
# Tlon Package Migration
This document captures the OpenClaw-side plan for migrating the Tlon plugin package from
`@openclaw/tlon` to `@tloncorp/openclaw` without breaking existing installs.
## Goals
- Keep the plugin id and channel id as `tlon`.
- Switch new onboarding and docs to install `@tloncorp/openclaw`.
- Auto-migrate existing `plugins.installs.tlon` records that still point at `@openclaw/tlon`.
- Preserve version continuity on the Tlon side so pinned or version-qualified specs can move cleanly.
- Keep local checkout installs (`./extensions/tlon`) working as before.
## Required invariants
The published `@tloncorp/openclaw` package must remain a drop-in replacement for the Tlon plugin.
- `package.json.name` should be `@tloncorp/openclaw`.
- `package.json.version` should continue the existing `2026.x.y` line instead of resetting to `0.x`.
- `openclaw.channel.id` must stay `tlon`.
- `openclaw.install.npmSpec` must be `@tloncorp/openclaw`.
- `openclaw.extensions` must point at the real published entrypoint.
- `openclaw.plugin.json.id` must stay `tlon`.
- `openclaw.plugin.json.channels` must contain `tlon`.
## OpenClaw migration behavior
On config load, OpenClaw should detect old Tlon npm install specs and rewrite them in place.
- Rewrite `plugins.installs.tlon.spec` from `@openclaw/tlon` to `@tloncorp/openclaw`.
- Rewrite version-qualified specs the same way, preserving the suffix after the package name.
- Apply this to any `plugins.installs.tlon` record that still references the old package name.
- Clear stale npm resolution metadata so the next `openclaw plugins update tlon` resolves against the
new package without comparing the old package integrity hash to the new package artifact.
Fields to clear when the spec is rewritten:
- `resolvedName`
- `resolvedVersion`
- `resolvedSpec`
- `integrity`
- `shasum`
- `resolvedAt`
Fields to preserve:
- `source`
- `installPath`
- `sourcePath`
- `version`
- `installedAt`
## Rollout
1. Publish `@tloncorp/openclaw` with the correct manifest and version continuity.
2. Update OpenClaw onboarding metadata and docs to point new installs at `@tloncorp/openclaw`.
3. Ship the config migration so existing installs start updating from the new package name.
4. Keep `@openclaw/tlon` available during the transition window.
5. Deprecate `@openclaw/tlon` after OpenClaw releases with the migration have had time to land.
## External follow-up in tloncorp/openclaw-tlon
- Publish the next `2026.x.y` version under `@tloncorp/openclaw`.
- Update the README to say the plugin is installed separately, not “included with OpenClaw”.
- Verify the published tarball contains the correct `openclaw.plugin.json`.

View File

@@ -19,6 +19,32 @@ When the operator says “release”, immediately do this preflight (no extra qu
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`).
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
## Versioning
Current OpenClaw releases use date-based versioning.
- Stable release version: `YYYY.M.D`
- Git tag: `vYYYY.M.D`
- Examples from repo history: `v2026.2.26`, `v2026.3.8`
- Beta prerelease version: `YYYY.M.D-beta.N`
- Git tag: `vYYYY.M.D-beta.N`
- Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1`
- Use the same version string everywhere, minus the leading `v` where Git tags are not used:
- `package.json`: `2026.3.8`
- Git tag: `v2026.3.8`
- GitHub release title: `openclaw 2026.3.8`
- Do not zero-pad month or day. Use `2026.3.8`, not `2026.03.08`.
- Stable and beta are npm dist-tags, not separate release lines:
- `latest` = stable
- `beta` = prerelease/testing
- Dev is the moving head of `main`, not a normal git-tagged release.
- The release workflow enforces the current stable/beta tag formats and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date.
Historical note:
- Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history.
- Treat those as legacy tag patterns. New releases should use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta.
1. **Version & metadata**
- [ ] Bump `package.json` version (e.g., `2026.1.29`).
@@ -67,8 +93,11 @@ When the operator says “release”, immediately do this preflight (no extra qu
6. **Publish (npm)**
- [ ] Confirm git status is clean; commit and push as needed.
- [ ] `npm login` (verify 2FA) if needed.
- [ ] `npm publish --access public` (use `--tag beta` for pre-releases).
- [ ] Confirm npm trusted publishing is configured for the `openclaw` package.
- [ ] Push the matching git tag to trigger `.github/workflows/openclaw-npm-release.yml`.
- Stable tags publish to npm `latest`.
- Beta tags publish to npm `beta`.
- The workflow rejects tags that do not match `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date.
- [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`).
### Troubleshooting (notes from 2.0.0-beta2 release)
@@ -84,6 +113,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
7. **GitHub release + appcast**
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
- Pushing the tag also triggers the npm release workflow.
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `openclaw X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**.
- [ ] Attach artifacts: `npm pack` tarball (optional), `OpenClaw-X.Y.Z.zip`, and `OpenClaw-X.Y.Z.dSYM.zip` (if generated).
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).

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