Compare commits

..

95 Commits

Author SHA1 Message Date
Mason Huang
35dedb8e40 fix(telegram): avoid rich messages in group chats 2026-06-16 06:58:16 +08:00
joshavant
21e3cfa5e9 Add Apple Watch screenshot pipeline 2026-06-16 00:40:40 +02:00
Vincent Koc
c8e70708e9 fix(ci): restore main checks 2026-06-16 06:36:10 +08:00
Harjoth Khara
0bb415bf66 fix(sessions): preserve behavior overrides across rollover
Carry user behavior overrides across implicit daily/idle session rollover while preserving existing model fallback clearing behavior.
2026-06-16 06:35:26 +08:00
Dr Rushindra Sinha
00c58b6613 fix(whatsapp): notify when trailing media send fails
Notify users when a later WhatsApp media attachment fails instead of silently dropping it.
2026-06-16 06:35:09 +08:00
Vincent Koc
50dbd46aa6 test(agents): mark runtime contract delivery as sent 2026-06-16 06:31:06 +08:00
Keeley Hoek
baeedaa316 fix(discord): reject malformed realtime consult calls
Catch malformed realtime consult tool-call args, return a tool error, and avoid crashing Discord voice handling. Includes e2e coverage.
2026-06-16 06:27:49 +08:00
litang9
0f71a665ed fix(logging): avoid stalled warnings for active model calls
Classify owned silent model calls as long-running until the abort threshold while preserving stalled handling for ownerless stale activity, with diagnostics tests and docs.
2026-06-16 06:27:40 +08:00
lundog
bcd1fdb1db fix(media): stop pruning media on write
Stop media writes from triggering opportunistic pruning and leave retention cleanup to the configured maintenance timer. Preserve explicit cleanup options and cover shallow/root/recursive cleanup behavior.
2026-06-16 06:27:22 +08:00
Alix-007
1b5ac45e60 fix(doctor): separate platform-incompatible skills
Track platform-incompatible skills separately from missing requirements, keep doctor --fix from treating them as broken installs, and cover the status output.
2026-06-16 06:26:20 +08:00
Bakhtiyar
3d3d291902 fix(auth): keep alias-compatible auth-profile overrides
Use alias-aware credential compatibility before clearing auth-profile overrides, preventing compatible CLI sessions from flapping auth profiles. Includes regression coverage.
2026-06-16 06:26:11 +08:00
Andy Ye
57bad4bdf8 fix(cron): suppress announce control replies
Use the shared suppressed-control-reply detector for cron delivery so NO_REPLY, ANNOUNCE_SKIP, and REPLY_SKIP do not leak to outbound channels, with direct/text delivery coverage.
2026-06-16 06:25:21 +08:00
James W. Niu
923828ccd7 fix(infra): drop duplicated restart word
Avoid rendering "Gateway restart restart" for restart sentinel summaries, while keeping the existing wording for other restart kinds.
2026-06-16 06:25:12 +08:00
Franco Viotti
7305d9baca fix(googlechat): return sent thread metadata
Return Google Chat message and thread metadata from message sends, and cover the action/API result shape in tests.
2026-06-16 06:23:07 +08:00
ooiuuii
212fef8703 fix(cli): accept --no-color after subcommands
Normalize root --no-color before command parsing while preserving command option values, and add coverage for subcommand/root option placement.
2026-06-16 06:20:56 +08:00
pick-cat
7a008c53f4 fix(control-ui): keep workboard card titles visible
Keep workboard card titles visible when a column overflows by pinning implicit rows to content height, and add e2e coverage for the overflow case.

Fixes #91717
2026-06-16 06:18:22 +08:00
Vincent Koc
93fa065fb3 fix(ci): update Vitest past browser advisory 2026-06-16 06:16:04 +08:00
Vincent Koc
bdc3017c1a test(agents): require delivery evidence in suppression fixtures 2026-06-16 06:16:02 +08:00
Vincent Koc
6cb631afc9 fix(agents): distinguish delivery routes from references 2026-06-16 05:56:48 +08:00
Vincent Koc
fec6a0407f fix(agents): validate visible delivery receipts 2026-06-16 05:56:48 +08:00
Vincent Koc
43cef3a80c fix(agents): record visible delivery target evidence 2026-06-16 05:56:48 +08:00
Dallin Romney
e32929e12c Add slim evidence mode for QA profile evidence (#93179)
* test(qa): compact profile evidence execution metadata

* docs(qa): document compact profile evidence

* test(qa): support compact evidence mode

* test(qa): rename compact evidence mode to slim

* docs(qa): trim slim evidence wording

* fix(qa): avoid commander runtime import
2026-06-15 14:50:40 -07:00
pick-cat
f018945eff fix(doctor): import default-agent auth profiles into sqlite (#93156)
* fix(doctor): import default-agent auth profiles into sqlite

* fix(doctor): type legacy config auth imports

* fix(doctor): preserve auth json precedence

* fix(doctor): import config auth with state-only stores

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

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-15 17:42:51 -04:00
Agustin Rivera
8b0eac7927 fix(plugins): enforce install policy in wrappers (#93357) 2026-06-15 13:59:37 -07:00
Josh Lehman
efa9a6110b refactor: add transcript runtime identity contract (#89201) 2026-06-15 13:04:03 -07:00
Michael Appel
32af1c0697 Control Telegram group history context (#89547)
* fix(telegram): control group history context

* fix(telegram): keep history mode type local

* fix(telegram): respect history mode during forum recovery
2026-06-15 12:37:04 -07:00
Vincent Koc
b3128ba93d fix(cron): require explicit poll delivery targets 2026-06-16 03:09:36 +08:00
Shakker
eb67ac5cbe fix: trim whatsapp admission sender identity 2026-06-15 20:06:16 +01:00
Marcus Castro
ef6b7e3659 docs(plugins): record whatsapp admission compatibility 2026-06-15 20:06:16 +01:00
Marcus Castro
a355825060 refactor(whatsapp): deprecate admission top-level fields 2026-06-15 20:06:16 +01:00
Marcus Castro
2cc25aa909 test(whatsapp): cover inbound admission contract 2026-06-15 20:06:16 +01:00
Marcus Castro
2758140607 refactor(whatsapp): add inbound admission envelope 2026-06-15 20:06:16 +01:00
Agustin Rivera
55d1324c7d fix(flock): bind allow-always to wrapped command (#93362)
* fix(flock): bind allow-always to wrapped command

* fix(flock): handle wrapper option aliases

* fix(flock): handle wrapper option aliases
2026-06-15 11:56:15 -07:00
Shakker
dc573a38dc fix: update dependency pins 2026-06-15 19:48:43 +01:00
Agustin Rivera
9dbe25e0f6 fix(outbound): guard cross-context message mutations (#93358) 2026-06-15 11:46:16 -07:00
Vincent Koc
e3ca10438a fix(agents): recognize bare-ok broadcast sends 2026-06-16 02:45:41 +08:00
Vincent Koc
68cbd1ae63 test(agents): align fallback delivery mock 2026-06-16 02:45:41 +08:00
Vincent Koc
0ea08076c3 fix(agents): preserve CLI message delivery evidence 2026-06-16 02:45:41 +08:00
Agustin Rivera
04d8a96b6c fix(ci): verify performance workflow downloads (#93355) 2026-06-15 11:43:55 -07:00
Shakker
5c2487dc9a test: type cli test mocks 2026-06-15 19:34:16 +01:00
Shakker
c40db057da fix: clean ios release signing lint 2026-06-15 19:29:26 +01:00
Chunyue Wang
df521a6459 fix(gateway): guard fast-path startup migrations (#93118)
* fix(cron): run legacy cron store migration in gateway fast path

* fix(cli): run gateway startup migrations

* fix(gateway): guard startup migrations and config selection

* fix(gateway): reconcile final startup environment

* fix(gateway): preserve guarded startup env semantics

* fix(gateway): guard service-mode recovery candidates

* fix(config): reconcile normalized env precedence

* fix(cli): clear replaced proxy signal handlers

* fix(gateway): reject invalid final config

* test(gateway): cover invalid future config reset guard

* test(cli): remove unused recovery state
2026-06-16 01:54:29 +08:00
Alix-007
a0b16f37e8 fix(sessions): cache validated transcripts across turns (#90412)
Avoid repeated full JSONL parsing and cloning on every embedded-agent turn by keeping a bounded, validated transcript cache and advancing repair incrementally.

The final implementation preserves lock ownership and exact fingerprint validation, publishes only verified writes, handles header rewrites and unterminated JSONL safely, and adds focused regression coverage.

Fixes #83943.

Co-authored-by: Alix-007 <li.long15@xydigit.com>
2026-06-15 10:32:35 -07:00
Michael Appel
cc000e0ffe fix(cron): preserve scheduled turn tool policy [AI] (#91499)
* fix(cron): preserve creator tool policy for scheduled turns

* fix(cron): cap edited scheduled turns

* fix(cron): preserve scheduled turn allowlists

* fix(cron): cap filtered tool surfaces

* fix(cron): cap scheduled turn conversions

* fix(cron): include bundled scheduled tools
2026-06-15 10:29:11 -07:00
joshavant
9092578d8d feat: configure ios app store release upload 2026-06-15 19:27:55 +02:00
joshavant
a23de348b2 feat: configure ios app store release signing 2026-06-15 19:27:55 +02:00
joshavant
7650397a22 fix: align ios agent rows 2026-06-15 19:27:55 +02:00
joshavant
5a2641fc41 test: add ios screenshot automation 2026-06-15 19:27:55 +02:00
joshavant
377f6181a9 feat: add ios screenshot fixture mode 2026-06-15 19:27:55 +02:00
joshavant
b896e22e10 fix: stabilize ios screenshot status bar 2026-06-15 19:27:55 +02:00
joshavant
1ffda5d3ca fix: migrate ios identifiers to openclawfoundation 2026-06-15 19:27:55 +02:00
joshavant
ec2788cf80 fix: launch configured ios bundle 2026-06-15 19:27:55 +02:00
joshavant
379de52b59 fix: use canonical ios bundle identifiers 2026-06-15 19:27:55 +02:00
joshavant
0944045b75 chore: prefer canonical ios signing team 2026-06-15 19:27:55 +02:00
joshavant
c932bf377b chore: update ios configuration 2026-06-15 19:27:54 +02:00
Agustin Rivera
ddb07fc597 fix(plugins): require owner for plugin writes (#93353) 2026-06-15 10:22:28 -07:00
Yuval Dinodia
caab343461 fix(reply): project preflight compaction gate by next-input on fresh tokens (#91488)
The fresh-tokens path of runPreflightCompactionIfNeeded fed the prompt-only
entry.totalTokens snapshot straight into the budget threshold check, dropping
the current user prompt estimate and the previous turn's output. The sibling
memory-flush gate and this function's own stale branch already project
base + output + estimate via resolveEffectivePromptTokens, so the preflight
gate under-triggered and let over-budget requests through to overflow-retry.

Project the fresh persisted base the same way: read transcript output when near
the threshold (mirroring the memory-flush gate's buffer) and run the fresh base
through resolveEffectivePromptTokens before the threshold check.
2026-06-16 01:10:42 +08:00
ZZ.No.1
8e55348ff9 fix(agents): canonicalize node binding selectors (#66985) 2026-06-16 01:09:01 +08:00
Bakhtiyar
8e56cf591d fix(cli): disable ScheduleWakeup/CronCreate in --print claude runs (#84434)
Claude Code built-ins ScheduleWakeup and CronCreate schedule a deferred
re-invocation managed by the persistent CLI runtime. In OpenClaw's
one-shot `claude -p` invocations the process exits at end_turn, so any
wakeup or cron registered during the run has no host to fire into and is
silently lost. Symptom: a CLI session spawns a background sub-agent,
calls ScheduleWakeup to poll for completion, ends the turn, and never
picks up the result — the work finishes unreviewed.

Append `--disallowedTools "ScheduleWakeup,CronCreate"` to both `args`
and `resumeArgs` in the anthropic CLI backend so the model cannot reach
for tools that don't survive the run mode. The right pattern in CLI
sessions is Monitor on the background output file, or a synchronous
sub-agent.
2026-06-16 01:08:51 +08:00
Peter Steinberger
40f1190c8b fix(configure): remove duplicate password wrapper 2026-06-15 12:54:54 -04:00
Josh Lehman
8ded756284 refactor: add transcript reader seam (#89121)
Merged via squash.

Prepared head SHA: 7ea7ea47ef
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-15 09:41:50 -07:00
Vyctor H. Brzezowski
f00de6b06a docs(cli): add agent selector to cli backend quick start (#74613) 2026-06-16 00:39:48 +08:00
Harjoth Khara
158d3db85a fix(gateway): pass managed inbound PDFs through chat.send (#90115)
* fix(gateway): pass managed inbound PDFs through when sandbox staging fails

chat.send force-stages offloaded non-image media into the sandbox workspace when
one exists. If that optional staging was unavailable or incomplete,
prestageMediaPathOffloads deleted the media buffers and failed the whole send
with a 5xx — even for already-managed inbound PDFs that are safe to read
host-side. A Control-UI-uploaded PDF could fail to send.

When staging throws or is incomplete, fall back to the absolute managed paths
iff every non-image offloaded ref is a managed-inbound application/pdf (reusing
the existing resolveInboundMediaReference allow-check + the PDF mime type). This
mirrors the existing no-sandbox passthrough: with MediaWorkspaceDir unset the
managed media dir is a default media-understanding local root, so the absolute
path resolves host-side. Gated all-or-nothing so a single non-managed or non-PDF
ref keeps the previous delete + 5xx behavior. Success path and oversized 4xx are
unchanged; managed buffers are not deleted on the fallback.

Fixes #90097

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

* fix(gateway): exempt managed PDFs from staging cap

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:39:17 +08:00
Alix-007
6cd6e3f39e fix(plugins): serialize binding approval saves (#88945) 2026-06-16 00:39:01 +08:00
Gio Della-Libera
4bb4252042 fix(cli): report Gemini CLI runtime auth status (#86544) 2026-06-16 00:38:27 +08:00
Spencer Fuller
95da644f6d docs(windows): fix WSL gateway-autostart recipe for WSL ≥ 2.6.1.0 idle-termination (#90992)
* docs(windows): fix WSL gateway-autostart recipe for WSL ≥ 2.6.1.0

Replace /bin/true with dbus-launch true to work around the WSL ≥ 2.6.1.0
idle-termination regression (microsoft/WSL #13416): the distro exits 15-20 s
after the last wsl.exe client detaches even with loginctl linger and an active
user service. dbus-launch true keeps a child-of-init process alive (workaround
from microsoft/WSL discussion #9245, validated on WSL 2.7.3.0).

Also replace /ru SYSTEM with /ru "$env:USERNAME". Per-user WSL distros (the
default setup) are not enumerable by the SYSTEM account — the task runs
silently without starting the distro. Running as the installing user account
fixes this; Windows prompts for the password at task creation time.

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

* docs(windows): add dbus-x11 prerequisite for WSL keepalive

dbus-launch is provided by dbus-x11, which is not installed by default
on fresh Ubuntu WSL distros. Without it the scheduled task hits
command-not-found silently. Add the apt-get install step before the
linger and gateway-install steps so the recipe is self-contained.

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

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-16 00:38:03 +08:00
ToToKr
6451550cd7 fix(heartbeat): refresh stale Current time line on every helper call (#44993) (#75025)
Rebase onto current upstream/main (head 4780546c12). Resolves the conflict from upstream's two-line Current time + Reference UTC helper output: appendCronStyleCurrentTimeLine now refreshes/collapses any prior helper-injected block via CURRENT_TIME_LINE_RE instead of returning early on a stale base.includes('Current time:') match. Preserves upstream-added doc comments. 16/16 current-time.test.ts pass; tsgo core clean.
2026-06-16 00:37:40 +08:00
Josh Lehman
10a4c7c10b feat(status): surface plugin health (#91952)
Merged via squash.

Prepared head SHA: 2cd914cec1
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-15 09:36:13 -07:00
Ayaan Zaidi
fa9b8c8c6b test(telegram): cover raw rich progress output 2026-06-15 22:02:39 +05:30
Ayaan Zaidi
f5a9456219 fix(telegram): preserve rich progress command output 2026-06-15 22:02:39 +05:30
ZengWen-DT
7208567382 fix(control-ui): respect agents.defaults.timeFormat for timestamps (#93297)
Thread the existing agents.defaults.timeFormat setting through the Control UI
bootstrap config so WebChat/Control UI timestamps render in the configured
12h/24h clock instead of always using the browser locale default. "auto"
keeps the browser default, so existing deployments are unchanged.

Closes #58147

Co-authored-by: zengwen <zeng_wen@foxmail.com>
2026-06-16 00:29:44 +08:00
fsdwen
b90f7d2fd0 fix(cron): trust agent output when channel is unresolved without explicit delivery (#92817)
resolveCronChannelOutputPolicy checked deliveryRequested === false
when there is no channel. Since deliveryRequested is optional
(?: boolean), undefined and missing opts both returned false,
blocking the hasRecoveredToolWarning rescue path for --no-deliver
cron runs whose agent recovered successfully.

Change === false to !== true: when no channel exists, prefer the
agent's final visible text unless delivery was explicitly
requested.

Fixes #90664

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 00:28:22 +08:00
Sash Zats
1e2363b687 fix(ios): refresh permission rows after grants (#91776) 2026-06-16 00:26:54 +08:00
hpt
c082a3b740 fix(tui): avoid inserting spaces into long CJK text (#78765) 2026-06-16 00:25:18 +08:00
ly-wang19
53541b2141 fix(cron): resolve lastRunStatus in cron list/show human output (#93245)
cron list/show printed "idle" for a job whose status is ok/error/skipped
when only lastRunStatus (the primary field) was set: formatStatus used
`lastStatus ?? "idle"` and omitted lastRunStatus, diverging from computeStatus
(the --json status resolver) whose JSDoc says it mirrors the human output.
Delete the duplicate formatStatus and render via the canonical computeStatus.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:23:14 +08:00
Sash Zats
a3070e3ddf fix(ios): respect chat header safe area (#91768) 2026-06-16 00:22:59 +08:00
Anurag Bheemappa Gnanamurthy
865e4db1cd fix(configure): mask gateway password input in CLI wizard prompt
Refs #90571
2026-06-16 00:11:52 +08:00
Anurag Bheemappa Gnanamurthy
31a10ee388 fix(models): mask paste-token input in CLI auth prompt
Mask paste-token TTY entry via the existing password prompt path while preserving stored token behavior.

Refs #90894.
Refs #90895.
2026-06-16 00:11:48 +08:00
苹果小姐
a34ddce9a2 refactor: remove dead code and improve string concatenation
Refs #91117
2026-06-16 00:11:18 +08:00
Alix-007
f6a3ac7e58 fix(daemon): prefer stderr over stale stdout in gateway restart diagnostics
Refs #93001.
2026-06-16 00:09:41 +08:00
Alix-007
95772c8541 fix(tui): keep stderr visible when local shell stdout fills the output cap
Keeps tail-truncated local shell output aligned with streaming behavior so trailing stderr remains visible when stdout fills the output cap.
2026-06-16 00:09:32 +08:00
xydt-tanshanshan
85a635368e fix(memory): prevent empty expected model in memory index identity
Guard memory index identity resolution against empty or whitespace provider models by falling back to fts-only, and use fts-only as the fallback source model when an adapter fallback cannot resolve a model.

This prevents empty expectedModel mismatch reasons that can leave memory search dirty while preserving registered adapter default-model resolution.

Refs #90787
2026-06-15 23:56:39 +08:00
ly-wang19
0888debcd3 fix(cron): add cron edit --clear-model
Completes the CLI half of #91298.
2026-06-15 23:54:00 +08:00
Rohit
794bd89fa0 fix(cli): allow zero Discord timeout duration
Fixes #93327
2026-06-15 23:42:51 +08:00
openclaw-clownfish[bot]
93b7e3d717 fix(memory-core): safely refresh qmd index during collection repair
Squash merge ProjectClownfish replacement PR #92910.\n\nSource PR credit: thanks @imadal1n for the original index rewrite direction in #68590.
2026-06-15 23:34:01 +08:00
Ayaan Zaidi
da92615816 feat(telegram): send rich messages as rich html (#93286)
* feat(telegram): render rich messages through rich html

* docs(telegram): teach agents rich formatting

* fix(telegram): bound rich draft payloads (#93286)

* fix(telegram): narrow rich draft payload type (#93286)

* fix(telegram): preserve rich table cell formatting (#93286)

* fix(telegram): honor rich table mode config (#93286)

* fix(telegram): default rich markdown tables (#93286)

* fix(telegram): gate rich table block mode (#93286)

* fix(telegram): normalize raw rich html limits (#93286)

* fix(telegram): preserve link preview suppression (#93286)

* fix(telegram): preserve rich markdown headings (#93286)

* fix(telegram): reject unsupported rich media sources (#93286)

* fix(telegram): honor link preview off in rich chunks (#93286)

* fix(telegram): avoid double escaping markdown media (#93286)

* fix(telegram): render markdown media via placeholders (#93286)

* fix(telegram): preserve table text in prompt context (#93286)
2026-06-15 20:38:41 +05:30
Vincent Koc
767e8280ac fix(cli): harden official plugin recovery (#93325)
* fix(cli): harden official plugin recovery

* fix(config): preserve include write context

* fix(config): reject external include mutations

* fix(config): bind snapshots to config paths

* fix(config): preserve write ownership

* fix(cli): preflight plugin config mutations

* chore(plugin-sdk): refresh api baseline

* test(config): prove install env policy mutations

* fix(cli): preflight plugin updates

* fix(cli): preflight non-npm id migrations

* chore(plugin-sdk): refresh api baseline

* fix(cli): satisfy plugin recovery checks
2026-06-15 23:07:29 +08:00
Ayaan Zaidi
c1219d161d fix(android): preserve history context usage (#92837) (thanks @Tosko4) 2026-06-15 19:55:17 +05:30
Ayaan Zaidi
636aab6891 refactor(android): distill session event refresh 2026-06-15 19:55:17 +05:30
Tosko4
8f9493c213 fix(android): clear stale context usage snapshots 2026-06-15 19:55:17 +05:30
Tosko4
826ea2bf85 fix(android): show live chat context usage 2026-06-15 19:55:17 +05:30
Peter Steinberger
0bbac63d00 fix(tasks): migrate legacy agent attribution 2026-06-15 07:09:01 -07:00
Peter Steinberger
1fef20c96b fix(tasks): preserve requester agent attribution 2026-06-15 07:09:01 -07:00
Alix-007
77d6ad6f65 fix(tasks): honor explicit agentId in gateway tasks.list and repair test helper signature
Two code-review findings. (1) gateway taskMatchesAgent fell through to a requester/owner/child session-key scan even when the task had an explicit agentId, so a worker subagent task owned by agent:main:main also matched agentId:main; make explicit task.agentId authoritative and keep the session-key fallback only for legacy records without an agentId, with a gateway tasks.list regression. (2) the cross-agent attribution test passed async (root) to the zero-arg withTaskRegistryTempDir helper (TS2345/TS7006); drop the unused parameter and redundant env assignment.
2026-06-15 07:09:01 -07:00
Alix-007
206ac169e6 fix: attribute spawned task runs to child agent 2026-06-15 07:09:01 -07:00
544 changed files with 39482 additions and 7874 deletions

5
.github/labeler.yml vendored
View File

@@ -318,11 +318,6 @@
- any-glob-to-any-file:
- "extensions/policy/**"
- "docs/cli/policy.md"
"extensions: feeds":
- changed-files:
- any-glob-to-any-file:
- "extensions/feeds/**"
- "docs/plugins/reference/feeds.md"
"extensions: open-prose":
- changed-files:
- any-glob-to-any-file:

View File

@@ -1365,6 +1365,8 @@ jobs:
boundary_shard: 2/4,3/4,4/4
- check_name: check-session-accessor-boundary
group: session-accessor-boundary
- check_name: check-session-transcript-reader-boundary
group: session-transcript-reader-boundary
- check_name: check-additional-extension-channels
group: extension-channels
- check_name: check-additional-extension-bundled
@@ -1520,6 +1522,9 @@ jobs:
run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary
fi
;;
session-transcript-reader-boundary)
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
;;
extension-channels)
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
;;

View File

@@ -56,6 +56,7 @@ concurrency:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
OCM_VERSION: v0.2.15
OCM_LINUX_X64_SHA256: b849b8de5d77e97e0df9319703254ae95e29d7f26a7552ea79bf173ff110ea0a
KOVA_REPOSITORY: openclaw/Kova
PERFORMANCE_MODEL_ID: gpt-5.5
@@ -187,11 +188,20 @@ jobs:
set -euo pipefail
KOVA_SRC="${RUNNER_TEMP}/kova-src"
echo "KOVA_SRC=$KOVA_SRC" >> "$GITHUB_ENV"
mkdir -p "$HOME/.local/bin" "$(dirname "$KOVA_SRC")"
curl -fsSL https://raw.githubusercontent.com/shakkernerd/ocm/main/install.sh \
| bash -s -- --version "$OCM_VERSION" --prefix "$HOME/.local" --force
git clone --filter=blob:none "https://github.com/${KOVA_REPOSITORY}.git" "$KOVA_SRC"
git -C "$KOVA_SRC" checkout "$KOVA_REF"
mkdir -p "$HOME/.local/bin" "$(dirname "$KOVA_SRC")" "${RUNNER_TEMP}/ocm-install"
ocm_archive="${RUNNER_TEMP}/ocm-${OCM_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
curl -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused \
-o "$ocm_archive" \
"https://github.com/shakkernerd/ocm/releases/download/${OCM_VERSION}/ocm-x86_64-unknown-linux-gnu.tar.gz"
echo "${OCM_LINUX_X64_SHA256} ${ocm_archive}" | sha256sum -c -
tar -xzf "$ocm_archive" -C "${RUNNER_TEMP}/ocm-install"
install -m 0755 "${RUNNER_TEMP}/ocm-install/ocm" "$HOME/.local/bin/ocm"
git init -b main "$KOVA_SRC"
git -C "$KOVA_SRC" remote add origin "https://github.com/${KOVA_REPOSITORY}.git"
git -C "$KOVA_SRC" fetch --filter=blob:none --depth 1 origin "$KOVA_REF"
git -C "$KOVA_SRC" checkout --detach FETCH_HEAD
cat > "$HOME/.local/bin/kova" <<EOF
#!/usr/bin/env bash
export KOVA_HOME="${KOVA_HOME}"

View File

@@ -443,6 +443,7 @@ class NodeRuntime(
updateStatus()
micCapture.onGatewayConnectionChanged(true)
scope.launch {
subscribeOperatorSessionEvents()
refreshHomeCanvasOverviewIfConnected()
if (voiceReplySpeakerLazy.isInitialized()) {
voiceReplySpeaker.refreshConfig()
@@ -485,6 +486,14 @@ class NodeRuntime(
},
)
private suspend fun subscribeOperatorSessionEvents() {
try {
operatorSession.request("sessions.subscribe", null)
} catch (err: Throwable) {
Log.d("OpenClawRuntime", "sessions.subscribe failed: ${err.message ?: err::class.java.simpleName}")
}
}
private val nodeSession =
GatewaySession(
scope = scope,

View File

@@ -311,7 +311,6 @@ class ChatController(
}
}
/** Applies gateway chat/agent stream events to local transcript and pending-run state. */
fun handleGatewayEvent(
event: String,
payloadJson: String?,
@@ -321,7 +320,6 @@ class ChatController(
scope.launch { pollHealthIfNeeded(force = false) }
}
"health" -> {
// If we receive a health snapshot, the gateway is reachable.
_healthOk.value = true
}
"seqGap" -> {
@@ -332,6 +330,17 @@ class ChatController(
if (payloadJson.isNullOrBlank()) return
handleChatEvent(payloadJson)
}
"sessions.changed" -> {
if (payloadJson.isNullOrBlank()) {
refreshSessionsForCurrentWindow()
} else {
handleSessionsChangedEvent(payloadJson)
}
}
"session.message" -> {
if (payloadJson.isNullOrBlank()) return
handleSessionMessageEvent(payloadJson)
}
"agent" -> {
if (payloadJson.isNullOrBlank()) return
handleAgentEvent(payloadJson)
@@ -353,6 +362,7 @@ class ChatController(
)
if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return
val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value)
updateSessionFromHistory(history)
prunePersistedOptimisticMessages(history.messages)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_sessionId.value = history.sessionId
@@ -388,6 +398,10 @@ class ChatController(
}
}
private fun refreshSessionsForCurrentWindow() {
scope.launch { fetchSessions(limit = _sessions.value.size.takeIf { it > 0 } ?: 100) }
}
private suspend fun pollHealthIfNeeded(force: Boolean) {
val now = System.currentTimeMillis()
val last = lastHealthPollAtMs
@@ -457,6 +471,7 @@ class ChatController(
sessionKey = currentSessionKey,
previousMessages = _messages.value,
)
updateSessionFromHistory(history)
prunePersistedOptimisticMessages(history.messages)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_sessionId.value = history.sessionId
@@ -472,6 +487,31 @@ class ChatController(
}
}
private fun handleSessionsChangedEvent(payloadJson: String) {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
if (payload["reason"].asStringOrNull() == "delete") {
removeSessionEntry(payload["sessionKey"].asStringOrNull() ?: payload["key"].asStringOrNull())
return
}
val entry = parseEventSessionEntry(payload)
if (entry != null) {
upsertSessionEntry(entry)
} else {
refreshSessionsForCurrentWindow()
}
}
private fun handleSessionMessageEvent(payloadJson: String) {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
val entry = parseEventSessionEntry(payload)
if (entry != null) {
upsertSessionEntry(entry)
}
}
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? =
payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
private fun handleAgentEvent(payloadJson: String) {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
val sessionKey = payload["sessionKey"].asStringOrNull()?.trim()
@@ -600,6 +640,7 @@ class ChatController(
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
val sid = root["sessionId"].asStringOrNull()
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
val sessionInfo = root["sessionInfo"].asObjectOrNull()?.let { parseSessionEntry(it, fallbackKey = sessionKey) }
val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList())
val messages =
@@ -622,20 +663,69 @@ class ChatController(
sessionId = sid,
thinkingLevel = thinkingLevel,
messages = reconcileMessageIds(previous = previousMessages, incoming = messages),
sessionInfo = sessionInfo,
)
}
private fun parseSessions(jsonString: String): List<ChatSessionEntry> {
val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList()
val sessions = root["sessions"].asArrayOrNull() ?: return emptyList()
return sessions.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
if (key.isEmpty()) return@mapNotNull null
val updatedAt = obj["updatedAt"].asLongOrNull()
val displayName = obj["displayName"].asStringOrNull()?.trim()
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
}
return sessions.mapNotNull { item -> parseSessionEntry(item.asObjectOrNull()) }
}
private fun parseSessionEntry(
obj: JsonObject?,
fallbackKey: String? = null,
): ChatSessionEntry? {
if (obj == null) return null
val key =
obj["key"].asStringOrNull()?.trim().orEmpty()
.ifEmpty { obj["sessionKey"].asStringOrNull()?.trim().orEmpty() }
.ifEmpty { fallbackKey?.trim().orEmpty() }
if (key.isEmpty()) return null
return ChatSessionEntry(
key = key,
updatedAtMs = obj["updatedAt"].asLongOrNull(),
displayName = obj["displayName"].asStringOrNull()?.trim(),
totalTokens = obj["totalTokens"].asLongOrNull(),
totalTokensFresh = obj["totalTokensFresh"].asBooleanOrNull(),
contextTokens = obj["contextTokens"].asLongOrNull(),
hasContextUsageMetadata =
"totalTokens" in obj ||
"totalTokensFresh" in obj ||
"contextTokens" in obj,
)
}
private fun updateSessionFromHistory(history: ChatHistory) {
val info = history.sessionInfo ?: return
upsertSessionEntry(info, preserveExistingContextUsageWithoutTotal = true)
}
private fun upsertSessionEntry(
entry: ChatSessionEntry,
preserveExistingContextUsageWithoutTotal: Boolean = false,
) {
val current = _sessions.value
val index = current.indexOfFirst { it.key == entry.key }
_sessions.value =
if (index >= 0) {
current.toMutableList().also {
it[index] =
mergeChatSessionEntry(
existing = it[index],
next = entry,
preserveExistingContextUsageWithoutTotal = preserveExistingContextUsageWithoutTotal,
)
}
} else {
listOf(entry) + current
}
}
private fun removeSessionEntry(sessionKey: String?) {
val key = sessionKey?.trim()?.takeIf { it.isNotEmpty() } ?: return
_sessions.value = _sessions.value.filterNot { it.key == key }
}
private fun parseRunId(resJson: String): String? =
@@ -857,3 +947,44 @@ private fun JsonElement?.asLongOrNull(): Long? =
is JsonPrimitive -> content.toLongOrNull()
else -> null
}
private fun JsonElement?.asBooleanOrNull(): Boolean? =
when (this) {
is JsonPrimitive -> content.toBooleanStrictOrNull()
else -> null
}
internal fun mergeChatSessionEntry(
existing: ChatSessionEntry,
next: ChatSessionEntry,
preserveExistingContextUsageWithoutTotal: Boolean = false,
): ChatSessionEntry {
val preserveExistingContextUsage = preserveExistingContextUsageWithoutTotal && next.totalTokens == null
return existing.copy(
updatedAtMs = next.updatedAtMs ?: existing.updatedAtMs,
displayName = next.displayName ?: existing.displayName,
totalTokens =
when {
preserveExistingContextUsage -> existing.totalTokens
next.hasContextUsageMetadata -> next.totalTokens
else -> null
},
totalTokensFresh =
when {
preserveExistingContextUsage -> existing.totalTokensFresh
next.hasContextUsageMetadata -> next.totalTokensFresh
else -> null
},
contextTokens =
when {
preserveExistingContextUsage -> next.contextTokens ?: existing.contextTokens
next.hasContextUsageMetadata -> next.contextTokens
else -> null
},
hasContextUsageMetadata =
when {
preserveExistingContextUsage -> existing.hasContextUsageMetadata || next.contextTokens != null
else -> next.hasContextUsageMetadata
},
)
}

View File

@@ -40,6 +40,10 @@ data class ChatSessionEntry(
val key: String,
val updatedAtMs: Long?,
val displayName: String? = null,
val totalTokens: Long? = null,
val totalTokensFresh: Boolean? = null,
val contextTokens: Long? = null,
val hasContextUsageMetadata: Boolean = totalTokens != null || totalTokensFresh != null || contextTokens != null,
)
/**
@@ -50,6 +54,7 @@ data class ChatHistory(
val sessionId: String?,
val thinkingLevel: String?,
val messages: List<ChatMessage>,
val sessionInfo: ChatSessionEntry? = null,
)
/**

View File

@@ -74,6 +74,7 @@ import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt
/** Full chat surface that wires MainViewModel state to messages, attachments, voice, and composer actions. */
@Composable
@@ -95,6 +96,7 @@ fun ChatScreen(
val sessions by viewModel.chatSessions.collectAsState()
val chatDraft by viewModel.chatDraft.collectAsState()
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
val contextUsage = resolveChatContextUsage(sessionKey = sessionKey, mainSessionKey = mainSessionKey, sessions = sessions)
val context = LocalContext.current
val resolver = context.contentResolver
val scope = rememberCoroutineScope()
@@ -196,6 +198,7 @@ fun ChatScreen(
onValueChange = { input = it },
attachments = attachments,
thinkingLevel = thinkingLevel,
contextUsage = contextUsage,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
onThinkingLevelChange = viewModel::setChatThinkingLevel,
@@ -685,6 +688,7 @@ private fun ChatComposer(
onValueChange: (String) -> Unit,
attachments: List<PendingImageAttachment>,
thinkingLevel: String,
contextUsage: ChatContextUsage,
healthOk: Boolean,
pendingRunCount: Int,
onThinkingLevelChange: (String) -> Unit,
@@ -699,7 +703,11 @@ private fun ChatComposer(
AttachmentStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
}
ChatContextMeter(thinkingLevel = thinkingLevel, onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) })
ChatContextMeter(
thinkingLevel = thinkingLevel,
contextUsage = contextUsage,
onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) },
)
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ChatInputPill(value = value, onValueChange = onValueChange, onPickImages = onPickImages, onVoice = onVoice, modifier = Modifier.weight(1f))
@@ -735,8 +743,10 @@ private fun ChatComposer(
@Composable
private fun ChatContextMeter(
thinkingLevel: String,
contextUsage: ChatContextUsage,
onClick: () -> Unit,
) {
val contextFraction = contextMeterWidth(contextUsage) ?: 0f
Row(
modifier = Modifier.width(178.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -755,7 +765,13 @@ private fun ChatContextMeter(
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
Text(text = "Context ${contextPercent(thinkingLevel)}%", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
Text(
text = contextMeterLabel(contextUsage, thinkingLevel),
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Box(
@@ -768,7 +784,7 @@ private fun ChatContextMeter(
Box(
modifier =
Modifier
.fillMaxWidth(thinkingMeterWidth(thinkingLevel))
.fillMaxWidth(contextFraction)
.height(3.dp)
.background(ClawTheme.colors.primary, RoundedCornerShape(999.dp)),
)
@@ -902,6 +918,32 @@ private fun isActiveSessionChoice(
return choiceKey == current
}
internal data class ChatContextUsage(
val totalTokens: Long?,
val totalTokensFresh: Boolean?,
val contextTokens: Long?,
)
internal fun resolveChatContextUsage(
sessionKey: String,
mainSessionKey: String,
sessions: List<ChatSessionEntry>,
): ChatContextUsage {
val entry =
sessions.firstOrNull {
isActiveSessionChoice(
choiceKey = it.key,
sessionKey = sessionKey,
mainSessionKey = mainSessionKey,
)
}
return ChatContextUsage(
totalTokens = entry?.totalTokens,
totalTokensFresh = entry?.totalTokensFresh,
contextTokens = entry?.contextTokens,
)
}
@Composable
private fun SendButton(
enabled: Boolean,
@@ -958,16 +1000,28 @@ private fun nextThinkingValue(value: String): String =
else -> "off"
}
/** Maps thinking presets to the visual context meter fill fraction. */
private fun thinkingMeterWidth(value: String): Float =
when (value.lowercase(Locale.US)) {
"low" -> 0.34f
"medium" -> 0.58f
"high" -> 0.82f
else -> 0.18f
}
internal fun contextMeterWidth(usage: ChatContextUsage): Float? {
if (usage.totalTokensFresh == false) return null
val total = usage.totalTokens?.takeIf { it >= 0L } ?: return null
val context = usage.contextTokens?.takeIf { it > 0L } ?: return null
return (total.toDouble() / context.toDouble()).coerceIn(0.0, 1.0).toFloat()
}
private fun contextPercent(value: String): Int = (thinkingMeterWidth(value) * 100).toInt()
internal fun contextMeterLabel(
usage: ChatContextUsage,
thinkingLevel: String,
): String {
val contextLabel = contextMeterWidth(usage)?.let { "Context ${(it * 100).roundToInt()}%" } ?: "Context --"
return "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}"
}
internal fun contextMeterThinkingLabel(value: String): String =
when (value.lowercase(Locale.US)) {
"low" -> "low"
"medium" -> "medium"
"high" -> "high"
else -> "off"
}
private fun formatChatTimestamp(timestampMs: Long): String = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(timestampMs))

View File

@@ -59,4 +59,96 @@ class ChatControllerSessionPolicyTest {
),
)
}
@Test
fun sessionMergeClearsUsageWhenNewSnapshotOmitsUsageMetadata() {
val existing =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 1L,
displayName = "Phone",
totalTokens = 41_000L,
totalTokensFresh = true,
contextTokens = 100_000L,
)
val next =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 2L,
displayName = "Phone renamed",
hasContextUsageMetadata = false,
)
val merged = mergeChatSessionEntry(existing, next)
assertEquals("agent:main:phone", merged.key)
assertEquals(2L, merged.updatedAtMs)
assertEquals("Phone renamed", merged.displayName)
assertEquals(null, merged.totalTokens)
assertEquals(null, merged.totalTokensFresh)
assertEquals(null, merged.contextTokens)
assertFalse(merged.hasContextUsageMetadata)
}
@Test
fun sessionMergePreservesUsageWhenHistorySnapshotOmitsTotalTokens() {
val existing =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 1L,
displayName = "Phone",
totalTokens = 41_000L,
totalTokensFresh = true,
contextTokens = 100_000L,
)
val next =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 2L,
displayName = "Phone renamed",
totalTokensFresh = false,
contextTokens = 120_000L,
)
val merged =
mergeChatSessionEntry(
existing = existing,
next = next,
preserveExistingContextUsageWithoutTotal = true,
)
assertEquals(2L, merged.updatedAtMs)
assertEquals("Phone renamed", merged.displayName)
assertEquals(41_000L, merged.totalTokens)
assertEquals(true, merged.totalTokensFresh)
assertEquals(120_000L, merged.contextTokens)
assertTrue(merged.hasContextUsageMetadata)
}
@Test
fun sessionMergeAppliesExplicitStaleUsageMetadata() {
val existing =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 1L,
totalTokens = 41_000L,
totalTokensFresh = true,
contextTokens = 100_000L,
)
val next =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 2L,
totalTokens = 82_000L,
totalTokensFresh = false,
contextTokens = 100_000L,
)
val merged = mergeChatSessionEntry(existing, next)
assertEquals(82_000L, merged.totalTokens)
assertEquals(false, merged.totalTokensFresh)
assertEquals(100_000L, merged.contextTokens)
assertTrue(merged.hasContextUsageMetadata)
}
}

View File

@@ -0,0 +1,84 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.chat.ChatSessionEntry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ChatContextMeterTest {
@Test
fun contextMeterUsesActiveSessionTokenBudget() {
val sessions =
listOf(
ChatSessionEntry(key = "main", updatedAtMs = 1L, displayName = "Main", totalTokens = 8_000L, totalTokensFresh = true, contextTokens = 10_000L),
ChatSessionEntry(
key = "agent:main:mobile:test-device",
updatedAtMs = 2L,
displayName = "Phone",
totalTokens = 1_250L,
totalTokensFresh = true,
contextTokens = 5_000L,
),
)
val usage =
resolveChatContextUsage(
sessionKey = "agent:main:mobile:test-device",
mainSessionKey = "main",
sessions = sessions,
)
assertEquals(ChatContextUsage(totalTokens = 1_250L, totalTokensFresh = true, contextTokens = 5_000L), usage)
assertEquals(0.25f, contextMeterWidth(usage))
assertEquals("Context 25% · high", contextMeterLabel(usage, "high"))
}
@Test
fun contextMeterResolvesCanonicalMainAlias() {
val sessions =
listOf(
ChatSessionEntry(
key = "agent:main:node-phone",
updatedAtMs = 1L,
displayName = "Main",
totalTokens = 41_000L,
totalTokensFresh = true,
contextTokens = 100_000L,
),
)
val usage =
resolveChatContextUsage(
sessionKey = "main",
mainSessionKey = "agent:main:node-phone",
sessions = sessions,
)
assertEquals(ChatContextUsage(totalTokens = 41_000L, totalTokensFresh = true, contextTokens = 100_000L), usage)
assertEquals("Context 41% · off", contextMeterLabel(usage, "off"))
}
@Test
fun contextMeterDoesNotInventPercentWhenBudgetIsMissing() {
val usage = ChatContextUsage(totalTokens = 8_200L, totalTokensFresh = true, contextTokens = null)
assertNull(contextMeterWidth(usage))
assertEquals("Context -- · medium", contextMeterLabel(usage, "medium"))
}
@Test
fun contextMeterClampsOverfullSessions() {
val usage = ChatContextUsage(totalTokens = 150_000L, totalTokensFresh = true, contextTokens = 100_000L)
assertEquals(1.0f, contextMeterWidth(usage))
assertEquals("Context 100% · low", contextMeterLabel(usage, "low"))
}
@Test
fun contextMeterDoesNotDisplayStaleTokenUsage() {
val usage = ChatContextUsage(totalTokens = 82_000L, totalTokensFresh = false, contextTokens = 100_000L)
assertNull(contextMeterWidth(usage))
assertEquals("Context -- · high", contextMeterLabel(usage, "high"))
}
}

View File

@@ -58,11 +58,11 @@ Maintenance update for the current OpenClaw release.
## 2026.5.12 - 2026-05-12
Maintenance update for the current OpenClaw beta release.
Maintenance update for the current OpenClaw release.
## 2026.5.10 - 2026-05-10
Maintenance update for the current OpenClaw beta release.
Maintenance update for the current OpenClaw release.
- Gateway connections now recover after a trusted Gateway certificate changes by refreshing the stored certificate pin during reconnect.
@@ -128,7 +128,7 @@ Maintenance update for the current OpenClaw release.
## 2026.4.19 - 2026-04-19
Maintenance update for the current OpenClaw beta release.
Maintenance update for the current OpenClaw release.
## 2026.4.18 - 2026-04-18
@@ -136,11 +136,11 @@ Maintenance update for the current OpenClaw release.
## 2026.4.15 - 2026-04-15
Maintenance update for the current OpenClaw beta release.
Maintenance update for the current OpenClaw release.
## 2026.4.14 - 2026-04-14
Maintenance update for the current OpenClaw beta release.
Maintenance update for the current OpenClaw release.
## 2026.4.12 - 2026-04-12

View File

@@ -0,0 +1,53 @@
{
"teamId": "FWJYW4S8P8",
"signingRepo": "git@github.com:openclaw/ios-signing.git",
"certificateType": "IOS_DISTRIBUTION",
"profileType": "IOS_APP_STORE",
"targets": [
{
"target": "OpenClaw",
"displayName": "OpenClaw",
"bundleId": "ai.openclawfoundation.app",
"platform": "IOS",
"profileKey": "OPENCLAW_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
"capabilities": ["PUSH_NOTIFICATIONS"]
},
{
"target": "OpenClawShareExtension",
"displayName": "OpenClaw Share",
"bundleId": "ai.openclawfoundation.app.share",
"platform": "IOS",
"profileKey": "OPENCLAW_SHARE_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.share",
"capabilities": []
},
{
"target": "OpenClawActivityWidget",
"displayName": "OpenClaw Activity Widget",
"bundleId": "ai.openclawfoundation.app.activitywidget",
"platform": "IOS",
"profileKey": "OPENCLAW_ACTIVITY_WIDGET_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.activitywidget",
"capabilities": []
},
{
"target": "OpenClawWatchApp",
"displayName": "OpenClaw Watch App",
"bundleId": "ai.openclawfoundation.app.watchkitapp",
"platform": "IOS",
"profileKey": "OPENCLAW_WATCH_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
"capabilities": []
},
{
"target": "OpenClawWatchExtension",
"displayName": "OpenClaw Watch Extension",
"bundleId": "ai.openclawfoundation.app.watchkitapp.extension",
"platform": "IOS",
"profileKey": "OPENCLAW_WATCH_EXTENSION_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
"capabilities": []
}
]
}

View File

@@ -1,14 +1,16 @@
// Shared iOS signing defaults for local development + CI.
#include "Version.xcconfig"
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
OPENCLAW_IOS_DEFAULT_TEAM = FWJYW4S8P8
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
OPENCLAW_CODE_SIGN_STYLE = Automatic
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
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
@@ -18,7 +20,7 @@ OPENCLAW_WATCH_EXTENSION_PROFILE =
#include? "../LocalSigning.xcconfig"
CODE_SIGN_STYLE = $(OPENCLAW_CODE_SIGN_STYLE)
CODE_SIGN_IDENTITY = Apple Development
CODE_SIGN_IDENTITY = $(OPENCLAW_CODE_SIGN_IDENTITY)
DEVELOPMENT_TEAM = $(OPENCLAW_DEVELOPMENT_TEAM)
// Let Xcode manage provisioning for the selected local team unless a local override pins one.

View File

@@ -2,16 +2,18 @@
// This file is only an example and should stay committed.
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
// Leave empty with automatic signing.
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =

View File

@@ -4,8 +4,8 @@ This iOS app is super-alpha and internal-use only. The first public App Store re
## Distribution Status
- Public distribution: not available.
- Internal beta distribution: local archive + TestFlight upload via Fastlane.
- Public distribution: App Store Connect app created; production signing is configured through the App Store release Fastlane path.
- Internal TestFlight distribution: uses the same App Store distribution archive uploaded to App Store Connect.
- Local/manual deploy from source via Xcode remains the default development path.
## Super-Alpha Disclaimer
@@ -47,7 +47,7 @@ Shortcut command (same flow + open project):
pnpm ios:open
```
## Local Beta Release Flow
## App Store Release Flow
Prereqs:
@@ -55,51 +55,82 @@ Prereqs:
- `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
- Apple account signed into Xcode for the canonical OpenClaw team (`FWJYW4S8P8`)
- `asc` CLI authenticated for the canonical OpenClaw team
- Release-owner access to the encrypted signing repo password (`ASC_MATCH_PASSWORD`)
- App Store Connect app already created for `ai.openclawfoundation.app`
- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh` when auto-resolving a build number or uploading to App Store Connect
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`.
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- Local development uses the canonical `ai.openclawfoundation.app*` bundle IDs when the OpenClaw team is available, and unique `ai.openclawfoundation.app.test.*` bundle IDs only for non-canonical fallback teams.
- App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`.
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
- `asc` owns one-time Developer Portal setup and encrypted signing sync. Fastlane owns release handling after those assets exist.
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, and a production `aps-environment` entitlement.
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
- `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation.
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- `apps/ios/version.json` is the pinned iOS release version source.
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.
- The pinned iOS version must use CalVer like `2026.4.10`.
- That pinned value becomes:
- `CFBundleShortVersionString = 2026.4.10`
- `CFBundleVersion = next TestFlight build number for 2026.4.10`
- `CFBundleVersion = next App Store Connect build number for 2026.4.10`
- Changing the root gateway version does not change the iOS app version until you explicitly pin from the gateway.
- See `apps/ios/VERSIONING.md` for the full workflow.
Relay behavior for beta builds:
Relay behavior for App Store builds:
- Beta builds default to `https://ios-push-relay.openclaw.ai`.
- Release builds default to `https://ios-push-relay.openclaw.ai`.
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
Signing setup commands:
```bash
pnpm ios:release:signing:plan
pnpm ios:release:signing:check
pnpm ios:release:signing:setup
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull
```
Release-owner secrets:
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `ASC_MATCH_PASSWORD`.
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
- Rotating release signing means revoking/replacing the Developer Portal certificate or profile with `asc`, then pushing a fresh encrypted sync state.
Prepare the generated release xcconfig/project without archiving:
```bash
pnpm ios:release:prepare -- --build-number 7
```
Archive without upload:
```bash
pnpm ios:beta:archive
pnpm ios:release:archive
```
Archive and upload to TestFlight:
Archive and upload to App Store Connect:
```bash
pnpm ios:beta
pnpm ios:release:upload
```
If you need to force a specific build number:
```bash
pnpm ios:beta -- --build-number 7
pnpm ios:release:upload -- --build-number 7
```
### Maintainer Quick Release Checklist
Use this when a clone is missing local iOS release setup and you want the shortest path to a TestFlight upload.
Use this when a clone is missing local iOS release setup and you want the shortest path to an App Store Connect upload.
1. Confirm Fastlane auth is set up:
@@ -119,38 +150,50 @@ scripts/ios-asc-keychain-setup.sh \
This should create `apps/ios/fastlane/.env` with the non-secret ASC variables while the private key stays in Keychain.
3. Optional: set a custom official/TestFlight relay URL for the build. If unset, the beta flow uses `https://ios-push-relay.openclaw.ai`.
3. Confirm the App Store Connect app and Apple Developer identifiers/capabilities exist for:
- `ai.openclawfoundation.app`
- `ai.openclawfoundation.app.share`
- `ai.openclawfoundation.app.activitywidget`
- `ai.openclawfoundation.app.watchkitapp`
- `ai.openclawfoundation.app.watchkitapp.extension`
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted signing assets to the shared private repo.
4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`.
```bash
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
```
4. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
5. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
```bash
pnpm ios:version:pin -- --from-gateway
```
5. Upload the beta:
6. Upload the build:
```bash
pnpm ios:beta
pnpm ios:release:upload
```
6. Expected behavior:
7. Expected behavior:
- Fastlane reads `apps/ios/version.json`
- verifies synced iOS versioning artifacts
- resolves the next TestFlight build number for that short version
- generates `apps/ios/build/BetaRelease.xcconfig`
- resolves the next App Store Connect build number for that short version
- generates deterministic App Store screenshots
- uploads release notes and screenshots to the editable App Store version
- generates `apps/ios/build/AppStoreRelease.xcconfig`
- archives `OpenClaw`
- uploads the IPA to TestFlight
- uploads the IPA to App Store Connect for TestFlight/App Review use
- leaves App Review submission for a maintainer to complete manually
7. Expected outputs after a successful run:
- `apps/ios/build/beta/OpenClaw-<version>.ipa`
- `apps/ios/build/beta/OpenClaw-<version>.app.dSYM.zip`
- Fastlane log line like `Uploaded iOS beta: version=<version> short=<short> build=<build>`
8. Expected outputs after a successful run:
- `apps/ios/build/app-store/OpenClaw-<version>.ipa`
- `apps/ios/build/app-store/OpenClaw-<version>.app.dSYM.zip`
- Fastlane log line like `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
8. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
9. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
## iOS Versioning Workflow
@@ -176,7 +219,7 @@ Recommended flow:
1. Keep `apps/ios/version.json` pinned to the current train version.
2. Update `apps/ios/CHANGELOG.md`, usually under `## Unreleased` while iterating.
3. Run `pnpm ios:version:sync` after changelog changes.
4. Upload more TestFlight builds with `pnpm ios:beta`.
4. Upload more TestFlight builds with `pnpm ios:release:upload`.
5. Let Fastlane bump only the numeric build number.
### Starting the next production release train
@@ -189,7 +232,7 @@ pnpm ios:version:pin -- --from-gateway
2. Update `apps/ios/CHANGELOG.md` for the new release as needed.
3. Run `pnpm ios:version:sync`.
4. Submit the first TestFlight build for that newly pinned version.
4. Submit the first App Store Connect build for that newly pinned version.
5. Keep iterating on that same version until the release candidate is ready.
See `apps/ios/VERSIONING.md` for the detailed spec.
@@ -197,9 +240,9 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
## APNs Expectations For Local/Manual Builds
- The app calls `registerForRemoteNotifications()` at launch.
- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`.
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
- Local/manual builds default to `OpenClawPushTransport=direct` and `OpenClawPushDistribution=local`.
- Local/manual builds default to `OpenClawPushTransport=direct`, `OpenClawPushDistribution=local`, and a development `aps-environment` entitlement.
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
@@ -319,7 +362,7 @@ Automatic wake/reconnect hardening:
5. If network path is unclear:
- switch to manual host/port + TLS in Gateway Advanced settings
6. In Xcode console, filter for subsystem/category signals:
- `ai.openclaw.ios`
- `ai.openclawfoundation.app`
- `GatewayDiag`
- `APNs registration failed`
7. Validate background expectations:

View File

@@ -17,7 +17,7 @@ final class ShareViewController: UIViewController {
var attachments: [ShareAttachment]
}
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "ShareExtension")
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ShareExtension")
private var statusLabel: UILabel?
private let draftTextView = UITextView()
private let sendButton = UIButton(type: .system)

View File

@@ -5,16 +5,21 @@
#include "Config/Version.xcconfig"
OPENCLAW_CODE_SIGN_STYLE = Manual
OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
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
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
OPENCLAW_APP_PROFILE = ai.openclaw.client Development
OPENCLAW_SHARE_PROFILE = ai.openclaw.client.share Development
OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
// so later assignments in local files override the defaults above.

View File

@@ -14,7 +14,50 @@ enum AppleReviewDemoMode {
}
static var agents: [AgentSummary] {
[
LocalChatFixture.appleReviewDemo.agents
}
}
enum ScreenshotFixtureMode {
static let gatewayName = "OpenClaw Gateway"
static let gatewayAddress = "Mac Studio on local network"
static let gatewayID = "screenshot-fixture-gateway"
static var agents: [AgentSummary] {
LocalChatFixture.appScreenshots.agents
}
}
struct LocalChatFixture {
let sessionKey: String
let sessionIDPrefix: String
let displayName: String
let subject: String
let workspace: String
let modelProvider: String
let modelID: String
let modelName: String
let responsePrefix: String
let seedMessages: [String]
let agents: [AgentSummary]
static let appleReviewDemo = LocalChatFixture(
sessionKey: "main",
sessionIDPrefix: "apple-review-demo",
displayName: "Apple Review Demo",
subject: "Gateway review flow",
workspace: "Apple Review Demo",
modelProvider: "demo",
modelID: "local-demo",
modelName: "Apple Review Demo",
responsePrefix: "Demo mode is active.",
seedMessages: [
"""
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
without a private Gateway.
""",
],
agents: [
AgentSummary(
id: "main",
name: "Main",
@@ -25,12 +68,70 @@ enum AppleReviewDemoMode {
thinkinglevels: nil,
thinkingoptions: ["auto", "low", "medium"],
thinkingdefault: "auto"),
]
}
])
static let appScreenshots = LocalChatFixture(
sessionKey: "main",
sessionIDPrefix: "screenshot-fixture",
displayName: "Molty",
subject: "Mobile command center",
workspace: "OpenClaw",
modelProvider: "openai",
modelID: "gpt-5.5",
modelName: "GPT-5.5",
responsePrefix: "OpenClaw is connected to your gateway.",
seedMessages: [
"""
OpenClaw is connected to your gateway. I can coordinate agents, inspect project context, and prepare \
actions from your phone.
""",
"""
The Molty agent is ready. Recent context, voice controls, and gateway settings are available \
across the app.
""",
],
agents: [
AgentSummary(
id: "main",
name: "Molty",
identity: ["emoji": AnyCodable("M")],
workspace: "OpenClaw",
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
agentruntime: ["kind": AnyCodable("gateway")],
thinkinglevels: nil,
thinkingoptions: ["auto", "low", "medium", "high"],
thinkingdefault: "auto"),
AgentSummary(
id: "research",
name: "Research",
identity: ["emoji": AnyCodable("RS")],
workspace: "OpenClaw",
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
agentruntime: ["kind": AnyCodable("gateway")],
thinkinglevels: nil,
thinkingoptions: ["auto", "low", "medium", "high"],
thinkingdefault: "medium"),
AgentSummary(
id: "automation",
name: "Automation",
identity: ["emoji": AnyCodable("AU")],
workspace: "OpenClaw",
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
agentruntime: ["kind": AnyCodable("gateway")],
thinkinglevels: nil,
thinkingoptions: ["auto", "low", "medium", "high"],
thinkingdefault: "auto"),
])
}
struct AppleReviewDemoChatTransport: OpenClawChatTransport {
private let store = AppleReviewDemoChatStore()
struct LocalFixtureChatTransport: OpenClawChatTransport {
private let fixture: LocalChatFixture
private let store: LocalFixtureChatStore
init(fixture: LocalChatFixture) {
self.fixture = fixture
self.store = LocalFixtureChatStore(fixture: fixture)
}
func createSession(
key: String,
@@ -47,9 +148,9 @@ struct AppleReviewDemoChatTransport: OpenClawChatTransport {
func listModels() async throws -> [OpenClawChatModelChoice] {
[
OpenClawChatModelChoice(
modelID: "local-demo",
name: "Apple Review Demo",
provider: "demo",
modelID: self.fixture.modelID,
name: self.fixture.modelName,
provider: self.fixture.modelProvider,
contextWindow: 128_000),
]
}
@@ -101,26 +202,102 @@ struct AppleReviewDemoChatTransport: OpenClawChatTransport {
func compactSession(sessionKey _: String) async throws {}
}
private actor AppleReviewDemoChatStore {
private let sessionKey = "main"
struct AppleReviewDemoChatTransport: OpenClawChatTransport {
private let transport = LocalFixtureChatTransport(fixture: .appleReviewDemo)
func createSession(
key: String,
label: String?,
parentSessionKey: String?) async throws -> OpenClawChatCreateSessionResponse
{
try await self.transport.createSession(key: key, label: label, parentSessionKey: parentSessionKey)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
try await self.transport.requestHistory(sessionKey: sessionKey)
}
func listModels() async throws -> [OpenClawChatModelChoice] {
try await self.transport.listModels()
}
func sendMessage(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
try await self.transport.sendMessage(
sessionKey: sessionKey,
message: message,
thinking: thinking,
idempotencyKey: idempotencyKey,
attachments: attachments)
}
func abortRun(sessionKey: String, runId: String) async throws {
try await self.transport.abortRun(sessionKey: sessionKey, runId: runId)
}
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse {
try await self.transport.listSessions(limit: limit)
}
func setSessionModel(sessionKey: String, model: String?) async throws {
try await self.transport.setSessionModel(sessionKey: sessionKey, model: model)
}
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws {
try await self.transport.setSessionThinking(sessionKey: sessionKey, thinkingLevel: thinkingLevel)
}
func requestHealth(timeoutMs: Int) async throws -> Bool {
try await self.transport.requestHealth(timeoutMs: timeoutMs)
}
func waitForRunCompletion(runId: String, timeoutMs: Int) async -> Bool {
await self.transport.waitForRunCompletion(runId: runId, timeoutMs: timeoutMs)
}
func events() -> AsyncStream<OpenClawChatTransportEvent> {
self.transport.events()
}
func setActiveSessionKey(_ sessionKey: String) async throws {
try await self.transport.setActiveSessionKey(sessionKey)
}
func resetSession(sessionKey: String) async throws {
try await self.transport.resetSession(sessionKey: sessionKey)
}
func compactSession(sessionKey: String) async throws {
try await self.transport.compactSession(sessionKey: sessionKey)
}
}
private actor LocalFixtureChatStore {
private let fixture: LocalChatFixture
private var messages: [OpenClawChatMessage]
init() {
self.messages = AppleReviewDemoChatStore.seedMessages()
init(fixture: LocalChatFixture) {
self.fixture = fixture
self.messages = Self.seedMessages(fixture: fixture)
}
func createSession(key: String) throws -> OpenClawChatCreateSessionResponse {
try Self.decode(
CreateSessionPayload(ok: true, key: key, sessionId: "apple-review-demo-\(key)"),
CreateSessionPayload(ok: true, key: key, sessionId: "\(self.fixture.sessionIDPrefix)-\(key)"),
as: OpenClawChatCreateSessionResponse.self)
}
func history(sessionKey: String) throws -> OpenClawChatHistoryPayload {
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey)
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey, fallback: self.fixture.sessionKey)
return try Self.decode(
HistoryPayload(
sessionKey: normalizedSessionKey,
sessionId: "apple-review-demo-\(normalizedSessionKey)",
sessionId: "\(self.fixture.sessionIDPrefix)-\(normalizedSessionKey)",
messages: self.messages,
thinkingLevel: "auto"),
as: OpenClawChatHistoryPayload.self)
@@ -135,9 +312,8 @@ private actor AppleReviewDemoChatStore {
Self.message(
role: "assistant",
text: """
Demo mode is active. I can show the review flow locally for \(subject), including chat, agent \
selection, settings, and Gateway-connected UI states. Live automation requires pairing a real \
OpenClaw Gateway.
\(self.fixture.responsePrefix) I can help with \(subject), summarize current project context, \
prepare agent actions, and keep the mobile workflow connected to the gateway.
""",
timestamp: now + 1))
return try Self.decode(
@@ -147,15 +323,15 @@ private actor AppleReviewDemoChatStore {
func sessions() throws -> OpenClawChatSessionsListResponse {
let entry = OpenClawChatSessionEntry(
key: self.sessionKey,
key: self.fixture.sessionKey,
kind: "chat",
displayName: "Apple Review Demo",
displayName: self.fixture.displayName,
surface: "ios",
subject: "Gateway review flow",
subject: self.fixture.subject,
room: nil,
space: nil,
updatedAt: Date().timeIntervalSince1970 * 1000,
sessionId: "apple-review-demo-main",
sessionId: "\(self.fixture.sessionIDPrefix)-\(self.fixture.sessionKey)",
systemSent: true,
abortedLastRun: false,
thinkingLevel: "auto",
@@ -163,52 +339,51 @@ private actor AppleReviewDemoChatStore {
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: "demo",
model: "local-demo",
modelProvider: self.fixture.modelProvider,
model: self.fixture.modelID,
contextTokens: 128_000,
thinkingLevels: [
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
],
thinkingOptions: ["auto", "low", "medium"],
thinkingLevels: Self.thinkingLevels,
thinkingOptions: Self.thinkingOptions,
thinkingDefault: "auto")
return OpenClawChatSessionsListResponse(
ts: Date().timeIntervalSince1970 * 1000,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(
modelProvider: "demo",
model: "local-demo",
modelProvider: self.fixture.modelProvider,
model: self.fixture.modelID,
contextTokens: 128_000,
thinkingLevels: [
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
],
thinkingOptions: ["auto", "low", "medium"],
thinkingLevels: Self.thinkingLevels,
thinkingOptions: Self.thinkingOptions,
thinkingDefault: "auto",
mainSessionKey: self.sessionKey),
mainSessionKey: self.fixture.sessionKey),
sessions: [entry])
}
func reset() {
self.messages = Self.seedMessages()
self.messages = Self.seedMessages(fixture: self.fixture)
}
private static func seedMessages() -> [OpenClawChatMessage] {
let now = Date().timeIntervalSince1970 * 1000
return [
self.message(
role: "assistant",
text: """
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
without a private Gateway.
""",
timestamp: now),
private static var thinkingOptions: [String] {
["auto", "low", "medium", "high"]
}
private static var thinkingLevels: [OpenClawChatThinkingLevelOption] {
[
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
OpenClawChatThinkingLevelOption(id: "high", label: "High"),
]
}
private static func seedMessages(fixture: LocalChatFixture) -> [OpenClawChatMessage] {
let now = Date().timeIntervalSince1970 * 1000
return fixture.seedMessages.enumerated().map { index, text in
self.message(role: "assistant", text: text, timestamp: now + Double(index))
}
}
private static func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage {
OpenClawChatMessage(
role: role,
@@ -223,9 +398,9 @@ private actor AppleReviewDemoChatStore {
timestamp: timestamp)
}
private static func normalizedSessionKey(_ value: String) -> String {
private static func normalizedSessionKey(_ value: String, fallback: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "main" : trimmed
return trimmed.isEmpty ? fallback : trimmed
}
private static func decode<T: Decodable>(_ value: some Encodable, as type: T.Type) throws -> T {

View File

@@ -5,7 +5,7 @@ import OpenClawProtocol
import OSLog
struct IOSGatewayChatTransport: OpenClawChatTransport {
static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ios.chat.transport")
static let defaultChatSendTimeoutMs = 30000
private let gateway: GatewayNodeSession

View File

@@ -303,7 +303,7 @@ extension AgentProTab {
}
.padding(.vertical, 14)
.padding(.horizontal, 13)
.frame(minHeight: AgentLayout.rowMinHeight, alignment: .center)
.frame(maxWidth: .infinity, minHeight: AgentLayout.rowMinHeight, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
self.appModel.setSelectedAgentId(agent.id)
@@ -557,7 +557,7 @@ extension AgentProTab {
}
var liveGatewayConnected: Bool {
!self.appModel.isAppleReviewDemoModeEnabled &&
!self.appModel.isLocalGatewayFixtureEnabled &&
self.gatewayConnected &&
self.appModel.isOperatorGatewayConnected
}

View File

@@ -6,7 +6,7 @@ struct ChatProTab: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel: OpenClawChatViewModel?
@State private var viewModelUsesAppleReviewDemoTransport = false
@State private var viewModelTransportModeID = ""
let headerLeadingAction: OpenClawSidebarHeaderAction?
let headerTitle: String?
let headerSubtitle: String?
@@ -64,6 +64,7 @@ struct ChatProTab: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.safeAreaPadding(.top, 8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.navigationBarHidden(true)
@@ -78,6 +79,10 @@ struct ChatProTab: View {
self.syncChatViewModel()
self.viewModel?.refresh()
}
.onChange(of: self.appModel.isScreenshotFixtureModeEnabled) { _, _ in
self.syncChatViewModel()
self.viewModel?.refresh()
}
.onChange(of: self.appModel.isOperatorGatewayConnected) { _, connected in
guard connected else { return }
self.syncChatViewModel()
@@ -103,7 +108,6 @@ struct ChatProTab: View {
self.connectionPillButton
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 8)
.padding(.bottom, 4)
}
@@ -135,14 +139,12 @@ struct ChatProTab: View {
private func syncChatViewModel() {
let sessionKey = self.appModel.chatSessionKey
let usesDemoTransport = self.appModel.isAppleReviewDemoModeEnabled
let transportModeID = self.appModel.chatTransportModeID
guard let viewModel else {
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
self.viewModelTransportModeID = transportModeID
self.viewModel = OpenClawChatViewModel(
sessionKey: sessionKey,
transport: usesDemoTransport
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
transport: self.appModel.makeChatTransport(),
onSessionChanged: { sessionKey in
self.appModel.focusChatSession(sessionKey)
},
@@ -151,13 +153,11 @@ struct ChatProTab: View {
})
return
}
if self.viewModelUsesAppleReviewDemoTransport != usesDemoTransport {
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
if self.viewModelTransportModeID != transportModeID {
self.viewModelTransportModeID = transportModeID
self.viewModel = OpenClawChatViewModel(
sessionKey: sessionKey,
transport: usesDemoTransport
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
transport: self.appModel.makeChatTransport(),
onSessionChanged: { sessionKey in
self.appModel.focusChatSession(sessionKey)
},
@@ -226,7 +226,7 @@ struct ChatProTab: View {
guard self.gatewayDisplayState == .connected else {
return false
}
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
return self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
}
private var gatewayDisplayState: GatewayDisplayState {

View File

@@ -370,12 +370,11 @@ struct CommandCenterTab: View {
}
private var sessionListAvailable: Bool {
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
}
private var sessionListMode: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
self.appModel.chatTransportModeID
}
private var sessionItems: [WorkItem] {
@@ -414,9 +413,7 @@ struct CommandCenterTab: View {
}
do {
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
let transport = self.appModel.makeChatTransport()
let response = try await transport.listSessions(limit: Self.recentSessionsFetchLimit)
self.defaultChatSessionEntry = response.sessions.first {
$0.key == self.appModel.defaultChatSessionKey
@@ -765,9 +762,7 @@ struct CommandSessionsScreen: View {
defer { self.isLoading = false }
do {
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
let transport = self.appModel.makeChatTransport()
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
self.sessions = response.sessions
} catch {
@@ -779,11 +774,10 @@ struct CommandSessionsScreen: View {
extension NodeAppModel {
fileprivate var isCommandSessionListAvailable: Bool {
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
self.isLocalChatFixtureEnabled || self.isOperatorGatewayConnected
}
fileprivate var commandSessionListMode: String {
if self.isAppleReviewDemoModeEnabled { return "demo" }
return self.isOperatorGatewayConnected ? "operator" : "offline"
self.chatTransportModeID
}
}

View File

@@ -180,12 +180,11 @@ struct IPadActivityScreen: View {
}
private var sessionsAvailable: Bool {
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
}
private var sessionsMode: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
self.appModel.chatTransportModeID
}
private var sessionRows: [CommandCenterTab.WorkItem] {
@@ -215,9 +214,7 @@ struct IPadActivityScreen: View {
defer { self.isLoading = false }
do {
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
let transport = self.appModel.makeChatTransport()
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
self.sessions = response.sessions
} catch {

View File

@@ -6,7 +6,7 @@ final class NetworkStatusService: @unchecked Sendable {
func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload {
await withCheckedContinuation { cont in
let monitor = NWPathMonitor()
let queue = DispatchQueue(label: "ai.openclaw.ios.network-status")
let queue = DispatchQueue(label: "ai.openclawfoundation.app.network-status")
let state = NetworkStatusState()
monitor.pathUpdateHandler = { path in

View File

@@ -125,6 +125,7 @@ final class GatewayConnectionController {
private(set) var pendingTrustPrompt: TrustPrompt?
private let discovery = GatewayDiscoveryModel()
private let discoveryEnabled: Bool
private weak var appModel: NodeAppModel?
private var didAutoConnect = false
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
@@ -137,6 +138,7 @@ final class GatewayConnectionController {
}
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
self.discoveryEnabled = startDiscovery
self.appModel = appModel
GatewaySettingsStore.bootstrapPersistence()
@@ -146,7 +148,7 @@ final class GatewayConnectionController {
self.updateFromDiscovery()
self.observeDiscovery()
if startDiscovery {
if self.discoveryEnabled {
self.discovery.start()
}
}
@@ -156,6 +158,11 @@ final class GatewayConnectionController {
}
func setScenePhase(_ phase: ScenePhase) {
guard self.discoveryEnabled else {
self.discovery.stop()
return
}
switch phase {
case .background:
self.discovery.stop()
@@ -169,6 +176,12 @@ final class GatewayConnectionController {
}
func restartDiscovery() {
guard self.discoveryEnabled else {
self.discovery.stop()
self.updateFromDiscovery()
return
}
self.discovery.stop()
self.didAutoConnect = false
self.discovery.start()

View File

@@ -59,7 +59,7 @@ final class GatewayDiscoveryModel {
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
serviceType: OpenClawBonjour.gatewayServiceType,
domain: domain,
queueLabelPrefix: "ai.openclaw.ios.gateway-discovery",
queueLabelPrefix: "ai.openclawfoundation.app.gateway-discovery",
onState: { [weak self] state in
guard let self else { return }
self.statesByDomain[domain] = state

View File

@@ -2,9 +2,9 @@ import Foundation
import os
enum GatewaySettingsStore {
private static let gatewayService = "ai.openclaw.gateway"
private static let nodeService = "ai.openclaw.node"
private static let talkService = "ai.openclaw.talk"
private static let gatewayService = "ai.openclawfoundation.app.gateway"
private static let nodeService = "ai.openclawfoundation.app.node"
private static let talkService = "ai.openclawfoundation.app.talk"
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
@@ -443,8 +443,8 @@ enum GatewaySettingsStore {
}
enum GatewayDiagnostics {
private static let logger = Logger(subsystem: "ai.openclaw.ios", category: "GatewayDiag")
private static let queue = DispatchQueue(label: "ai.openclaw.gateway.diagnostics")
private static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "GatewayDiag")
private static let queue = DispatchQueue(label: "ai.openclawfoundation.app.gateway.diagnostics")
private static let maxLogBytes: Int64 = 512 * 1024
private static let keepLogBytes: Int64 = 256 * 1024
private static let logSizeCheckEveryWrites = 50

View File

@@ -4,7 +4,7 @@
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>ai.openclaw.ios.bgrefresh</string>
<string>$(OPENCLAW_APP_BUNDLE_ID).bgrefresh</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
@@ -28,7 +28,7 @@
<array>
<dict>
<key>CFBundleURLName</key>
<string>ai.openclaw.ios</string>
<string>ai.openclawfoundation.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>openclaw</string>

View File

@@ -7,7 +7,7 @@ import os
final class LiveActivityManager {
static let shared = LiveActivityManager()
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "LiveActivity")
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "LiveActivity")
private let connectingStaleSeconds: TimeInterval = 120
private let hydrationStaleSeconds: TimeInterval = 300
private var currentActivity: Activity<OpenClawActivityAttributes>?

View File

@@ -88,14 +88,14 @@ final class NodeAppModel {
var pendingApprovalIDs: [String]?
}
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
private let watchExecApprovalLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchExecApproval")
private let deepLinkLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "DeepLink")
private let pushWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PushWake")
private let pendingActionLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PendingAction")
private let locationWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "LocationWake")
private let watchReplyLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "WatchReply")
private let watchExecApprovalLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "WatchExecApproval")
private let execApprovalNotificationLogger = Logger(
subsystem: "ai.openclaw.ios",
subsystem: "ai.openclawfoundation.app",
category: "ExecApprovalNotification")
enum CameraHUDKind {
case photo
@@ -112,6 +112,7 @@ final class NodeAppModel {
var nodeStatusText: String = "Offline"
var operatorStatusText: String = "Offline"
private(set) var isAppleReviewDemoModeEnabled: Bool = false
private(set) var isScreenshotFixtureModeEnabled: Bool = false
var isOperatorGatewayConnected: Bool {
self.operatorConnected
}
@@ -206,6 +207,36 @@ final class NodeAppModel {
self.operatorGateway
}
var localChatFixture: LocalChatFixture? {
if self.isScreenshotFixtureModeEnabled { return .appScreenshots }
if self.isAppleReviewDemoModeEnabled { return .appleReviewDemo }
return nil
}
var isLocalChatFixtureEnabled: Bool {
self.localChatFixture != nil
}
var isLocalGatewayFixtureEnabled: Bool {
self.isAppleReviewDemoModeEnabled || self.isScreenshotFixtureModeEnabled
}
var chatTransportModeID: String {
if self.isScreenshotFixtureModeEnabled { return "screenshots" }
if self.isAppleReviewDemoModeEnabled { return "apple-review-demo" }
return self.isOperatorGatewayConnected ? "operator" : "offline"
}
func makeChatTransport() -> any OpenClawChatTransport {
if self.isScreenshotFixtureModeEnabled {
return LocalFixtureChatTransport(fixture: .appScreenshots)
}
if self.isAppleReviewDemoModeEnabled {
return AppleReviewDemoChatTransport()
}
return IOSGatewayChatTransport(gateway: self.operatorSession)
}
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
@@ -547,7 +578,7 @@ final class NodeAppModel {
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
guard !self.isLocalGatewayFixtureEnabled else { return }
self.setOperatorConnected(false)
self.gatewayConnected = false
self.talkMode.updateGatewayConnected(false)
@@ -914,7 +945,7 @@ final class NodeAppModel {
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
guard !self.isLocalGatewayFixtureEnabled else { return }
self.setOperatorConnected(false)
self.gatewayConnected = false
self.gatewayStatusText = "Reconnecting…"
@@ -1963,6 +1994,7 @@ extension NodeAppModel {
/// Preferred entry-point: apply a single config object and start both sessions.
func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig, forceReconnect: Bool = false) {
self.isAppleReviewDemoModeEnabled = false
self.isScreenshotFixtureModeEnabled = false
self.connectToGateway(
url: cfg.url,
// Preserve the caller-provided stableID (may be empty) and let connectToGateway
@@ -1997,7 +2029,7 @@ extension NodeAppModel {
private func restartGatewaySessionsAfterForegroundStaleConnection() async {
await self.resetGatewaySessionsForForcedReconnect()
guard !self.isAppleReviewDemoModeEnabled else { return }
guard !self.isLocalGatewayFixtureEnabled else { return }
self.setOperatorConnected(false)
self.gatewayConnected = false
self.gatewayStatusText = "Reconnecting…"
@@ -2008,6 +2040,7 @@ extension NodeAppModel {
func disconnectGateway() {
self.isAppleReviewDemoModeEnabled = false
self.isScreenshotFixtureModeEnabled = false
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
@@ -2043,6 +2076,7 @@ extension NodeAppModel {
extension NodeAppModel {
private func prepareForGatewayConnect(stableID: String) {
self.isAppleReviewDemoModeEnabled = false
self.isScreenshotFixtureModeEnabled = false
self.gatewayAutoReconnectEnabled = true
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
@@ -2085,7 +2119,7 @@ extension NodeAppModel {
}
private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
guard !self.isAppleReviewDemoModeEnabled else { return }
guard !self.isLocalGatewayFixtureEnabled else { return }
self.lastGatewayProblem = problem
self.gatewayStatusText = problem.statusText
self.gatewayServerName = nil
@@ -2111,7 +2145,7 @@ extension NodeAppModel {
}
private func applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
guard !self.isAppleReviewDemoModeEnabled else { return }
guard !self.isLocalGatewayFixtureEnabled else { return }
self.operatorGatewayProblem = problem
self.lastGatewayProblem = problem
self.gatewayStatusText = problem.statusText
@@ -2344,7 +2378,7 @@ extension NodeAppModel {
onConnected: { [weak self] in
guard let self else { return }
let shouldUseConnection = await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return false }
guard !self.isLocalGatewayFixtureEnabled else { return false }
self.setOperatorConnected(true)
self.clearOperatorGatewayConnectionProblemIfCurrent()
self.forceOperatorTalkPermissionUpgradeRequest = false
@@ -2366,7 +2400,7 @@ extension NodeAppModel {
onDisconnected: { [weak self] reason in
guard let self else { return }
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
guard !self.isLocalGatewayFixtureEnabled else { return }
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
LiveActivityManager.shared.endActivity(reason: "operator_disconnected")
@@ -2391,7 +2425,7 @@ extension NodeAppModel {
GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)")
let problem: GatewayConnectionProblem? = await MainActor.run {
let nextProblem = GatewayConnectionProblemMapper.map(error: error)
guard !self.isAppleReviewDemoModeEnabled else { return nil }
guard !self.isLocalGatewayFixtureEnabled else { return nil }
if let nextProblem {
if nextProblem.needsPairingApproval || nextProblem.pauseReconnect {
self.applyOperatorGatewayConnectionProblem(nextProblem)
@@ -2457,7 +2491,7 @@ extension NodeAppModel {
continue
}
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
guard !self.isLocalGatewayFixtureEnabled else { return }
self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
@@ -2485,7 +2519,7 @@ extension NodeAppModel {
onConnected: { [weak self] in
guard let self else { return }
let shouldUseConnection = await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return false }
guard !self.isLocalGatewayFixtureEnabled else { return false }
self.clearGatewayConnectionProblem()
self.gatewayStatusText = "Connected"
self.gatewayServerName = url.host ?? "gateway"
@@ -2544,7 +2578,7 @@ extension NodeAppModel {
onDisconnected: { [weak self] reason in
guard let self else { return }
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
guard !self.isLocalGatewayFixtureEnabled else { return }
if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason),
let lastGatewayProblem = self.lastGatewayProblem
{
@@ -2594,7 +2628,7 @@ extension NodeAppModel {
let nextProblem = GatewayConnectionProblemMapper.map(
error: error,
preserving: self.lastGatewayProblem)
guard !self.isAppleReviewDemoModeEnabled else { return nil }
guard !self.isLocalGatewayFixtureEnabled else { return nil }
if let nextProblem {
self.applyGatewayConnectionProblem(nextProblem)
} else {
@@ -2635,7 +2669,7 @@ extension NodeAppModel {
}
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
guard !self.isLocalGatewayFixtureEnabled else { return }
self.lastGatewayProblem = nil
self.gatewayStatusText = "Offline"
LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped")
@@ -2785,6 +2819,7 @@ extension NodeAppModel {
extension NodeAppModel {
func enterAppleReviewDemoMode() {
self.isAppleReviewDemoModeEnabled = true
self.isScreenshotFixtureModeEnabled = false
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
@@ -2825,6 +2860,47 @@ extension NodeAppModel {
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
}
func enterScreenshotFixtureMode() {
self.isAppleReviewDemoModeEnabled = false
self.isScreenshotFixtureModeEnabled = true
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.operatorGatewayProblem = nil
self.nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
self.gatewayHealthMonitor.stop()
LiveActivityManager.shared.endActivity(reason: "screenshot_fixture")
Task {
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
}
self.gatewayStatusText = "Connected"
self.nodeStatusText = "Connected"
self.gatewayServerName = ScreenshotFixtureMode.gatewayName
self.gatewayRemoteAddress = ScreenshotFixtureMode.gatewayAddress
self.connectedGatewayID = ScreenshotFixtureMode.gatewayID
self.activeGatewayConnectConfig = nil
self.gatewayConnected = true
self.setOperatorConnected(true)
self.hasOperatorAdminScope = true
self.mainSessionBaseKey = "main"
self.selectedAgentId = nil
self.gatewayDefaultAgentId = "main"
self.gatewayAgents = ScreenshotFixtureMode.agents
self.focusedChatSessionKey = nil
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.talkMode.enterScreenshotFixtureMode()
self.homeCanvasRevision &+= 1
}
}
extension NodeAppModel {

View File

@@ -3,7 +3,6 @@
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
</dict>
</plist>

View File

@@ -22,9 +22,22 @@ enum OpenClawAppModelRegistry {
@MainActor
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push")
private let backgroundWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "BackgroundWake")
private static let wakeRefreshTaskIdentifier = "ai.openclaw.ios.bgrefresh"
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "Push")
private let backgroundWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "BackgroundWake")
private static var wakeRefreshTaskIdentifier: String {
"\(appBundleIdentifier).bgrefresh"
}
private static var appBundleIdentifier: String {
guard let bundleId = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleId.isEmpty
else {
return "ai.openclawfoundation.app"
}
return bundleId
}
private var backgroundWakeTask: Task<Bool, Never>?
private var pendingAPNsDeviceToken: Data?
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
@@ -92,6 +105,10 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
func _test_resolvedAppModel() -> NodeAppModel? {
self.resolvedAppModel()
}
func _test_wakeRefreshTaskIdentifier() -> String {
Self.wakeRefreshTaskIdentifier
}
#endif
func application(
@@ -611,9 +628,21 @@ struct OpenClawApp: App {
Self.installUncaughtExceptionLogger()
GatewaySettingsStore.bootstrapPersistence()
let appModel = NodeAppModel()
#if DEBUG
if Self.screenshotModeEnabled {
UIView.setAnimationsEnabled(false)
UserDefaults.standard.set(true, forKey: "gateway.onboardingComplete")
UserDefaults.standard.set(true, forKey: "gateway.hasConnectedOnce")
UserDefaults.standard.set(true, forKey: "onboarding.quickSetupDismissed")
appModel.enterScreenshotFixtureMode()
}
#endif
OpenClawAppModelRegistry.appModel = appModel
_appModel = State(initialValue: appModel)
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
_gatewayController = State(
initialValue: GatewayConnectionController(
appModel: appModel,
startDiscovery: !Self.screenshotModeEnabled))
}
var body: some Scene {
@@ -649,6 +678,14 @@ struct OpenClawApp: App {
?? .system
}
private static var screenshotModeEnabled: Bool {
#if DEBUG
ProcessInfo.processInfo.arguments.contains("--openclaw-screenshot-mode")
#else
false
#endif
}
@MainActor
private func applyAppearancePreference() {
let style = self.appearancePreference.userInterfaceStyle

View File

@@ -13,7 +13,7 @@ private struct StoredPushRelayRegistrationState: Codable {
}
enum PushRelayRegistrationStore {
private static let service = "ai.openclaw.pushrelay"
private static let service = "ai.openclawfoundation.app.pushrelay"
private static let registrationStateAccount = "registration-state"
private static let appAttestKeyIDAccount = "app-attest-key-id"
private static let appAttestedKeyIDAccount = "app-attested-key-id"

View File

@@ -56,7 +56,7 @@ final class ScreenRecordService: @unchecked Sendable {
outPath: outPath)
let state = CaptureState()
let recordQueue = DispatchQueue(label: "ai.openclaw.screenrecord")
let recordQueue = DispatchQueue(label: "ai.openclawfoundation.app.screenrecord")
try await self.startCapture(state: state, config: config, recordQueue: recordQueue)
try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000)

View File

@@ -26,7 +26,7 @@ private func sendReachableWatchMessage(_ payload: [String: Any], with session: W
}
final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
private nonisolated static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
private nonisolated static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "watch.messaging")
private let session: WCSession?
private let callbacksLock = NSLock()

View File

@@ -121,13 +121,18 @@ struct PrivacyAccessSectionView: View {
switch self.contactsStatus {
case .notDetermined:
Task {
_ = await PermissionRequestBridge.awaitRequest { completion in
let granted = await PermissionRequestBridge.awaitRequest { completion in
let store = CNContactStore()
store.requestAccess(for: .contacts) { granted, _ in
completion(granted)
}
}
await MainActor.run { self.refreshAll() }
await MainActor.run {
self.refreshAll()
if granted {
self.contactsStatus = .authorized
}
}
}
case .denied, .restricted:
self.openSettings()
@@ -164,8 +169,13 @@ struct PrivacyAccessSectionView: View {
switch self.calendarStatus {
case .notDetermined:
Task {
_ = await self.requestCalendarWriteOnly()
await MainActor.run { self.refreshAll() }
let granted = await self.requestCalendarWriteOnly()
await MainActor.run {
self.refreshAll()
if granted {
self.calendarStatus = .writeOnly
}
}
}
case .denied, .restricted:
self.openSettings()
@@ -206,8 +216,13 @@ struct PrivacyAccessSectionView: View {
switch self.calendarStatus {
case .notDetermined, .writeOnly:
Task {
_ = await self.requestCalendarFull()
await MainActor.run { self.refreshAll() }
let granted = await self.requestCalendarFull()
await MainActor.run {
self.refreshAll()
if granted {
self.calendarStatus = .fullAccess
}
}
}
case .denied, .restricted:
self.openSettings()
@@ -248,8 +263,13 @@ struct PrivacyAccessSectionView: View {
switch self.remindersStatus {
case .notDetermined, .writeOnly:
Task {
_ = await self.requestRemindersFull()
await MainActor.run { self.refreshAll() }
let granted = await self.requestRemindersFull()
await MainActor.run {
self.refreshAll()
if granted {
self.remindersStatus = .fullAccess
}
}
}
case .denied, .restricted:
self.openSettings()

View File

@@ -121,7 +121,7 @@ final class RealtimeTalkRelaySession {
private let gateway: GatewayNodeSession
private let options: Options
private let pcmPlayer: PCMStreamingAudioPlaying
private let logger = Logger(subsystem: "ai.openclaw", category: "RealtimeTalkRelay")
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "RealtimeTalkRelay")
private let onStatus: (String) -> Void
private let onIssue: (TalkRuntimeIssue) -> Void
private let onSpeakingChanged: (Bool) -> Void

View File

@@ -170,7 +170,7 @@ final class TalkModeManager: NSObject {
private var incrementalSpeechPrefetch: IncrementalSpeechPrefetchState?
private var incrementalSpeechPrefetchMonitorTask: Task<Void, Never>?
private let logger = Logger(subsystem: "ai.openclaw", category: "TalkMode")
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "TalkMode")
private static func nowSeconds() -> TimeInterval {
ProcessInfo.processInfo.systemUptime
@@ -220,6 +220,34 @@ final class TalkModeManager: NSObject {
}
}
func enterScreenshotFixtureMode() {
self.updateGatewayConnected(true)
self.isEnabled = false
self.isListening = false
self.isSpeaking = false
self.isUserSpeechDetected = false
self.statusText = "Ready"
self.gatewayTalkConfigLoaded = true
self.gatewayTalkApiKeyConfigured = true
self.gatewayTalkDefaultModelId = "gpt-realtime-2"
self.gatewayTalkDefaultVoiceId = "marin"
self.gatewayTalkProviderLabel = "OpenAI"
self.gatewayTalkTransportLabel = "Gateway Relay"
self.gatewayTalkUsesRealtime = true
self.gatewayTalkUsesRealtimeRelay = true
self.gatewayTalkRealtimeProviderLabel = "OpenAI"
self.gatewayTalkRealtimeModelId = "gpt-realtime-2"
self.gatewayTalkRealtimeVoiceId = "marin"
self.gatewayTalkVoiceModeTitle = "Realtime Voice"
self.gatewayTalkVoiceModeSubtitle = "Gateway relay ready"
self.gatewayTalkVoiceModeAccessibilityValue = "Realtime Voice, Gateway relay ready"
self.gatewayTalkActiveModeTitle = "Ready"
self.gatewayTalkActiveModeSubtitle = "Listening starts from this phone"
self.gatewayTalkLastIssueText = nil
self.gatewayTalkCurrentFallbackIssue = nil
self.gatewayTalkPermissionState = .ready
}
func setEnabled(_ enabled: Bool) {
self.isEnabled = enabled
if enabled {

View File

@@ -17,7 +17,7 @@ protocol TalkRealtimeWebRTCSessionDelegate: AnyObject {
@MainActor
final class TalkRealtimeWebRTCSession: NSObject {
private static let logger = Logger(subsystem: "ai.openclaw", category: "TalkRealtimeWebRTC")
private static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "TalkRealtimeWebRTC")
private static let consultToolName = "openclaw_agent_consult"
private static let controlToolName = "openclaw_agent_control"
private static let defaultOfferURL = "https://api.openai.com/v1/realtime/calls"

View File

@@ -371,15 +371,18 @@ import UIKit
}
@Test @MainActor func loadLastConnectionReadsSavedValues() {
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
let prior = KeychainStore.loadString(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
defer {
if let prior {
_ = KeychainStore.saveString(prior, service: "ai.openclaw.gateway", account: "lastConnection")
_ = KeychainStore.saveString(
prior,
service: "ai.openclawfoundation.app.gateway",
account: "lastConnection")
} else {
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
}
}
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
GatewaySettingsStore.saveLastGatewayConnectionManual(
host: "gateway.example.com",
@@ -395,15 +398,18 @@ import UIKit
}
@Test @MainActor func loadLastConnectionReturnsNilForInvalidData() {
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
let prior = KeychainStore.loadString(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
defer {
if let prior {
_ = KeychainStore.saveString(prior, service: "ai.openclaw.gateway", account: "lastConnection")
_ = KeychainStore.saveString(
prior,
service: "ai.openclawfoundation.app.gateway",
account: "lastConnection")
} else {
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
}
}
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
// Plant legacy UserDefaults with invalid host/port to exercise migration + validation.
withUserDefaults([

View File

@@ -7,8 +7,8 @@ private struct KeychainEntry: Hashable {
let account: String
}
private let gatewayService = "ai.openclaw.gateway"
private let nodeService = "ai.openclaw.node"
private let gatewayService = "ai.openclawfoundation.app.gateway"
private let nodeService = "ai.openclawfoundation.app.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")

View File

@@ -4,7 +4,7 @@ import Testing
@Suite struct KeychainStoreTests {
@Test func saveLoadUpdateDeleteRoundTrip() {
let service = "ai.openclaw.tests.\(UUID().uuidString)"
let service = "ai.openclawfoundation.app.tests.\(UUID().uuidString)"
let account = "value"
#expect(KeychainStore.delete(service: service, account: account))

View File

@@ -1,8 +1,9 @@
import Foundation
import Testing
@testable import OpenClaw
@Suite(.serialized) struct OpenClawAppDelegateTests {
@Test @MainActor func resolvesRegistryModelBeforeViewTaskAssignsDelegateModel() {
@Test @MainActor func `resolves registry model before view task assigns delegate model`() {
let registryModel = NodeAppModel()
OpenClawAppModelRegistry.appModel = registryModel
defer { OpenClawAppModelRegistry.appModel = nil }
@@ -12,7 +13,7 @@ import Testing
#expect(delegate._test_resolvedAppModel() === registryModel)
}
@Test @MainActor func prefersExplicitDelegateModelOverRegistryFallback() {
@Test @MainActor func `prefers explicit delegate model over registry fallback`() {
let registryModel = NodeAppModel()
let explicitModel = NodeAppModel()
OpenClawAppModelRegistry.appModel = registryModel
@@ -23,4 +24,11 @@ import Testing
#expect(delegate._test_resolvedAppModel() === explicitModel)
}
@Test @MainActor func `derives background refresh task identifier from app bundle identifier`() {
let delegate = OpenClawAppDelegate()
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "ai.openclawfoundation.app.tests"
#expect(delegate._test_wakeRefreshTaskIdentifier() == "\(bundleIdentifier).bgrefresh")
}
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>OpenClawUITests</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
</dict>
</plist>

View File

@@ -0,0 +1,58 @@
import XCTest
@MainActor
final class OpenClawSnapshotUITests: XCTestCase {
private struct ScreenshotTarget {
let initialTab: String
let initialDestination: String
let name: String
}
private static let screenshotTargets = [
ScreenshotTarget(initialTab: "control", initialDestination: "overview", name: "01-control-connected"),
ScreenshotTarget(initialTab: "chat", initialDestination: "chat", name: "02-chat-connected"),
ScreenshotTarget(initialTab: "talk", initialDestination: "talk", name: "03-talk-connected"),
ScreenshotTarget(initialTab: "agent", initialDestination: "agents", name: "04-agent-connected"),
ScreenshotTarget(initialTab: "settings", initialDestination: "settings", name: "05-settings-connected"),
]
private var app: XCUIApplication?
override func setUpWithError() throws {
try super.setUpWithError()
self.continueAfterFailure = false
}
override func tearDownWithError() throws {
self.app?.terminate()
self.app = nil
try super.tearDownWithError()
}
func testConnectedGatewayTabs() {
for target in Self.screenshotTargets {
self.launchApp(for: target)
snapshot(target.name, timeWaitingForIdle: 5)
}
}
private func launchApp(for target: ScreenshotTarget) {
self.app?.terminate()
let app = XCUIApplication()
setupSnapshot(app, waitForAnimations: false)
app.launchArguments += [
"--openclaw-screenshot-mode",
"--openclaw-initial-tab",
target.initialTab,
"--openclaw-initial-destination",
target.initialDestination,
"--openclaw-sidebar-visibility",
"hidden",
]
app.launch()
self.app = app
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 8))
}
}

View File

@@ -0,0 +1,309 @@
//
// SnapshotHelper.swift
// Example
//
// Created by Felix Krause on 10/8/15.
//
// -----------------------------------------------------
// IMPORTANT: When modifying this file, make sure to
// increment the version number at the very
// bottom of the file to notify users about
// the new SnapshotHelper.swift
// -----------------------------------------------------
import Foundation
import XCTest
var deviceLanguage = ""
var locale = ""
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
}
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
if waitForLoadingIndicator {
Snapshot.snapshot(name)
} else {
Snapshot.snapshot(name, timeWaitingForIdle: 0)
}
}
/// - Parameters:
/// - name: The name of the snapshot
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
}
enum SnapshotError: Error, CustomDebugStringConvertible {
case cannotFindSimulatorHomeDirectory
case cannotRunOnPhysicalDevice
var debugDescription: String {
switch self {
case .cannotFindSimulatorHomeDirectory:
return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
case .cannotRunOnPhysicalDevice:
return "Can't use Snapshot on a physical device."
}
}
}
@objcMembers
open class Snapshot: NSObject {
static var app: XCUIApplication?
static var waitForAnimations = true
static var cacheDirectory: URL?
static var screenshotsDirectory: URL? {
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
}
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.app = app
Snapshot.waitForAnimations = waitForAnimations
do {
let cacheDir = try getCacheDirectory()
Snapshot.cacheDirectory = cacheDir
setLanguage(app)
setLocale(app)
setLaunchArguments(app)
} catch let error {
NSLog(error.localizedDescription)
}
}
class func setLanguage(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("language.txt")
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
} catch {
NSLog("Couldn't detect/set language...")
}
}
class func setLocale(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("locale.txt")
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
} catch {
NSLog("Couldn't detect/set locale...")
}
if locale.isEmpty && !deviceLanguage.isEmpty {
locale = Locale(identifier: deviceLanguage).identifier
}
if !locale.isEmpty {
app.launchArguments += ["-AppleLocale", "\"\(locale)\""]
}
}
class func setLaunchArguments(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
do {
let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
let results = matches.map { result -> String in
(launchArguments as NSString).substring(with: result.range)
}
app.launchArguments += results
} catch {
NSLog("Couldn't detect/set launch_arguments...")
}
}
open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
if timeout > 0 {
waitForLoadingIndicatorToDisappear(within: timeout)
}
NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
if Snapshot.waitForAnimations {
sleep(1) // Waiting for the animation to be finished (kind of)
}
#if os(OSX)
guard let app = self.app else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
#else
guard self.app != nil else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
let screenshot = XCUIScreen.main.screenshot()
#if os(iOS)
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
#else
let image = screenshot.image
#endif
guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
do {
// The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
let range = NSRange(location: 0, length: simulator.count)
simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
#if swift(<5.0)
UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
#else
try image.pngData()?.write(to: path, options: .atomic)
#endif
} catch let error {
NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
NSLog(error.localizedDescription)
}
#endif
}
class func fixLandscapeOrientation(image: UIImage) -> UIImage {
#if os(watchOS)
return image
#else
if #available(iOS 10.0, *) {
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
return renderer.image { context in
image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
}
} else {
return image
}
#endif
}
class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
#if os(tvOS)
return
#endif
guard let app = self.app else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
_ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
}
class func getCacheDirectory() throws -> URL {
let cachePath = "Library/Caches/tools.fastlane"
// on OSX config is stored in /Users/<username>/Library
// and on iOS/tvOS/WatchOS it's in simulator's home dir
#if os(OSX)
let homeDir = URL(fileURLWithPath: NSHomeDirectory())
return homeDir.appendingPathComponent(cachePath)
#elseif arch(i386) || arch(x86_64) || arch(arm64)
guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
throw SnapshotError.cannotFindSimulatorHomeDirectory
}
let homeDir = URL(fileURLWithPath: simulatorHostHome)
return homeDir.appendingPathComponent(cachePath)
#else
throw SnapshotError.cannotRunOnPhysicalDevice
#endif
}
}
private extension XCUIElementAttributes {
var isNetworkLoadingIndicator: Bool {
if hasAllowListedIdentifier { return false }
let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
}
var hasAllowListedIdentifier: Bool {
let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
return allowListedIdentifiers.contains(identifier)
}
func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
if elementType == .statusBar { return true }
guard frame.origin == .zero else { return false }
let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
}
}
private extension XCUIElementQuery {
var networkLoadingIndicators: XCUIElementQuery {
let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
return element.isNetworkLoadingIndicator
}
return self.containing(isNetworkLoadingIndicator)
}
var deviceStatusBars: XCUIElementQuery {
guard let app = Snapshot.app else {
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
}
let deviceWidth = app.windows.firstMatch.frame.width
let isStatusBar = NSPredicate { (evaluatedObject, _) in
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
return element.isStatusBar(deviceWidth)
}
return self.containing(isStatusBar)
}
}
private extension CGFloat {
func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
return numberA...numberB ~= self
}
}
// Please don't remove the lines below
// They are used to detect outdated configuration files
// SnapshotHelperVersion [1.27]

View File

@@ -64,7 +64,7 @@ Pinned iOS version `2026.4.10` maps to:
- `apps/ios/fastlane/metadata/en-US/release_notes.txt`
- generated from `apps/ios/CHANGELOG.md`
- `apps/ios/build/Version.xcconfig`
- local gitignored build override generated per build or beta prep
- local gitignored build override generated per build or release prep
## Tooling surfaces
@@ -81,16 +81,21 @@ Pinned iOS version `2026.4.10` maps to:
- `scripts/ios-pin-version.ts`
- explicitly pins iOS to a chosen release version or the current gateway version
### Build and beta flow
### Build and App Store release flow
- `scripts/ios-write-version-xcconfig.sh`
- reads the pinned iOS version
- writes the local numeric build override file in `apps/ios/build/Version.xcconfig`
- `scripts/ios-beta-prepare.sh`
- prepares beta signing and bundle settings against the pinned iOS version
- `scripts/ios-release-prepare.sh`
- prepares App Store distribution signing and bundle settings against the pinned iOS version
- `scripts/ios-release-signing.mjs`
- validates the checked-in App Store signing manifest
- creates or verifies Developer Portal bundle IDs, capabilities, certificates, and profiles through `asc`
- syncs encrypted signing assets with the private shared signing repo
- `apps/ios/fastlane/Fastfile`
- resolves version metadata from the pinned iOS helper
- increments TestFlight build numbers for the pinned short version
- increments App Store Connect build numbers for the pinned short version
- uploads screenshots and release notes before archiving a release build
## Release-note resolution order
@@ -118,7 +123,7 @@ pnpm ios:version:pin -- --version 2026.4.10
1. keep `apps/ios/version.json` pinned to the current TestFlight train version
2. update `apps/ios/CHANGELOG.md` under `## Unreleased` while iterating
3. upload more betas with the usual flow
3. upload more App Store Connect builds with `pnpm ios:release:upload`
4. let Fastlane increment only `CFBundleVersion`
This keeps the TestFlight version stable while review is in flight.
@@ -139,12 +144,15 @@ pnpm ios:version:pin -- --from-gateway
- `apps/ios/fastlane/metadata/en-US/release_notes.txt`
3. update `apps/ios/CHANGELOG.md` for the new release if needed
4. run `pnpm ios:version:sync` again if the changelog changed
5. submit the first TestFlight build for that newly pinned version
5. upload the first App Store Connect build for that newly pinned version
6. keep iterating only by build number until the release candidate is ready
7. release that reviewed TestFlight build to production
7. manually submit the reviewed build for App Review in App Store Connect
8. release the approved build to production
## Important invariant
Fastlane and Xcode should consume only the pinned iOS version from `apps/ios/version.json`.
Changing `package.json.version` alone must not change the iOS app version until a maintainer explicitly runs the pin step.
App Review submission must remain manual. Automation may create/update the editable App Store version, upload screenshots, upload release notes, and upload builds, but it should not submit a build for review.

View File

@@ -3,10 +3,17 @@ import SwiftUI
@main
struct OpenClawWatchApp: App {
@Environment(\.scenePhase) private var scenePhase
@State private var inboxStore = WatchInboxStore()
@State private var inboxStore = WatchInboxStore(
requestNotificationAuthorization: !OpenClawWatchApp.isScreenshotMode)
@State private var receiver: WatchConnectivityReceiver?
@State private var execApprovalRefreshTask: Task<Void, Never>?
private static let screenshotModeDefaultsKey = "openclaw.watch.screenshotMode"
private static let isScreenshotMode = ProcessInfo.processInfo.arguments.contains(
"--openclaw-watch-screenshot-mode")
|| ProcessInfo.processInfo.environment["OPENCLAW_WATCH_SCREENSHOT_MODE"] == "1"
|| UserDefaults.standard.bool(forKey: OpenClawWatchApp.screenshotModeDefaultsKey)
var body: some Scene {
WindowGroup {
WatchInboxView(
@@ -37,6 +44,10 @@ struct OpenClawWatchApp: App {
self.refreshExecApprovalReview(force: true)
})
.task {
if OpenClawWatchApp.isScreenshotMode {
self.inboxStore.configureScreenshotFixture()
return
}
if self.receiver == nil {
let receiver = WatchConnectivityReceiver(store: self.inboxStore)
receiver.activate()
@@ -78,3 +89,32 @@ struct OpenClawWatchApp: App {
}
}
}
@MainActor
extension WatchInboxStore {
fileprivate func configureScreenshotFixture() {
self.consume(
execApprovalSnapshot: WatchExecApprovalSnapshotMessage(
approvals: [],
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
snapshotId: nil),
transport: "screenshot")
self.consume(
message: WatchNotifyMessage(
id: "watch-screenshot-quick-reply",
title: "Molty request",
body: "Molty Gateway checklist ready.",
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
promptId: "watch-screenshot-prompt",
sessionKey: "watch-screenshot-session",
kind: "release-checklist",
details: nil,
expiresAtMs: nil,
risk: "medium",
actions: [
WatchPromptAction(id: "approve", label: "Approve", style: nil),
WatchPromptAction(id: "later", label: "Later", style: "cancel"),
]),
transport: "screenshot")
}
}

View File

@@ -170,12 +170,17 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
private var hasCompletedExecApprovalSnapshotRefreshInSession = false
private var lastDeliveryKey: String?
init(defaults: UserDefaults = .standard) {
init(
defaults: UserDefaults = .standard,
requestNotificationAuthorization: Bool = true)
{
self.defaults = defaults
self.restorePersistedState()
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
Task {
await self.ensureNotificationAuthorization()
if requestNotificationAuthorization {
Task {
await self.ensureNotificationAuthorization()
}
}
}

View File

@@ -19,3 +19,6 @@
# Deliver toggles (off by default)
# DELIVER_METADATA=1
# DELIVER_SCREENSHOTS=1
# Screenshot generation
# OPENCLAW_SNAPSHOT_DEVICES=iPhone 16 Pro Max,iPad Pro 13-inch (M4)

View File

@@ -1,4 +1,4 @@
app_identifier("ai.openclaw.client")
app_identifier("ai.openclawfoundation.app")
# Auth is expected via App Store Connect API key.
# Provide either:

View File

@@ -1,10 +1,23 @@
require "shellwords"
require "open3"
require "json"
require "fileutils"
require "tmpdir"
require "tempfile"
require "cgi"
default_platform(:ios)
BETA_APP_IDENTIFIER = "ai.openclaw.client"
APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app"
DEFAULT_SNAPSHOT_DEVICES = ["iPhone 16 Pro Max", "iPad Pro 13-inch (M4)"].freeze
DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)"
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode"
WATCH_SNAPSHOT_STATUS_BAR_TIME = "09:41"
SNAPSHOT_STATUS_BAR_ARGUMENTS = "--time 09:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100".freeze
REQUIRED_SCREENSHOT_FAMILIES = {
"iPhone" => /iPhone/,
"13-inch iPad" => /iPad (Air|Pro) 13-inch/
}.freeze
def load_env_file(path)
return unless File.exist?(path)
@@ -33,10 +46,294 @@ def screenshot_upload_requested?
ENV["DELIVER_SCREENSHOTS"] == "1"
end
def release_notes_upload_requested?
ENV["DELIVER_RELEASE_NOTES"] == "1"
end
def screenshot_paths
Dir[File.join(__dir__, "screenshots", "**", "*.png")]
end
def validate_required_screenshots!(paths)
missing_families = REQUIRED_SCREENSHOT_FAMILIES.filter_map do |name, pattern|
name unless paths.any? { |path| File.basename(path).match?(pattern) }
end
return if missing_families.empty?
UI.user_error!("DELIVER_SCREENSHOTS=1 but screenshots are missing for: #{missing_families.join(', ')}.")
end
def snapshot_devices
raw = ENV["OPENCLAW_SNAPSHOT_DEVICES"].to_s.strip
return DEFAULT_SNAPSHOT_DEVICES if raw.empty?
raw.split(",").map(&:strip).reject(&:empty?)
end
def watch_snapshot_device
raw = ENV["OPENCLAW_WATCH_SNAPSHOT_DEVICE"].to_s.strip
raw.empty? ? DEFAULT_WATCH_SNAPSHOT_DEVICE : raw
end
def available_simulator_devices
stdout, stderr, status = Open3.capture3("xcrun", "simctl", "list", "devices", "available", "--json")
unless status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Failed to list simulator devices: #{detail}")
end
JSON.parse(stdout).fetch("devices").values.flatten
rescue JSON::ParserError => e
UI.user_error!("Invalid JSON from simctl device list: #{e.message}")
end
def resolve_simulator_device(name)
devices = available_simulator_devices
exact = devices.find { |device| device["name"] == name }
return exact if exact
watch_devices = devices.select { |device| device["name"].to_s.include?("Apple Watch") }
fallback = watch_devices.find { |device| device["name"].to_s.include?("Ultra") } || watch_devices.first
UI.user_error!("No available Apple Watch simulators found.") unless fallback
UI.important("Apple Watch simulator '#{name}' was not found; using '#{fallback.fetch("name")}'.")
fallback
end
def bundle_identifier_for_product(product_path)
info_plist_path = File.join(product_path, "Info.plist")
UI.user_error!("Expected Info.plist at #{info_plist_path}.") unless File.exist?(info_plist_path)
stdout, stderr, status = Open3.capture3(
"/usr/libexec/PlistBuddy",
"-c",
"Print:CFBundleIdentifier",
info_plist_path
)
unless status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Failed to read bundle identifier from #{info_plist_path}: #{detail}")
end
bundle_identifier = stdout.to_s.strip
UI.user_error!("Missing bundle identifier in #{info_plist_path}.") if bundle_identifier.empty?
bundle_identifier
end
def write_watch_screenshot_mode_defaults(udid, bundle_identifiers)
bundle_identifiers.each do |bundle_identifier|
sh(
shell_join([
"xcrun",
"simctl",
"spawn",
udid,
"defaults",
"write",
bundle_identifier,
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY,
"-bool",
"YES",
])
)
end
end
def clear_watch_screenshot_mode_defaults(udid, bundle_identifiers)
bundle_identifiers.each do |bundle_identifier|
sh(
"#{shell_join([
"xcrun",
"simctl",
"spawn",
udid,
"defaults",
"delete",
bundle_identifier,
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY,
])} >/dev/null 2>&1 || true"
)
end
end
def status_bar_unsupported?(detail)
detail.include?("Status bar overrides not supported on this platform") ||
detail.include?("Operation not supported")
end
def set_watch_status_bar_override(udid)
stdout, stderr, status = Open3.capture3(
"xcrun",
"simctl",
"status_bar",
udid,
"override",
"--time",
WATCH_SNAPSHOT_STATUS_BAR_TIME
)
return true if status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
if status_bar_unsupported?(detail)
UI.important("watchOS simulator status bar overrides are not supported; Watch screenshot clock will use simulator time.")
return false
end
UI.user_error!("Failed to override Watch simulator status bar: #{detail}")
end
def clear_watch_status_bar_override(udid)
stdout, stderr, status = Open3.capture3("xcrun", "simctl", "status_bar", udid, "clear")
return if status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Failed to clear Watch simulator status bar override: #{detail}") unless status_bar_unsupported?(detail)
end
def normalize_watch_screenshot_status_bar(path)
script = <<~SWIFT
import AppKit
import Foundation
let path = CommandLine.arguments[1]
let timeText = CommandLine.arguments[2]
guard let source = NSImage(contentsOfFile: path),
let cgImage = source.cgImage(forProposedRect: nil, context: nil, hints: nil)
else {
fputs("Failed to load screenshot at \\(path)\\n", stderr)
exit(2)
}
let width = CGFloat(cgImage.width)
let height = CGFloat(cgImage.height)
guard let bitmap = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(width),
pixelsHigh: Int(height),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0),
let graphicsContext = NSGraphicsContext(bitmapImageRep: bitmap)
else {
fputs("Failed to create normalized screenshot bitmap at \\(path)\\n", stderr)
exit(3)
}
bitmap.size = NSSize(width: width, height: height)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = graphicsContext
source.draw(
in: NSRect(x: 0, y: 0, width: width, height: height),
from: NSRect(x: 0, y: 0, width: width, height: height),
operation: .copy,
fraction: 1.0)
NSColor.black.setFill()
NSBezierPath(rect: NSRect(x: width - 146, y: height - 92, width: 124, height: 70)).fill()
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .right
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedDigitSystemFont(ofSize: 34, weight: .semibold),
.foregroundColor: NSColor.white,
.paragraphStyle: paragraphStyle,
]
timeText.draw(
in: NSRect(x: width - 134, y: height - 82, width: 102, height: 44),
withAttributes: attributes)
NSGraphicsContext.restoreGraphicsState()
guard let png = bitmap.representation(using: .png, properties: [:])
else {
fputs("Failed to encode normalized screenshot at \\(path)\\n", stderr)
exit(4)
}
try png.write(to: URL(fileURLWithPath: path))
SWIFT
Tempfile.create(["openclaw-watch-status-bar", ".swift"]) do |file|
file.write(script)
file.flush
sh(shell_join(["xcrun", "swift", file.path, path, "9:41"]))
end
end
def capture_watch_screenshot
device = resolve_simulator_device(watch_snapshot_device)
device_name = device.fetch("name")
udid = device.fetch("udid")
output_dir = File.join(ios_root, "fastlane", "screenshots", "en-US")
output_path = File.join(output_dir, "#{device_name}-01-quick-reply.png")
derived_data_path = File.join(ios_root, "build", "WatchScreenshotDerivedData")
app_path = File.join(derived_data_path, "Build", "Products", "Debug-watchsimulator", "OpenClawWatchApp.app")
FileUtils.mkdir_p(output_dir)
Dir[File.join(output_dir, "Apple Watch*-*.png")].each { |path| FileUtils.rm_f(path) }
FileUtils.rm_rf(derived_data_path)
sh(
xcodebuild_shell_join([
"xcodebuild",
"-project",
File.join(ios_root, "OpenClaw.xcodeproj"),
"-scheme",
"OpenClawWatchApp",
"-configuration",
"Debug",
"-destination",
"platform=watchOS Simulator,id=#{udid}",
"-derivedDataPath",
derived_data_path,
"build",
])
)
UI.user_error!("Watch screenshot build did not produce #{app_path}.") unless File.exist?(app_path)
extension_path = File.join(app_path, "PlugIns", "OpenClawWatchExtension.appex")
watch_app_identifier = bundle_identifier_for_product(app_path)
watch_extension_identifier = bundle_identifier_for_product(extension_path)
screenshot_mode_bundle_identifiers = [watch_app_identifier, watch_extension_identifier]
sh("#{shell_join(["xcrun", "simctl", "boot", udid])} >/dev/null 2>&1 || true")
sh(shell_join(["xcrun", "simctl", "bootstatus", udid, "-b"]))
sh("#{shell_join(["xcrun", "simctl", "uninstall", udid, watch_app_identifier])} >/dev/null 2>&1 || true")
status_bar_overridden = false
begin
sh(shell_join(["xcrun", "simctl", "install", udid, app_path]))
write_watch_screenshot_mode_defaults(udid, screenshot_mode_bundle_identifiers)
status_bar_overridden = set_watch_status_bar_override(udid)
sh(
"SIMCTL_CHILD_OPENCLAW_WATCH_SCREENSHOT_MODE=1 #{shell_join([
"xcrun",
"simctl",
"launch",
udid,
watch_app_identifier,
"--openclaw-watch-screenshot-mode",
])}"
)
sleep(3)
sh(shell_join(["xcrun", "simctl", "io", udid, "screenshot", output_path]))
normalize_watch_screenshot_status_bar(output_path)
ensure
clear_watch_status_bar_override(udid) if status_bar_overridden
clear_watch_screenshot_mode_defaults(udid, screenshot_mode_bundle_identifiers)
end
UI.success("Captured Apple Watch screenshot: #{output_path}")
output_path
end
def maybe_decode_hex_keychain_secret(value)
return value unless env_present?(value)
@@ -103,6 +400,92 @@ def ios_root
File.expand_path("..", __dir__)
end
def preserve_file(path)
existed = File.exist?(path)
contents = existed ? File.binread(path) : nil
yield
ensure
if existed
File.binwrite(path, contents)
else
FileUtils.rm_f(path)
end
end
def preserve_local_signing
preserve_file(File.join(ios_root, ".local-signing.xcconfig")) do
yield
end
end
def app_store_signing_manifest
JSON.parse(File.read(File.join(ios_root, "Config", "AppStoreSigning.json")))
end
def app_store_provisioning_profiles
app_store_signing_manifest.fetch("targets").each_with_object({}) do |target, profiles|
profiles[target.fetch("bundleId")] = target.fetch("profileName")
end
end
def xml_string(value)
CGI.escapeHTML(value.to_s)
end
def write_app_store_export_options(path)
manifest = app_store_signing_manifest
profile_entries = app_store_provisioning_profiles.map do |bundle_id, profile_name|
" <key>#{xml_string(bundle_id)}</key>\n <string>#{xml_string(profile_name)}</string>"
end.join("\n")
File.write(path, <<~PLIST)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>teamID</key>
<string>#{xml_string(manifest.fetch("teamId"))}</string>
<key>provisioningProfiles</key>
<dict>
#{profile_entries}
</dict>
<key>destination</key>
<string>export</string>
<key>stripSwiftSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
PLIST
end
def release_signing_check!
sh(shell_join(["node", File.join(repo_root, "scripts", "ios-release-signing.mjs"), "--mode", "check"]))
end
def release_notes_path
File.join(__dir__, "metadata", "en-US", "release_notes.txt")
end
def release_notes_metadata_path
source = release_notes_path
UI.user_error!("Missing release notes at #{source}. Run `pnpm ios:version:sync`.") unless File.exist?(source)
temp_root = Dir.mktmpdir("openclaw-release-notes")
target_dir = File.join(temp_root, "en-US")
FileUtils.mkdir_p(target_dir)
FileUtils.cp(source, File.join(target_dir, "release_notes.txt"))
temp_root
end
def read_ios_version_metadata
script_path = File.join(repo_root, "scripts", "ios-version.ts")
stdout, stderr, status = Open3.capture3(
@@ -156,69 +539,102 @@ def shell_join(parts)
Shellwords.join(parts.compact)
end
def resolve_beta_build_number(api_key:, short_version:)
explicit = ENV["IOS_BETA_BUILD_NUMBER"]
def xcodebuild_shell_join(parts)
xcode_path = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin"
shell_join(["env", "PATH=#{xcode_path}", *parts])
end
def resolve_release_build_number(api_key:, short_version:)
explicit = ENV["IOS_RELEASE_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}.")
UI.user_error!("Invalid iOS release build number '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/)
UI.message("Using explicit iOS release build number #{explicit}.")
return explicit
end
latest_build = latest_testflight_build_number(
api_key: api_key,
app_identifier: BETA_APP_IDENTIFIER,
app_identifier: APP_STORE_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}).")
UI.message("Resolved iOS release build number #{next_build} for #{short_version} (latest App Store Connect build: #{latest_build}).")
next_build.to_s
end
def beta_build_number_needs_asc_auth?
explicit = ENV["IOS_BETA_BUILD_NUMBER"]
def release_build_number_needs_asc_auth?
explicit = ENV["IOS_RELEASE_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}).")
def prepare_app_store_release!(version:, build_number:)
script_path = File.join(repo_root, "scripts", "ios-release-prepare.sh")
UI.message("Preparing iOS App Store 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)
release_xcconfig = File.join(ios_root, "build", "AppStoreRelease.xcconfig")
UI.user_error!("Missing App Store release xcconfig at #{release_xcconfig}.") unless File.exist?(release_xcconfig)
ENV["XCODE_XCCONFIG_FILE"] = beta_xcconfig
beta_xcconfig
ENV["XCODE_XCCONFIG_FILE"] = release_xcconfig
release_xcconfig
end
def build_beta_release(context)
def build_app_store_release(context)
version = context[:version]
output_directory = File.join("build", "beta")
project_path = File.join(ios_root, "OpenClaw.xcodeproj")
output_directory = File.join(ios_root, "build", "app-store")
archive_path = File.join(output_directory, "OpenClaw-#{version}.xcarchive")
export_options_path = File.join(output_directory, "ExportOptions.plist")
output_name = "OpenClaw-#{version}.ipa"
expected_ipa_path = File.join(output_directory, output_name)
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"
}
FileUtils.mkdir_p(output_directory)
FileUtils.rm_rf(archive_path)
Dir[File.join(output_directory, "*.ipa")].each { |path| FileUtils.rm_f(path) }
write_app_store_export_options(export_options_path)
sh(
xcodebuild_shell_join([
"xcodebuild",
"-project",
project_path,
"-scheme",
"OpenClaw",
"-configuration",
"Release",
"-destination",
"generic/platform=iOS",
"-archivePath",
archive_path,
"clean",
"archive",
])
)
sh(
xcodebuild_shell_join([
"xcodebuild",
"-exportArchive",
"-archivePath",
archive_path,
"-exportPath",
output_directory,
"-exportOptionsPlist",
export_options_path,
])
)
exported_ipas = Dir[File.join(output_directory, "*.ipa")]
UI.user_error!("xcodebuild export did not produce an IPA in #{output_directory}.") if exported_ipas.empty?
UI.user_error!("xcodebuild export produced multiple IPAs in #{output_directory}: #{exported_ipas.join(", ")}") if exported_ipas.length > 1
exported_ipa = exported_ipas.first
FileUtils.mv(exported_ipa, expected_ipa_path) unless exported_ipa == expected_ipa_path
{
archive_path: archive_path,
build_number: context[:build_number],
ipa_path: lane_context[SharedValues::IPA_OUTPUT_PATH],
ipa_path: expected_ipa_path,
short_version: context[:short_version],
version: version
}
@@ -272,40 +688,40 @@ platform :ios do
api_key
end
private_lane :prepare_beta_context do |options|
private_lane :prepare_app_store_context do |options|
require_api_key = options[:require_api_key] == true
needs_api_key = require_api_key || beta_build_number_needs_asc_auth?
needs_api_key = require_api_key || release_build_number_needs_asc_auth?
api_key = needs_api_key ? asc_api_key : nil
sync_ios_versioning!
version_metadata = read_ios_version_metadata
version = version_metadata[:version]
short_version = version_metadata[:short_version]
build_number = resolve_beta_build_number(api_key: api_key, short_version: short_version)
beta_xcconfig = prepare_beta_release!(version: version, build_number: build_number)
build_number = resolve_release_build_number(api_key: api_key, short_version: short_version)
release_xcconfig = prepare_app_store_release!(version: version, build_number: build_number)
{
api_key: api_key,
beta_xcconfig: beta_xcconfig,
build_number: build_number,
release_xcconfig: release_xcconfig,
short_version: short_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]}")
desc "Build an App Store distribution archive locally without uploading"
lane :app_store_archive do
context = prepare_app_store_context(require_api_key: false)
build = build_app_store_release(context)
UI.success("Built iOS App Store 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
context = prepare_beta_context(require_api_key: true)
build = build_beta_release(context)
desc "Build + upload an App Store distribution build to App Store Connect"
lane :app_store do
context = prepare_app_store_context(require_api_key: true)
build = build_app_store_release(context)
upload_to_testflight(
api_key: context[:api_key],
@@ -314,7 +730,33 @@ platform :ios do
uses_non_exempt_encryption: false
)
UI.success("Uploaded iOS beta: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
ensure
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Generate screenshots, update App Store version metadata, then upload an App Store build"
lane :release_upload do
release_signing_check!
preserve_local_signing do
screenshots
end
ENV["DELIVER_SCREENSHOTS"] = "1"
ENV["DELIVER_RELEASE_NOTES"] = "1"
metadata
context = prepare_app_store_context(require_api_key: true)
build = build_app_store_release(context)
upload_to_testflight(
api_key: context[:api_key],
ipa: build[:ipa_path],
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
UI.important("App Review submission remains manual in App Store Connect.")
ensure
ENV.delete("XCODE_XCCONFIG_FILE")
end
@@ -330,8 +772,19 @@ platform :ios do
app_identifier = nil unless env_present?(app_identifier)
app_id = nil unless env_present?(app_id)
if screenshot_upload_requested? && screenshot_paths.empty?
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
if screenshot_upload_requested?
paths = screenshot_paths
if paths.empty?
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
end
validate_required_screenshots!(paths)
end
metadata_path = File.join(__dir__, "metadata")
skip_metadata = ENV["DELIVER_METADATA"] != "1"
if release_notes_upload_requested? && skip_metadata
metadata_path = release_notes_metadata_path
skip_metadata = false
end
deliver_options = {
@@ -341,10 +794,13 @@ platform :ios do
copyright: "2026 OpenClaw",
primary_category: "PRODUCTIVITY",
secondary_category: "UTILITIES",
metadata_path: metadata_path,
skip_screenshots: !screenshot_upload_requested?,
skip_metadata: ENV["DELIVER_METADATA"] != "1",
skip_metadata: skip_metadata,
skip_binary_upload: true,
overwrite_screenshots: screenshot_upload_requested?,
skip_app_version_update: false,
submit_for_review: false,
run_precheck_before_submit: false
}
deliver_options[:app_identifier] = app_identifier if app_identifier
@@ -357,6 +813,40 @@ platform :ios do
deliver(**deliver_options)
end
desc "Generate deterministic iOS screenshots for App Store metadata"
lane :screenshots do
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh")]))
sh(shell_join(["xcodegen", "generate", "--spec", File.join(ios_root, "project.yml"), "--project", ios_root]))
capture_ios_screenshots(
project: File.join(ios_root, "OpenClaw.xcodeproj"),
scheme: "OpenClawUITests",
configuration: "Debug",
devices: snapshot_devices,
languages: ["en-US"],
launch_arguments: ["--openclaw-screenshot-mode"],
output_directory: File.join(ios_root, "fastlane", "screenshots"),
clear_previous_screenshots: true,
reinstall_app: true,
concurrent_simulators: false,
override_status_bar: true,
override_status_bar_arguments: SNAPSHOT_STATUS_BAR_ARGUMENTS,
skip_open_summary: true,
xcargs: "-allowProvisioningUpdates"
)
watch_screenshot
end
desc "Generate deterministic Apple Watch screenshot for App Store metadata"
lane :watch_screenshot do
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh")]))
sh(shell_join(["xcodegen", "generate", "--spec", File.join(ios_root, "project.yml"), "--project", ios_root]))
capture_watch_screenshot
end
desc "Validate App Store Connect API auth"
lane :auth_check do
asc_api_key

View File

@@ -29,12 +29,12 @@ ASC_KEYCHAIN_SERVICE=openclaw-asc-key
ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
```
Important: `apps/ios/fastlane/.env` is only for Fastlane/App Store Connect auth and optional beta-archive settings. It does **not** configure gateway-side direct APNs push delivery for local iOS builds.
Important: `apps/ios/fastlane/.env` is only for Fastlane/App Store Connect auth and optional release-archive settings. It does **not** configure gateway-side direct APNs push delivery for local iOS builds.
Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle):
```bash
ASC_APP_IDENTIFIER=ai.openclaw.client
ASC_APP_IDENTIFIER=ai.openclawfoundation.app
# or
ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID
```
@@ -53,7 +53,26 @@ Code signing variable (optional in `.env`):
IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID
```
Tip: run `scripts/ios-team-id.sh` from repo root to print a Team ID for `.env`. The helper prefers the canonical OpenClaw team (`Y5PE65HELJ`) when present locally; otherwise it prefers the first non-personal team from your Xcode account (then personal team if needed). Fastlane uses this helper automatically if `IOS_DEVELOPMENT_TEAM` is missing.
Tip: run `scripts/ios-team-id.sh --require-canonical` from repo root to verify the canonical OpenClaw iOS team (`FWJYW4S8P8`) is available locally. Fastlane uses the same canonical-only path when `IOS_DEVELOPMENT_TEAM` is missing, and rejects non-canonical teams for release archives.
App Store release signing is manual and profile-pinned. The canonical manifest is `apps/ios/Config/AppStoreSigning.json`.
One-time or rotation setup:
```bash
pnpm ios:release:signing:plan
pnpm ios:release:signing:check
pnpm ios:release:signing:setup
```
Shared encrypted signing storage:
```bash
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull
```
The signing repo is private and encrypted. Store `ASC_MATCH_PASSWORD` in the release-owner vault, not in this product repo. `sync:pull` writes decrypted assets under `apps/ios/build/signing/`; import the distribution certificate/private key into Keychain before archiving.
For local/manual iOS builds that stay on direct APNs, configure the gateway host separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`. Those gateway runtime env vars are separate from Fastlane's `.env`.
@@ -66,28 +85,36 @@ fastlane ios auth_check
ASC auth is only required when:
- uploading to TestFlight
- uploading to App Store Connect
- 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.
If you pass `--build-number` to `pnpm ios:release:archive`, the local archive path does not need ASC auth.
Archive locally without upload:
```bash
pnpm ios:beta:archive
pnpm ios:release:archive
```
Upload to TestFlight:
Generate deterministic App Store screenshots:
```bash
pnpm ios:beta
pnpm ios:screenshots
```
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it captures the tab set on `iPhone 16 Pro Max` and `iPad Pro 13-inch (M4)`; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
Upload to App Store Connect:
```bash
pnpm ios:release:upload
```
Direct Fastlane entry point:
```bash
cd apps/ios
fastlane ios beta
fastlane ios release_upload
```
Maintainer recovery path for a fresh clone on the same Mac:
@@ -115,7 +142,7 @@ fastlane ios auth_check
pnpm ios:version:pin -- --from-gateway
```
5. Set the official/TestFlight relay URL before release:
5. Set the official relay URL before release:
```bash
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
@@ -124,14 +151,14 @@ export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
6. Upload:
```bash
pnpm ios:beta
pnpm ios:release:upload
```
Quick verification after upload:
- confirm `apps/ios/build/beta/OpenClaw-<version>.ipa` exists
- confirm Fastlane prints `Uploaded iOS beta: version=<version> short=<short> build=<build>`
- remember that TestFlight processing can take a few minutes after the upload succeeds
- confirm `apps/ios/build/app-store/OpenClaw-<version>.ipa` exists
- confirm Fastlane prints `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
- remember that App Store Connect/TestFlight processing can take a few minutes after the upload succeeds
Versioning rules:
@@ -141,9 +168,10 @@ Versioning rules:
- `pnpm ios:version:pin -- --from-gateway` promotes the current root gateway version into the pinned iOS release version
- Fastlane uses the pinned iOS version only; changing `package.json.version` alone does not change the iOS app version
- Fastlane sets `CFBundleShortVersionString` to the pinned iOS version, for example `2026.4.10`
- Fastlane resolves `CFBundleVersion` as the next integer TestFlight build number for that short version
- Fastlane resolves `CFBundleVersion` as the next integer App Store Connect build number for that short version
- Run `pnpm ios:version:sync` after changing `apps/ios/version.json` or `apps/ios/CHANGELOG.md`
- `pnpm ios:version:check` validates that checked-in iOS version artifacts are in sync
- 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
- The release flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
- Local App Store signing uses a temporary generated xcconfig with profile names from `apps/ios/Config/AppStoreSigning.json` and leaves local development signing overrides untouched
- `pnpm ios:release:upload` generates and uploads screenshots and release notes before archiving, then uploads the IPA without submitting it for App Review
- See `apps/ios/VERSIONING.md` for the detailed workflow

View File

@@ -0,0 +1,24 @@
project("OpenClaw.xcodeproj")
scheme("OpenClawUITests")
configuration("Debug")
devices([
"iPhone 16 Pro Max",
"iPad Pro 13-inch (M4)",
])
languages([
"en-US",
])
launch_arguments([
"--openclaw-screenshot-mode",
])
output_directory("fastlane/screenshots")
clear_previous_screenshots(true)
reinstall_app(true)
concurrent_simulators(false)
override_status_bar(true)
override_status_bar_arguments("--time 09:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100")
skip_open_summary(true)

View File

@@ -10,6 +10,15 @@ ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
DELIVER_METADATA=1 fastlane ios metadata
```
## Release notes only
`pnpm ios:release:upload` uses this mode before archiving so the editable App Store version has current release notes without rewriting all metadata:
```bash
cd apps/ios
DELIVER_RELEASE_NOTES=1 fastlane ios metadata
```
## Optional: include screenshots
```bash
@@ -39,6 +48,7 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`.
- `release_notes.txt` is generated from `apps/ios/CHANGELOG.md`; after changelog updates, run `pnpm ios:version:sync`.
- Release notes resolve from `## <pinned iOS version>` first, then fall back to `## Unreleased` while a TestFlight train is still in progress.
- When starting a new production release train, pin the iOS version first with `pnpm ios:version:pin -- --from-gateway`.
- The release upload flow uploads release notes and screenshots before the IPA, and never submits for App Review.
- `privacy_url.txt` is set to `https://openclaw.ai/privacy`.
- If app lookup fails in `deliver`, set one of:
- `ASC_APP_IDENTIFIER` (bundle ID)

View File

@@ -1,6 +1,6 @@
name: OpenClaw
options:
bundleIdPrefix: ai.openclaw
bundleIdPrefix: ai.openclawfoundation
deploymentTarget:
iOS: "18.0"
xcodeVersion: "16.0"
@@ -37,6 +37,19 @@ schemes:
test:
targets:
- OpenClawLogicTests
OpenClawUITests:
shared: true
build:
targets:
OpenClawUITests: all
test:
targets:
- OpenClawUITests
OpenClawWatchApp:
shared: true
build:
targets:
OpenClawWatchApp: all
targets:
OpenClaw:
@@ -91,7 +104,7 @@ targets:
swiftlint lint --config "$SRCROOT/.swiftlint.yml" --use-script-input-file-lists
settings:
base:
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
@@ -105,11 +118,13 @@ targets:
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
configs:
Debug:
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: development
OPENCLAW_PUSH_TRANSPORT: direct
OPENCLAW_PUSH_DISTRIBUTION: local
OPENCLAW_PUSH_RELAY_BASE_URL: ""
OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
Release:
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: production
OPENCLAW_PUSH_TRANSPORT: direct
OPENCLAW_PUSH_DISTRIBUTION: local
OPENCLAW_PUSH_RELAY_BASE_URL: ""
@@ -120,7 +135,7 @@ targets:
CFBundleDisplayName: OpenClaw
CFBundleIconName: AppIcon
CFBundleURLTypes:
- CFBundleURLName: ai.openclaw.ios
- CFBundleURLName: ai.openclawfoundation.app
CFBundleURLSchemes:
- openclaw
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
@@ -133,7 +148,7 @@ targets:
- audio
- remote-notification
BGTaskSchedulerPermittedIdentifiers:
- ai.openclaw.ios.bgrefresh
- "$(OPENCLAW_APP_BUNDLE_ID).bgrefresh"
NSLocalNetworkUsageDescription: OpenClaw discovers and connects to your OpenClaw gateway on the local network.
NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true
@@ -176,7 +191,7 @@ targets:
- sdk: AppIntents.framework
settings:
base:
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ENABLE_APPINTENTS_METADATA: NO
@@ -216,10 +231,11 @@ targets:
- sdk: ActivityKit.framework
settings:
base:
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_PROFILE)"
TARGETED_DEVICE_FAMILY: "1,2"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
@@ -251,7 +267,7 @@ targets:
settings:
base:
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ENABLE_APPINTENTS_METADATA: NO
@@ -285,7 +301,7 @@ targets:
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
settings:
base:
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)"
@@ -318,10 +334,10 @@ targets:
- sdk: AppIntents.framework
settings:
base:
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.tests
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID).tests"
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
@@ -346,10 +362,10 @@ targets:
- package: OpenClawKit
settings:
base:
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.logic-tests
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID).logic-tests"
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
SWIFT_EMIT_CONST_VALUE_PROTOCOLS: ""
SWIFT_VERSION: "6.0"
@@ -360,3 +376,32 @@ targets:
CFBundleDisplayName: OpenClawLogicTests
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
OpenClawUITests:
type: bundle.ui-testing
platform: iOS
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
sources:
- path: UITests
dependencies:
- target: OpenClaw
settings:
base:
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID).ui-tests"
SDKROOT: iphoneos
SUPPORTED_PLATFORMS: "iphonesimulator iphoneos"
SUPPORTS_MACCATALYST: NO
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
TEST_TARGET_NAME: OpenClaw
SWIFT_VERSION: "5.0"
info:
path: UITests/Info.plist
properties:
CFBundleDisplayName: OpenClawUITests
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"

View File

@@ -1,2 +1,2 @@
b121079a0912b3051a9fc319a675ef920da9db23364ca0c0ccd3c9f0a05a3a49 plugin-sdk-api-baseline.json
61a0108da670e0f44ba4b861c002eb6eaa5cf63e392d4e7e7de42044cbe7d115 plugin-sdk-api-baseline.jsonl
303312830e2d7275bfe5abcdbdb3b47fd8648067a7b51ca043503a78bb18d275 plugin-sdk-api-baseline.json
71e94e1de9f1b03aa44da55ec63d16146ab279740c44854d5998bc0f04d6ae0d plugin-sdk-api-baseline.jsonl

View File

@@ -157,6 +157,9 @@ If stdout is non-empty, that text is the delivered result. If stdout is empty an
<ParamField path="--model" type="string">
Model override; uses the selected allowed model for the job.
</ParamField>
<ParamField path="--clear-model" type="boolean">
On `cron edit`, removes the per-job model override so the job follows normal cron model-selection precedence (a stored cron-session override if set, otherwise the agent/default model). Cannot be combined with `--model`.
</ParamField>
<ParamField path="--thinking" type="string">
Thinking level override.
</ParamField>
@@ -471,6 +474,7 @@ Model override note:
- If the model is allowed, that exact provider/model reaches the isolated agent run.
- If it is not allowed or cannot be resolved, cron fails the run with an explicit validation error.
- API `cron.update` payload patches can set `model: null` to clear a stored job model override.
- `openclaw cron edit <job-id> --clear-model` clears that override from the CLI (same effect as the `model: null` patch) and cannot be combined with `--model`.
- Configured fallback chains still apply because cron `--model` is a job primary, not a session `/model` override.
- Payload `fallbacks` replaces configured fallbacks for that job; `fallbacks: []` disables fallback and makes the run strict.
- A plain `--model` with no explicit or configured fallback list does not fall through to the agent primary as a silent extra retry target.

View File

@@ -360,7 +360,7 @@ A sweeper runs every **60 seconds** and handles four things:
</Accordion>
<Accordion title="Tasks and sessions">
A task may reference a `childSessionKey` (where work runs) and a `requesterSessionKey` (who started it). Sessions are conversation context; tasks are activity tracking on top of that.
A task may reference a `childSessionKey` (where work runs) and a `requesterSessionKey` (who started it). Its `agentId` identifies the agent executing the work, while the requester and owner fields preserve launch and control context. Sessions are conversation context; tasks are activity tracking on top of that.
</Accordion>
<Accordion title="Tasks and agent runs">
A task's `runId` links to the agent run doing the work. Agent lifecycle events (start, end, error) automatically update the task status - you do not need to manage the lifecycle manually.

View File

@@ -274,6 +274,23 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
},
},
}
```
Group history context defaults to `mention-only`: prior group messages are
included only when they were addressed to the bot, are replies to the bot,
or are the bot's own messages. Set `includeGroupHistoryContext: "recent"` to
include recent room history for trusted groups. Set
`includeGroupHistoryContext: "none"` to send no prior Telegram group history
with the next turn.
```json5
{
channels: {
telegram: {
includeGroupHistoryContext: "recent",
},
},
}
```
Getting the group chat ID:
@@ -403,11 +420,11 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
<Accordion title="Rich message formatting">
Outbound text uses Telegram rich messages.
- Markdown text is sent as rich Markdown without converting it to HTML.
- Explicit HTML payloads are sent as rich HTML.
- Markdown text is rendered through OpenClaw's Markdown IR and sent as Telegram rich HTML.
- Explicit rich HTML payloads preserve supported Bot API 10.1 tags such as headings, tables, details, rich media, and formulas.
- Media captions still use Telegram HTML captions because rich messages do not replace captions.
Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
This keeps model text away from Telegram Rich Markdown sigils, so currency like `$400-600K` is not parsed as math. Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text.

View File

@@ -168,7 +168,7 @@ Use `--due` when you want the manual command to run only if the job is currently
## Models
`cron add|edit --model <ref>` selects an allowed model for the job.
`cron add|edit --model <ref>` selects an allowed model for the job. `cron edit <job-id> --clear-model` removes the per-job model override so the job follows normal cron model-selection precedence (a stored cron-session override if present, otherwise the agent/default model); it cannot be combined with `--model`.
<Warning>
If the model is not allowed or cannot be resolved, cron fails the run with an explicit validation error instead of falling back to the job's agent or default model selection.

View File

@@ -18,9 +18,8 @@ report drift through `doctor --lint`. The final conformance signal is a clean
instead of creating a separate health gate.
Policy currently manages configured channels, MCP servers, model providers,
network SSRF posture, ingress/channel access posture, Gateway exposure posture,
feed catalog source posture, agent workspace posture, data-handling posture,
OpenClaw config secret provider/auth profile posture, and governed tool
network SSRF posture, ingress/channel access posture, Gateway exposure posture, agent workspace posture,
data-handling posture, OpenClaw config secret provider/auth profile posture, and governed tool
declarations. For example, IT or a workspace operator can record that Telegram
is not an approved channel provider, restrict MCP servers and model refs to
approved entries, require private-network fetch/browser access to remain
@@ -115,13 +114,6 @@ data-handling posture, config secret provider/auth profile posture, and tool met
"requireUrlAllowlists": true,
},
},
"feeds": {
"sources": {
"require": ["company-approved"],
"requirePinned": true,
"allowUnsigned": false,
},
},
"agents": {
"workspace": {
"allowedAccess": ["none", "ro"],
@@ -180,9 +172,8 @@ when a concrete rule is present. OpenClaw reads current `channels.*` settings
settings, direct-message session scope, channel DM policy, channel group policy,
channel/group mention gates, Gateway bind/auth/Control UI/Tailscale/remote/HTTP
posture, OpenClaw config agent sandbox workspace access and tool deny posture,
configured Feeds plugin source declarations, OpenClaw config agent sandbox
workspace access and tool deny posture, data-handling config posture, config
secret provider and SecretRef provenance, config auth profile metadata, configured
data-handling config posture, config secret
provider and SecretRef provenance, config auth profile metadata, configured
global/per-agent tool posture, and `TOOLS.md` declarations as evidence, then
reports observed state that does not conform. If a policy denies non-loopback
Gateway binds, omit `gateway.bind` only when you
@@ -369,17 +360,6 @@ Every scope present in `policy.jsonc` must be valid and enforceable.
| `gateway.http.denyEndpoints` | Gateway HTTP API endpoints | Deny endpoint ids such as `chatCompletions` or `responses`. |
| `gateway.http.requireUrlAllowlists` | Gateway HTTP URL-fetch inputs | Set to `true` to require URL allowlists on URL-fetch inputs. |
#### Feed catalog sources
| Policy field | Observed state | Use when |
| ----------------------------- | ------------------------------------------------ | -------------------------------------------------------------- |
| `feeds.sources.require` | `plugins.entries.feeds.config.sources[].id` | Require specific feed source ids to be configured and enabled. |
| `feeds.sources.requirePinned` | Feed source `trust` and `integrity` declarations | Set to `true` to require enabled feed sources to be pinned. |
| `feeds.sources.allowUnsigned` | Feed source `trust` declarations | Set to `false` to reject enabled sources using unsigned trust. |
Feed policy observes only configured source declarations. It does not fetch
feed documents, install entries, or enforce install decisions at runtime.
#### Agent workspace
| Policy field | Observed state | Use when |
@@ -611,16 +591,6 @@ Example JSON output:
"value": false
}
],
"feeds": [
{
"id": "company-approved",
"source": "oc://openclaw.config/plugins/entries/feeds/config/sources/#0",
"enabled": true,
"url": "https://feeds.example.com#0123456789ab",
"trust": "pinned",
"integrityPresent": true
}
],
"gatewayExposure": [
{
"id": "gateway-bind",
@@ -770,9 +740,6 @@ Policy currently verifies:
| `policy/gateway-remote-enabled` | Gateway remote mode is active when policy denies it. |
| `policy/gateway-http-endpoint-enabled` | A Gateway HTTP API endpoint is enabled while denied by policy. |
| `policy/gateway-http-url-fetch-unrestricted` | Gateway HTTP URL-fetch input lacks a required URL allowlist. |
| `policy/feeds-required-source-missing` | A required feed source id is not configured and enabled. |
| `policy/feeds-source-unpinned` | An enabled feed source is not pinned when policy requires pinned feeds. |
| `policy/feeds-source-unsigned` | An enabled feed source uses unsigned trust when policy denies unsigned feeds. |
| `policy/agents-workspace-access-denied` | Agent sandbox mode or workspace access is outside the policy allowlist. |
| `policy/agents-tool-not-denied` | An agent or default config does not deny a tool required by policy. |
| `policy/tools-profile-unapproved` | A configured global or per-agent tool profile is outside the allowlist. |

View File

@@ -166,7 +166,7 @@ surfaces, while Codex native hooks remain a separate lower-level Codex mechanism
- `agent.wait` default: 30s (just the wait). `timeoutMs` param overrides.
- Agent runtime: `agents.defaults.timeoutSeconds` default 172800s (48 hours); enforced in `runEmbeddedAgent` abort timer.
- Cron runtime: isolated agent-turn `timeoutSeconds` is owned by cron. The scheduler starts that timer when execution begins, aborts the underlying run at the configured deadline, then runs bounded cleanup before recording the timeout so a stale child session cannot keep the lane stuck.
- Session liveness diagnostics: with diagnostics enabled, `diagnostics.stuckSessionWarnMs` classifies long `processing` sessions that have no observed reply, tool, status, block, or ACP progress. Active embedded runs, model calls, and tool calls report as `session.long_running`; active work with no recent progress reports as `session.stalled`; `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity. Stale session bookkeeping releases the affected session lane immediately after recovery gates pass; stalled embedded runs are abort-drained only after `diagnostics.stuckSessionAbortMs` (default: at least 5 minutes and 3x the warning threshold) so queued work can resume without cutting off merely slow runs. Recovery emits structured requested/completed outcomes, and diagnostic state is marked idle only if the same processing generation is still current. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
- Session liveness diagnostics: with diagnostics enabled, `diagnostics.stuckSessionWarnMs` classifies long `processing` sessions that have no observed reply, tool, status, block, or ACP progress. Active embedded runs, model calls, and tool calls report as `session.long_running`; owned silent model calls also stay `session.long_running` until `diagnostics.stuckSessionAbortMs` so slow or non-streaming providers are not reported as stalled too early. Active work with no recent progress reports as `session.stalled`; owned model calls switch to `session.stalled` at or after the abort threshold, and ownerless stale model/tool activity is not hidden as long-running. `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity. Stale session bookkeeping releases the affected session lane immediately after recovery gates pass; stalled embedded runs are abort-drained only after `diagnostics.stuckSessionAbortMs` (default: at least 5 minutes and 3x the warning threshold) so queued work can resume without cutting off merely slow runs. Recovery emits structured requested/completed outcomes, and diagnostic state is marked idle only if the same processing generation is still current. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
- Model idle timeout: OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers.<id>.timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but it is still bounded by any lower `agents.defaults.timeoutSeconds` or run-specific timeout because those control the whole agent run. Otherwise OpenClaw uses `agents.defaults.timeoutSeconds` when configured, capped at 120s by default. Cron-triggered runs with no explicit model or agent timeout disable the idle watchdog and rely on the cron outer timeout.
- Provider HTTP request timeout: `models.providers.<id>.timeoutSeconds` applies to that provider's model HTTP fetches, including connect, headers, body, SDK request timeout, total guarded-fetch abort handling, and model stream idle watchdog. Use this for slow local/self-hosted providers such as Ollama before raising the whole agent runtime timeout, and keep the agent/runtime timeout at least as high when the model request needs to run longer.

View File

@@ -56,8 +56,9 @@ the resolved scenarios through `qa suite`. `--surface` and
`--category` filter the selected profile instead of defining separate lanes.
The resulting `qa-evidence.json` includes a profile scorecard summary with
selected-category counts and missing coverage IDs; the individual evidence
entries remain the source of truth for the tests, coverage roles, artifacts,
and results:
entries remain the source of truth for the tests, coverage roles, and results.
Slim evidence omits per-entry `execution` and sets `evidenceMode: "slim"`;
`smoke-ci` defaults to slim, and `--evidence-mode full` restores full entries:
```bash
pnpm openclaw qa run \

View File

@@ -126,7 +126,7 @@ keys.
- If commands seem stuck, enable verbose logs and look for "queued for ...ms" lines to confirm the queue is draining.
- If you need queue depth, enable verbose logs and watch for queue timing lines.
- Codex app-server runs that accept a turn and then stop emitting progress are interrupted by the Codex adapter so the active session lane can release instead of waiting for the outer run timeout.
- When diagnostics are enabled, sessions that remain in `processing` past `diagnostics.stuckSessionWarnMs` with no observed reply, tool, status, block, or ACP progress are classified by current activity. Active work logs as `session.long_running`; active work with no recent progress logs as `session.stalled`; `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity, and only that path can release the affected session lane so queued work drains. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
- When diagnostics are enabled, sessions that remain in `processing` past `diagnostics.stuckSessionWarnMs` with no observed reply, tool, status, block, or ACP progress are classified by current activity. Active work logs as `session.long_running`; owned silent model calls also stay `session.long_running` until `diagnostics.stuckSessionAbortMs` so slow or non-streaming providers are not reported as stalled too early. Active work with no recent progress logs as `session.stalled`; owned model calls switch to `session.stalled` at or after the abort threshold, and ownerless stale model/tool activity is not hidden as long-running. `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity, and only that path can release the affected session lane so queued work drains. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
## Related

View File

@@ -35,9 +35,12 @@ You can use Claude Code CLI **without any config** (the bundled Anthropic plugin
registers a default backend):
```bash
openclaw agent --message "hi" --model claude-cli/claude-sonnet-4-6
openclaw agent --agent main --message "hi" --model claude-cli/claude-sonnet-4-6
```
`main` is the default agent id when no explicit agent list is configured. If
you use multiple agents, replace it with the agent id you want to run.
If your gateway runs under launchd/systemd and PATH is minimal, add just the
command path:

View File

@@ -249,12 +249,18 @@ still be detected.
OpenClaw classifies sessions by the work it can still observe:
- `session.long_running`: active embedded work, model calls, or tool calls are
still making progress.
still making progress. Owned model calls that stay silent past
`diagnostics.stuckSessionWarnMs` also report as long-running before
`diagnostics.stuckSessionAbortMs` so slow or non-streaming model providers do
not look like stalled gateway sessions while they remain abort-observable.
- `session.stalled`: active work exists, but the active run has not reported
recent progress. Stalled embedded runs stay observe-only at first, then
abort-drain after `diagnostics.stuckSessionAbortMs` with no progress so queued
turns behind the lane can resume. When unset, the abort threshold defaults to
the safer extended window of at least 5 minutes and 3x
recent progress. Owned model calls switch from `session.long_running` to
`session.stalled` at or after `diagnostics.stuckSessionAbortMs`; ownerless
stale model/tool activity is not treated as harmless long-running work.
Stalled embedded runs stay observe-only at first, then abort-drain after
`diagnostics.stuckSessionAbortMs` with no progress so queued turns behind the
lane can resume. When unset, the abort threshold defaults to the safer
extended window of at least 5 minutes and 3x
`diagnostics.stuckSessionWarnMs`.
- `session.stuck`: stale session bookkeeping with no active work, or an idle
queued session with stale ownerless model/tool activity. This releases the

View File

@@ -542,7 +542,9 @@ runtime state.
`TaskSummary` includes `id`, `status`, and optional metadata such as `kind`,
`runtime`, `title`, `agentId`, `sessionKey`, `childSessionKey`, `ownerKey`,
`runId`, `taskId`, `flowId`, `parentTaskId`, `sourceId`, timestamps, progress,
terminal summary, and sanitized error text.
terminal summary, and sanitized error text. `agentId` identifies the agent
executing the task; `sessionKey` and `ownerKey` preserve requester and control
context.
### Operator helper methods

View File

@@ -189,6 +189,8 @@ inside every shard.
mixed flow, Vitest, and Playwright scenario selections.
- When dispatched by `pnpm openclaw qa run --qa-profile <profile>`, embeds the
selected taxonomy profile scorecard in the same `qa-evidence.json`.
`smoke-ci` writes slim evidence, which sets `evidenceMode: "slim"` and omits
per-entry `execution`.
- Runs multiple selected scenarios in parallel by default with isolated
gateway workers. `qa-channel` defaults to concurrency 4 (bounded by the
selected scenario count). Use `--concurrency <count>` to tune the worker

View File

@@ -431,7 +431,7 @@ Notes:
- Unrecognized node `platform` / `deviceFamily` metadata uses a conservative default allowlist that excludes `system.run` and `system.which`. If you intentionally need those commands for an unknown platform, add them explicitly via `gateway.nodes.allowCommands`.
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped `--env` values are reduced to an explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `flock`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
- On Windows node hosts in allowlist mode, shell-wrapper runs via `cmd.exe /c` require approval (allowlist entry alone does not auto-allow the wrapper form).
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
- Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`.

View File

@@ -109,7 +109,7 @@ Notes:
- Choosing "Always Allow" in the prompt adds that command to the allowlist.
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`) and then merged with the app's environment.
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped environment overrides are reduced to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `flock`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
## Deep links

View File

@@ -186,6 +186,7 @@ into Windows.
Inside WSL:
```bash
sudo apt-get install -y dbus-x11
sudo loginctl enable-linger "$(whoami)"
openclaw gateway install
```
@@ -193,7 +194,7 @@ openclaw gateway install
In PowerShell as Administrator:
```powershell
schtasks /create /tn "WSL Boot" /tr "wsl.exe -d Ubuntu --exec /bin/true" /sc onstart /ru SYSTEM
schtasks /create /tn "WSL Boot" /tr "wsl.exe -d Ubuntu --exec dbus-launch true" /sc onstart /ru "$env:USERNAME"
```
Replace `Ubuntu` with your distro name from:
@@ -202,6 +203,11 @@ Replace `Ubuntu` with your distro name from:
wsl --list --verbose
```
> **Note:** Two changes from older recipes:
>
> - **`dbus-launch true` instead of `/bin/true`** — On WSL ≥ 2.6.1.0 a regression ([microsoft/WSL #13416](https://github.com/microsoft/WSL/issues/13416)) causes the distro to idle-terminate 1520 seconds after the last client exits, even with linger enabled. `dbus-launch true` keeps a child-of-init process alive as a workaround ([community discussion, microsoft/WSL #9245](https://github.com/microsoft/WSL/discussions/9245)).
> - **`/ru "$env:USERNAME"` instead of `/ru SYSTEM`** — Per-user WSL distros (the default setup) are not visible to the SYSTEM account; the task appears to run but the distro is never started. Running as your own account avoids this. Windows will prompt for your password when the task is created.
After reboot, verify from WSL:
```bash

View File

@@ -132,6 +132,9 @@ Current compatibility records include:
`reply(...)`, and `mediaPath` while callback consumers migrate to the nested
`WebInboundCallbackMessage` `event`, `payload`, `quote`, `group`, and
`platform` contexts
- WhatsApp `WebInboundMessage` top-level admission fields such as `from`,
`conversationId`, `accountId`, `accessControlPassed`, and `chatType` while
callback consumers migrate to the `admission` envelope
- legacy memory-plugin split registration while memory plugins move to
`registerMemoryCapability`
- legacy memory-specific embedding provider registration while embedding
@@ -191,6 +194,23 @@ name its exact nested replacement. Common examples:
Plugins should inspect the `label`, `source`, and `type` before treating its
`payload` as authoritative.
### WhatsApp Inbound Admission Fields
Accepted WhatsApp callback messages now carry `admission`, a public-safe
envelope for the access-control decision that admitted the message. New callback
code should read admission facts from `msg.admission` instead of the older
top-level admission fields.
The top-level fields remain available until **2026-08-30**. The TypeScript
`@deprecated` annotations name each replacement:
- `from` and `conversationId` move to `admission.conversation.id`.
- `accountId` moves to `admission.accountId`.
- `accessControlPassed` is a derived compatibility view of
`admission.ingress.decision === "allow"`; on messages that already carry
`admission`, writing the legacy boolean does not rewrite the ingress graph.
- `chatType` moves to `admission.conversation.kind`.
## Release notes
Release notes should include upcoming plugin deprecations with target dates and

View File

@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
## Core npm package
91 plugins
90 plugins
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
@@ -101,8 +101,6 @@ Each entry lists the package, distribution route, and description.
- **[fal](/plugins/reference/fal)** (`@openclaw/fal-provider`) - included in OpenClaw. Adds fal model provider support to OpenClaw.
- **[feeds](/plugins/reference/feeds)** (`@openclaw/feeds`) - included in OpenClaw. Adds configured catalog feed source validation for skills and plugins.
- **[file-transfer](/plugins/reference/file-transfer)** (`@openclaw/file-transfer`) - included in OpenClaw. Fetch, list, and write files on paired nodes via dedicated node commands. Bypasses bash stdout truncation by using base64 over node.invoke for binaries up to 16 MB.
- **[firecrawl](/plugins/reference/firecrawl)** (`@openclaw/firecrawl-plugin`) - included in OpenClaw. Adds agent-callable tools. Adds web fetch provider support. Adds web search provider support.

View File

@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
pnpm plugins:inventory:gen
```
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 128
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 127
generated plugin reference pages by distribution, package, and description.

View File

@@ -1,107 +0,0 @@
---
summary: "Adds configured catalog feed source validation for skills and plugins."
read_when:
- You are installing, configuring, or auditing the feeds plugin
title: "Feeds plugin"
---
# Feeds plugin
Adds configured catalog feed source validation for skills and plugins.
## Distribution
- Package: `@openclaw/feeds`
- Install route: included in OpenClaw
## Surface
plugin
## Configure feed sources
Feed sources live under the bundled `feeds` plugin config. A source can point at
an `https://` or `file://` feed document and can optionally be pinned by
integrity.
```jsonc
{
"plugins": {
"entries": {
"feeds": {
"enabled": true,
"config": {
"sources": [
{
"id": "company-approved",
"url": "https://feeds.example.com/openclaw/feed.json",
"trust": "pinned",
"integrity": "sha256:...",
},
],
},
},
},
},
}
```
## Discover entries
```bash
openclaw feeds sources
openclaw feeds list --source company-approved
openclaw feeds search calendar --type plugin
```
## Install from a feed
`openclaw feeds install` resolves exactly one feed entry, checks the configured
feed install policy, and then hands off to the existing OpenClaw skill or plugin
install command. The feeds plugin does not introduce a second installer.
```bash
openclaw feeds install calendar-helper --source company-approved --type plugin --dry-run
openclaw feeds install calendar-helper --source company-approved --type plugin
openclaw feeds install calendar-helper --source company-approved --type plugin --force
```
Use `--dry-run` to print the underlying install command without running it. Use
`--force` to forward force behavior to the existing installer.
## Install policy
`installPolicy` controls approval checks for explicit feed-backed installs.
```jsonc
{
"plugins": {
"entries": {
"feeds": {
"enabled": true,
"config": {
"installPolicy": {
"mode": "enforce",
"requireApproval": true,
},
"sources": [
{
"id": "company-approved",
"url": "file:///opt/openclaw/feeds/company.json",
},
],
},
},
},
},
}
```
- `mode: "off"` performs no approval check.
- `mode: "warn"` reports unapproved entries and continues.
- `mode: "enforce"` blocks unapproved entries.
- `requireApproval: true` requires `approval.status: "approved"` on feed entries.
If `requireApproval` is `true` and `mode` is omitted, OpenClaw treats the policy
as enforce. If `mode` is `enforce` and `requireApproval` is omitted, approval is
required.

View File

@@ -89,10 +89,11 @@ reduced to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`,
`NO_COLOR`, `FORCE_COLOR`).
For `allow-always` decisions in allowlist mode, known dispatch wrappers (`env`,
`nice`, `nohup`, `stdbuf`, `timeout`) persist the inner executable path instead
of the wrapper path. Shell multiplexers (`busybox`, `toybox`) are unwrapped for
shell applets (`sh`, `ash`, etc.) the same way. If a wrapper or multiplexer
cannot be safely unwrapped, no allowlist entry is persisted automatically.
`flock`, `nice`, `nohup`, `stdbuf`, `timeout`) persist the inner executable path
instead of the wrapper path. Shell multiplexers (`busybox`, `toybox`) are
unwrapped for shell applets (`sh`, `ash`, etc.) the same way. If a wrapper or
multiplexer cannot be safely unwrapped, no allowlist entry is persisted
automatically.
If you allowlist interpreters like `python3` or `node`, prefer
`tools.exec.strictInlineEval=true` so inline eval still requires an explicit

View File

@@ -231,7 +231,8 @@ plugins.
| `/help` | Show the short help summary |
| `/commands` | Show the generated command catalog |
| `/tools [compact\|verbose]` | Show what the current agent can use right now |
| `/status` | Show execution/runtime status, Gateway and system uptime, plus provider usage/quota |
| `/status` | Show execution/runtime status, Gateway and system uptime, plugin health, plus provider usage/quota |
| `/status plugins` | Show detailed plugin health: load errors, quarantines, channel failures, dependency issues, compatibility notices |
| `/goal [status\|start\|pause\|resume\|complete\|block\|clear] ...` | Manage the current session's durable [goal](/tools/goal) |
| `/diagnostics [note]` | Owner-only support-report flow. Asks for exec approval every time |
| `/crestodian <request>` | Run the Crestodian setup and repair helper from an owner DM |

View File

@@ -48,6 +48,8 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
"user",
"--allowedTools",
"mcp__openclaw__*",
"--disallowedTools",
"ScheduleWakeup,CronCreate",
],
resumeArgs: [
"-p",
@@ -59,6 +61,8 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
"user",
"--allowedTools",
"mcp__openclaw__*",
"--disallowedTools",
"ScheduleWakeup,CronCreate",
"--resume",
"{sessionId}",
],

View File

@@ -748,9 +748,9 @@
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.0.tgz",
"integrity": "sha512-iriNhQ57SYA5Jbdi+41AyPdx6jPPkFO7DODzkOBmqFhgYn/JzX2HxgxYPY18eQAs3CP/AWqtPvkWn8rclRAxdQ==",
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.1.tgz",
"integrity": "sha512-oXf2UgIty8jnwfN4yvL1x79VLhL5uiKjZJbSGXGCIUmHmItTP4eS/UIlWDCeNx3seg+ujfn9vDlPMSrsh7wO+Q==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {

View File

@@ -2390,6 +2390,49 @@ describe("DiscordVoiceManager", () => {
);
});
it("rejects malformed realtime consult tool calls without crashing Discord voice", async () => {
const manager = createManager({
groupPolicy: "open",
voice: {
enabled: true,
mode: "agent-proxy",
realtime: { provider: "openai" },
},
});
await manager.join({ guildId: "g1", channelId: "1001" });
const bridgeParams = lastRealtimeBridgeParams() as
| {
onToolCall?: (
event: {
itemId: string;
callId: string;
name: string;
args: unknown;
},
session: typeof realtimeSessionMock,
) => void;
}
| undefined;
expect(() =>
bridgeParams?.onToolCall?.(
{
itemId: "item-empty-consult",
callId: "call-empty-consult",
name: "openclaw_agent_consult",
args: {},
},
realtimeSessionMock,
),
).not.toThrow();
expect(agentCommandMock).not.toHaveBeenCalled();
expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith("call-empty-consult", {
error: "question required",
});
});
it("does not require speaker context for internal exact-speech consults", async () => {
const manager = createManager({
groupPolicy: "open",

View File

@@ -1124,7 +1124,17 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
session.submitToolResult(callId, { text: exactSpeechText });
return;
}
const consultMessage = buildRealtimeVoiceAgentConsultChatMessage(event.args);
let consultMessage: string;
try {
consultMessage = buildRealtimeVoiceAgentConsultChatMessage(event.args);
} catch (error) {
const message = formatErrorMessage(error);
logger.warn(
`discord voice: realtime consult rejected malformed args call=${callId || "unknown"}: ${message}`,
);
session.submitToolResult(callId, { error: message });
return;
}
logger.info(
`discord voice: realtime consult requested call=${callId || "unknown"} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId} question=${formatRealtimeLogPreview(consultMessage)}`,
);

View File

@@ -1 +0,0 @@
export { registerFeedsDoctorChecks } from "./src/doctor/register.js";

View File

@@ -1,28 +0,0 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerFeedsCli } from "./src/cli.js";
import { registerFeedsDoctorChecks } from "./src/doctor/register.js";
export default definePluginEntry({
id: "feeds",
name: "Feeds",
description: "Adds configured catalog feed source validation for skills and plugins.",
register(api) {
api.registerCli(
async ({ program }) => {
registerFeedsCli(program);
},
{
descriptors: [
{
name: "feeds",
description: "Inspect configured skill and plugin catalog feeds",
hasSubcommands: true,
},
],
},
);
registerFeedsDoctorChecks();
},
});
export { registerFeedsCli } from "./src/cli.js";
export { registerFeedsDoctorChecks } from "./src/doctor/register.js";

View File

@@ -1,68 +0,0 @@
{
"id": "feeds",
"name": "Feeds",
"description": "Adds configured catalog feed source validation for skills and plugins.",
"activation": {
"onStartup": false,
"onCommands": ["doctor", "feeds"]
},
"commandAliases": [{ "name": "feeds", "kind": "cli" }],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable feeds doctor checks."
},
"installPolicy": {
"type": "object",
"additionalProperties": false,
"description": "Optional install-time policy for entries discovered through feeds.",
"properties": {
"mode": {
"type": "string",
"enum": ["off", "warn", "enforce"],
"description": "Install policy mode. Defaults to off."
},
"requireApproval": {
"type": "boolean",
"description": "Require feed entries to declare approval.status=approved before install."
}
}
},
"sources": {
"type": "array",
"description": "Catalog feed sources used for curated skill and plugin discovery.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "url"],
"properties": {
"id": {
"type": "string",
"description": "Stable local feed identifier."
},
"url": {
"type": "string",
"description": "Absolute https:// or file:// feed document URL."
},
"enabled": {
"type": "boolean",
"description": "Enable this feed source. Defaults to true."
},
"trust": {
"type": "string",
"enum": ["unsigned", "pinned"],
"description": "Whether this source is accepted unsigned or pinned by integrity hash."
},
"integrity": {
"type": "string",
"description": "Optional sha256:<hex> hash for pinned feed documents."
}
}
}
}
}
}
}

View File

@@ -1,24 +0,0 @@
{
"name": "@openclaw/feeds",
"version": "2026.5.28",
"private": true,
"description": "OpenClaw feed source configuration and doctor checks",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.5.28"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -1,848 +0,0 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
feedsBuildCommand,
feedsDiffCommand,
feedsInstallCommand,
feedsListCommand,
feedsNoticesCommand,
feedsHashCommand,
feedsSearchCommand,
feedsSourcesCommand,
feedsUpdatesCommand,
feedsValidateCommand,
type FeedsCommandRuntime,
} from "./cli.js";
describe("Feeds CLI", () => {
it("lists configured sources", async () => {
const runtime = createRuntime({ sources: [{ id: "approved", url: "file:///feeds.json" }] });
const exitCode = await feedsSourcesCommand({ json: true }, runtime);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).sources).toEqual([
{ id: "approved", url: "file:///feeds.json", enabled: true },
]);
});
it("lists an empty source set when no sources are configured", async () => {
const runtime = createRuntime({});
const exitCode = await feedsSourcesCommand({ json: true }, runtime);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).sources).toEqual([]);
expect(runtime.stderr).toBe("");
});
it("loads file-backed feed entries", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{ type: "skill", id: "excel-review", version: "1.2.3", name: "Excel Review" },
{ type: "plugin", id: "teams-channel", tags: ["m365", "channel"] },
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsListCommand({ json: true }, runtime);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).entries).toEqual([
expect.objectContaining({
sourceId: "approved",
feedId: "company-approved",
id: "excel-review",
}),
expect.objectContaining({
sourceId: "approved",
feedId: "company-approved",
id: "teams-channel",
}),
]);
});
it("searches across entry metadata", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{ type: "skill", id: "excel-review", tags: ["m365"] },
{ type: "plugin", id: "calendar-helper", tags: ["outlook"] },
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsSearchCommand("outlook", { json: true }, runtime);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).entries).toEqual([
expect.objectContaining({ id: "calendar-helper" }),
]);
});
it("checks pinned feed integrity while loading entries", async () => {
const feed = JSON.stringify({ schemaVersion: 1, id: "company-approved", entries: [] });
const integrity = `sha256:${createHash("sha256").update(feed).digest("hex").toUpperCase()}`;
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json", trust: "pinned", integrity }],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsListCommand({ json: true }, runtime);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).entries).toEqual([]);
});
it("rejects pinned feed sources without integrity", async () => {
const feed = JSON.stringify({ schemaVersion: 1, id: "company-approved", entries: [] });
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json", trust: "pinned" }],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsListCommand({ json: true }, runtime);
expect(exitCode).toBe(2);
expect(runtime.stderr).toContain("Feed source approved requires integrity for pinned trust.");
});
it("formats install hints without installing feed entries", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{
type: "plugin",
id: "calendar-helper",
name: "Calendar Helper",
install: { source: "clawhub", spec: "openclaw-calendar" },
},
{
type: "skill",
id: "excel-review",
install: { source: "clawhub", slug: "excel-review" },
},
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: { "/feeds/company.json": feed },
});
runtime.isTTY = true;
const exitCode = await feedsSearchCommand("calendar", { type: "plugin" }, runtime);
expect(exitCode).toBe(0);
expect(runtime.stdout).toContain("approved\tplugin\tcalendar-helper - Calendar Helper");
expect(runtime.stdout).toContain("Install: openclaw plugins install clawhub:openclaw-calendar");
expect(runtime.stdout).not.toContain("excel-review");
});
it("quotes install hint specs from feed metadata", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{
type: "plugin",
id: "unsafe-helper",
install: { source: "npm", spec: "safe-package && curl example.invalid" },
},
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: { "/feeds/company.json": feed },
});
runtime.isTTY = true;
const exitCode = await feedsSearchCommand("unsafe", { type: "plugin" }, runtime);
expect(exitCode).toBe(0);
expect(runtime.stdout).toContain(
"Install: openclaw plugins install 'safe-package && curl example.invalid'",
);
});
it("filters search results by entry type", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{ type: "plugin", id: "calendar-helper", tags: ["shared"] },
{ type: "skill", id: "calendar-review", tags: ["shared"] },
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsSearchCommand("shared", { type: "plugin", json: true }, runtime);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).entries).toEqual([
expect.objectContaining({
type: "plugin",
id: "calendar-helper",
}),
]);
});
it("rejects unsupported type filters", async () => {
const runtime = createRuntime({ sources: [{ id: "approved", url: "file:///feeds.json" }] });
const exitCode = await feedsListCommand({ type: "tool" }, runtime);
expect(exitCode).toBe(2);
expect(runtime.stderr).toContain("Invalid --type value. Expected skill or plugin.");
});
it("reports available feed updates from an installed inventory", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{
type: "skill",
id: "excel-review",
version: "1.2.0",
approval: { status: "approved" },
},
{
type: "plugin",
id: "calendar-helper",
version: "1.0.0",
},
],
});
const inventory = JSON.stringify({
entries: [
{ type: "skill", id: "excel-review", version: "1.0.0" },
{ type: "plugin", id: "calendar-helper", version: "1.0.0" },
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: {
"/feeds/company.json": feed,
"/installed.json": inventory,
},
});
const exitCode = await feedsUpdatesCommand(
{ installed: "/installed.json", json: true },
runtime,
);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).updates).toEqual([
expect.objectContaining({
type: "skill",
id: "excel-review",
installedVersion: "1.0.0",
availableVersion: "1.2.0",
approved: true,
}),
]);
});
it("filters feed updates to approved entries", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{ type: "skill", id: "approved-skill", version: "2.0.0", approval: { status: "approved" } },
{ type: "skill", id: "draft-skill", version: "2.0.0" },
],
});
const inventory = JSON.stringify({
entries: [
{ type: "skill", id: "approved-skill", version: "1.0.0" },
{ type: "skill", id: "draft-skill", version: "1.0.0" },
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: {
"/feeds/company.json": feed,
"/installed.json": inventory,
},
});
const exitCode = await feedsUpdatesCommand(
{ installed: "/installed.json", approvedOnly: true, json: true },
runtime,
);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).updates.map((entry: { id: string }) => entry.id)).toEqual([
"approved-skill",
]);
});
it("uses semver prerelease ordering for update notices", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{
type: "plugin",
id: "calendar-helper",
version: "1.0.0-beta.1",
approval: { status: "approved" },
install: { source: "clawhub", spec: "openclaw-calendar" },
},
{
type: "plugin",
id: "sheet-helper",
version: "1.0.0",
approval: { status: "approved" },
install: { source: "clawhub", spec: "openclaw-sheet" },
},
{
type: "plugin",
id: "docs-helper",
version: "1.0.0-rc.1",
approval: { status: "approved" },
install: { source: "clawhub", spec: "openclaw-docs" },
},
],
});
const inventory = JSON.stringify({
entries: [
{ type: "plugin", id: "calendar-helper", version: "1.0.0" },
{ type: "plugin", id: "sheet-helper", version: "1.0.0-beta.1" },
{ type: "plugin", id: "docs-helper", version: "1.0.0-beta.9" },
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: {
"/feeds/company.json": feed,
"/installed.json": inventory,
},
});
const exitCode = await feedsUpdatesCommand(
{ installed: "/installed.json", approvedOnly: true, json: true },
runtime,
);
expect(exitCode).toBe(0);
const updates = JSON.parse(runtime.stdout).updates;
expect(updates).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "sheet-helper",
installedVersion: "1.0.0-beta.1",
availableVersion: "1.0.0",
}),
expect.objectContaining({
id: "docs-helper",
installedVersion: "1.0.0-beta.9",
availableVersion: "1.0.0-rc.1",
}),
]),
);
expect(updates).toHaveLength(2);
});
it("reports subscriber update notices", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{
type: "plugin",
id: "calendar-helper",
version: "1.2.0",
approval: { status: "approved" },
install: { source: "clawhub", spec: "openclaw-calendar" },
},
],
});
const inventory = JSON.stringify({
entries: [{ type: "plugin", id: "calendar-helper", version: "1.0.0" }],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: {
"/feeds/company.json": feed,
"/installed.json": inventory,
},
});
const exitCode = await feedsNoticesCommand(
{ installed: "/installed.json", approvedOnly: true, json: true },
runtime,
);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout).notices).toEqual([
expect.objectContaining({
type: "plugin",
id: "calendar-helper",
sourceId: "approved",
installedVersion: "1.0.0",
availableVersion: "1.2.0",
approved: true,
installCommand: "openclaw plugins install clawhub:openclaw-calendar",
}),
]);
});
it("validates a local feed document and prints its integrity", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [{ type: "skill", id: "excel-review" }],
});
const integrity = `sha256:${createHash("sha256").update(feed).digest("hex")}`;
const runtime = createRuntime({
sources: [],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsValidateCommand("/feeds/company.json", { json: true }, runtime);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout)).toEqual({
ok: true,
id: "company-approved",
entries: 1,
integrity,
});
});
it("prints a local feed document integrity hash", async () => {
const feed = JSON.stringify({ schemaVersion: 1, id: "company-approved", entries: [] });
const integrity = `sha256:${createHash("sha256").update(feed).digest("hex")}`;
const runtime = createRuntime({
sources: [],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsHashCommand("/feeds/company.json", {}, runtime);
expect(exitCode).toBe(0);
expect(runtime.stdout).toBe(`${integrity}\n`);
});
it("rejects invalid local feed documents during validation", async () => {
const runtime = createRuntime({
sources: [],
files: { "/feeds/broken.json": JSON.stringify({ schemaVersion: 1, id: "broken" }) },
});
const exitCode = await feedsValidateCommand("/feeds/broken.json", {}, runtime);
expect(exitCode).toBe(2);
expect(runtime.stderr).toContain("entries must be an array");
});
it("builds a curated feed artifact from local inventory rules", async () => {
const inventory = JSON.stringify({
schemaVersion: 1,
id: "upstream",
entries: [
{
type: "skill",
id: "excel-review",
version: "1.0.0",
tags: ["m365", "approved"],
approval: { status: "approved" },
},
{ type: "plugin", id: "draft-plugin", tags: ["m365"] },
{
type: "skill",
id: "blocked-skill",
tags: ["m365", "blocked"],
approval: { status: "approved" },
},
],
});
const rules = JSON.stringify({
includeTypes: ["skill"],
includeTags: ["m365"],
excludeTags: ["blocked"],
requireApproval: true,
});
const runtime = createRuntime({
sources: [],
files: { "/feeds/inventory.json": inventory, "/feeds/rules.json": rules },
});
const exitCode = await feedsBuildCommand(
{
inventory: "/feeds/inventory.json",
rules: "/feeds/rules.json",
out: "/feeds/lobster-approved.json",
id: "lobster-approved",
generatedAt: "2026-05-28T00:00:00.000Z",
json: true,
},
runtime,
);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout)).toEqual(
expect.objectContaining({
ok: true,
id: "lobster-approved",
entries: 1,
out: "/feeds/lobster-approved.json",
}),
);
expect(JSON.parse(runtime.writes["/feeds/lobster-approved.json"])).toEqual({
schemaVersion: 1,
id: "lobster-approved",
generatedAt: "2026-05-28T00:00:00.000Z",
entries: [
expect.objectContaining({
type: "skill",
id: "excel-review",
approval: { status: "approved" },
}),
],
});
});
it("reports feed artifact deltas", async () => {
const previous = JSON.stringify({
schemaVersion: 1,
id: "lobster-approved",
entries: [
{
type: "skill",
id: "excel-review",
version: "1.0.0",
name: "Excel Review",
sha256: "old",
approval: { status: "pending" },
},
{ type: "plugin", id: "removed-plugin" },
],
});
const current = JSON.stringify({
schemaVersion: 1,
id: "lobster-approved",
entries: [
{
type: "skill",
id: "excel-review",
version: "1.1.0",
name: "Excel Review Pro",
sha256: "new",
approval: { status: "approved" },
},
{ type: "skill", id: "new-skill" },
],
});
const runtime = createRuntime({
sources: [],
files: { "/feeds/previous.json": previous, "/feeds/current.json": current },
});
const exitCode = await feedsDiffCommand(
{ previous: "/feeds/previous.json", current: "/feeds/current.json", json: true },
runtime,
);
expect(exitCode).toBe(0);
expect(JSON.parse(runtime.stdout)).toEqual(
expect.objectContaining({
added: [expect.objectContaining({ type: "skill", id: "new-skill" })],
removed: [expect.objectContaining({ type: "plugin", id: "removed-plugin" })],
updated: [{ type: "skill", id: "excel-review", previous: "1.0.0", current: "1.1.0" }],
approvalChanged: [
{ type: "skill", id: "excel-review", previous: "pending", current: "approved" },
],
metadataChanged: [{ type: "skill", id: "excel-review" }],
hashChanged: [{ type: "skill", id: "excel-review", previous: "old", current: "new" }],
}),
);
});
it("dry-runs an explicit feed-backed plugin install", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{
type: "plugin",
id: "calendar-helper",
install: { source: "clawhub", spec: "openclaw-calendar" },
},
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsInstallCommand("calendar-helper", { dryRun: true }, runtime);
expect(exitCode).toBe(0);
expect(runtime.stdout).toBe("openclaw plugins install clawhub:openclaw-calendar\n");
expect(runtime.commands).toEqual([]);
});
it("runs the existing install command for a selected feed entry", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{ type: "skill", id: "excel-review", install: { source: "clawhub", slug: "excel-review" } },
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsInstallCommand(
"excel-review",
{ type: "skill", force: true },
runtime,
);
expect(exitCode).toBe(0);
expect(runtime.commands).toEqual([["skills", "install", "excel-review", "--force"]]);
});
it("enforces approved feed install metadata when configured", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{
type: "plugin",
id: "calendar-helper",
install: { source: "clawhub", spec: "openclaw-calendar" },
},
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
installPolicy: { mode: "enforce", requireApproval: true },
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsInstallCommand("calendar-helper", {}, runtime);
expect(exitCode).toBe(2);
expect(runtime.stderr).toContain("is not approved by feed metadata");
expect(runtime.commands).toEqual([]);
});
it("defaults enforce mode to approved-only installs", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{
type: "plugin",
id: "calendar-helper",
install: { source: "clawhub", spec: "openclaw-calendar" },
},
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
installPolicy: { mode: "enforce" },
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsInstallCommand("calendar-helper", {}, runtime);
expect(exitCode).toBe(2);
expect(runtime.stderr).toContain("is not approved by feed metadata");
expect(runtime.commands).toEqual([]);
});
it("defaults requireApproval without a mode to enforce", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{
type: "plugin",
id: "calendar-helper",
install: { source: "clawhub", spec: "openclaw-calendar" },
},
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
installPolicy: { requireApproval: true },
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsInstallCommand("calendar-helper", {}, runtime);
expect(exitCode).toBe(2);
expect(runtime.stderr).toContain("is not approved by feed metadata");
expect(runtime.commands).toEqual([]);
});
it("warns but installs unapproved feed entries in warn mode", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{
type: "plugin",
id: "calendar-helper",
install: { source: "clawhub", spec: "openclaw-calendar" },
},
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
installPolicy: { mode: "warn", requireApproval: true },
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsInstallCommand("calendar-helper", {}, runtime);
expect(exitCode).toBe(0);
expect(runtime.stderr).toContain("Warning: Feed entry 'calendar-helper' is not approved");
expect(runtime.commands).toEqual([["plugins", "install", "clawhub:openclaw-calendar"]]);
});
it("installs approved feed entries when enforcement is enabled", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{
type: "plugin",
id: "calendar-helper",
install: { source: "clawhub", spec: "openclaw-calendar" },
approval: { status: "approved", owner: "platform" },
},
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
installPolicy: { mode: "enforce", requireApproval: true },
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsInstallCommand("calendar-helper", {}, runtime);
expect(exitCode).toBe(0);
expect(runtime.stderr).toBe("");
expect(runtime.commands).toEqual([["plugins", "install", "clawhub:openclaw-calendar"]]);
});
it("requires disambiguation before installing duplicate feed entry ids", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [
{ type: "skill", id: "shared", install: { source: "clawhub", slug: "shared-skill" } },
{ type: "plugin", id: "shared", install: { source: "clawhub", spec: "shared-plugin" } },
],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsInstallCommand("shared", {}, runtime);
expect(exitCode).toBe(2);
expect(runtime.stderr).toContain("Use --source or --type to choose one.");
expect(runtime.commands).toEqual([]);
});
it("rejects feed install entries without supported install metadata", async () => {
const feed = JSON.stringify({
schemaVersion: 1,
id: "company-approved",
entries: [{ type: "plugin", id: "unknown", install: { source: "container" } }],
});
const runtime = createRuntime({
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
files: { "/feeds/company.json": feed },
});
const exitCode = await feedsInstallCommand("unknown", {}, runtime);
expect(exitCode).toBe(2);
expect(runtime.stderr).toContain("does not include supported install metadata");
expect(runtime.commands).toEqual([]);
});
});
function createRuntime(params: {
readonly sources?: readonly Record<string, unknown>[];
readonly files?: Readonly<Record<string, string>>;
readonly installPolicy?: Record<string, unknown>;
}): FeedsCommandRuntime & {
stdout: string;
stderr: string;
isTTY?: boolean;
commands: readonly string[][];
writes: Record<string, string>;
} {
const runtime: FeedsCommandRuntime & {
stdout: string;
stderr: string;
isTTY?: boolean;
commands: string[][];
writes: Record<string, string>;
} = {
stdout: "",
stderr: "",
commands: [],
writes: {},
writeStdout(value) {
this.stdout += value;
},
error(value) {
this.stderr += `${value}\n`;
},
async runOpenClawCommand(argv) {
runtime.commands.push([...argv]);
return 0;
},
async writeFile(path, value) {
runtime.writes[path] = value;
},
async readConfigSnapshot(): Promise<any> {
return {
valid: true,
config: {
plugins: {
entries: {
feeds: {
enabled: true,
config: { sources: params.sources, installPolicy: params.installPolicy },
},
},
},
},
};
},
async readFile(path) {
const value = params.files?.[path];
if (value === undefined) {
throw new Error(`missing test file ${path}`);
}
return value;
},
};
return runtime;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,139 +0,0 @@
import { describe, expect, it } from "vitest";
import {
evaluateFeedsConfig,
FEEDS_CHECK_IDS,
registerFeedsDoctorChecks,
resetFeedsDoctorChecksForTest,
} from "./register.js";
describe("Feeds doctor checks", () => {
it("registers each feeds health check once", () => {
const registered: string[] = [];
resetFeedsDoctorChecksForTest();
registerFeedsDoctorChecks({
registerHealthCheck(check) {
registered.push(check.id);
},
});
expect(registered).toEqual(FEEDS_CHECK_IDS);
});
it("accepts configured https and file feed sources", () => {
const findings = evaluateFeedsConfig({
cfg: {
plugins: {
entries: {
feeds: {
enabled: true,
config: {
sources: [
{
id: "company-approved",
url: "https://feeds.example.com/openclaw/feed.json",
trust: "pinned",
integrity:
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
{
id: "local-review",
url: "file:///opt/openclaw/feeds/review.json",
},
],
},
},
},
},
},
});
expect(findings).toEqual([]);
});
it("reports invalid install policy config", () => {
const findings = evaluateFeedsConfig({
cfg: {
plugins: {
entries: {
feeds: {
enabled: true,
config: {
installPolicy: { mode: "block", requireApproval: "yes" },
sources: [{ id: "approved", url: "https://feeds.example.com/root.json" }],
},
},
},
},
},
});
expect(findings).toEqual([
expect.objectContaining({
checkId: "feeds/config-invalid",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/installPolicy/mode",
}),
]);
});
it("reports duplicate ids, unsupported urls, and missing pinned integrity", () => {
const findings = evaluateFeedsConfig({
cfg: {
plugins: {
entries: {
feeds: {
enabled: true,
config: {
sources: [
{ id: "company", url: "https://feeds.example.com/openclaw/feed.json" },
{ id: "company", url: "http://feeds.example.com/feed.json" },
{ id: "pinned", url: "https://feeds.example.com/pinned.json", trust: "pinned" },
],
},
},
},
},
},
});
expect(findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "feeds/source-duplicate-id",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources/#1/id",
}),
expect.objectContaining({
checkId: "feeds/source-url-invalid",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources/#1/url",
}),
expect.objectContaining({
checkId: "feeds/source-integrity-missing",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources/#2/integrity",
}),
]),
);
});
it("warns when the enabled feeds plugin has no sources", () => {
const findings = evaluateFeedsConfig({
cfg: {
plugins: {
entries: {
feeds: {
enabled: true,
config: {},
},
},
},
},
});
expect(findings).toEqual([
expect.objectContaining({
checkId: "feeds/source-missing",
severity: "warning",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources",
}),
]);
});
});

View File

@@ -1,337 +0,0 @@
import {
registerHealthCheck as registerPluginHealthCheck,
type HealthCheck,
type HealthCheckContext,
type HealthFinding,
} from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
const CHECK_IDS = {
configInvalid: "feeds/config-invalid",
sourceMissing: "feeds/source-missing",
sourceDuplicateId: "feeds/source-duplicate-id",
sourceUrlInvalid: "feeds/source-url-invalid",
sourceIntegrityInvalid: "feeds/source-integrity-invalid",
sourceIntegrityMissing: "feeds/source-integrity-missing",
} as const;
export const FEEDS_CHECK_IDS = [
CHECK_IDS.configInvalid,
CHECK_IDS.sourceMissing,
CHECK_IDS.sourceDuplicateId,
CHECK_IDS.sourceUrlInvalid,
CHECK_IDS.sourceIntegrityInvalid,
CHECK_IDS.sourceIntegrityMissing,
] as const;
type FeedsCheckId = (typeof FEEDS_CHECK_IDS)[number];
export type FeedsDoctorRegistrationHost = {
readonly registerHealthCheck: (check: HealthCheck) => void;
};
let registered = false;
export function registerFeedsDoctorChecks(host?: FeedsDoctorRegistrationHost): void {
if (registered) {
return;
}
const registerHealthCheck = host?.registerHealthCheck ?? registerPluginHealthCheck;
for (const check of feedsHealthChecks) {
registerHealthCheck(check);
}
registered = true;
}
export function resetFeedsDoctorChecksForTest(): void {
registered = false;
}
const feedsHealthChecks: readonly HealthCheck[] = FEEDS_CHECK_IDS.map((id) => ({
id,
kind: "plugin",
description: feedsCheckDescription(id),
source: "feeds",
async detect(ctx) {
return evaluateFeedsConfig(ctx).filter((finding) => finding.checkId === id);
},
}));
function feedsCheckDescription(id: FeedsCheckId): string {
switch (id) {
case CHECK_IDS.configInvalid:
return "The Feeds plugin configuration is well-formed.";
case CHECK_IDS.sourceMissing:
return "The enabled Feeds plugin has at least one configured source.";
case CHECK_IDS.sourceDuplicateId:
return "Feed source ids are unique.";
case CHECK_IDS.sourceUrlInvalid:
return "Feed source URLs are supported absolute URLs.";
case CHECK_IDS.sourceIntegrityInvalid:
return "Feed source integrity hashes use sha256:<hex> syntax.";
case CHECK_IDS.sourceIntegrityMissing:
return "Pinned feed sources declare an integrity hash.";
}
const exhaustive: never = id;
return exhaustive;
}
export function evaluateFeedsConfig(
ctx: Pick<HealthCheckContext, "cfg">,
): readonly HealthFinding[] {
const config = ctx.cfg.plugins?.entries?.feeds?.config;
const configPath = "plugins.entries.feeds.config";
const configOcPath = "oc://openclaw.config/plugins/entries/feeds/config";
if (config === undefined) {
return [
{
checkId: CHECK_IDS.sourceMissing,
severity: "warning",
message: "The enabled Feeds plugin has no configured feed sources.",
source: "feeds",
path: configPath,
ocPath: configOcPath,
fixHint: "Add plugins.entries.feeds.config.sources with at least one feed source.",
},
];
}
if (!isRecord(config)) {
return [
invalidConfigFinding({
propertyPath: configPath,
target: configOcPath,
message: "plugins.entries.feeds.config must be an object.",
fixHint: "Set plugins.entries.feeds.config to an object with a sources array.",
}),
];
}
const findings: HealthFinding[] = [];
const installPolicyFinding = evaluateInstallPolicyConfig(config.installPolicy);
if (installPolicyFinding !== undefined) {
findings.push(installPolicyFinding);
}
const sources = config.sources;
if (sources === undefined) {
findings.push({
checkId: CHECK_IDS.sourceMissing,
severity: "warning",
message: "The enabled Feeds plugin has no configured feed sources.",
source: "feeds",
path: `${configPath}.sources`,
ocPath: `${configOcPath}/sources`,
fixHint: "Add at least one feed source.",
});
return findings;
}
if (!Array.isArray(sources)) {
findings.push(
invalidConfigFinding({
propertyPath: `${configPath}.sources`,
target: `${configOcPath}/sources`,
message: "plugins.entries.feeds.config.sources must be an array.",
fixHint: "Set sources to an array of feed source objects.",
}),
);
return findings;
}
if (sources.length === 0) {
findings.push({
checkId: CHECK_IDS.sourceMissing,
severity: "warning",
message: "The enabled Feeds plugin has an empty feed source list.",
source: "feeds",
path: `${configPath}.sources`,
ocPath: `${configOcPath}/sources`,
fixHint: "Add at least one feed source or disable the Feeds plugin.",
});
return findings;
}
const seenIds = new Map<string, number>();
sources.forEach((source, index) => {
findings.push(...evaluateFeedSource(source, index, seenIds));
});
return findings;
}
function evaluateInstallPolicyConfig(value: unknown): HealthFinding | undefined {
const basePath = "plugins.entries.feeds.config.installPolicy";
const baseOcPath = "oc://openclaw.config/plugins/entries/feeds/config/installPolicy";
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
return invalidConfigFinding({
propertyPath: basePath,
target: baseOcPath,
message: "plugins.entries.feeds.config.installPolicy must be an object.",
fixHint: 'Use installPolicy: { mode: "warn", requireApproval: true } or remove it.',
});
}
if (
value.mode !== undefined &&
value.mode !== "off" &&
value.mode !== "warn" &&
value.mode !== "enforce"
) {
return invalidConfigFinding({
propertyPath: `${basePath}.mode`,
target: `${baseOcPath}/mode`,
message: "plugins.entries.feeds.config.installPolicy.mode must be off, warn, or enforce.",
fixHint: 'Use mode "off", "warn", or "enforce".',
});
}
if (value.requireApproval !== undefined && typeof value.requireApproval !== "boolean") {
return invalidConfigFinding({
propertyPath: `${basePath}.requireApproval`,
target: `${baseOcPath}/requireApproval`,
message: "plugins.entries.feeds.config.installPolicy.requireApproval must be a boolean.",
fixHint: "Set requireApproval to true or false.",
});
}
return undefined;
}
function evaluateFeedSource(
source: unknown,
index: number,
seenIds: Map<string, number>,
): readonly HealthFinding[] {
const sourcePath = `plugins.entries.feeds.config.sources[${index}]`;
const sourceOcPath = `oc://openclaw.config/plugins/entries/feeds/config/sources/#${index}`;
if (!isRecord(source)) {
return [
invalidConfigFinding({
propertyPath: sourcePath,
target: sourceOcPath,
message: `Feed source ${index} must be an object.`,
fixHint: "Replace this source with an object containing id and url.",
}),
];
}
const findings: HealthFinding[] = [];
const id = typeof source.id === "string" ? source.id.trim() : "";
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(id)) {
findings.push(
invalidConfigFinding({
propertyPath: `${sourcePath}.id`,
target: `${sourceOcPath}/id`,
message: `Feed source ${index} must have a stable lowercase id.`,
fixHint: "Use a lowercase id such as company-approved or clawhub-public.",
}),
);
} else {
const previous = seenIds.get(id);
if (previous !== undefined) {
findings.push({
checkId: CHECK_IDS.sourceDuplicateId,
severity: "error",
message: `Feed source id '${id}' duplicates sources[${previous}].`,
source: "feeds",
path: `${sourcePath}.id`,
ocPath: `${sourceOcPath}/id`,
fixHint: "Give each feed source a unique id.",
});
} else {
seenIds.set(id, index);
}
}
const url = typeof source.url === "string" ? source.url.trim() : "";
if (!isSupportedFeedUrl(url)) {
findings.push({
checkId: CHECK_IDS.sourceUrlInvalid,
severity: "error",
message: `Feed source ${id || index} must use an absolute https:// or file:// URL.`,
source: "feeds",
path: `${sourcePath}.url`,
ocPath: `${sourceOcPath}/url`,
fixHint: "Use an absolute https:// URL for hosted feeds or file:// URL for local feeds.",
});
}
const trust = source.trust;
if (trust !== undefined && trust !== "unsigned" && trust !== "pinned") {
findings.push(
invalidConfigFinding({
propertyPath: `${sourcePath}.trust`,
target: `${sourceOcPath}/trust`,
message: `Feed source ${id || index} has unsupported trust value '${formatUnknown(trust)}'.`,
fixHint: 'Use trust "unsigned" or "pinned".',
}),
);
}
const integrity = source.integrity;
if (integrity !== undefined && !isSha256Integrity(integrity)) {
findings.push({
checkId: CHECK_IDS.sourceIntegrityInvalid,
severity: "error",
message: `Feed source ${id || index} has an invalid integrity hash.`,
source: "feeds",
path: `${sourcePath}.integrity`,
ocPath: `${sourceOcPath}/integrity`,
fixHint: "Use sha256:<64 lowercase or uppercase hexadecimal characters>.",
});
}
if (trust === "pinned" && integrity === undefined) {
findings.push({
checkId: CHECK_IDS.sourceIntegrityMissing,
severity: "error",
message: `Pinned feed source ${id || index} must declare an integrity hash.`,
source: "feeds",
path: `${sourcePath}.integrity`,
ocPath: `${sourceOcPath}/integrity`,
fixHint: 'Add integrity: "sha256:<hex>" or change trust to "unsigned".',
});
}
return findings;
}
function invalidConfigFinding(params: {
readonly propertyPath: string;
readonly target: string;
readonly message: string;
readonly fixHint: string;
}): HealthFinding {
return {
checkId: CHECK_IDS.configInvalid,
severity: "error",
message: params.message,
source: "feeds",
path: params.propertyPath,
ocPath: params.target,
fixHint: params.fixHint,
};
}
function isSupportedFeedUrl(value: string): boolean {
if (value === "") {
return false;
}
try {
const parsed = new URL(value);
return parsed.protocol === "https:" || parsed.protocol === "file:";
} catch {
return false;
}
}
function isSha256Integrity(value: unknown): boolean {
return typeof value === "string" && /^sha256:[a-f0-9]{64}$/iu.test(value);
}
function formatUnknown(value: unknown): string {
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value);
} catch {
return "<unprintable>";
}
}

View File

@@ -1,155 +0,0 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
export type FeedSourceConfig = {
readonly id: string;
readonly url: string;
readonly enabled: boolean;
readonly trust?: "unsigned" | "pinned";
readonly integrity?: string;
};
export type FeedEntryType = "skill" | "plugin";
export type FeedEntry = {
readonly type: FeedEntryType;
readonly id: string;
readonly version?: string;
readonly name?: string;
readonly description?: string;
readonly tags?: readonly string[];
readonly sourceUrl?: string;
readonly sha256?: string;
readonly install?: Record<string, unknown>;
readonly approval?: Record<string, unknown>;
};
export type FeedDocument = {
readonly schemaVersion: 1;
readonly id: string;
readonly generatedAt?: string;
readonly entries: readonly FeedEntry[];
};
export type LoadedFeedDocument = {
readonly source: FeedSourceConfig;
readonly document: FeedDocument;
readonly sha256: string;
};
export type FeedFetch = (url: string) => Promise<{ readonly ok: boolean; readonly text: string }>;
export type FeedDocumentRuntime = {
readonly fetch?: FeedFetch;
readonly readFile?: (path: string) => Promise<Buffer | string>;
};
export async function loadFeedDocument(
source: FeedSourceConfig,
runtime: FeedDocumentRuntime = {},
): Promise<LoadedFeedDocument> {
const raw = await readFeedBytes(source.url, runtime);
const sha256 = createHash("sha256").update(raw).digest("hex");
if (source.trust === "pinned" && source.integrity === undefined) {
throw new Error(`Feed source ${source.id} requires integrity for pinned trust.`);
}
if (source.integrity !== undefined && source.integrity.toLowerCase() !== `sha256:${sha256}`) {
throw new Error(`Feed source ${source.id} integrity mismatch.`);
}
const parsed = parseFeedDocument(JSON.parse(raw.toString("utf8")), source.id);
return { source, document: parsed, sha256 };
}
export function parseFeedDocument(value: unknown, sourceId = "feed"): FeedDocument {
if (!isRecord(value)) {
throw new Error(`Feed source ${sourceId} must contain a JSON object.`);
}
if (value.schemaVersion !== 1) {
throw new Error(`Feed source ${sourceId} must use schemaVersion 1.`);
}
if (typeof value.id !== "string" || value.id.trim() === "") {
throw new Error(`Feed source ${sourceId} must declare a feed id.`);
}
if (value.generatedAt !== undefined && typeof value.generatedAt !== "string") {
throw new Error(`Feed source ${sourceId} generatedAt must be a string when present.`);
}
if (!Array.isArray(value.entries)) {
throw new Error(`Feed source ${sourceId} entries must be an array.`);
}
return {
schemaVersion: 1,
id: value.id,
...(typeof value.generatedAt === "string" ? { generatedAt: value.generatedAt } : {}),
entries: value.entries.map((entry, index) => parseFeedEntry(entry, sourceId, index)),
};
}
export function feedEntryMatchesQuery(entry: FeedEntry, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (normalized === "") {
return true;
}
const haystack = [
entry.type,
entry.id,
entry.version,
entry.name,
entry.description,
...(entry.tags ?? []),
]
.filter((value): value is string => typeof value === "string")
.join("\n")
.toLowerCase();
return haystack.includes(normalized);
}
async function readFeedBytes(url: string, runtime: FeedDocumentRuntime): Promise<Buffer> {
const parsed = new URL(url);
if (parsed.protocol === "file:") {
const read = runtime.readFile ?? readFile;
const value = await read(fileURLToPath(parsed));
return Buffer.isBuffer(value) ? value : Buffer.from(value);
}
if (parsed.protocol === "https:") {
const fetcher = runtime.fetch ?? defaultFetch;
const response = await fetcher(url);
if (!response.ok) {
throw new Error(`Feed URL ${url} did not return a successful response.`);
}
return Buffer.from(response.text, "utf8");
}
throw new Error(`Unsupported feed URL protocol for ${url}.`);
}
async function defaultFetch(url: string): Promise<{ readonly ok: boolean; readonly text: string }> {
const response = await fetch(url);
return { ok: response.ok, text: await response.text() };
}
function parseFeedEntry(value: unknown, sourceId: string, index: number): FeedEntry {
if (!isRecord(value)) {
throw new Error(`Feed source ${sourceId} entry ${index} must be an object.`);
}
if (value.type !== "skill" && value.type !== "plugin") {
throw new Error(`Feed source ${sourceId} entry ${index} must be a skill or plugin.`);
}
if (typeof value.id !== "string" || value.id.trim() === "") {
throw new Error(`Feed source ${sourceId} entry ${index} must declare an id.`);
}
return {
type: value.type,
id: value.id,
...(typeof value.version === "string" ? { version: value.version } : {}),
...(typeof value.name === "string" ? { name: value.name } : {}),
...(typeof value.description === "string" ? { description: value.description } : {}),
...(Array.isArray(value.tags) && value.tags.every((tag) => typeof tag === "string")
? { tags: value.tags }
: {}),
...(typeof value.sourceUrl === "string" ? { sourceUrl: value.sourceUrl } : {}),
...(typeof value.sha256 === "string" ? { sha256: value.sha256 } : {}),
...(isRecord(value.install) ? { install: value.install } : {}),
...(isRecord(value.approval) ? { approval: value.approval } : {}),
};
}

View File

@@ -183,16 +183,15 @@
}
},
"node_modules/form-data": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.4.tgz",
"integrity": "sha512-Y/3MmRiR8Nd+0CUtrbvcKtKzLWiUfpQ7DFVggH8PwmGt/0r7RSy32GuP4hpCJlQNEBusisSx1DLtD8uD386HJQ==",
"deprecated": "This version has an incorrect dependency; please use v2.5.5",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz",
"integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"has-own": "^1.0.1",
"hasown": "^2.0.4",
"mime-types": "^2.1.35",
"safe-buffer": "^5.2.1"
},
@@ -258,13 +257,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-own": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/has-own/-/has-own-1.0.1.tgz",
"integrity": "sha512-RDKhzgQTQfMaLvIFhjahU+2gGnRBK6dYOd5Gd9BzkmnBneOCRYjRC003RIMrdAbH52+l+CnMS4bBCXGer8tEhg==",
"deprecated": "This project is not maintained. Use Object.hasOwn() instead.",
"license": "MIT"
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -371,9 +363,9 @@
}
},
"node_modules/protobufjs": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.0.tgz",
"integrity": "sha512-iriNhQ57SYA5Jbdi+41AyPdx6jPPkFO7DODzkOBmqFhgYn/JzX2HxgxYPY18eQAs3CP/AWqtPvkWn8rclRAxdQ==",
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.1.tgz",
"integrity": "sha512-oXf2UgIty8jnwfN4yvL1x79VLhL5uiKjZJbSGXGCIUmHmItTP4eS/UIlWDCeNx3seg+ujfn9vDlPMSrsh7wO+Q==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {

View File

@@ -135,6 +135,7 @@ describe("googlechat message actions", () => {
});
sendGoogleChatMessage.mockResolvedValue({
messageName: "spaces/AAA/messages/msg-1",
threadName: "spaces/AAA/threads/thread-1",
});
if (!googlechatMessageActions.handleAction) {
@@ -174,7 +175,12 @@ describe("googlechat message actions", () => {
thread: "thread-1",
attachments: [{ attachmentUploadToken: "token-1", contentName: "remote.png" }],
});
expectJsonResult(result, { ok: true, to: "spaces/AAA" });
expectJsonResult(result, {
ok: true,
to: "spaces/AAA",
messageName: "spaces/AAA/messages/msg-1",
threadName: "spaces/AAA/threads/thread-1",
});
});
it("routes upload-file through the same attachment upload path with filename override", async () => {
@@ -198,6 +204,7 @@ describe("googlechat message actions", () => {
});
sendGoogleChatMessage.mockResolvedValue({
messageName: "spaces/BBB/messages/msg-2",
threadName: "spaces/BBB/threads/thread-2",
});
if (!googlechatMessageActions.handleAction) {
@@ -232,7 +239,12 @@ describe("googlechat message actions", () => {
thread: undefined,
attachments: [{ attachmentUploadToken: "token-2", contentName: "renamed.txt" }],
});
expectJsonResult(result, { ok: true, to: "spaces/BBB" });
expectJsonResult(result, {
ok: true,
to: "spaces/BBB",
messageName: "spaces/BBB/messages/msg-2",
threadName: "spaces/BBB/threads/thread-2",
});
});
it("removes only matching app reactions on react remove", async () => {

View File

@@ -148,7 +148,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
buffer: loaded.buffer,
contentType: loaded.contentType,
});
await sendGoogleChatMessage({
const sent = await sendGoogleChatMessage({
account,
space,
text: content,
@@ -162,20 +162,20 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
]
: undefined,
});
return jsonResult({ ok: true, to: space });
return jsonResult({ ok: true, to: space, ...sent });
}
if (action === "upload-file") {
throw new Error("upload-file requires media, filePath, or path");
}
await sendGoogleChatMessage({
const sent = await sendGoogleChatMessage({
account,
space,
text: content,
thread: threadId ?? undefined,
});
return jsonResult({ ok: true, to: space });
return jsonResult({ ok: true, to: space, ...sent });
}
if (action === "react") {

View File

@@ -142,7 +142,7 @@ export async function sendGoogleChatMessage(params: {
thread?: string;
cardsV2?: GoogleChatCardV2[];
attachments?: Array<{ attachmentUploadToken: string; contentName?: string }>;
}): Promise<{ messageName?: string } | null> {
}): Promise<{ messageName?: string; threadName?: string } | null> {
const { account, space, text, thread, cardsV2, attachments } = params;
if (
text &&
@@ -175,11 +175,11 @@ export async function sendGoogleChatMessage(params: {
urlObj.searchParams.set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD");
}
const url = urlObj.toString();
const result = await fetchJson<{ name?: string }>(account, url, {
const result = await fetchJson<{ name?: string; thread?: { name?: string } }>(account, url, {
method: "POST",
body: JSON.stringify(body),
});
return result ? { messageName: result.name } : null;
return result ? { messageName: result.name, threadName: result.thread?.name } : null;
}
export async function updateGoogleChatMessage(params: {

View File

@@ -99,10 +99,15 @@ const account = {
config: {},
} as ResolvedGoogleChatAccount;
function stubSuccessfulSend(name: string) {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ name }), { status: 200 }));
function stubSuccessfulSend(name: string, threadName?: string) {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ name, ...(threadName ? { thread: { name: threadName } } : {}) }),
{
status: 200,
},
),
);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
@@ -239,9 +244,9 @@ describe("sendGoogleChatMessage", () => {
});
it("adds messageReplyOption when sending to an existing thread", async () => {
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/123");
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/123", "spaces/AAA/threads/xyz");
await sendGoogleChatMessage({
const result = await sendGoogleChatMessage({
account,
space: "spaces/AAA",
text: "hello",
@@ -260,6 +265,10 @@ describe("sendGoogleChatMessage", () => {
};
expect(body.text).toBe("hello");
expect(body.thread?.name).toBe("spaces/AAA/threads/xyz");
expect(result).toEqual({
messageName: "spaces/AAA/messages/123",
threadName: "spaces/AAA/threads/xyz",
});
});
it("does not set messageReplyOption for non-thread sends", async () => {

View File

@@ -392,10 +392,22 @@ export const imessageMessageActions: ChannelMessageActionAdapter = {
react: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
edit: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
unsend: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
reply: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
sendWithEffect: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
sendAttachment: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
"upload-file": { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
reply: {
aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
},
sendWithEffect: {
aliases: ["chatGuid", "chatIdentifier", "chatId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
},
sendAttachment: {
aliases: ["chatGuid", "chatIdentifier", "chatId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
},
"upload-file": {
aliases: ["chatGuid", "chatIdentifier", "chatId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
},
renameGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
setGroupIcon: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
addParticipant: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },

View File

@@ -273,4 +273,39 @@ describe("memory reindex state", () => {
),
).toBe(false);
});
it("falls back to fts-only when provider.model is an empty string", () => {
expect(
resolveMemoryIndexIdentityState(
createIdentityParams({
provider: { id: "openai", model: "" },
meta: createMeta({ model: "fts-only" }),
}),
),
).toEqual({ status: "valid" });
});
it("reports mismatch when empty-string expected model is compared to a non-fts index", () => {
const state = resolveMemoryIndexIdentityState(
createIdentityParams({
provider: { id: "openai", model: "" },
meta: createMeta({ model: "text-embedding-3-small" }),
}),
);
expect(state.status).toBe("mismatched");
if (state.status === "mismatched") {
expect(state.reason).toContain("expected fts-only");
}
});
it("falls back to fts-only when provider.model is whitespace-only", () => {
expect(
resolveMemoryIndexIdentityState(
createIdentityParams({
provider: { id: "openai", model: " " },
meta: createMeta({ model: "fts-only" }),
}),
),
).toEqual({ status: "valid" });
});
});

View File

@@ -159,7 +159,7 @@ export function resolveMemoryIndexIdentityState(params: {
if (!meta) {
return { status: "missing", reason: "index metadata is missing" };
}
const expectedModel = params.provider ? params.provider.model : "fts-only";
const expectedModel = params.provider?.model?.trim() || "fts-only";
const matchingModelIdentities = [
{ model: expectedModel, providerKey: params.providerKey },
...(params.providerAliases ?? []),

View File

@@ -517,7 +517,7 @@ export abstract class MemoryManagerSyncOps {
this.settings.provider,
model:
this.settings.model.trim() ||
resolveEmbeddingProviderFallbackModel(this.settings.provider, "", this.cfg),
resolveEmbeddingProviderFallbackModel(this.settings.provider, "fts-only", this.cfg),
});
const provider = hasProviderOverride
? params.provider!

View File

@@ -285,6 +285,10 @@ describe("QmdMemoryManager", () => {
throw new Error(`expected missing path ${targetPath}`);
}
function qmdIndexConfigPath(selectedAgentId = agentId): string {
return path.join(stateDir, "agents", selectedAgentId, "qmd", "xdg-config", "qmd", "index.yml");
}
async function createManager(params?: {
mode?: "full" | "status" | "cli";
cfg?: OpenClawConfig;
@@ -1966,6 +1970,121 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("refreshes qmd index config with quoted collection values during update repair", async () => {
const notesDir = path.join(workspaceDir, "Notes #1: blue");
await fs.mkdir(notesDir, { recursive: true });
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 0, onBoot: false },
paths: [{ path: notesDir, pattern: "**/* #tag: [draft].md", name: "notes" }],
},
},
} as OpenClawConfig;
let updateCalls = 0;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "update") {
updateCalls += 1;
const child = createMockChild({ autoClose: false });
if (updateCalls === 1) {
emitAndClose(
child,
"stderr",
"SQLiteError: UNIQUE constraint failed: documents.collection, documents.path",
1,
);
return child;
}
queueMicrotask(() => {
child.closeWith(0);
});
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "status" });
await expect(manager.sync({ reason: "manual" })).resolves.toBeUndefined();
const indexConfig = await fs.readFile(qmdIndexConfigPath(), "utf8");
expect(indexConfig).toContain(' "notes-main":');
expect(indexConfig).toContain(` path: ${JSON.stringify(notesDir)}`);
expect(indexConfig).toContain(' pattern: "**/* #tag: [draft].md"');
expect(updateCalls).toBe(2);
await manager.close();
});
it("forces repair remove/add even when managed collections are still listed", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: true,
update: { interval: "0s", debounceMs: 0, onBoot: false },
paths: [],
},
},
} as OpenClawConfig;
let updateCalls = 0;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "collection" && args[1] === "list") {
const child = createMockChild({ autoClose: false });
emitAndClose(
child,
"stdout",
JSON.stringify([
{ name: "memory-root-main", path: workspaceDir, mask: "MEMORY.md" },
{ name: "memory-dir-main", path: path.join(workspaceDir, "memory"), mask: "**/*.md" },
]),
);
return child;
}
if (args[0] === "update") {
updateCalls += 1;
const child = createMockChild({ autoClose: false });
if (updateCalls === 1) {
emitAndClose(
child,
"stderr",
"SQLiteError: UNIQUE constraint failed: documents.collection, documents.path",
1,
);
return child;
}
queueMicrotask(() => {
child.closeWith(0);
});
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "full" });
await expect(manager.sync({ reason: "manual" })).resolves.toBeUndefined();
const removeCalls = spawnMock.mock.calls
.map((call: unknown[]) => call[1] as string[])
.filter((args: string[]) => args[0] === "collection" && args[1] === "remove")
.map((args) => args[2]);
const addCalls = spawnMock.mock.calls
.map((call: unknown[]) => call[1] as string[])
.filter((args: string[]) => args[0] === "collection" && args[1] === "add")
.map((args) => args[args.indexOf("--name") + 1]);
expect(updateCalls).toBe(2);
expect(removeCalls).toEqual(["memory-root-main", "memory-dir-main"]);
expect(addCalls).toEqual(["memory-root-main", "memory-dir-main"]);
await manager.close();
});
it("does not rebuild collections for unrelated unique constraint failures", async () => {
cfg = {
...cfg,

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