Compare commits

..

508 Commits

Author SHA1 Message Date
Patrick Erichsen
0f96c16812 docs: interpolate ClawHub npm release tag 2026-06-11 11:56:02 -07:00
Patrick Erichsen
9827490f5f fix: rely on ClawHub plugin publish checks 2026-06-11 11:51:57 -07:00
Shakker
575cae59d4 fix: preserve utils exports in doctor health tests 2026-06-11 17:32:25 +01:00
Shakker
4e4dc10db0 fix: avoid support write shadowed variable 2026-06-11 17:32:25 +01:00
Shakker
34a1102506 fix: preflight skill writes before rollback metadata 2026-06-11 17:32:25 +01:00
Shakker
1156ab637c refactor: move workspace skill writes to lifecycle 2026-06-11 17:32:25 +01:00
Shakker
0fc2faa0f4 test: narrow whatsapp mention fixture 2026-06-11 17:29:24 +01:00
Shakker
fdf3667e09 test: clean whatsapp alias assertions 2026-06-11 17:29:24 +01:00
Shakker
de9260f813 fix: keep whatsapp inbound aliases live 2026-06-11 17:29:24 +01:00
Marcus Castro
4d45884419 docs(plugins): record whatsapp inbound compatibility 2026-06-11 17:29:24 +01:00
Marcus Castro
1bea7d8ef3 test(whatsapp): cover inbound context compatibility 2026-06-11 17:29:24 +01:00
Marcus Castro
008d785a80 test(whatsapp): update auto reply inbound fixtures 2026-06-11 17:29:24 +01:00
Marcus Castro
eebcb100b8 refactor(whatsapp): read inbound contexts in auto reply 2026-06-11 17:29:24 +01:00
Marcus Castro
b5295a6a34 refactor(whatsapp): introduce inbound message contexts 2026-06-11 17:29:24 +01:00
Shakker
3d6252a517 test: harden stalled websocket cleanup 2026-06-11 15:32:58 +01:00
Shakker
c4d3f0545c fix: validate workshop support symlink writes 2026-06-11 15:20:38 +01:00
Shakker
6b0525f237 fix: gate Skill Workshop symlink writes 2026-06-11 15:20:38 +01:00
abnershang
287b10a895 feat(skills): allow trusted workshop symlink targets 2026-06-11 15:20:38 +01:00
Sally O'Malley
ea813a2476 fix: handle explicit silent assistant replies (#92073)
Signed-off-by: sallyom <somalley@redhat.com>
2026-06-11 10:20:08 -04:00
Matt H
9be1699074 fix(wizard): report keyless web search providers as ready
Onboarding finalize now treats configured web search providers with requiresCredential: false as ready instead of warning that an API key is missing. This covers keyless providers such as Parallel Search (Free), DuckDuckGo, and Ollama while preserving credential-required warnings for providers that need keys.\n\nProof: focused wizard/search tests; oxlint on changed files; git diff --check; autoreview clean; Azure Crabbox check:changed cbx_b92ef084c21c passed; GitHub checks green.
2026-06-11 23:18:49 +09:00
mushuiyu_xydt
777f7409d8 fix(installer): stop after failed Node package installs
Linux Node package-manager setup/install failures now fail the installer immediately instead of falling through to a misleading success path. Adds regression coverage for NodeSource setup and apt nodejs install failures under conditional shell invocation.\n\nFixes #73837\n\nProof: bash -n scripts/install.sh; node scripts/run-vitest.mjs test/scripts/install-sh.test.ts; node scripts/run-oxlint.mjs test/scripts/install-sh.test.ts; git diff --check origin/main...HEAD; autoreview clean; Azure Crabbox check:changed cbx_6286dc1e287b passed.
2026-06-11 22:58:43 +09:00
clawsweeper[bot]
2bec2caf0c fix(channel): harden local setup trust (#92175)
Summary:
- The PR extends channel setup trust enforcement and trusted catalog fallback from workspace-origin plugins to ... nfigured load paths into catalog discovery, and adds focused regression plus Docker/package proof coverage.
- PR surface: Source +190, Tests +892, Other +324. Total +1406 across 13 files.
- Reproducibility: yes. The source PR provides a concrete clean-main Docker/package path where an explicitly t ... ns unresolved, while the patched package resolves it and still blocks untrusted module and setup execution.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(channel): stabilize trusted catalog dts typing
- PR branch already contained follow-up commit before automerge: fix(channel): repair trusted catalog exclusions typing
- PR branch already contained follow-up commit before automerge: test(channel): cover local channel plugin trust
- PR branch already contained follow-up commit before automerge: chore(deps): refresh plugin shrinkwraps
- PR branch already contained follow-up commit before automerge: test(channel): route trust regression in command shard
- PR branch already contained follow-up commit before automerge: test(channel): remove e2e-named trust regression

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

Prepared head SHA: eabee04d54
Review: https://github.com/openclaw/openclaw/pull/92175#issuecomment-4680798117

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-11 13:48:41 +00:00
liuhao1024
047785eb30 fix(cron): report SQLite storage path in cron.status instead of legacy jobs.json (#92144)
* fix(cron): report SQLite storage path in cron.status instead of legacy jobs.json

The `cron.status` gateway response returned `storePath` pointing to the
legacy `jobs.json` path, but cron jobs are actually stored in the shared
SQLite state database. This misled operators and agents into looking for
a JSON file that no longer exists.

- Add `storage: "sqlite"` and `sqlitePath` fields to CronStatusSummary
- Mark legacy `storePath` as @deprecated (kept for backward compat)
- Update CLI warning to prefer sqlitePath over storePath
- Add regression assertions in read-ops test

Fixes #91766

* fix(macos): prefer sqlitePath in cron status display

* fix(macos): add sqlitePath to CronSchedulerStatus type
2026-06-11 22:39:42 +09:00
Shakker
f7ee25291a chore: remove redundant proof scripts 2026-06-11 14:29:12 +01:00
Vincent Koc
79d7defd0b test(ci): relax docker signal wait 2026-06-11 21:52:19 +09:00
Vincent Koc
68ec783e74 fix(agents): project thinking catalog compat 2026-06-11 21:14:50 +09:00
兰之
9a6c71a47d fix(agents): retry same model across short rate-limit windows (#91911)
Bound same-model rate-limit retries to explicit short-window signals or parsed short Retry-After values, honor Retry-After in the retry sleep, preserve zero-rotation fallback behavior, and record same-model rate-limit retries separately from profile rotations.

Verification:
- node scripts/run-vitest.mjs src/agents/embedded-agent-runner/run/assistant-failover.test.ts src/agents/embedded-agent-runner/run/helpers.test.ts
- Azure Crabbox cbx_bdb5a7807a1f / coral-shrimp: OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
2026-06-11 21:00:49 +09:00
openclaw-clownfish[bot]
99d0bdc23a fix(cli): validate gateway RPC timeout inputs
Reject malformed or explicit empty Gateway RPC timeout values before opening Gateway calls, align the shared Gateway RPC omitted-timeout fallback with the 30000 ms CLI default, and validate explicit `cron add --timeout-seconds` values at the CLI boundary.

Carries forward the useful source work from #54646 and the earlier timeout-validation context from #40953. #60661 remains separate accepted-run timeout semantics work and is intentionally not folded into this change.

Validation:
- `npm run review-results -- /tmp/clownfish-check-27341769444`
- `git diff --check`
- OpenClaw PR checks on `ce7bd8b9388a5689b14ddc2b3a984f7b4647e5ca`: 132 pass, 0 pending, 0 failing
- ClawSweeper re-review: https://github.com/openclaw/clawsweeper/actions/runs/27344244608

Co-authored-by: RayRuan <43744645+ruanrrn@users.noreply.github.com>
Co-authored-by: Homeran <11574611+comeran@users.noreply.github.com>
2026-06-11 20:52:07 +09:00
Vincent Koc
6fb0c940fa fix(release): gate beta publish on plugin verification
Delay public GitHub release publication until postpublish verification, dependency evidence upload, proof append, and required plugin publish gates pass.

Also updates release-maintainer instructions so newly publishable plugins are minted/prepublished through an owner-approved path without consuming the next auto-bumped beta version unless that path is the actual release publish.
2026-06-11 20:42:58 +09:00
Ben Newell
cdb55b3edb fix(memory-core): retry narrative message reads (#89091)
* fix(memory-core): retry narrative message reads

* fix(memory-core): wait longer for narrative text
2026-06-11 20:36:18 +09:00
Dream Hunter
8d72cb9401 fix(memory): abort orphaned embedding work when memory_search times out (#91742)
* fix(memory): abort orphaned embedding work when memory_search times out

memory_search raced its 15s deadline with Promise.race and returned a clean
timeout to the agent, but the underlying embedQueryWithRetry loop kept
retrying (3 attempts x 60s) against the embedding backend with no consumer.
Thread the tool-owned AbortSignal through manager.search ->
embedQueryWithRetry -> runEmbeddingOperationWithTimeout so the deadline
cancels in-flight embedding work, stops the retry loop, and skips
fallback-provider activation for an absent caller.

Fixes #91718

* fix(memory): let the deadline result win before aborting the search

Abort listeners dispatch synchronously, so an abort-aware search could
reject the raced task before the timeout promise resolved and replace the
stable 'memory_search timed out after 15s' result with a provider-wrapped
abort error. Resolve the timeout first, then abort.

* fix(memory): scope deadline abort to builtin embeddings

* fix(memory): preserve deadline signal across fallback

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 20:36:11 +09:00
Chunyue Wang
4f3c2cd2df fix(ollama): use provider thinking default in SDK session factory (#91657)
* fix(ollama): use provider thinking default in SDK session factory

* fix(agents): preserve model metadata for thinking defaults

* fix(agents): resolve custom Ollama thinking policy

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 20:36:05 +09:00
Evgeni Obuchowski
16ea3f272f fix(plugins): stop derived metadata snapshot rescan storm in /models (regression shipped since v2026.5.18) (#92127)
Since 5734193fdf ("fix(plugins): keep metadata snapshot memo fresh",
first shipped in v2026.5.18), the in-process plugin metadata snapshot
memo stores derived-registry results under a key recomputed from the
freshly built snapshot.index, while lookups key off the persisted-index
registry state. On installs where the registry resolves as "derived"
(persisted index absent or not covering the running checkout), the two
keys never match. Worse, the lookup-side adoption loop returns the most
recently stored registryState for the context, so two alternating call
shapes (e.g. the model-catalog build mixing workspace-scoped and global
lookups) each adopt the other shape's state, compute a key that was
never stored, and re-run the full plugin manifest scan - on every call,
forever. Chat /models (and each subsequent provider/model pick) pays
multiple full manifest scans plus all downstream snapshot-identity cache
invalidation per step, pinning a CPU core for seconds on every
interaction, in every chat channel.

Fix: store the memo under the exact memoKey/registryState the call
looked up by, instead of re-deriving a second key from snapshot.index.
Freshness is unchanged - the lookup context hash and the plugin metadata
lifecycle clears (install/reload/doctor) still own invalidation. The
now-unused index parameter of resolvePersistedRegistryMemoState is
removed.

Measured on the author's VPS (real plugin discovery, identical catalog
output of 263 entries on both sides): the full-discovery model catalog
build behind chat /models dropped from ~6.3s to ~0.3s (~21x), with
repeat snapshot lookups going from full rescans to memo hits.

Regression test: alternating derived call shapes must not re-scan
(red on main: 4 scans; green with this fix: 2).
2026-06-11 20:35:59 +09:00
Yuval Dinodia
83705fba04 fix(config): stop config.patch replacePaths index suffix from widening array consent (#91966)
* fix(config): stop config.patch replacePaths index suffix from widening array consent

normalizeConfigPatchReplacePath stripped a trailing array bracket, so an entry/index-scoped token like bindings[0] or bindings[] collapsed onto the bare whole-array token (bindings). That bare token is both the merge replaceArrayPaths key and the destructive-array gate's exact-path token, so an index-scoped consent silently authorized a full-array replacement and dropped unrelated base entries on the gateway config.patch path, and the same collapse let the agent self-edit tool truncate id-keyed arrays whenever no protected path happened to be involved.

Keep the interior index normalization (agents.list[0].skills -> agents.list[].skills) but no longer collapse a trailing bracket, so a bracket/index-suffixed token never matches the bare whole-array token and the destructive-array gate stays fail-closed unless the documented exact path is passed. Update the agent-tool test whose expectation depended on the old collapse: agents.list[0] now does a non-destructive id-keyed merge that only changes model and is correctly allowed.

* fix(config): distinguish indexed and array replace consent

* test(config): cover replace consent syntax

* fix(config): make replace path normalization idempotent

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 20:35:52 +09:00
Vincent Koc
3883d7365e fix(qqbot): guard silent-final tool flushing 2026-06-11 20:09:55 +09:00
吴杨帆
71d3d8bc74 fix(doctor): warn on unsupported hook entry loaders (#89319)
* fix(doctor): warn on unsupported hook entry loaders

* fix(doctor): guard null hook entry configs

* fix(doctor): guard null hook entry configs

* fix(doctor): repair misplaced hook loader paths

* fix(doctor): clarify unsupported hook entry repair

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 20:09:51 +09:00
Alix-007
0b80732137 fix(cron): reject durations that overflow to a non-finite value (#89448)
* fix(cron): reject durations that overflow to a non-finite value

parseDurationMs guarded the parsed mantissa but returned Math.floor(n * factor)
with no finite check on the product. A finite mantissa times a large unit factor
(e.g. "1e302d", factor 86_400_000) overflows to Infinity, which was returned as
the millisecond value. Reject a non-finite result instead, matching the existing
contract that already rejects non-finite / non-positive mantissas.

Fixes #83906.

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

* ci: rerun flaky runner checks

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:09:45 +09:00
Syu
038da08073 fix(discord): clean migrated thread binding state (#89552)
* fix(discord): clean migrated thread binding state

* fix(discord): omit undefined thread binding fields

---------

Co-authored-by: Hex <hex@openclaw.ai>
2026-06-11 20:09:39 +09:00
Yuval Dinodia
4b4211e6c7 fix(agents): keep migrated session entry ids unique on v1 upgrade (#89085)
migrateV1ToV2 assigned each entry id via generateId(ids) but never added the
result back into ids, so the collision-check set stayed empty for the whole
migration and generateId's check was a no-op. A v1 to v2 upgrade could then mint
two entries with the same 8-hex id, and because the migration rebuilds the
parent/child tree from those ids it would parent the second entry to itself,
corrupting the branch. Add ids.add(entry.id) so the generator sees prior ids and
retries on collision.

Adds a regression test that drives the real SessionManager.open migration path
with a seeded id collision.
2026-06-11 20:09:33 +09:00
cornna
1532b46e2a fix(models): clarify provider model registration hint (#89508)
Co-authored-by: Cornna <96944678+ymylive@users.noreply.github.com>
2026-06-11 20:09:27 +09:00
Vincent Koc
60e818f563 fix(agents): forward channel identity to CLI hooks 2026-06-11 19:33:00 +09:00
Sliverp
7e88c287a1 fix(qqbot): flush tool output before silent non-streaming final (#92074)
* fix(qqbot): flush tool output before silent non-streaming final

* fix qqbot silent final delivery

* chore: drop local plugin runtime helper

* fix: suppress stale qqbot tool flush

* fix(qqbot): flush tool output before silent non-streaming final (#92074) (thanks @sliverp)

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
2026-06-11 18:28:32 +08:00
Leon GyeongMan Baek
96e1b8c3ba Google: show detailed Gemini CLI OAuth extraction failures (#41991)
* Google: preserve Gemini CLI OAuth failure context

Port the diagnostics-only fix to the bundled Google OAuth implementation so user-facing setup errors explain why automatic Gemini CLI client-config discovery failed.

Constraint: #54289 may remove or gate automatic Gemini CLI credential extraction
Rejected: Change extraction consent behavior here | security/product decision belongs in #54289
Confidence: medium
Scope-risk: narrow
Tested: pnpm test -- extensions/google/oauth.test.ts
Tested: pnpm check
Tested: pnpm format:check extensions/google/oauth.credentials.ts extensions/google/oauth.test.ts
Not-tested: full pnpm test suite

* Google: clarify Gemini bundle fallback diagnostic comment

Keep the follow-up limited to the explanatory comment so it matches the diagnostic error preservation added around bundle traversal failures.

Constraint: comment-only cleanup after diagnostics port
Confidence: high
Scope-risk: narrow
Tested: pnpm format:check extensions/google/oauth.credentials.ts
Not-tested: tests not run; comment-only change

* fix: resolve OAuth test rebase conflict
2026-06-11 19:24:52 +09:00
Lu Wang
43b4e27699 fix(thinking): apply Claude profile to anthropic-messages catalog rows (#92053)
* fix(thinking): apply Claude profile to anthropic-messages catalog rows

When a custom provider (e.g. `jdcloud-anthropic`) fronted Claude Opus over
the native anthropic-messages adapter, `--thinking xhigh` was silently
clamped to `off`. The thinking-profile dispatcher resolves bundled plugin
policy surfaces by exact provider id, so a renamed Anthropic-compatible
provider never reached the anthropic plugin's policy and `xhigh` was not
in the resulting profile.

`auto-reply/thinking.ts` already had a fallback keyed on
`context.api === "anthropic-messages"` that attached
`CLAUDE_FABLE_5_THINKING_PROFILE` for Fable models. Generalize it to use
`resolveClaudeThinkingProfile(modelId, params)` instead — the same
canonical helper the anthropic plugin uses — which still returns the Fable
profile for Fable models and now returns the correct Opus 4.7/4.8 profile
(with `xhigh`/`adaptive`/`max`) for Claude Opus regardless of provider id.

Non-Claude models on anthropic-messages routes still get the base
profile, and a Claude id on a non-Anthropic transport (e.g. an
openai-completions catalog row) is unaffected.

Fixes #91975

* fix(thinking): match native Anthropic includeNativeMax in fallback

Address ClawSweeper P2 review on #92053. The anthropic-messages fallback
in `resolveThinkingProfile` calls `resolveClaudeThinkingProfile` but
omits the `{ includeNativeMax: true }` option that the bundled anthropic
plugin uses (extensions/anthropic/provider-policy-api.ts:38,45).

For native-xhigh Claude families (Opus 4.7/4.8) this had no effect since
the native-xhigh branch already exposes `max`. But adaptive Claude
families that take the adaptive-default branch (e.g. claude-sonnet-4-6,
claude-opus-4-6) silently lost `max` parity on custom anthropic-messages
providers compared to native Anthropic policy.

Also add a regression test on `claude-sonnet-4-6` that verifies the
adaptive-branch path keeps `max` for custom providers.

* docs(thinking): document deliberate compat.xhigh bypass on anthropic-messages

Self-review surfaced a subtle behavior change worth documenting: when the
anthropic-messages fallback was generalized, non-Claude models on this
transport stop honoring catalog `compat.supportedReasoningEfforts: ["xhigh"]`
because they take the Claude base profile instead of falling through to the
later `catalogSupportsXHigh` upgrade path.

This is intentional — anthropic-messages does not carry a generic xhigh
contract; xhigh on this protocol is a Claude-family capability. Add an
inline comment at the resolver site and a regression test that locks the
suppression so the next reader (or a future patch) doesn't accidentally
restore the upgrade path.

* fix(thinking): extract Claude profile to leaf to break import cycle

The previous commits added a `resolveClaudeThinkingProfile` import from
`auto-reply/thinking.ts` to `plugin-sdk/provider-model-shared.ts`. The
shared barrel re-exports `provider-replay-helpers` and `plugins/types`,
which transitively reach back into `auto-reply` via the gateway server
methods chain — creating the madge cycle reported by
`check:madge-import-cycles`:

    auto-reply/thinking.ts
      -> ... -> plugin-sdk/provider-model-shared.ts
      -> plugins/{config-schema, host-hooks, ...} -> plugins/types.ts

Move `BASE_CLAUDE_THINKING_LEVELS`, `isClaudeAdaptiveThinkingDefaultModelId`,
and `resolveClaudeThinkingProfile` to a new leaf module
`src/plugins/provider-claude-thinking.ts` whose only imports are
`@openclaw/llm-core` and the existing leaf `provider-thinking.types`.

`provider-model-shared.ts` continues to re-export both helpers so existing
consumers (`extensions/anthropic/*`, the public test surface) are
unaffected. `auto-reply/thinking.ts` now imports the leaf directly,
breaking the cycle.

* test(thinking): add live proof harness for #91975 anthropic-messages clamp

---------

Co-authored-by: wanglu241 <wanglu241@jd.com>
2026-06-11 19:24:46 +09:00
Lu Wang
43b1088962 fix(cli-runner): scope claude-cli queue to live-session owner identity (#91946) (#91974)
* fix(cli-runner): scope claude-cli queue to live-session owner identity

Fresh claude-cli runs without a stored cliSessionId previously collapsed
onto a single workspace-scoped queue key, serializing all fan-out within
one workspace regardless of subagent lane configuration.

Replace the workspace fallback with the same owner identity that
claude-live-session.ts already uses for its live-session map
(agentAccountId + agentId + authProfileId + sessionId + sessionKey),
keeping per-session resume safety while letting independent OpenClaw
sessions in the same workspace run concurrently.

Refactor buildClaudeLiveKey() to share the new buildClaudeOwnerKey()
helper so the queue key and the live-session key cannot drift.

Refs: #91946

* test(cli-runner): pin owner-key hash + document buildClaudeOwnerKey contract

Add a golden-hash regression test for buildClaudeOwnerKey using the
exact legacy fixture, so a future refactor that reorders fields or
flips the JSON encoding can't silently orphan every deployed Claude
live session at upgrade. Hash verified empirically against the prior
inline sha256(JSON.stringify(...)) in buildClaudeLiveKey.

Add a JSDoc on buildClaudeOwnerKey explaining the cross-module contract
between the CLI run queue and the live-session map.

Refs: #91946

* docs(cli-runner): tighten buildClaudeOwnerKey contract comment

The previous comment claimed an encoding mismatch would orphan deployed
live sessions across upgrades. The Claude live-session registry is
process-local, so any restart already discards every entry — the real
invariant is that the queue path and live-session path produce
byte-identical owner keys *within a single process*, so a fresh queued
turn picks up the same live session the registry already holds. Update
the helper docstring and the golden-hash test description accordingly;
the pinned hash and behavior are unchanged.

* test(cli-runner): add owner-key concurrency demo script

A pure-Node, no-test-runner demo that reproduces the PR-head queue
behavior end-to-end: BEFORE-PR collapse (workspace lane), distinct-owner
overlap, and identical-owner serialization, all in one run with
millisecond-stamped event ordering. Useful as a low-overhead regression
check for the owner-key contract and as a maintainer-runnable proof
artifact for #91946.

* test(cli-runner): satisfy oxlint curly + no-promise-executor-return

Wrap single-statement if/for-of bodies in braces and rewrite the
sleep helper so its Promise executor is a void block instead of an
arrow with an implicit return. No behavior change; demo output and
the byte-equivalent slice fingerprints are unchanged.

---------

Co-authored-by: wanglu241 <wanglu241@jd.com>
2026-06-11 19:24:39 +09:00
Sunjae Kim
5d42ad6654 Stabilize A2A prompt cache metadata (#90173)
A2A session routing identifiers are needed for delivery provenance, but concrete session keys in extraSystemPrompt make the agent system prompt vary between otherwise identical handoffs. Keep the model-facing system context stable by describing high-cardinality session slots with placeholders while retaining concrete values in inputProvenance. Channel names stay concrete: they are low-cardinality (discord/slack/webchat/...), so they do not meaningfully fragment the cache, and they inform reply formatting on the receiving agent.

Constraint: OpenClaw contributor PRs require focused behavior proof and tests for prompt/cache-facing changes.

Rejected: Removing routing metadata entirely | would weaken model context for requester/target roles.

Rejected: Placeholdering channel values too | drops model-visible formatting context for negligible cache benefit (reviewer feedback).

Confidence: medium

Scope-risk: narrow

Directive: Keep concrete session identifiers out of extraSystemPrompt; preserve them in structured provenance or payload fields. Low-cardinality channel labels may stay model-visible.

Tested: node scripts/run-vitest.mjs src/agents/tools/sessions-send-helpers.test.ts src/agents/openclaw-tools.sessions.test.ts

Tested: corepack pnpm exec oxfmt --check src/agents/tools/sessions-send-helpers.ts src/agents/tools/sessions-send-helpers.test.ts src/agents/openclaw-tools.sessions.test.ts

Tested: node scripts/run-oxlint.mjs src/agents/tools/sessions-send-helpers.ts src/agents/tools/sessions-send-helpers.test.ts src/agents/openclaw-tools.sessions.test.ts

Tested: git diff --check

Tested: live before/after provider cache trace (isolated local gateway, two A2A sends from distinct requester sessions; see PR Real behavior proof)

Co-authored-by: Sunjae Kim <sunjaekim@bigvalue.co.kr>
2026-06-11 19:24:32 +09:00
xydt-tanshanshan
865fdab075 fix(memory): preserve live SQLite index during swaps
Fixes #91216.

Preserve the live memory SQLite index during atomic reindex swaps by publishing the POSIX main DB with an overwrite rename, keeping target WAL/SHM sidecars rollbackable until publish succeeds, and refusing no-create post-swap reopens that would otherwise auto-create an empty DB.

Verification:
- node scripts/run-vitest.mjs extensions/memory-core/src/memory/manager.atomic-reindex.test.ts extensions/memory-core/src/memory/manager-db-probe.test.ts extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts extensions/memory-core/src/memory/manager-reindex-state.test.ts extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts extensions/memory-core/src/memory/manager.readonly-recovery.test.ts
- git diff --check origin/main...HEAD
- node scripts/run-oxlint.mjs extensions/memory-core/src/memory/manager-atomic-reindex.ts extensions/memory-core/src/memory/manager.atomic-reindex.test.ts extensions/memory-core/src/memory/manager-db.ts extensions/memory-core/src/memory/manager-db-probe.test.ts extensions/memory-core/src/memory/manager-sync-ops.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- GitHub CI on 60df2b4178
2026-06-11 19:15:20 +09:00
openclaw-clownfish[bot]
c692fabeba fix(feishu): reply inside P2P direct-message threads (#92136)
* fix(feishu): keep P2P replies inside direct-message threads

* fix(clownfish): address review for ghcrawl-165996-agentic-merge (1)

* fix(clownfish): address review for ghcrawl-165996-agentic-merge (1)

Co-authored-by: LiaoyuanNing@TTC <259494737+LiaoyuanNing@users.noreply.github.com>

---------

Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: LiaoyuanNing@TTC <259494737+LiaoyuanNing@users.noreply.github.com>
2026-06-11 18:57:51 +09:00
lizeyu-xydt
1e878dde7c #92109: [Bug]: EmbeddedAttemptSessionTakeoverError caused by Btrfs ctimeNs instability (#92123)
* fix(session-lock): remove ctimeNs from session file fingerprint comparison

Btrfs background maintenance (snapshots, scrub, quota) updates ctime
without any file content change. Including ctimeNs in the fingerprint
causes false-positive EmbeddedAttemptSessionTakeoverError on all Btrfs
filesystems, breaking cron jobs and subagent spawns with 100% failure.

dev + ino + size + mtimeNs are sufficient to detect external writes —
any content change will also update mtimeNs and/or size. ctimeNs only
tracks metadata changes and adds no meaningful protection.

Closes #92109

* test(session-lock): cover ctime-only fence drift

* fix(session-lock): narrow ctime drift acceptance

* fix(session-lock): trust verified ctime drift

* fix(session-lock): bound ctime drift digest

* fix(session-lock): skip absent ctime digest

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 18:40:38 +09:00
Vincent Koc
dbf1b742be test(ci): align Anthropic agent expectations 2026-06-11 18:35:11 +09:00
Vincent Koc
7ae90845ab Merge branch 'perf/main-ci-tail' of https://github.com/openclaw/openclaw into perf/main-ci-tail
* 'perf/main-ci-tail' of https://github.com/openclaw/openclaw:
  perf(ci): isolate Docker tooling tests
2026-06-11 18:15:53 +09:00
Vincent Koc
f1401b2cac perf(ci): isolate Docker tooling tests 2026-06-11 18:13:36 +09:00
Vincent Koc
4430f89697 perf(ci): isolate Docker tooling tests 2026-06-11 17:40:04 +09:00
Ayaan Zaidi
0c4fc0a2e3 refactor(agents): derive CLI commentary classification from consumer presence 2026-06-11 13:57:39 +05:30
ragesaq
6396221858 fix(auto-reply): stop dropping claude-cli narration when commentary lane is off
After #91976, the claude-cli JSONL parser reclassifies assistant text that
precedes a tool_use block as commentary. The classification gate
(commentaryProgressEnabled !== undefined) was looser than the delivery gate
(commentaryProgressEnabled === true && onItemEvent), so any channel that
defined the flag as false engaged classification with no consumer wired:
flushPendingClaudeCommentaryText() called an undefined onCommentaryText and
silently discarded the text. On Discord with verbose off this dropped all
inter-tool narration and the pre-final-answer preamble text.

Two-layer fix:
- Align the classify gate with the delivery gate in both CLI dispatch sites
  (agent-runner-execution, followup-runner) so classification only engages
  when a commentary consumer exists.
- Defense in depth: flushPendingClaudeCommentaryText() now falls back to the
  assistant text lane instead of discarding when no consumer is wired, so no
  future gate mismatch can silently eat model output.

Reported on Discord: claude-cli backend lost interleaved narration and the
regular-text reasoning preamble with or without /verbose on.
2026-06-11 13:57:39 +05:30
Peter Steinberger
418d7e1e83 fix(clickclack): allow explicit enable through plugin allowlist (#92084)
Allow an explicit canonical ClickClack enable/setup selection to record ClickClack in a nonempty plugin allowlist, while preserving unrelated allowlist rejection, denylist authority, and global plugin disablement.

Validated at source head 24af9d8e75 with focused regressions, built-CLI disposable-config E2E, security checks, and autoreview. Merged under owner authorization despite the two documented untouched-main agent-core baseline failures.
2026-06-10 21:47:20 -07:00
Marvinthebored
8e81bf774e fix(sessions): preserve user model override across daily/idle rollover (#90128)
User-driven /model (and sessions.patch) overrides were dropped when a
session rolled over at the daily/idle reset boundary, reverting to the
configured default on the next turn despite the 'Model set to ... for
this session' ack. The override-preservation carryover in
initSessionState was gated on resetTriggered, so implicit stale
rollovers (the common case for always-on channel sessions) skipped it.

Run resolveResetPreservedSelection for any rollover that mints a new
session from an existing entry (explicit /new + /reset AND implicit
stale daily/idle). resolveResetPreservedSelection already preserves only
user-driven overrides and clears auto-fallback pins, so resets still
return to the default.

Adds regression tests in session.test.ts covering both cases.

Fixes #90119

Filed with AI assistance (OpenClaw agent); reviewed by @Peetiegonzalez.

Co-authored-by: Marvinthebored <marvinthebored@users.noreply.github.com>
2026-06-10 23:31:49 -04:00
Ayaan Zaidi
bd96e4d22d refactor(auto-reply): distill verbose commentary lane wiring 2026-06-11 08:18:04 +05:30
Ayaan Zaidi
317ba47543 test(discord): add onVerboseProgressVisibility to dispatch params type 2026-06-11 08:18:04 +05:30
Cameron Beeley
b25b8f396c fix(discord): yield the commentary draft while durable verbose progress is active
Discord consumes the dispatch verbose-progress visibility getter the same way
Telegram does: while the durable lane is delivering commentary as standalone
messages, the ephemeral progress draft skips its preamble lines so commentary
renders exactly once. Covered by an active/inactive regression pair.
2026-06-11 08:18:04 +05:30
Cameron Beeley
4ce1d7843a feat(auto-reply): emit durable tool summaries from CLI runner tool results
The CLI parser already emits tool result events (name, toolCallId, isError,
sanitized result), but the runner bridge dropped them, so CLI-backed runs had
no durable tool record under verbose while embedded runs did. The bridge now
forwards result events, and both runners feed a summary tracker that renders
the same formatToolAggregate line the embedded runner emits (meta captured
from the start event args), plus the tool output block when full verbose
output is enabled. Delivery rides each runner's existing tool-result route, so
verbose gating, ordering ahead of the final answer, and the Telegram durable
routing all apply unchanged.
2026-06-11 08:18:04 +05:30
Cameron Beeley
dc55a5b112 feat(telegram): route verbose progress payloads durably instead of into the streaming draft
With streaming on, the dispatcher diverted tool-kind payloads (including the
new durable commentary messages) into the ephemeral progress draft, where they
were discarded when the final answer arrived - so verbose runs lost their
progress record whenever streaming was enabled. While the durable verbose lane
is active (per the dispatch visibility getter), tool payloads are now sent as
real standalone messages and the draft yields its commentary lines; tool/plan
draft lines keep the draft since they have no durable counterpart. Reasoning
lane and tool status reactions are unaffected.
2026-06-11 08:18:04 +05:30
Cameron Beeley
a397fcabd9 feat(auto-reply): deliver inter-tool commentary as standalone verbose progress messages
When verbose progress is enabled, preamble item events now flush as durable
standalone progress messages through the same delivery path as tool summaries,
instead of living only in ephemeral channel streaming drafts. The latest text
per item id is buffered so snapshot-style producers send one message per item;
the buffer flushes when the producer moves on (next item, tool event, block
reply, or final reply) and drains before the final answer.

Verbose runs also force commentary classification on (commentaryProgressEnabled),
so inter-tool text routes to the commentary lane rather than being folded into
the final answer text.

Dispatch additionally exposes a live verbose-progress visibility getter via the
new onVerboseProgressVisibility reply option, so draft-rendering channels can
route progress to the durable lane while it is active.
2026-06-11 08:18:04 +05:30
Shubhankar Tripathy
0bcabea9cc fix(discord): scope command-deploy cache by application id (#77367)
* fix(discord): scope command-deploy cache by application id

Multi-bot Discord setups share a single command-deploy-cache.json under the
state dir. Cache keys were unscoped (`global:reconcile`, `guild:<id>`), so a
later account whose command set hashed identically to an earlier account would
hit the shared hash and skip its own application's command reconcile entirely
— Discord's Integration panel showed 'This application has no commands' for
the secondary bot even though gateway connect, application id, and token were
all valid.

Scope every cache key with `app:<clientId>:` so each Discord application
reconciles independently. Add regression tests covering: two applications with
identical command sets each call REST against their own application; a single
application with the same command set still hits the persisted cache; the
on-disk cache JSON contains application-scoped keys.

Fixes #77359.

* fix(discord): merge on-disk hashes inside persistHashes to survive concurrent writes

Codex follow-up on #77359 noted that server-channels.ts can start multiple
Discord deployers concurrently, so two deployers that both load the cache
file before either persists end up with the second writer overwriting the
first writer's app-scoped key — defeating the rate-limit cache that the
file exists to provide.

Inside persistHashes, re-read the on-disk cache and merge it with our
in-memory entries before the rename. Our in-memory entries always win on
key collisions (we just produced them); on-disk entries we don't have in
memory are preserved. Refresh in-memory state after the write so future
writes from the same deployer also keep entries other deployers added.

This is the lighter of the two repairs the codex review suggested
(re-read/merge vs serialize writes); it covers the realistic case where
one deployer writes before the other persists. Add a regression test that
exercises the load-then-other-deployer-writes-then-persist sequence.

* fix(discord): serialize command-deploy cache persists via in-process mutex

Codex follow-up on #77367 noted: re-read-before-write inside persistHashes
isn't enough — two deployers running persistHashes in true parallel can
both read the same snapshot before either writes, and the later rename
overwrites the earlier writer's app-scoped entries.

Add a module-level Map<storePath, Promise<void>> mutex and wrap the
read-merge-write cycle in withCachePersistLock so concurrent persists for
the same on-disk path serialize. In-process is sufficient because Discord
deployers only run inside the gateway process.

New regression test fires three deployers via Promise.all on the same
tick and asserts all three application-scoped entries survive — pre-fix
this race lost at least one entry.

* fix(discord): add override modifier on StaticCommand.description to satisfy strict TS

Current main enables noImplicitOverride; the StaticCommand test helper
re-declares the concrete BaseCommand.description property, which now
requires an explicit 'override' modifier (TS4114).

* test(discord): suppress typescript/unbound-method on vitest mock refs

The createRest() helper returns vi.fn() handlers cast as RequestClient,
so expect(rest.get).toHaveBeenCalledTimes(...) triggers
typescript/unbound-method 12 times. File-level disable: these are
vitest mock identities, not unbound class methods.

* fix(discord): clean up command cache lock

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

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-10 22:45:54 -04:00
Vincent Koc
d4fcc38696 fix(anthropic): repair Mythos contract types 2026-06-11 11:15:47 +09:00
Vincent Koc
25ca39e876 fix(tts): preserve async model discovery 2026-06-11 11:11:23 +09:00
Vincent Koc
7acedeaf11 fix(anthropic): require Mythos adaptive thinking 2026-06-11 11:11:23 +09:00
Vincent Koc
4a06e4c773 test(tts): update prepared completion contract 2026-06-11 11:11:23 +09:00
Vincent Koc
33f44d0f27 fix(tts): use prepared completion auth 2026-06-11 11:11:23 +09:00
Vincent Koc
f76bdffd06 fix(bedrock): normalize Mythos minimal effort 2026-06-11 11:11:23 +09:00
Vincent Koc
aa5b1bd697 fix(auth): apply runtime auth to labels 2026-06-11 11:11:22 +09:00
Vincent Koc
abec88cfab fix(auth): apply runtime request overrides everywhere 2026-06-11 11:11:22 +09:00
Vincent Koc
25bea06596 fix(foundry): type runtime auth result 2026-06-11 11:11:22 +09:00
Vincent Koc
08655fb02b fix(foundry): bind auth and thinking contracts 2026-06-11 11:11:22 +09:00
Vincent Koc
76ce9d6d22 fix(foundry): expose Claude 4.6 max effort 2026-06-11 11:11:22 +09:00
Vincent Koc
cc344fee65 fix(foundry): make API labels exhaustive 2026-06-11 11:11:22 +09:00
Vincent Koc
78d621855d fix(foundry): label Anthropic Messages onboarding 2026-06-11 11:11:22 +09:00
Vincent Koc
f0bbe4d95e fix(bedrock): guard Mantle thinking options 2026-06-11 11:11:22 +09:00
Vincent Koc
fdf1ec9f5c fix(bedrock): route Mythos through Mantle 2026-06-11 11:11:22 +09:00
Vincent Koc
d6081f99ff fix(bedrock): keep optional thinking opt-in 2026-06-11 11:11:22 +09:00
Vincent Koc
819fc0037a fix(foundry): align Claude thinking contracts 2026-06-11 11:11:22 +09:00
Vincent Koc
bebe96b402 test(foundry): type auth lookup 2026-06-11 11:11:22 +09:00
Vincent Koc
818c3c276e fix(foundry): preserve canonical thinking identity 2026-06-11 11:11:22 +09:00
Vincent Koc
7d681ab603 fix(vertex): default Mythos to adaptive thinking 2026-06-11 11:11:22 +09:00
Vincent Koc
e3b619505c fix(foundry): use bearer auth in native transport 2026-06-11 11:11:21 +09:00
Vincent Koc
72bfc5e9bf fix(foundry): preserve bearer auth intent 2026-06-11 11:11:21 +09:00
Vincent Koc
6b0f718b9a fix(providers): encode Mythos adaptive requests 2026-06-11 11:11:21 +09:00
Vincent Koc
9b57db3b60 fix(foundry): route bearer auth from headers 2026-06-11 11:11:21 +09:00
Vincent Koc
5e4a84e04f fix(vertex): preserve Mythos thinking policy 2026-06-11 11:11:21 +09:00
Vincent Koc
9ffb5407f7 fix(providers): reconcile Claude profile routing 2026-06-11 11:11:21 +09:00
Vincent Koc
4457ae3e35 fix(providers): preserve Claude output limits 2026-06-11 11:11:21 +09:00
Vincent Koc
a33e58cfbb test(anthropic): type Foundry auth fixtures 2026-06-11 11:11:21 +09:00
Vincent Koc
aabb8498da fix(providers): bound Claude model matching 2026-06-11 11:11:21 +09:00
Vincent Koc
55384bc236 fix(foundry): clamp Mythos Preview thinking 2026-06-11 11:11:21 +09:00
Vincent Koc
e7136005eb fix(types): narrow provider auth metadata 2026-06-11 11:11:21 +09:00
Vincent Koc
9db8601235 fix(foundry): avoid stale setup metadata 2026-06-11 11:11:21 +09:00
Vincent Koc
2031603542 fix(providers): preserve Mythos max routing 2026-06-11 11:11:21 +09:00
Vincent Koc
b4aa520b07 fix(bedrock): preserve Mythos Preview thinking policy 2026-06-11 11:11:21 +09:00
Vincent Koc
4afe616f22 fix(providers): apply auth patch deletions 2026-06-11 11:11:21 +09:00
Vincent Koc
06312ad805 fix(foundry): preserve Sonnet output limits 2026-06-11 11:11:20 +09:00
Vincent Koc
c4493943e1 fix(foundry): clear API key auth on Entra setup 2026-06-11 11:11:20 +09:00
Vincent Koc
15806717d9 fix(foundry): infer selected Claude routes 2026-06-11 11:11:20 +09:00
Vincent Koc
0a8dfc21be fix(foundry): align Claude onboarding contracts 2026-06-11 11:11:20 +09:00
Vincent Koc
378ae44aad fix(foundry): keep API-key Claude auth 2026-06-11 11:11:20 +09:00
Vincent Koc
b66f0b6ca3 fix(foundry): use bearer auth for Claude 2026-06-11 11:11:20 +09:00
Vincent Koc
2fa9a5eaa0 fix(foundry): scope Entra tokens by API 2026-06-11 11:11:20 +09:00
Vincent Koc
3ba617681f fix(providers): preserve adaptive Claude routing 2026-06-11 11:11:20 +09:00
Vincent Koc
7b0b6290b5 test: stabilize full Mac regression suite 2026-06-11 11:10:00 +09:00
Shakker
dac468c731 fix: repair update restart type checks 2026-06-11 02:59:39 +01:00
Vincent Koc
c78d27e4b3 fix(test): bound gateway harness teardown 2026-06-11 10:52:59 +09:00
Vincent Koc
16382e4066 perf(ci): move tooling off artifact critical path 2026-06-11 10:52:59 +09:00
Shakker
36b01d9534 test: update memory helper routing expectation 2026-06-11 02:30:35 +01:00
zengLingbiao
46a3442251 fix(web_fetch): sanitize URL whitespace from LLM tool call arguments (fixes #91651) (#91950)
* fix(web_fetch): sanitize URL whitespace from LLM tool call arguments (fixes #91651)

* fix(web_fetch): narrow URL repair to only strip scheme-authority whitespace

Constrain normalization to the reported malformed-scheme pattern
(whitespace between :// and authority) instead of deleting all
whitespace globally. Path and query spaces are intentionally
preserved — the WHATWG URL parser percent-encodes those correctly.

Add focused regression tests proving path/query/pct-encoded spaces
survive sanitization. (fixes #91651)

* fix(web-fetch): preserve scheme-like path text

* fix(web-fetch): preserve unicode URL text

* fix(web-fetch): retain raw URL argument text

* fix(web-fetch): trim leading unicode URL whitespace

* fix(web-fetch): normalize bare authority whitespace

* fix(web-fetch): satisfy URL sanitizer checks

* fix(web-fetch): pass URL sanitizer lint

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 10:21:07 +09:00
Shakker
400b3e04fb fix: stop unknown-root git services 2026-06-11 02:18:47 +01:00
Shakker
535b1f53ac fix: keep update status polling read only 2026-06-11 02:18:47 +01:00
Shakker
082a4fa6a5 fix: preserve consumed update restart status 2026-06-11 02:18:47 +01:00
Shakker
df52f8ff64 fix: fail stopped service update restart errors 2026-06-11 02:18:47 +01:00
Shakker
2e9c93cb3a fix: isolate update tests from supervisor env 2026-06-11 02:18:47 +01:00
Shakker
80fc9b112b fix: avoid stale cleanup for reachable gateway probes 2026-06-11 02:18:47 +01:00
Shakker
6d32e42a99 fix: continue dev preflight after manager failures 2026-06-11 02:18:47 +01:00
Shakker
9db41b3876 fix: verify managed git restart service 2026-06-11 02:18:47 +01:00
Shakker
dee334f17a fix: constrain dev remote fallback 2026-06-11 02:18:47 +01:00
Shakker
05996096cc fix: clean up failed dev branch fallback 2026-06-11 02:18:47 +01:00
Shakker
54040c6c0e fix: roll back failed dev upstream setup 2026-06-11 02:18:47 +01:00
Shakker
34f2446601 fix: preserve dev branch upstream after fallback 2026-06-11 02:18:47 +01:00
Shakker
4ef71795bc fix: honor selected dev preflight fallback 2026-06-11 02:18:47 +01:00
Shakker
ba2782e64c fix: stop package service during dev switch 2026-06-11 02:18:47 +01:00
Shakker
cf6572dea4 fix: preserve git mode for supervised handoff 2026-06-11 02:18:47 +01:00
Shakker
a7b58f21c5 fix: resolve dev preflight manager from candidate 2026-06-11 02:18:47 +01:00
Shakker
6b3bcc986f fix: require service identity for update handoffs 2026-06-11 02:18:47 +01:00
Shakker
124625ec4d fix: stop git service when switching install roots 2026-06-11 02:18:47 +01:00
Shakker
e44835ae29 fix: resolve dev update preflight from remote main 2026-06-11 02:18:47 +01:00
Shakker
906158f572 fix: extend managed update handoff polling 2026-06-11 02:18:47 +01:00
Shakker
e1c4c3151c fix: keep gateway stopped after plugin update failure 2026-06-11 02:18:47 +01:00
Shakker
6ea8ac7ea3 fix: defer git update service stop until mutation 2026-06-11 02:18:47 +01:00
Shakker
f385aaab8a fix: protect managed git update service ownership 2026-06-11 02:18:47 +01:00
Shakker
3394a4ad2c fix: refresh update status sentinel 2026-06-11 02:18:47 +01:00
Shakker
862790fbd7 fix: verify supervised update handoff in UI 2026-06-11 02:18:47 +01:00
abnershang
a98c158414 docs: align supervised git update handoff docs 2026-06-11 02:18:47 +01:00
abnershang
48ed8f3e81 fix(update): hand off supervised git updates 2026-06-11 02:18:47 +01:00
abnershang
3a2adf856b fix: hand off supervised git updates 2026-06-11 02:18:47 +01:00
Vincent Koc
f03e3372b3 test(memory): type self-heal identity assertions 2026-06-11 10:10:30 +09:00
NVIDIAN
0328ca53cd fix(agents): honor configured CLI resume timeouts (#90912)
* fix(agents): honor configured CLI resume timeouts

* fix(agents): pass explicit CLI timeout overrides

* fix(agents): preserve timeout provenance

* test(agents): cover configured timeout provenance

* fix(agents): resolve CLI timeout provenance centrally

* fix(agents): carry reply timeout provenance

* fix(agents): forward CLI run lane

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 10:05:43 +09:00
Peter Steinberger
88038db3e5 fix(agent): dampen Discord stale thread replies (#91962)
* fix(agent): dampen Discord stale thread replies

* fix(agent): scope Discord quiet prompt by chat type

* fix(agent): forward queued chat type

---------

Co-authored-by: Dallin Romney <dallinromney@gmail.com>
2026-06-10 18:02:58 -07:00
Sergio Cadavid
610691c796 fix(media): resolve state-relative inbound attachments (#92055)
* fix(media): resolve state-relative inbound attachments

* fix(media): preserve cwd attachment precedence

* test(media): cover relative path collisions

* fix(media): skip blocked cwd collisions

* fix(media): validate relative candidates before selection

* fix(media): canonicalize candidate roots

* fix(media): preserve lexical candidate handoff

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 10:00:02 +09:00
Hansraj Singh Thakur
eb673e5c3d fix(cron): structural top-of-hour match in stagger heuristic (#92030)
* fix(cron): structural top-of-hour match in stagger heuristic

Top-of-hour detection used includes('*') on the hour field, accepting
malformed tokens like '5*'. Match only '*' or '*/N' structurally.

* fix(cron): preserve wildcard hour lists

* fix(cron): support question-mark wildcard

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 09:59:57 +09:00
Chunyue Wang
f194be19e2 fix(diagnostics): release wedged session lane when stuck recovery aborts with queued session work (#91802) 2026-06-11 09:59:22 +09:00
xydt-tanshanshan
1a2eb74b9d fix(memory): self-heal missing index identity by initializing provider during sync (#91897)
* [AI] fix(memory): self-heal missing index identity by initializing provider during sync

When a gateway's memory index identity becomes "missing" with chunks
already indexed, canRebuildMissingIdentity stays false because
this.provider is null (async provider init hasn completed yet), so
needsMissingIdentityReindex is false, and the sync loop bails out
with dirty=true forever — the gateway never self-heals.

Now, when indexIdentity.status is "missing" and a provider is
configured but this.provider is null, runSync() calls
ensureProviderInitialized() first, then re-evaluates the identity
state. If the provider becomes available,
canRebuildMissingIdentity flips to true, unlocking the self-heal
reindex path.

Refs #91167

* [AI] fix(memory): allow FTS-only self-heal when chunks are all FTS-only and provider unavailable

When a gateway's memory index identity is 'missing' with chunks already
indexed, canRebuildMissingIdentity stays false if the embedding provider
is unavailable, causing the sync loop to bail out with dirty=true forever.

The previous approach (calling ensureProviderInitialized inside runSync)
was redundant because the public sync() method already initializes the
provider before runSyncWithReadonlyRecovery.

The real fix: when every existing chunk has model='fts-only', rebuilding
the index as FTS-only is safe — no semantic data is lost. So
canRebuildMissingIdentity should also be true when hasOnlyFtsChunks,
even if the provider is unavailable.

Also adds hasSemanticChunks() helper to detect whether any chunks have
a non-fts-only model.

Non-forced test: seeds FTS-only chunks with no meta, syncs without
force, verifies identity transitions from 'missing' to 'valid'.

Refs #91167

* [AI] fix(memory): gate hasSemanticChunks scan to missing-identity path only

Only compute hasOnlyFtsChunks when identity is missing, chunks exist,
and the provider is unavailable. This avoids scanning the chunks table
for model classification on every ordinary sync.

* test(memory): protect semantic index self-heal

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 09:53:01 +09:00
Vincent Koc
adad27d744 fix(exec): honor state dir approvals (#92056) 2026-06-11 09:30:56 +09:00
Vincent Koc
d559dfecfa test(models): complete blank-base-url provider fixture 2026-06-11 08:53:41 +09:00
Alix-007
f5dd33c975 fix(control-ui): make Control UI bootstrap config endpoint base-path-relative (#66946) (#91305)
* fix(control-ui): make bootstrap config endpoint base-path-relative (#66946)

CONTROL_UI_BOOTSTRAP_CONFIG_PATH embedded a hard-coded /__openclaw prefix
instead of being base-path-relative. When the Control UI is served under
/__openclaw__/, both the gateway and the browser loader compose
${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}, producing the doubled
/__openclaw__/__openclaw/control-ui-config.json URL that 404s.

Make the constant base-path-relative (/control-ui-config.json) so the
composed URL is correct under any base path, align the Vite dev stub and
the docs, and add gateway.controlUi.basePath "/__openclaw__" coverage.

* fix(control-ui): serve bootstrap config at default __openclaw__ entry (#66946)

The reporter runs the default deployment (no gateway.controlUi.basePath),
so the Control UI SPA is mounted under the default /__openclaw__/ namespace.
A browser opening that entry infers basePath="/__openclaw__" from the URL
(inferBasePathFromPathname) and fetches /__openclaw__/control-ui-config.json,
but an empty-base-path gateway only served the bare /control-ui-config.json,
so the default-entry bootstrap request 404'd and chat never finished loading.

Make handleControlUiHttpRequest also accept the default-namespace alias
/__openclaw__/control-ui-config.json when no base path is configured. The
alias is derived from the existing CONTROL_UI_NAMESPACE_PREFIX mount constant
and is purely additive: the bare /control-ui-config.json endpoint and the
configured-base-path endpoint are both preserved (no route removed).

Add gateway HTTP coverage for the real default-entry scenario (empty base
path + /__openclaw__/... request) that fails without the alias, alongside the
configured-base-path, bare-path compatibility, and doubled-path 404 cases.

* fix(control-ui): preserve legacy bootstrap endpoint as compat alias (#66946)

Current main and v2026.6.1 serve and document the single-underscore
/__openclaw/control-ui-config.json bootstrap endpoint under an empty
base path (that literal was CONTROL_UI_BOOTSTRAP_CONFIG_PATH before the
path was made base-path-relative). Making the constant relative dropped
that match, so older bundles and clients hitting the documented endpoint
would 404 after upgrading.

Accept the legacy single-underscore path as an empty-base-path
compatibility alias in matchesControlUiBootstrapConfigPath, derived from
the legacy /__openclaw namespace joined with the canonical config
constant (so it tracks any filename rename) and named
LEGACY_BOOTSTRAP_CONFIG_PATH with a comment. The canonical
/control-ui-config.json and the default-namespace
/__openclaw__/control-ui-config.json aliases are unchanged; only this
path is added. The doubled /__openclaw__/__openclaw/... path still 404s.

Add a focused regression that the legacy endpoint returns config under an
empty base path; it 404s without the alias (verified non-vacuous).

* fix(control-ui): preserve legacy bootstrap route under configured base path (#66946)

The previous revision preserved the single-underscore
/__openclaw/control-ui-config.json bootstrap endpoint only under an empty
base path. A deployment with a configured gateway.controlUi.basePath
(e.g. /x) served and documented that endpoint at
${basePath}/__openclaw/control-ui-config.json before this PR made the
config path base-path-relative, so configured-base-path users, older
bundles, and clients that still request it would 404 after upgrading.

Extend matchesControlUiBootstrapConfigPath so the legacy single-underscore
suffix is accepted under every base path, not just the empty one. The
matcher now checks the canonical and legacy suffixes uniformly as
${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH} and
${basePath}${LEGACY_BOOTSTRAP_CONFIG_PATH} for both the empty and
configured cases, reusing the existing LEGACY_BOOTSTRAP_CONFIG_PATH
constant (no new hard-coded literal). The default-namespace
/__openclaw__/control-ui-config.json alias stays empty-base-path-only
(it is the path the inferred default entry requests when no base path is
configured). All three empty-base-path behaviors are unchanged; the
doubled /__openclaw__/__openclaw/... path still 404s under both an empty
and a configured base path.

Add a focused regression that the configured-base-path legacy endpoint
returns the bootstrap config; it 404s without the alias (verified
non-vacuous). No CHANGELOG.md change.

* fix(ui): mount config stub under vite base

* fix(ui): preserve default config stub route

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 08:49:09 +09:00
Vincent Koc
439d2a9404 fix(ui): narrow submitted progress status 2026-06-11 08:43:19 +09:00
Ahmed Tarek
c66fd30595 🐛 fix(openai): remove chatgpt-responses transport override from gpt-5.3-codex catalog entry (#91720)
* fix(openai): remove chatgpt-responses transport override from gpt-5.3-codex catalog entry

The static catalog entry for gpt-5.3-codex hardcoded
api: "openai-chatgpt-responses" and baseUrl: "https://chatgpt.com/backend-api",
forcing all users through the ChatGPT backend — which requires OAuth, not
a standard API key. This broke gpt-5.3-codex for every API-key user after
v2026.6.1.

Remove the transport overrides so the model inherits the provider defaults
(openai-responses + api.openai.com/v1), restoring v2026.5.18 behavior.
OAuth/Codex users are unaffected — dynamic model resolution in
shouldResolveDynamicModelThroughCodex handles the ChatGPT routing based
on provider config, not the static catalog entry.

* ci: retrigger opengrep scan (transient install failure)
2026-06-11 08:42:46 +09:00
Yuval Dinodia
98e239d012 fix(models): keep bundled provider catalog when configured base URL is blank (#91270) (#91292)
* fix(models): keep bundled provider catalog when configured base URL is blank

A models.providers.<id> entry with a blank baseUrl ("") erased the
bundled provider catalog from the generated model registry. The empty
base URL flowed into provider discovery and the catalog merge, where it
overrode the bundled transport URL; the resulting provider then failed
isWritableProviderConfig and was dropped from models.json entirely.

For Google this meant gemini-2.5-flash and gemini-flash-latest stopped
resolving on the embedded runtime with model_not_found, even though the
bundled Google catalog and forward-compat resolver know those ids. This
hit users in merge mode whose config was partially written by a plugin.

Strip blank provider base URLs before discovery and merge so a blank
value means "use the provider default" instead of clobbering it.

Fixes #91270

* test(models): cover blank provider base url

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 08:40:46 +09:00
Ahmed Tarek
69f31ebb0c 🐛 fix(agents): classify harness provider mismatch as format error (#91710) (#91711)
* fix(agents): classify harness provider mismatch as format error (#91710)

When an agent harness rejects a model because the provider id is not in
its supported set, the error message was unclassified — falling through
to reason="unknown" in the model fallback notice. This made harness
provider mismatches (e.g. stale codex plugin rejecting openai/gpt-5.3-codex)
invisible to the user.

Add a format error pattern for the harness rejection message so the
fallback notice reports "format" instead of "unknown".

* ci: retrigger with real behavior proof in PR body
2026-06-11 08:40:41 +09:00
FMLS
84241461fd feat(cron): add readable ISO time fields to cron runs JSON output (#91471)
Adds `tsIso`, `runAtIso`, and `nextRunAtIso` to the JSON output of the `cron runs` command. This enhancement derives local-offset ISO 8601 strings from the existing numeric timestamps purely at the display layer, matching the diagnostic log format. The underlying SQLite storage, protocol schema, and raw numeric fields remain completely unchanged to ensure strict backward compatibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 08:37:28 +09:00
Hansraj Singh Thakur
8ff77c8168 fix(gateway): recover config hot-reload after watcher errors (#92027)
A chokidar watcher 'error' permanently disabled config hot-reload with a
single warn. Re-create the watcher with bounded backoff (500ms/2s/5s, 3
retries); on exhausted budget escalate to log.error and flip a persistent
hotReloadStatus to disabled. stop() clears any pending re-create timer.
2026-06-11 08:36:23 +09:00
Yzx
ecf29d74ec fix(opencode-go): add qwen plus tiered pricing (#91351)
* fix(opencode-go): add qwen plus tiered pricing

* fix(usage): tier pricing by total prompt tokens

* fix(pricing): select qwen tiers by prompt tokens

* fix(pricing): preserve tier basis across catalogs

* fix(opencode-go): keep qwen pricing representable

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 08:36:14 +09:00
Hansraj Singh Thakur
003b3ca6b2 perf(agents): sanitize compaction messages once for token estimation (#92026)
splitMessagesByTokenShare wrapped each message in a 1-element array and
double-cloned it per message. Sanitize the full array once and pass a
precomputed per-message token count array; totals unchanged, allocations
reduced.
2026-06-11 08:30:47 +09:00
Hansraj Singh Thakur
91cc69d70e perf(agents): memoize XML attribute regex in DSML stream parser (#92034)
parseXmlAttribute compiled a fresh RegExp on every call in the DeepSeek DSML
streaming parser. Memoize by attribute name via a module-level Map and escape
the name; behavior unchanged (non-global regex).
2026-06-11 08:30:27 +09:00
Hansraj Singh Thakur
201b5f312f fix(tools): surface unsupported-signal in anyOf availability (#92029)
An anyOf availability group swallowed a nested unsupported-signal authoring
error when any sibling branch parsed as available. Propagate unsupported-signal
diagnostics regardless of sibling availability.
2026-06-11 08:30:04 +09:00
zhang-guiping
feb030f2c6 fix(ui): show prompt progress while sending (#91215)
* fix(ui): show prompt progress while sending

* fix(ui): scope prompt progress to queued send state

* fix(ui): prioritize newer submitted progress

* fix(ui): keep submitted progress authoritative

* fix(ui): preserve current-run terminal status

* test(ui): keep terminal fixture current

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 08:29:44 +09:00
AMARA
f049477dd4 fix(memory): write dream fallback without subagent runtime (#90121) 2026-06-11 08:29:22 +09:00
Ruben Cuevas Menendez
2b89623c50 fix(xai): clarify x_search query guidance (#91163) 2026-06-11 08:29:07 +09:00
Hansraj Singh Thakur
a4a4c76617 fix(sessions): derive channel from direct-chat session keys in send-policy (#92022)
deriveChannelFromKey returned undefined for direct/dm keys, so channel-scoped
send rules never fired for direct chats. Treat parts[0] as the channel for
direct/dm keys too, matching deriveChatTypeFromKey.
2026-06-11 08:28:21 +09:00
Hansraj Singh Thakur
b9e1099f5e fix(gateway): log swallowed background-task finalization errors (#92033)
Background-task finalization swallowed all errors. Log via formatForLog at
warn so non-transient failures are observable, staying non-blocking.
2026-06-11 08:28:03 +09:00
Hansraj Singh Thakur
5d6899c731 fix(mcp): always log channel-bridge notification failures (#92032)
MCP channel-bridge notification failures were only logged when verbose,
otherwise swallowed. Emit one low-noise diagnostic always; gate only the
error detail behind verbose.
2026-06-11 08:27:45 +09:00
zengLingbiao
15498f88fb fix(memory-core): check SQLite plugin state for dreaming ingestion audit after JSON migration (fixes #92017) (#92020)
* fix(memory-core): check SQLite plugin state for dreaming ingestion audit after JSON migration (fixes #92017)

* fix: add SQLite-only regression tests for dreaming ingestion audit (fixes #92017)
2026-06-11 08:26:50 +09:00
Vincent Koc
3659ff8bbf fix(agents): prefer explicit sessions_send keys (#92047)
Honor caller-provided sessionKey values when stale label metadata is also present, and keep denied session-id sends from echoing the resolved canonical session key.

Supersedes openclaw/openclaw#74009 and fixes openclaw/openclaw#64699.

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-11 08:26:25 +09:00
Harjoth Khara
f995f9f411 fix(fal): parse raw completed queue results (#92051) 2026-06-11 08:25:25 +09:00
Truffle
13dce48321 fix(state): tolerate chmod-less state volumes
Keep state database startup working on filesystems without POSIX chmod support while failing closed for ordinary EPERM ownership failures. EPERM is tolerated only for already-private targets or when a disposable same-directory probe confirms chmod is unsupported.\n\nFixes #91919
2026-06-11 08:20:42 +09:00
Seth Moffett
1b23e73830 Issue 89661: add unit test 2026-06-11 00:13:44 +01:00
Seth Moffett
54db3f9df5 Issue 89661: Skill toggle bug fix 2026-06-11 00:13:44 +01:00
Vincent Koc
cae66a7d5b test(ci): restore upgrade survivor session fixture (#92049) 2026-06-11 08:06:19 +09:00
Dallin Romney
2db65e5778 fix(macos): clarify unsupported voice wake UI (#91754) 2026-06-10 15:59:03 -07:00
Dallin Romney
c7ed990769 fix: preserve non-oneOf schema array order (#91891) 2026-06-10 15:58:30 -07:00
Shakker
a450ff036a fix: repair origin main CI failures 2026-06-10 23:49:41 +01:00
Vincent Koc
a7b0d325af fix(sessions): repair shipped stale transcript paths 2026-06-11 07:30:34 +09:00
Vincent Koc
0923ee251e fix(sessions): rewrite migrated transcript paths 2026-06-11 06:41:37 +09:00
Agustin Rivera
e15b646f18 fix(security): block build tool env overrides (#92007) 2026-06-10 14:41:25 -07:00
Alex Knight
3faf669801 Redact tool output secrets (#85196)
* redact tool output secrets

* Expand tool-output secret redaction

* fix(security): keep redaction prefilter in sync with expanded defaults

- build DEFAULT_REDACT_PREFILTER_RE from sources covering every default
  pattern family: new vendor prefixes, webhook hosts, bare query/form keys,
  userinfo/connection-string passwords, and percent/plus/invisible
  obfuscated keys (including trailing separator splices)
- run default-pattern redaction tests through the default options path and
  redact the vendor corpus per token so prefilter gaps fail tests
- fix quoted standalone assignment values containing the other quote char
  or an unterminated quote; never re-mask *** placeholders
- align net-policy URL query-name separator stripping with logging key
  normalization (Hangul fillers)

* fix(security): keep base64-prefix redaction out of media payloads

- pure-base64-alphabet token prefixes (gAAAA, AKIA, ASIA, dapi,
  ATCTT3xFfG, ATATT, ATBB) now require a non-alphanumeric left boundary,
  skip explicit ;base64, payload spans, and run unchunked so chunk starts
  cannot fake the boundary or hide the container from the lookbehind
- tokens after URL/path delimiters or assignments still mask; data-URL
  media survives redaction byte-identical (fixes chat media mirror CI)
- regression tests: tiny-PNG data URL, in-blob plus boundary,
  chunk-aligned large data URL, reset-path Fernet token, path AWS key

---------

Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
2026-06-11 07:34:50 +10:00
Vincent Koc
2d404f1b86 fix(release): align survivor session migration assertion 2026-06-11 05:57:55 +09:00
Vincent Koc
8042ec4cb8 fix(qa): scope runtime parity mock requests 2026-06-11 05:19:10 +09:00
anagnorisis2peripeteia
f1f00cbf1d fix: capture cron wake origin session
Capture the originating sessionKey and agentId for cron wake tool calls so non-main session and multi-agent wakes return to the conversation lane that requested them.

Carry stored delivery context through queued wake events so topic/thread replies route correctly, while preserving the default no-origin wake behavior and explicit target:none opt-out.

Refs #46886.
Refs #64556.
Thanks @anagnorisis2peripeteia.

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>
2026-06-10 20:52:40 +01:00
Vincent Koc
e6b0a22f36 test(update): align corrupt plugin repair guidance 2026-06-11 04:33:25 +09:00
Colin
2129d5b3ab fix(control-ui): refine mobile chat layout 2026-06-10 20:03:13 +01:00
Vincent Koc
797aab5ab7 test(memory): create rem preview source fixture 2026-06-11 03:52:40 +09:00
Shakker
d7f211b2b4 test: clarify usage cache refresh proof 2026-06-10 19:25:35 +01:00
Edward Abrams
e6e27c354c perf(usage-cost-cache): throttle full-cache rewrites during refresh
refreshCostUsageCache rewrote the entire .usage-cost-cache.json after every
single scanned session. With ~8190 stale session files and a 108MB cache, that
was O(N * cacheSize) work — sustained CPU burn from repeated 100MB+
JSON.stringify + atomic-replace cycles every refresh.

Checkpoint policy now batches durable writes:
  - At most one rewrite per 256 scanned files
  - Or one rewrite per 5s of wall time
  - Final write only when something actually changed (no-op refresh on a
    fully-fresh cache no longer rewrites the file)

Crash safety is preserved: an interrupted refresh still has a recent
checkpoint on disk, and the next run rescans only the unfinished tail
(file size + mtime + pricingFingerprint match).

Validation:
  - pnpm vitest run src/infra/session-cost-usage.test.ts (39/39 pass)
  - New test 'throttles cache writes during a large stale refresh' confirms
    cache renames stay below sessionCount/4 (was ~sessionCount+1) and that
    a no-op refresh issues zero cache writes.
  - pnpm check:changed (clean)

Beads: openclaw-0zr
2026-06-10 19:25:35 +01:00
Shakker
a69dd43de5 docs: document command timeout result code 2026-06-10 19:21:35 +01:00
NVIDIAN
781e03a179 fix(process): return timeout code for killed commands 2026-06-10 19:21:35 +01:00
Vincent Koc
fbb1eab4c7 fix(release): raise package scan headroom 2026-06-11 03:07:13 +09:00
Vincent Koc
5d2eb3c3e1 fix(release): stabilize candidate validation 2026-06-11 03:04:02 +09:00
Vincent Koc
aeb537c286 test(reply): type direct delivery payloads 2026-06-11 02:59:32 +09:00
Vincent Koc
caa83b0c42 fix(reply): retain direct fragment boundaries 2026-06-11 02:59:32 +09:00
Vincent Koc
c944986c7b fix(reply): preserve meaningful final whitespace 2026-06-11 02:59:32 +09:00
Vincent Koc
a427aff304 test(agents): expose assistant message index 2026-06-11 02:59:32 +09:00
Vincent Koc
32f8411256 test(agents): update embedded subscription fixture 2026-06-11 02:59:32 +09:00
Vincent Koc
40da4a31d2 fix(reply): preserve direct block emission order 2026-06-11 02:59:32 +09:00
Vincent Koc
ea7e5ee436 fix(reply): preserve direct block emission order 2026-06-11 02:59:32 +09:00
Vincent Koc
756e9d16a0 fix(reply): carry final assistant message identity 2026-06-11 02:59:32 +09:00
Vincent Koc
80dc6cf6ab fix(reply): use latest direct assistant group 2026-06-11 02:59:31 +09:00
Vincent Koc
8f82f5809f fix(reply): match direct fragments to final messages 2026-06-11 02:59:31 +09:00
Vincent Koc
389c0aba98 fix(reply): group direct block delivery evidence 2026-06-11 02:59:31 +09:00
Vincent Koc
41000143a1 fix(reply): dedupe directly sent block media 2026-06-11 02:59:31 +09:00
Vincent Koc
a1546fae80 fix(reply): preserve repeated direct block fragments 2026-06-11 02:59:31 +09:00
Vincent Koc
551664c3c5 fix(reply): dedupe combined direct block text 2026-06-11 02:59:31 +09:00
Vincent Koc
7a4f678a29 test(reply): assert unmatched final text 2026-06-11 02:59:31 +09:00
Vincent Koc
c7c695c208 fix(reply): preserve unmatched final text 2026-06-11 02:59:31 +09:00
Vincent Koc
9ef34da5a9 fix(reply): cover direct streaming block media 2026-06-11 02:59:31 +09:00
Vincent Koc
9922da3965 fix(reply): dedupe media finals after direct blocks 2026-06-11 02:59:31 +09:00
Ted Li
50c2068268 fix(cron): use final-call usage for session token totals (#91737)
* fix: use final-call usage for cron session totals

* fix(cron): preserve usable token snapshots

* fix(cron): derive token snapshots with fallback

* fix(cron): separate session and run token totals

* fix(cron): derive cumulative telemetry totals

* fix(cron): include cached usage in run totals

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 02:39:06 +09:00
Yuval Dinodia
b0520fa320 fix(cron): reject cron expressions that have no reachable run time (#91688)
* fix(cron): reject cron expressions with no reachable run time

A structurally valid but never-matching cron expression (for example
"0 0 30 2 *", Feb 30) was accepted at registration and then silently never
fired: the job showed as enabled but never ran, never appeared in the run
log, and was never auto-disabled.

croner constructs these expressions without error and nextRun() returns null,
so computeNextRunAtMs returns undefined with no throw, and the scheduler
treated it as "no work to do".

Add a satisfiability assertion at the shared service validation boundary
(createJob and applyJobPatch) so every create/update path and every caller
(gateway RPC, CLI, and the in-process host-hook and dreaming callers that
bypass the RPC layer) rejects an expression with no upcoming run time.
applyJobPatch only asserts when the patch changes the schedule, so a job that
predates this change can still be disabled or repaired. The thrown message is
classified by the gateway as INVALID_REQUEST.

* fix(cron): validate enabled schedules with service clock

* fix(cron): keep schedule validation service-owned

* fix(cron): validate staggered schedule reachability

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 02:39:00 +09:00
Vincent Koc
cdd923c286 docs(changelog): refresh 2026.6.6 notes 2026-06-11 02:34:35 +09:00
Vincent Koc
09356fb985 docs(release): align remaining patch placeholders 2026-06-11 02:33:19 +09:00
Vincent Koc
fb9dc867b1 docs(release): fix sequential patch numbering 2026-06-11 02:31:50 +09:00
Hemant Sudarshan
2e4bf410a2 fix(browser): honor cdp url for default session profile (#80143) 2026-06-10 12:36:11 -04:00
Andy Ye
33a3e05683 fix(webchat): finalize provider failure lifecycle (#91895)
* fix(webchat): finalize provider failure lifecycle

* chore: narrow fallback failure lifecycle marker

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

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-10 12:33:21 -04:00
Devin Robison
49737a50af Fail closed on exec approval timeout (#89938)
* Fail closed on exec approval timeout

* Align exec approval fallback default docs
2026-06-10 10:32:54 -06:00
kun9053
78a5e3ede7 fix(agents): preserve reasoning_content replay for Gemma 4 openai-completions models (#91645) (#91696)
Gemma 4 models via openai-completions (vLLM, OpenRouter, etc.) were
silently losing in-turn reasoning_content during multi-turn tool replay.
Unlike DeepSeek/Xiaomi which 400 on the missing field, these providers
accept the request but produce degraded tool-call quality — arguments
collapse to {} and identical tool calls repeat.

Root cause: two places explicitly excluded Gemma 4 from reasoning replay:

1. shouldTrustReasoningContentReplayMetadata() in openai-transport-stream
   skipped Gemma 4 by model ID, causing sanitizeCompletionsReasoningReplayFields
   to strip reasoning_content before sending.

2. buildOpenAICompatibleReplayPolicy() in provider-replay-helpers forced
   dropReasoningFromHistory=true for Gemma 4, discarding reasoning blocks
   during history sanitization before they reached the transport layer.

Remove both exclusions. Gemma 4 on non-OpenAI providers now preserves
in-turn reasoning through the existing provider/metadata checks that
already gate this correctly for other reasoning models.

Co-authored-by: wangyk <prowangyankun@foxmail.com>
2026-06-11 01:30:59 +09:00
shushushu
7926acee98 fix(talk): show OpenAI Realtime WebRTC assistant transcripts (#90426)
* fix(talk): show OpenAI Realtime WebRTC assistant transcripts

Handle current OpenAI Realtime assistant transcript events on the direct
WebRTC transport (response.output_audio_transcript.delta/done,
response.output_text.*), keep legacy response.audio_transcript.*
compatibility, and dedup duplicate assistant bubbles in the shared
conversation aggregation so both webrtc and gateway-relay benefit.

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

* fix(talk): drop unsafe transcript rewrite heuristic

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 01:16:21 +09:00
Vincent Koc
17be26bc4f fix(gateway): arm qmd startup maintenance
Fix Gateway QMD startup so interval and embedding maintenance are armed when configured, even when the immediate on-boot update is disabled.
2026-06-11 01:13:41 +09:00
weiqinl
7a34986ef0 fix(mcp): repair OAuth redirect, errors, and unicode schema patterns (#91451)
* fix(mcp): repair OAuth redirect, errors, and unicode schema patterns

Fixes #91433

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): address PR review on redirect compat and OAuth errors

Preserve 127.0.0.1 default, retry DCR with localhost on rejection,
fix lint no-new probes, and test body-less OAuth error responses.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): preserve OAuth redirect and error body text

Persist localhost redirect after DCR fallback so --code exchange matches,
and read body-less foreign response text before SDK error parsing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): harden OAuth and schema normalization

* fix(mcp): persist only successful OAuth fallback

* fix(mcp): preserve OAuth retry state

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 01:10:20 +09:00
Jason (Json)
e24c3df27d fix(auth): verify SQLite auth migration before cleanup
Verify imported auth profiles and auth runtime state can be read back from the canonical SQLite store before doctor removes legacy JSON auth files.

The migration now keeps legacy JSON in place with a warning when SQLite verification misses expected imported data, avoiding source cleanup before durable readback succeeds.

Verification:
- `scripts/pr review-tests 91740 src/commands/doctor-auth-flat-profiles.test.ts`
- `./node_modules/.bin/oxfmt --check src/commands/doctor-auth-flat-profiles.ts src/commands/doctor-auth-flat-profiles.test.ts`
- `./node_modules/.bin/oxlint src/commands/doctor-auth-flat-profiles.ts src/commands/doctor-auth-flat-profiles.test.ts`
- `scripts/pr-prepare gates 91740` passed `pnpm build` and `pnpm check`; broad local `pnpm test` failed in unrelated baseline shards and was explicitly waived by maintainer.
- Real behavior proof: https://github.com/openclaw/openclaw/actions/runs/27285927515/job/80593035530

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Co-authored-by: Radek Sienkiewicz <velvet-shark@users.noreply.github.com>
2026-06-10 17:18:18 +02:00
Vincent Koc
dbcae5b78d fix(memory): keep ignored-name QMD roots watchable
Fix QMD watcher ignore handling for explicitly configured roots whose directory names are normally ignored, and prefer the most-specific configured watch root for overlapping collections.

Validated with focused QMD/tooling tests, full core support boundary tests, green CI, and ClawSweeper re-review.
2026-06-11 00:12:15 +09:00
Peter Steinberger
0e7b5c3429 feat(anthropic): support Claude Fable 5 adaptive thinking (#91882)
* feat(anthropic): support Claude Fable 5

* test(anthropic): tighten Fable stream fixtures

* fix(anthropic): preserve Vertex input types

* test(anthropic): use provider-ready Vertex effort

* fix(anthropic): support Fable deployment aliases

* fix(anthropic): discard incomplete Fable output

* feat(anthropic): support Fable on Bedrock

* fix(anthropic): preserve Fable reasoning contracts

* refactor(anthropic): unify canonical Claude model policy

* fix(anthropic): satisfy extension thinking types

* test(anthropic): complete canonical alias fixture

* fix(bedrock): scope thinking case declarations
2026-06-10 08:08:35 -07:00
Agustin Rivera
ade5ac0350 fix(browser): validate discovered CDP websocket URLs (#91747)
* fix(browser): validate discovered cdp websocket urls

* fix(browser): validate cdp tab creation websockets

* fix(browser): guard termination cdp websocket

* fix(browser): use .toString() instead of String() to satisfy oxlint no-base-to-string

* fix(browser): avoid cdp termination assertion stringification

* fix(browser): preserve cdp ssrf policy
2026-06-10 07:59:29 -07:00
lifuyue
ac21e89c13 Support existing-session browser CDP endpoints (#91736)
* Support existing-session browser CDP endpoints

* Fix browser existing-session test fixture type

---------

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-06-10 10:44:35 -04:00
brokemac79
b71d8e1c32 fix(sandbox): use materialized skill paths in startup prompts (#91791)
* fix(sandbox): use materialized skill paths in command prompts

* fix(sandbox): resolve backend prompt workdirs

* fix(sandbox): preserve custom backend prompt fallback

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-10 23:35:34 +09:00
Vincent Koc
4ecec2f9e2 test(tooling): isolate pnpm fallback path 2026-06-10 23:11:21 +09:00
wangmiao0668000666
bb6e47729c fix(compaction): lower default timeout from 900s to 180s, preserve explicit config (#91361)
Merged via squash.

Prepared head SHA: ac545967f9
Co-authored-by: wangmiao0668000666 <290215524+wangmiao0668000666@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-06-10 14:51:54 +02:00
Ayaan Zaidi
050c0813b3 docs(streaming): document Telegram block-mode preview chunk sizing 2026-06-10 16:30:25 +05:30
Ayaan Zaidi
7200c316e5 refactor(telegram): drop NO_REPLY precount debug logging 2026-06-10 16:30:25 +05:30
Ayaan Zaidi
3078a8335d refactor(telegram): trust grammy api contract in handler seams 2026-06-10 16:30:25 +05:30
Ayaan Zaidi
709855d623 fix(telegram): delete retired dispatch dedupe buckets after doctor import 2026-06-10 16:30:25 +05:30
Ayaan Zaidi
272b599369 feat(telegram): size block-mode preview chunks from streaming.preview.chunk 2026-06-10 16:30:25 +05:30
Sally O'Malley
05a3b44c93 fix(telegram): block unauthorized DM text from cache and prompt context (#91478)
Move Telegram inbound authorization into a single pre-cache gate so unauthorized DM text is never recorded into the reply-chain cache or dispatch-dedupe state before allowlist/pairing checks run. The gate covers fresh messages, edited messages, group edits, and edited channel posts; edits are authorized silently and never trigger pairing challenges, and requireTopic root DMs are dropped before pairing challenges.

Fixes #91209.

Thanks @sallyom!
2026-06-10 15:50:32 +05:30
Ayaan Zaidi
c84e521920 refactor(discord): distill reply hydration tests 2026-06-10 14:54:32 +05:30
FullerStackDev
86215c9634 test(discord): avoid unbound hydration mock 2026-06-10 14:54:32 +05:30
FullerStackDev
514ac29d03 test(discord): cover reply hydration fetch failures 2026-06-10 14:54:32 +05:30
FullerStackDev
b08eef0508 fix(discord): hydrate reply context metadata 2026-06-10 14:54:32 +05:30
Ayaan Zaidi
9c6186de43 fix(telegram): bound dispatch dedupe state 2026-06-10 14:52:22 +05:30
Ayaan Zaidi
0d41316bcc fix(state): preserve ttl for plugin-state imports 2026-06-10 14:52:22 +05:30
Ayaan Zaidi
9297c20a85 fix(telegram): use SDK dispatch replay dedupe 2026-06-10 14:52:22 +05:30
Ayaan Zaidi
b2dec9cd50 feat(plugin-sdk): expose persistent dedupe import helpers 2026-06-10 14:52:22 +05:30
Ayaan Zaidi
a283df4bfe refactor(update): simplify refresh failure restart path 2026-06-10 14:48:30 +05:30
FullerStackDev
cab62324f4 fix(update): recover package gateway restart after refresh failure 2026-06-10 14:48:30 +05:30
Ayaan Zaidi
1ec04c6baa refactor(update): unify finalization command registration 2026-06-10 14:43:46 +05:30
FullerStackDev
f0206d935a fix(update): avoid repair command lint shadow 2026-06-10 14:43:46 +05:30
FullerStackDev
800f4dcb62 fix(update): expose plugin convergence repair 2026-06-10 14:43:46 +05:30
Vincent Koc
43bbde4830 fix(build): respect PATH-less pnpm environments 2026-06-10 18:06:31 +09:00
Vincent Koc
7d3e8dc963 test(qa): restore memory fallback config safely 2026-06-10 18:03:15 +09:00
Vincent Koc
3b13d6ae38 fix(build): fall back to Corepack for pnpm 2026-06-10 18:03:15 +09:00
Vincent Koc
69aca06e02 test(ci): assert larger QA runners 2026-06-10 18:02:10 +09:00
Vincent Koc
0766a5c2ec fix(qa): corrupt Matrix idb state in sqlite 2026-06-10 17:49:59 +09:00
Vincent Koc
2c146261a2 fix(qa): accept completed mock image turns 2026-06-10 17:48:33 +09:00
Vincent Koc
87abb8defb fix(qa): preserve Matrix recovery state in sqlite 2026-06-10 17:35:41 +09:00
Vincent Koc
3b180d5d99 fix(qa): wait for restart wake before capability check 2026-06-10 17:09:18 +09:00
Ayaan Zaidi
b9095bf70d refactor(channel): share draft chunking resolver 2026-06-10 13:35:19 +05:30
Ayaan Zaidi
049c3c4877 test(telegram): cover callback API metadata 2026-06-10 13:29:11 +05:30
Ayaan Zaidi
1265da2a5c fix(telegram): use concrete callback API calls 2026-06-10 13:29:11 +05:30
Josh Avant
cfdabfbaab Fix stale visible reply recovery (#91840)
* fix visible reply stale recovery

* fix visible recovery lint loop

* fix visible reply registry recovery

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

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

---------

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

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

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

* fix(codex): block unrouted bound approvals

* fix(channels): satisfy ingress queue lint

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

* fix(extensions): satisfy ingress lint

* fix(codex): require trusted exec reviewer model

* fix(exec): share control command approval guards

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

* fix(codex): reject custom exec reviewer endpoints

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

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

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

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

* fix(codex): distrust aliased reviewer model refs

* fix(codex): preserve providers after thread rotation

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

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

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

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

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

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

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

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

* fix(codex): share trusted reviewer endpoint policy

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

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

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

* fix(codex): scope exec reviewer alias trust

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


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

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

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

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

* feat: improve iPad and iPhone control surfaces

* fix: preserve workboard dispatch compatibility

* fix: keep Talk reachable on iPad

* fix: add universal iPad app icons

* fix: address ready-review iOS feedback

* fix: avoid workboard board id shadowing

* fix ios sidebar separators

---------

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

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

* test: cover 90496 compaction and reset edge cases

* fix(codex): guard secondary native compaction binding

* fix(codex): keep native compaction hint internal

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

Prepared head SHA: 597684c365

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

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

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

Prepared head SHA: 39ea25767b

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

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

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

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

* fix(search): gate direct OpenAI native search

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

* fix(imessage): complete bounded approval discovery

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: satisfy TUI prewarm lint

* fix(tui): clarify runtime warmup submit block

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

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

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

* fix: preserve restrictive git protocol env

* fix: preserve restrictive git allowlists

* fix: filter inherited git protocol allowlists

* test: cover restrictive git allowlists

* test: avoid opengrep fixture false positives

* test: type env fixture helper narrowly

* fix: preserve zero git protocol booleans

* fix: preserve invalid git protocol booleans

* fix: force git protocol from user off

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

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

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

* test: cover inherited rustup env stripping [AI]

* fix: preserve inherited rustup env [AI]

* fix: filter ignored opengrep changed paths [AI]

* fix: honor opengrep ignored directory globs [AI]

* fix: match ignored opengrep descendants [AI]

* fix: cover rustup mirror overrides [AI]

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

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

* test: annotate host env security fixtures

* test: align opengrep fixture suppressions

* test: keep opengrep suppressions inline

* test: avoid opengrep fixture call patterns
2026-06-09 19:44:54 +05:30
Ayaan Zaidi
98d5c46530 fix(agents): keep compaction notices additive 2026-06-09 18:34:37 +05:30
Vincent Koc
65848d0b45 fix(discord): restore runtime timeout compatibility exports (#91686)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-09 20:43:04 +09:00
Vincent Koc
9ef67ea405 test(memory): drop backend config bridge test 2026-06-09 20:12:25 +09:00
Vincent Koc
66749a3713 test: consolidate wrapper facade coverage 2026-06-09 20:12:25 +09:00
Vincent Koc
73ce4fdcbb fix(plugin-sdk): align Discord component edit facade types (#91679)
* fix(plugin-sdk): align Discord component edit facade types

* test(plugin-sdk): satisfy Discord facade type lint

* test(upgrade): seed migrated survivor sessions

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-09 19:58:10 +09:00
mushuiyu_xydt
a36e05050a fix #88009: [Feature]: batched memory embedding should batch over files (#89138)
Merged via squash.

Prepared head SHA: 66d362a56d
Co-authored-by: mushuiyu886 <266724580+mushuiyu886@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-09 06:38:30 -04:00
Vincent Koc
2f02bbcbb3 fix: harden legacy session SQLite migration 2026-06-09 18:44:42 +09:00
openclaw-clownfish[bot]
5e1fbca3cb docs: clarify Android opt-in for release CI (#91665)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-09 18:20:39 +09:00
openclaw-clownfish[bot]
e949809f6e chore(plugin-sdk): refresh API baseline hash (#91661)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-09 18:20:35 +09:00
Vincent Koc
25160515e0 test(runner): skip deleted changed test targets 2026-06-09 17:34:23 +09:00
Vincent Koc
af5c7c5fd0 test(agents): drop duplicate prompt template wrapper coverage 2026-06-09 17:34:23 +09:00
Vincent Koc
cd0bca0823 test(qqbot): reduce group allways command scaffolding 2026-06-09 17:34:23 +09:00
Vincent Koc
0a1cf8a776 test(vitest): prune duplicate include wrapper coverage 2026-06-09 17:34:22 +09:00
Vincent Koc
446936d600 test(ui): prune duplicate presenter coverage 2026-06-09 17:34:22 +09:00
Mason Huang
257b251e26 fix(docs): continue partial i18n batches after file errors (#91642)
Summary:
- This PR passes the existing docs-i18n `--allow-partial` flag into sequential and parallel doc-mode schedulin ... ion as terminal, adds regression tests, and removes one non-null assertion in Microsoft Foundry onboarding.
- PR surface: Source 0, Other +286. Total +286 across 3 files.
- Reproducibility: yes. at source level: current main returns from sequential doc mode on the first `processFi ... d not run Go tests because this review is read-only, but the PR adds direct regression cases for that path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(docs): continue partial i18n batches after file errors
- PR branch already contained follow-up commit before automerge: fix(clawsweeper): address review for automerge-openclaw-openclaw-9164…

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

Prepared head SHA: b66c0983b4
Review: https://github.com/openclaw/openclaw/pull/91642#issuecomment-4656851389

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-09 08:10:54 +00:00
Vincent Koc
c517162536 fix(azure): narrow Foundry Responses client detection 2026-06-09 16:45:14 +09:00
Vincent Koc
1240de7588 fix(microsoft-foundry): filter unsupported Anthropic deployments
Co-authored-by: Otto Deng <ottodeng@users.noreply.github.com>
2026-06-09 16:45:14 +09:00
Vincent Koc
93d540d67b test(azure): keep reasoning replay assertions focused
Co-authored-by: thomas.krohnfuss <thomas.krohnfuss@hsu.hamburg>
2026-06-09 16:45:14 +09:00
Vincent Koc
1727ec7b2d fix(azure): use OpenAI-compatible client for Foundry Responses
Co-authored-by: thomas.krohnfuss <thomas.krohnfuss@hsu.hamburg>
2026-06-09 16:45:14 +09:00
Vincent Koc
b08e1109c6 fix(azure): support Responses text stream events
Co-authored-by: thomas.krohnfuss <thomas.krohnfuss@hsu.hamburg>
2026-06-09 16:45:13 +09:00
cxy
d12b7b0551 feat(qqbot): add /bot-group-allways command to toggle mention requirement (#91423)
* feat(qqbot): add /bot-group-allways command to toggle group mention requirement

Add slash command to configure defaultRequireMention for qqbot accounts.
Clear runtime config snapshot cache after config write to ensure
getRuntimeConfig() reads fresh values on next message.

- Add register-group-allways command (on/off/status)
- Support named accounts and default account
- Clear runtime config cache after write for immediate effect
- Add unit tests for group config resolution

* test(qqbot): fix group allways test imports

* feat(qqbot): add /bot-group-allways command to toggle group mention requirement (#91423) (thanks @cxyhhhhh)

---------

Co-authored-by: sliverp <870080352@qq.com>
2026-06-09 15:43:12 +08:00
openclaw-clownfish[bot]
994f4f99fe fix(line): canonicalize trailing-slash webhook paths (#91649)
* fix(line): canonicalize trailing-slash webhook paths

* fix(clownfish): address review for clawsweeper-commit-openclaw-openclaw-4cf228466770 (1)

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-09 16:26:16 +09:00
Vincent Koc
f57c3b55fd fix(microsoft-foundry): repair CI validation issues 2026-06-09 15:45:19 +09:00
openclaw-clownfish[bot]
56fe1e0c95 docs: include plugin prerelease in release validation approval (#91637)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-09 15:42:13 +09:00
Vincent Koc
7128fa8832 fix(image-generation): preserve explicit model defaults 2026-06-09 15:34:29 +09:00
Vincent Koc
e103d1231d fix(microsoft-foundry): classify manual MAI image setup 2026-06-09 15:34:29 +09:00
Vincent Koc
a1fb8cf304 test(image-generation): load live provider sources 2026-06-09 15:34:29 +09:00
Vincent Koc
ea51c3ea50 fix(microsoft-foundry): allow MAI image deployment prefixes 2026-06-09 15:34:28 +09:00
Vincent Koc
34e76e6e6f test(image-generation): include Foundry in live image sweep 2026-06-09 15:34:28 +09:00
Vincent Koc
090c492759 fix(microsoft-foundry): configure MAI image setup defaults 2026-06-09 15:34:28 +09:00
Vincent Koc
09a5cdaca3 fix(microsoft-foundry): expose auth for image setup 2026-06-09 15:34:28 +09:00
Vincent Koc
c93e837336 fix(microsoft-foundry): require deployment refs for MAI images 2026-06-09 15:34:28 +09:00
Vincent Koc
9cf46d7e5a fix(microsoft-foundry): trust only distinct MAI model metadata 2026-06-09 15:34:28 +09:00
Vincent Koc
5e4a160f54 fix(image-generation): allow explicit defaultless model refs 2026-06-09 15:34:28 +09:00
Vincent Koc
33cac9092b fix(microsoft-foundry): honor MAI image timeout deadlines 2026-06-09 15:34:28 +09:00
Vincent Koc
0c60bad890 fix(microsoft-foundry): require MAI image deployment defaults 2026-06-09 15:34:28 +09:00
Vincent Koc
a172db54b4 fix(microsoft-foundry): allow MAI deployment ids for image generation 2026-06-09 15:34:28 +09:00
Vincent Koc
5f13d0c817 docs(microsoft-foundry): document MAI image support 2026-06-09 15:34:28 +09:00
Vincent Koc
d0a84089a0 feat(microsoft-foundry): add MAI image provider 2026-06-09 15:34:28 +09:00
Vincent Koc
1ba782f286 feat(microsoft-foundry): classify MAI model metadata 2026-06-09 15:34:28 +09:00
Onur Solmaz
3137110167 fix(memory): move local llama.cpp runtime to provider plugin
* fix(memory): move local llama.cpp runtime to provider plugin

* chore: ignore llama cpp dynamic dependency

* test: remove invalid local provider alias fixture

* chore: refresh llama cpp shrinkwrap

* chore: drop stale memory embedding defaults facade
2026-06-09 14:30:35 +08:00
Vincent Koc
4c98a547d0 docs: redirect retired app sdk pages 2026-06-09 14:57:50 +09:00
Vincent Koc
634bcf6667 docs: clarify external app integration path 2026-06-09 14:56:17 +09:00
Josh Avant
e1978cf73c fix main session startup recovery (#91566) 2026-06-09 00:37:16 -05:00
colmbrogan
7e3100a120 fix(imessage): persist echo markers before send (#88969)
Persist short-lived pending iMessage echo markers before bridge sends so self-chat reflected rows cannot race ahead of post-send echo persistence. Keep monitor cache writes post-send, keep pending text out of generic echo matching, and observe skipped from-me catchup rows for self-chat dedupe.\n\nThanks @colmbrogan.
2026-06-08 22:25:25 -07:00
Pavan Kumar Gondhi
03a8d18cd4 fix(memory-lancedb): guard memory recall output [AI] (#91425)
* fix: guard memory recall output

* fix: overfetch memory recall candidates

* fix: avoid memory recall lint shadow
2026-06-09 10:31:55 +05:30
Vincent Koc
6fcc945702 fix(agents): trim dense text delta snapshots
Trim dense plain text-delta stream snapshots for OpenAI-compatible, Responses, and Ollama providers while preserving full snapshots on stream checkpoints and terminal events.

Reconstruct partial-less text deltas in the agent loop so live message updates continue to advance for immutable snapshot providers, and document the optional text_delta.partial contract.

Fixes #86599.
2026-06-09 13:21:23 +09:00
Vincent Koc
b3c946999d perf(control-ui): lazy load slash commands
Avoid clean Control UI startup command discovery, then hydrate slash commands only on explicit slash or palette intent. Proof: local focused unit tests, mocked Gateway E2E, Testbox check:changed, autoreview clean, and GitHub CI clean.
2026-06-09 13:09:50 +09:00
Patrick Erichsen
f05e9873c6 fix: let clawhub dry runs skip publish approval (#91591) 2026-06-08 21:04:32 -07:00
Josh Avant
9fdd56da21 fix(openai): require api-key auth for realtime voice (#91567)
* fix(openai): require api-key auth for realtime voice

* test(plugin-sdk): avoid auth profile store shadowing
2026-06-08 22:55:06 -05:00
BCM
4c55dd8549 fix(ui): guard WebRTC Talk startup cancellation
Stop stale async WebRTC setup after a user cancellation so a late microphone grant does not dereference a nulled peer or continue SDP setup.

Fixes #89434
2026-06-09 12:46:31 +09:00
Mason Huang
162957565a fix: make docs i18n frontmatter translation resilient (#91578)
Summary:
- The PR updates the docs i18n Go translator to bypass exact glossary matches, recover non-empty Codex last-me ... r a non-zero exit, fall back to source frontmatter scalars on translation errors, and add regression tests.
- PR surface: Other +201. Total +201 across 4 files.
- Reproducibility: yes. Current main source shows the relevant paths: exact glossary scalars still go through  ... re the last-message file; the PR body also describes a real translator smoke run that hit the failure mode.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix: make docs i18n frontmatter translation resilient

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

Prepared head SHA: efd98bba14
Review: https://github.com/openclaw/openclaw/pull/91578#issuecomment-4655639231

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-09 03:43:01 +00:00
Sally O'Malley
c8a8152cd7 fix docker store seed target packages (#91547) 2026-06-08 23:38:46 -04:00
T-800
84acb74a6a fix(feishu): retry on send rate-limit errors (230020/230006) (#89659)
* fix(feishu): add retry with linear backoff for send rate-limit errors

When Feishu returns code 230020 (per-chat rate limit), requestFeishuApi
now retries up to 2 times with linear backoff (500ms, 1000ms). The reply
path (im.message.reply) is also covered via the same retry helper.

Confirmed by a real 20-concurrent-send stress test: all 20 messages
succeed after retry.

Closes #70879

* ci: retrigger CI

* fix(feishu): retry HTTP 429 and code 11232 for message send rate limits

Feishu Open API has three send-time rate limit signals: HTTP 429
(gateway-wide quota), business code 11232 (tenant-level message
service: 100/min, 5/sec), and 230020 (per-chat). Previously only
230020 was retried; HTTP 429 and 11232 propagated as fatal errors.

- Add 11232 to FEISHU_SEND_RATE_LIMIT_CODES.
- In getFeishuSendRateLimitCode, recognize HTTP 429 before reading
  the body code so gateway-level limits enter the retry loop.
- Update doc comment listing both gateway and business sources.

* test(feishu): add focused retry coverage for 11232 and HTTP 429

The previous send.retry.test.ts only exercised 230020 / 230006 / non-rate
codes / plain errors. After expanding the retry policy in 90c787096 to
cover code 11232 (tenant-level message rate limit) and gateway-level
HTTP 429, ClawSweeper review #89659 (P2) flagged the tests as no longer
matching the production behavior.

- getFeishuSendRateLimitCode: assert 11232 returns 11232, HTTP 429
  returns 429, and HTTP 429 wins over body code when both are present.
- requestFeishuApi: cover 11232 retry-then-success, 429 retry-then-success,
  exhaustion paths for both, and a mixed 230020 → 11232 → ok recovery.

* fix(feishu): retry on fulfilled rate-limit response bodies (no-throw)

The Feishu node SDK sometimes resolves a non-throwing response that
carries a rate-limit code in its body (e.g. { code: 11232, msg: ... })
instead of rejecting. requestFeishuApi previously returned that body
straight away and downstream assertFeishuMessageApiSuccess failed once
with no retry — the same shape that issue #28157 fixed earlier on the
typing/reaction path via getBackoffCodeFromResponse.

ClawSweeper review on #89659 (P1, comment-shared.ts:140) flagged the
gap. Mirror the typing-path pattern for the send helper:

- Add getFeishuSendRateLimitCodeFromResponse to classify fulfilled
  bodies against FEISHU_SEND_RATE_LIMIT_CODES (230020, 11232).
- In requestFeishuApi, after each fulfilled await, classify before
  returning. If the body is a retryable rate limit and there are
  attempts left, continue the loop. After exhaustion, wrap the last
  fulfilled body into a synthetic AxiosError-shaped error so callers
  see the same error shape as the throw path.
- Add 11 focused tests covering fulfilled 11232/230020 retry-then-ok,
  exhaustion, mixed throw → fulfilled → ok recovery, and pass-through
  for code 0 / non-rate-limit codes.

* fix(feishu): break loop on final-attempt fulfilled rate-limit body

ClawSweeper review on dc8d3be7d (P1, comment-shared.ts:166) caught a
real bug: when the final retry attempt also fulfilled with a rate-limit
body (e.g. { code: 11232, ... }), the guard `attempt < FEISHU_SEND_MAX_RETRIES`
was false so control fell through to `return result` — bypassing the
synthetic-error exhaustion path and handing the rate-limit body to the
caller as if it were a successful response. The fulfilled-exhaustion
test missed this because Vitest's local fs module cache served the
pre-fix shape; running with a fresh cache reproduces the failure.

Split the fulfilled-rate-limit branch so the body is always captured,
then continue on a non-final attempt or break on the final attempt.
Breaking falls through to the synthetic AxiosError-shaped throw below,
which is exactly what the existing exhaustion test asserts.

* fix(feishu): retry on send rate-limit errors 230020/11232/429 (#89659) (thanks @ladygege)

---------

Co-authored-by: marshall.m <marshall.m@binance.com>
Co-authored-by: sliverp <870080352@qq.com>
2026-06-09 11:34:21 +08:00
Vincent Koc
9bbde70458 fix(skills): avoid per-file skill watchers 2026-06-09 12:27:53 +09:00
Vincent Koc
099abea089 perf(control-ui): warn on slow first replies (#91583) 2026-06-09 12:26:00 +09:00
Patrick Erichsen
e8cf6df3a3 feat: dogfood reusable ClawHub package publish 2026-06-08 20:19:21 -07:00
Youssef Hemimy
9210d8f7d9 fix(whatsapp): route captured replies through successor controller after restart (#85823)
Merged via squash.

Prepared head SHA: 5df8c79654
Co-authored-by: itsuzef <53057646+itsuzef@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-06-09 00:05:44 -03:00
Vincent Koc
c9050c982d perf(control-ui): trace first assistant event
Add Control UI first-assistant chat send timing instrumentation and tests.
2026-06-09 12:03:30 +09:00
Vincent Koc
0933726574 fix(sdk): surface event pump failures 2026-06-09 11:50:53 +09:00
Josh Avant
9f48254f09 Fix config.patch explicit array replacement (#91551)
* fix config patch explicit array replacement

* fix generated config patch protocol model

* fix config patch test helper typing

* fix shared auth patch replacement tests

* update config patch prompt snapshots

* harden qa lab config patch replace paths
2026-06-08 21:48:46 -05:00
Vincent Koc
329fa44d23 fix(memory-core): write deep sleep summaries to dreams 2026-06-09 11:31:31 +09:00
Josh Avant
aef1fad58d Fix transcript image redaction (#91529)
* fix transcript image redaction

* fix image redaction type predicate

* tighten transcript image redaction boundary
2026-06-08 21:23:15 -05:00
harjoth
82afc4678a fix(config): use Start-Process -FilePath for Windows config opener (#90157) 2026-06-09 11:16:15 +09:00
Ayaan Zaidi
aa935ddeb2 fix(telegram): keep compact command replies visible 2026-06-09 07:43:01 +05:30
Ayaan Zaidi
fff5261ade fix(telegram): keep compact acknowledgements in dispatcher 2026-06-09 07:43:01 +05:30
joelnishanth
5ef0d6c693 fix: resolve CI lint/type/deadcode failures
- Remove unused import normalizeOptionalLowercaseString from commands-compact.ts
- Remove unused type import ReplyPayload from bot-native-commands.ts
- Replace spread-in-map with Object.assign to satisfy oxlint
- Delete orphaned native-command-ack-fallback.ts and its test (superseded by direct delivery)
- Add isStatusNotice to ReplyPayloadLike test type
- Update test to verify status notices bypass dispatch pipeline
2026-06-09 07:43:01 +05:30
joelnishanth
38a11944f4 fix(telegram): deliver native /compact ack directly, bypassing dispatch pipeline
The dispatch pipeline (foreground fence, operation-busy checks, hooks)
silently dropped status-notice replies for /compact. Resolve the command
reply directly via getReplyFromConfig and deliver status notices through
deliverReplies without entering the full dispatch pipeline. Non-status
commands still use the buffered dispatch for streaming/tools.

Adds deliverDespiteSourceReplySuppression metadata for command replies,
a dedicated native-command-ack-fallback module, and regression tests.

Fixes #89525

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 07:43:01 +05:30
joelnishanth
1559c16a76 fix(telegram): classify generic control commands as text slash
Generic message ingress lacked commandSource, so /compact and other
authorized control commands were dropped when they missed bot.command.
Derive text-slash command metadata like Mattermost/iMessage.

Fixes #89525
2026-06-09 07:43:01 +05:30
Vincent Koc
280d1cb977 fix(auto-reply): deliver queued compaction notices 2026-06-09 11:07:37 +09:00
Josh Avant
14b1ebd640 fix: bound native hook relay lifetime (#91550) 2026-06-08 21:06:58 -05:00
Dallin Romney
5097749de3 fix: canonicalize codex protocol JSON assets (#91507) 2026-06-08 18:59:51 -07:00
Marcus Castro
27189b3e74 test(whatsapp): seed group activation store via facade 2026-06-08 22:58:04 -03:00
Vincent Koc
79c6136a9e perf(control-ui): avoid startup catalog wait
Start the optional model catalog load early for chat.startup and cap startup-only catalog waiting at 25ms, while preserving the 750ms optional catalog wait for other gateway surfaces. Adds regressions for slow catalog omission, async cached metadata, and agent-scoped startup metadata.
2026-06-09 10:35:39 +09:00
Vincent Koc
c4a0ca0b7a perf(agents): cache subagent registry reads 2026-06-09 10:16:15 +09:00
Vincent Koc
dfb44912ed fix(acpx): normalize Claude ACP model refs 2026-06-09 10:01:57 +09:00
Vincent Koc
80f1ae6ffe fix(infra): fail fast for sync sqlite facade execution 2026-06-09 09:55:20 +09:00
Vincent Koc
2c6bdc8b28 perf(control-ui): reuse startup model metadata (#91531) 2026-06-09 09:44:32 +09:00
brokemac79
72e40833ba fix(doctor): report managed plugin version drift
Fixes #90891.

Doctor now reports official managed plugin version drift from the daemon-local status path, using the probed running gateway version and suppressing the advisory when probe auth is skipped or unsafe. The status probe also avoids re-entering config-backed exec SecretRef credential resolution when exec refs are disabled.

Verification:
- `node scripts/run-vitest.mjs src/commands/agent-via-gateway.test.ts src/cli/daemon-cli/probe.test.ts src/cli/daemon-cli/status.gather.test.ts src/flows/doctor-health-contributions.test.ts src/commands/doctor-workspace-status.test.ts src/gateway/probe-auth.test.ts`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- Crabbox delegated Blacksmith Testbox `tbx_01ktmwa5q0c2eb688dkbkw8v2b`: `OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed`
2026-06-09 09:44:01 +09:00
Shakker
5b76436c45 test: satisfy cron cancellation lint 2026-06-09 01:22:29 +01:00
Shakker
9082233a43 fix: unblock timed cron cancellation 2026-06-09 01:22:29 +01:00
Shakker
24196e05f5 fix: unwind timeout-disabled cron cancellation 2026-06-09 01:22:29 +01:00
Shakker
93313c95a5 fix: preserve cron timeout terminal state 2026-06-09 01:22:29 +01:00
Shakker
be5bfdccd1 test: remove stale cron cancel harness hook 2026-06-09 01:22:29 +01:00
Shakker
372f85d368 fix: avoid cron cancel runtime cycle 2026-06-09 01:22:29 +01:00
ai-hpc
3cf94309d9 fix(cron): keep main-session cron cancel honest 2026-06-09 01:22:29 +01:00
ai-hpc
f45cd5e57e test(cron): type unresolved runner mock 2026-06-09 01:22:29 +01:00
ai-hpc
c13802c912 fix(cron): preserve timeout cleanup after cancel 2026-06-09 01:22:29 +01:00
ai-hpc
c3cdd4971b fix(cron): cancel active cron task runs 2026-06-09 01:22:29 +01:00
Vincent Koc
5f6ee9f913 fix(release): prepare ClawHub publish deps after target checkout 2026-06-09 08:48:07 +09:00
Vincent Koc
ebb9c6a013 test(release): dedupe gateway migration mock 2026-06-09 01:02:24 +02:00
Vincent Koc
0df7fe3056 chore(release): keep main changelog release-owned 2026-06-09 01:02:24 +02:00
Vincent Koc
50130d32a9 test(release): align qa tool coverage gate 2026-06-09 01:02:24 +02:00
Vincent Koc
7a0e65773a test(release): ignore terminal docker stats samples 2026-06-09 01:02:24 +02:00
Vincent Koc
c7b01cf201 test(release): stabilize qa runtime parity gate 2026-06-09 01:02:24 +02:00
Vincent Koc
bad449301f test(release): align kitchen sink rpc descriptors 2026-06-09 01:02:24 +02:00
Vincent Koc
2a611865f4 ci(release): retry Docker E2E image builds 2026-06-09 01:02:24 +02:00
Vincent Koc
1019b591d5 test(release): stabilize qa gateway restart readiness 2026-06-09 01:02:24 +02:00
Vincent Koc
ff5fac1439 ci(release): retry Docker BuildKit bootstrap 2026-06-09 01:02:24 +02:00
Vincent Koc
f29248fa62 ci(release): retry transient registry build failures 2026-06-09 01:02:23 +02:00
Vincent Koc
04b8c4f313 test(release): isolate trajectory export migration checks 2026-06-09 01:02:23 +02:00
Vincent Koc
f1a1fce982 test(release): isolate sandbox explain migration checks 2026-06-09 01:02:23 +02:00
Vincent Koc
9faa741536 test(release): isolate default session store migration tests 2026-06-09 01:02:23 +02:00
Vincent Koc
dc51c57e29 test(release): isolate sessions tail migration checks 2026-06-09 01:02:23 +02:00
Vincent Koc
95c72dde0f test(release): isolate sessions command migration tests 2026-06-09 01:02:23 +02:00
Vincent Koc
37c1e2725a test(release): stabilize beta three command shards 2026-06-09 01:02:23 +02:00
Vincent Koc
06b226e8b5 test(release): stabilize beta three validation 2026-06-09 01:02:23 +02:00
Vincent Koc
3ed8d5f2c3 fix(tasks): keep maintenance migration scoped 2026-06-09 01:02:23 +02:00
Vincent Koc
2d5bf186c1 test(release): stabilize task maintenance checks 2026-06-09 01:02:23 +02:00
Vincent Koc
3a2176267c test(release): stabilize beta three validation 2026-06-09 01:02:23 +02:00
Vincent Koc
4b55a0e04d test(release): clear beta validation blockers 2026-06-09 01:02:23 +02:00
Vincent Koc
9cdf853409 test(release): stabilize beta validation checks 2026-06-09 01:02:23 +02:00
Vincent Koc
cff8154954 fix(release): satisfy control ui registry lint 2026-06-09 01:02:22 +02:00
Vincent Koc
55de547b52 test(release): keep workshop state mocks current 2026-06-09 01:02:22 +02:00
Vincent Koc
505b23a137 fix(release): clear beta validation blockers 2026-06-09 01:02:22 +02:00
Vincent Koc
5496044f6d fix(release): cap docker e2e cpus 2026-06-09 01:02:22 +02:00
Vincent Koc
20604f7a8f test(memory-core): seed dreaming session store 2026-06-09 01:02:22 +02:00
Vincent Koc
a0f76b2b25 test(telegram): seed approval session store 2026-06-09 01:02:22 +02:00
Vincent Koc
fb97b3b4b3 test(discord): seed think autocomplete session store 2026-06-09 01:02:22 +02:00
Dallin Romney
112e98faa2 chore: bump codex app-server to 0.137.0 (#91496) 2026-06-08 15:42:41 -07:00
dependabot[bot]
646bc0d274 build(deps): bump the android-deps group in /apps/android with 3 updates (#91365)
* build(deps): bump the android-deps group in /apps/android with 3 updates

Bumps the android-deps group in /apps/android with 3 updates: androidx.core:core-ktx, [org.jetbrains.kotlin.plugin.compose](https://github.com/JetBrains/kotlin) and [org.jetbrains.kotlin.plugin.serialization](https://github.com/JetBrains/kotlin).


Updates `androidx.core:core-ktx` from 1.18.0 to 1.19.0

Updates `org.jetbrains.kotlin.plugin.compose` from 2.3.21 to 2.4.0
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.3.21...v2.4.0)

Updates `org.jetbrains.kotlin.plugin.serialization` from 2.3.21 to 2.4.0
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.3.21...v2.4.0)

Updates `org.jetbrains.kotlin.plugin.serialization` from 2.3.21 to 2.4.0
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.3.21...v2.4.0)

---
updated-dependencies:
- dependency-name: androidx.core:core-ktx
  dependency-version: 1.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: android-deps
- dependency-name: org.jetbrains.kotlin.plugin.compose
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: android-deps
- dependency-name: org.jetbrains.kotlin.plugin.serialization
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: android-deps
- dependency-name: org.jetbrains.kotlin.plugin.serialization
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: android-deps
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(android): support compile SDK 37

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-08 15:34:28 -07:00
dependabot[bot]
c967172f69 build(deps): bump the actions group with 2 updates (#91367)
Bumps the actions group with 2 updates: [github/codeql-action](https://github.com/github/codeql-action) and [docker/login-action](https://github.com/docker/login-action).


Updates `github/codeql-action` from 4.36.1 to 4.36.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Commits](https://github.com/github/codeql-action/compare/v4.36.1...v4.36.2)

Updates `docker/login-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](4907a6ddec...650006c6eb)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 15:03:30 -07:00
dependabot[bot]
6aa89bb5f8 build(deps): bump actions/cache from 4 to 5 (#91369)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 15:00:44 -07:00
Kevin Lin
a54f50a41b chore: add taxonomy file (#91512)
* chore: add taxonomy file

* add maturity scores

* move taxonomy doc
2026-06-08 14:55:44 -07:00
dependabot[bot]
f9f7475dbf build(deps): bump actions/github-script from 8 to 9 (#91368)
Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 14:54:48 -07:00
dependabot[bot]
b875c812f7 build(deps): bump github.com/steipete/peekaboo (#91364)
Bumps the swift-deps group in /apps/macos with 1 update: [github.com/steipete/peekaboo](https://github.com/steipete/Peekaboo).


Updates `github.com/steipete/peekaboo` from 3.3.0 to 3.4.0
- [Release notes](https://github.com/steipete/Peekaboo/releases)
- [Commits](https://github.com/steipete/Peekaboo/compare/v3.3.0...v3.4.0)

---
updated-dependencies:
- dependency-name: github.com/steipete/peekaboo
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: swift-deps
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 14:54:44 -07:00
Peter Steinberger
6c4fb997e5 fix: refresh npm shrinkwrap 2026-06-08 22:34:23 +01:00
Peter Steinberger
b14923d1f3 fix: remove extension-owned root dependency 2026-06-08 22:25:58 +01:00
Kevin Lin
4c5d8afa38 Revert "docs: add maturity scorecard mirror (#91317)" (#91508)
This reverts commit 6cc6f5e210.
2026-06-08 14:18:42 -07:00
Peter Steinberger
9aa6bfccce chore: update dependencies 2026-06-08 21:44:57 +01:00
Vincent Koc
b0998f7d15 fix(browser): accept statement evaluate bodies 2026-06-09 05:07:44 +09:00
Vincent Koc
46f4db6bbd test(config): print missing label stubs 2026-06-09 04:18:07 +09:00
Vincent Koc
9220761fba fix(slack): surface arg menu fallback warning 2026-06-09 04:07:09 +09:00
Julian Missig
a7847ac484 fix(imessage): honor block streaming config (#91449)
Merged via squash.

Prepared head SHA: 6e4e04fb2d
Co-authored-by: jmissig <1448107+jmissig@users.noreply.github.com>
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Reviewed-by: @omarshahine
2026-06-08 12:07:03 -07:00
Peter Steinberger
d4c6662341 docs: bump claude proxy Node.js requirement 2026-06-08 20:01:19 +01:00
Vincent Koc
224086e28b fix(file-transfer): clarify missing paired node errors
When no nodes are paired, file-transfer tools now fail before node alias resolution with an actionable message instead of generic unknown-node retries. The paired-node schema wording also steers agents away from local/host/gateway/auto guesses.\n\nFixes #91482.
2026-06-09 03:53:38 +09:00
Vincent Koc
5f6d4277b1 docs: clarify skill workshop tool policy 2026-06-09 03:39:54 +09:00
Vincent Koc
105d77d486 fix(ui): open docs markdown links on docs host
Rewrite recognized docs-root markdown links in Control UI renderers to https://docs.openclaw.ai while preserving Control UI routes, base-mounted resources, and plugin viewer URLs.

Fixes #89465.
2026-06-09 03:11:50 +09:00
ai-hpc
4eb4b87c8e fix(cron): recover no-deliver tool warnings 2026-06-08 19:08:38 +01:00
Vincent Koc
0176429ad7 fix(context): report compactable transcript counts
Adds /context detail diagnostics for active transcript compactability so prompt/cache usage is not mistaken for compactable conversation history.

Fixes #91150. Supersedes #91158.

Co-authored-by: Rain <94058511+Pluviobyte@users.noreply.github.com>
2026-06-09 02:16:11 +09:00
2074 changed files with 102097 additions and 104120 deletions

View File

@@ -19,7 +19,7 @@ attribution.
## Inputs
- Target base version: `YYYY.M.D`, without beta suffix.
- Target base version: `YYYY.M.PATCH`, without beta suffix.
- Base tag: last reachable shipped release tag, usually the previous stable or
the previous beta train requested by the operator.
- Target ref: exact branch/SHA being released.
@@ -37,7 +37,7 @@ attribution.
3. Read linked PRs/issues or diffs for ambiguous commits. Direct commits matter;
infer notes from subject, body, touched files, tests, and nearby commits.
4. Rewrite one stable-base section only:
- use `## YYYY.M.D`
- use `## YYYY.M.PATCH`
- do not create beta-specific headings
- do not leave a stale `## Unreleased` section above the target release
- if `Unreleased` contains release-bound notes, fold them into the target
@@ -93,7 +93,7 @@ attribution.
10. Validate and ship:
- `git diff --check`
- for docs/changelog-only changes, no broad tests are required
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.D notes" CHANGELOG.md`
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.PATCH notes" CHANGELOG.md`
- push, pull/rebase if needed, then branch/rebase release from latest `main`
## Quota / API Outage Rule

View File

@@ -36,8 +36,8 @@ Do not update these from mixed sources. All three ASC fields must come from the
## Workflow Shape
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
- Use `source_ref=release/YYYY.M.D` for private mac preflight/validation when building that branch variation.
- Keep `tag=vYYYY.M.D` pointing at the original stable release commit.
- Use `source_ref=release/YYYY.M.PATCH` for private mac preflight/validation when building that branch variation.
- Keep `tag=vYYYY.M.PATCH` pointing at the original stable release commit.
- Real mac publish must reuse:
- a successful private mac preflight run for the same tag/source SHA
- a successful private mac validation run for the same tag/source SHA
@@ -56,37 +56,37 @@ Private preflight:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f source_ref=release/YYYY.M.D \
-f tag=vYYYY.M.PATCH \
-f source_ref=release/YYYY.M.PATCH \
-f preflight_only=true \
-f smoke_test_only=false \
-f allow_late_calver_recovery=false \
-f public_release_branch=release/YYYY.M.D
-f public_release_branch=release/YYYY.M.PATCH
```
Private validation for a branch-variation preflight:
```bash
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f source_ref=release/YYYY.M.D
-f tag=vYYYY.M.PATCH \
-f source_ref=release/YYYY.M.PATCH
```
Real publish:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f tag=vYYYY.M.PATCH \
-f preflight_only=false \
-f smoke_test_only=false \
-f preflight_run_id=<successful-preflight-run> \
-f validate_run_id=<successful-validation-run> \
-f allow_late_calver_recovery=false \
-f public_release_branch=release/YYYY.M.D
-f public_release_branch=release/YYYY.M.PATCH
```
## Verify
- `gh release view vYYYY.M.D --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.D.zip`.
- `gh release view vYYYY.M.PATCH --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.PATCH.zip`.
- Appcast entry has `sparkle:version`, `sparkle:shortVersionString`, length, and `sparkle:edSignature`.

View File

@@ -10,12 +10,15 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
## Respect release guardrails
- Do not change version numbers without explicit operator approval.
- Versions use `YYYY.M.PATCH`, where `PATCH` is the sequential release-train number within the month, not the calendar day.
- Choose a new beta train from stable and beta releases only. Alpha-only tags do not consume or advance the beta/stable patch number. Continue the highest existing unpublished/published beta train with the next `beta.N` when appropriate; otherwise increment the highest stable/beta patch by one and start at `beta.1`.
- Example: after stable `2026.6.5`, the next new beta train is `2026.6.6-beta.1`, even if automated alpha-only tags such as `2026.6.10-alpha.1` exist.
- Ask permission before any npm publish or release step.
- This skill should be sufficient to drive the normal release flow end-to-end.
- Use the private maintainer release docs for credentials, recovery steps, and mac signing/notary specifics, and use `docs/reference/RELEASING.md` for public policy.
- Core `openclaw` publish is manual `workflow_dispatch`; creating or pushing a tag does not publish by itself.
- Normal release work happens on a branch cut from `main`, not directly on
`main`. Use `release/YYYY.M.D` for the branch name.
`main`. Use `release/YYYY.M.PATCH` for the branch name.
- If the operator asks for a release without saying stable/full, default to
beta only. Continue from beta to stable only when the operator explicitly asks
for the full release or an automated beta-and-stable train.
@@ -92,7 +95,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
## Keep release channel naming aligned
- `stable`: tagged releases only, published to npm `beta` by default; operators may target npm `latest` explicitly or promote later
- `beta`: prerelease tags like `vYYYY.M.D-beta.N`, with npm dist-tag `beta`
- `beta`: prerelease tags like `vYYYY.M.PATCH-beta.N`, with npm dist-tag `beta`
- Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes
- `dev`: moving head on `main`
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
@@ -108,7 +111,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
- `docs/install/updating.md`
- Peekaboo Xcode project and plist version fields
- Before creating a release tag, make every version location above match the version encoded by that tag.
- For fallback correction tags like `vYYYY.M.D-N`, the repo version locations still stay at `YYYY.M.D`.
- For fallback correction tags like `vYYYY.M.PATCH-N`, the repo version locations still stay at `YYYY.M.PATCH`.
- “Bump version everywhere” means all version locations above except `appcast.xml`.
- Release signing and notary credentials live outside the repo in the private maintainer docs.
- Every stable OpenClaw release ships the npm package, macOS app, and signed
@@ -129,19 +132,19 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
tagged commit when the delta is mac packaging, signing, workflow, or
validation-only release machinery. If mac packaging needs release-branch-only
fixes after the stable npm package or GitHub tag is already published, do not
create a `vYYYY.M.D-N` correction tag just to change the workflow source.
Dispatch the private mac workflows for the original `tag=vYYYY.M.D` with
`source_ref=release/YYYY.M.D` and `public_release_branch=release/YYYY.M.D`;
create a `vYYYY.M.PATCH-N` correction tag just to change the workflow source.
Dispatch the private mac workflows for the original `tag=vYYYY.M.PATCH` with
`source_ref=release/YYYY.M.PATCH` and `public_release_branch=release/YYYY.M.PATCH`;
provenance checks must prove the source SHA descends from the tag and
validation/preflight use the same source. Reserve `vYYYY.M.D-N` correction
validation/preflight use the same source. Reserve `vYYYY.M.PATCH-N` correction
tags for emergency hotfixes that must publish a new npm package/release
identity, not for ordinary mac-only packaging recovery.
- The production Sparkle feed lives at `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`, and the canonical published file is `appcast.xml` on `main` in the `openclaw` repo.
- That shared production Sparkle feed is stable-only. Beta mac releases may
upload assets to the GitHub prerelease, but they must not replace the shared
`appcast.xml` unless a separate beta feed exists.
- For fallback correction tags like `vYYYY.M.D-N`, the repo version still stays
at `YYYY.M.D`, but the mac release must use a strictly higher numeric
- For fallback correction tags like `vYYYY.M.PATCH-N`, the repo version still stays
at `YYYY.M.PATCH`, but the mac release must use a strictly higher numeric
`APP_BUILD` / Sparkle build than the original release so existing installs
see it as newer.
- Stable Windows Hub release closeout requires the signed
@@ -151,7 +154,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
workflow after the matching `openclaw/openclaw-windows-node` release exists;
it verifies Authenticode signatures on Windows before uploading assets.
- Website Windows Hub download links should target exact canonical
`openclaw/openclaw/releases/download/vYYYY.M.D/...` assets for the current
`openclaw/openclaw/releases/download/vYYYY.M.PATCH/...` assets for the current
stable release, or `releases/latest/download/...` only after verifying the
redirect resolves to that same tag, so the installable signed Windows artifact
is visible from both the GitHub release page and openclaw.ai.
@@ -165,7 +168,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
beta release tag as the base, then inspect every commit through the target
release SHA.
- The changelog rewrite is not optional for beta reruns: any `beta.N` after a
rebase or backport must refresh the same stable-base `## YYYY.M.D` section
rebase or backport must refresh the same stable-base `## YYYY.M.PATCH` section
before the new version/tag commit.
- Include both merged PR commits and direct commits on `main`. Direct commits
matter: infer notes from their subject, body, touched files, linked issues,
@@ -188,11 +191,11 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
- Changelog entries should be user-facing, not internal release-process notes.
- GitHub release and prerelease bodies must use the full matching
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
or editing a release, extract from `## YYYY.M.D` through the line before the
or editing a release, extract from `## YYYY.M.PATCH` through the line before the
next level-2 heading and use that complete block as the release notes.
- To update an existing GitHub Release body, resolve the numeric release id and
patch that resource with the notes file as the `body` field:
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.D --jq .id`, then
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.PATCH --jq .id`, then
`gh api -X PATCH repos/openclaw/openclaw/releases/<id> -F body=@/tmp/notes.md`.
Do not trust `gh release edit --notes-file` or `--input` JSON if verification
disagrees; verify with `gh api repos/openclaw/openclaw/releases/<id>` because
@@ -205,10 +208,10 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
record's `docsPath` or `/plugins/compatibility` when no more specific
deprecation page exists.
- When cutting a mac release with a beta GitHub prerelease:
- tag `vYYYY.M.D-beta.N` from the release commit
- create a prerelease titled `openclaw YYYY.M.D-beta.N`
- tag `vYYYY.M.PATCH-beta.N` from the release commit
- create a prerelease titled `openclaw YYYY.M.PATCH-beta.N`
- use release notes from the stable base `CHANGELOG.md` version section
(`## YYYY.M.D`), not a beta-specific heading
(`## YYYY.M.PATCH`), not a beta-specific heading
- attach at least the zip and dSYM zip, plus dmg if available
- Keep the top version entries in `CHANGELOG.md` sorted by impact:
- `### Changes` first
@@ -218,10 +221,10 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
Use the OpenClaw account's existing release-post style:
- Format: `OpenClaw YYYY.M.D 🦞` or `🦞 OpenClaw YYYY.M.D is live`, blank line,
- Format: `OpenClaw YYYY.M.PATCH 🦞` or `🦞 OpenClaw YYYY.M.PATCH is live`, blank line,
then 3-4 emoji-led bullets, blank line, one short punchline, then the release
link.
- For beta: say `OpenClaw YYYY.M.D-beta.N 🦞` or `OpenClaw YYYY.M.D beta N is
- For beta: say `OpenClaw YYYY.M.PATCH-beta.N 🦞` or `OpenClaw YYYY.M.PATCH beta N is
live`; keep it clearly beta and avoid implying stable promotion.
- Lead with user-visible capabilities, then important integrations, then
reliability/security/install fixes. Compress "lots of fixes" into one
@@ -314,6 +317,23 @@ pnpm release:check
pnpm test:install:smoke
```
- Before tagging, diff publishable plugin package manifests against the last
reachable stable/beta release tag. For every newly publishable package
(`openclaw.release.publishToNpm: true` or `publishToClawHub: true`) whose
package name did not exist in the base tag, verify the target registry package
already exists in npm/ClawHub or stop and help the owner mint/prepublish the
package first. Do not hide or disable release surfaces just to unblock a
train unless the owner explicitly decides the plugin should not ship in that
release; first-package registry ownership is release prep, not product
rollback. The mint/prepublish path must either be the real release publish
path for the auto-bumped beta version, or a deliberately non-consuming
registry-prep step that cannot occupy the next beta version/tag. Confirm
registry owner, npm scope/package-creation permission, provenance path, and
first-package publish plan before the full release publish continues. Useful
npm probe:
`npm view <package-name> version dist-tags --json --prefer-online`; a 404 for
a package newly added to the release is a release-prep blocker, not something
to discover from the publish job.
- Use `pnpm qa:otel:smoke` when release validation needs telemetry coverage.
It starts a local OTLP/HTTP trace receiver, runs QA-lab's
`otel-trace-smoke`, and checks span names plus content/identifier redaction
@@ -332,8 +352,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
```
- This verifies the published registry install path in a fresh temp prefix.
- For stable correction releases like `YYYY.M.D-N`, it also verifies the
upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so a correction publish cannot
- For stable correction releases like `YYYY.M.PATCH-N`, it also verifies the
upgrade path from `YYYY.M.PATCH` to `YYYY.M.PATCH-N` so a correction publish cannot
silently leave existing global installs on the old base stable payload.
- Treat install smoke as a pack-budget gate too. `pnpm test:install:smoke`
now fails the candidate update tarball when npm reports an oversized
@@ -480,7 +500,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
`npm login --auth-type=legacy`, then confirm `npm whoami` reports
`steipete`.
- Promote with a fresh OTP:
`npm dist-tag add openclaw@YYYY.M.D latest --otp "$OTP"`.
`npm dist-tag add openclaw@YYYY.M.PATCH latest --otp "$OTP"`.
- Verify with a cache-bypassed registry read, for example:
`npm view openclaw dist-tags --json --prefer-online --cache /tmp/openclaw-npm-cache-verify-$$`
and `npm view openclaw@latest version dist.tarball --json --prefer-online`.
@@ -506,7 +526,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
the npm version is already published.
- npm validation-only preflight may still be dispatched from ordinary branches
when testing workflow changes before merge. Release checks and real publish
use only `main` or `release/YYYY.M.D`.
use only `main` or `release/YYYY.M.PATCH`.
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
public validation-only handoff. It validates the tag/release state and points
operators to the private repo. It still rebuilds the JS outputs needed for
@@ -531,7 +551,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
waives the full gate; mac beta validation is still only required when
requested.
- Real publish runs may be dispatched from `main` or from a
`release/YYYY.M.D` branch. For release-branch runs, the tag must be contained
`release/YYYY.M.PATCH` branch. For release-branch runs, the tag must be contained
in that release branch, and the real publish must reuse a successful preflight
from the same branch.
- The release workflows stay tag-based; rely on the documented release sequence
@@ -559,7 +579,11 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- Use `NPM_TOKEN` only for explicit npm dist-tag management modes, because npm
does not support trusted publishing for `npm dist-tag add`.
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
- Publishable plugins that are new to npm require owner-led first-package
minting before the full release publish. Do not consume the next beta version
with an ad-hoc manual package publish; use the release-owned auto-bumped
version path, or a non-consuming registry setup/preflight step. Bundled
disk-tree-only plugins stay unpublished.
## Fallback local mac publish
@@ -599,8 +623,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
4. Pull latest `main` and confirm current `main` CI is green.
5. Run `/changelog` for the stable base target version on `main`, commit the
changelog rewrite immediately, push, and pull/rebase. For beta releases,
keep the changelog heading as `## YYYY.M.D`, not `## YYYY.M.D-beta.N`.
6. Create `release/YYYY.M.D` from that post-changelog `main` commit.
keep the changelog heading as `## YYYY.M.PATCH`, not `## YYYY.M.PATCH-beta.N`.
6. Create `release/YYYY.M.PATCH` from that post-changelog `main` commit.
7. Make every repo version location match the beta tag before creating it.
8. Commit release preparation changes on the release branch and push the branch.
9. Immediately dispatch Actions > `OpenClaw Performance` from `main` with
@@ -616,7 +640,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
mac app, signing, notarization, and appcast path.
12. Confirm the target npm version is not already published.
13. Create and push the git tag from the release branch.
14. Create or refresh the matching GitHub release.
14. Do not create or publish the matching GitHub release page yet. The real
publish workflow creates or undrafts it only after postpublish verification
and release evidence upload pass.
15. Dispatch Actions > `QA-Lab - All Lanes` against the release tag and wait
for the mock parity, live Matrix, and live Telegram credentialed-channel
lanes to pass.
@@ -639,20 +665,29 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
with `preflight_only=true` and wait for it to pass. Save that run id because
the real publish requires it to reuse the notarized mac artifacts.
21. If any preflight or validation run fails, fix the issue on a new commit,
delete the tag and matching GitHub release, recreate them from the fixed
commit, and rerun all relevant preflights from scratch before continuing.
Never reuse old preflight results after the commit changes. For pushed or
published beta tags, do not delete/recreate; increment to the next beta tag.
For preflight-only failures where npm did not publish the beta version,
delete/recreate the same beta tag and prerelease at the fixed commit instead
of skipping a prerelease number.
delete the tag and any accidental draft/incomplete GitHub release, recreate
the tag from the fixed commit, and rerun all relevant preflights from
scratch before continuing. Never reuse old preflight results after the
commit changes. Once the npm version exists, do not rerun the publish
workflow for that same version; finalize the existing draft/evidence state
manually or cut a correction tag. For pushed or published beta tags, do not
delete/recreate; increment to the next beta tag. For preflight-only failures
where npm did not publish the beta version, delete/recreate the same beta
tag and any accidental draft/incomplete prerelease at the fixed commit
instead of skipping a prerelease number.
22. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
`latest` only when you intentionally want direct stable publish), keep it
the same as the preflight run, and pass the successful npm
`preflight_run_id`.
23. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
24. Run postpublish verification:
24. Wait for the real publish workflow to run postpublish verification,
create or update the GitHub release as a draft, upload dependency evidence,
append release verification proof, and only then undraft/publish it. If a
waited plugin publish fails after OpenClaw npm succeeds, the workflow keeps
the release draft with OpenClaw npm evidence and exits red; do not undraft
until the plugin publish gap is repaired. The standalone verifier command
remains the recovery probe:
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
25. Run the post-published beta verification roster. First scan current `main`
for critical fixes that landed after the release branch cut; backport only

View File

@@ -37,9 +37,11 @@ This is good for auditability if commits are clearly machine-authored and gated
- Branch name: `tideclaw/alpha/YYYY-MM-DD-HHMMZ`
- Base: current `origin/main` SHA at trigger time.
- State file: resolve from `$release-private` on the Tideclaw host.
- Release tag: `vYYYY.M.D-alpha.N`
- Release tag: `vYYYY.M.PATCH-alpha.N`
- npm dist-tag: `alpha`
`PATCH` is a sequential monthly release-train number, never the calendar day. Determine the alpha train from stable and beta releases; ignore alpha-only patch numbers when choosing the next train. Use one greater than the highest stable/beta patch for the month, then increment only `alpha.N` for repeated nightlies on that train. If a beta exists on that next patch, move alpha to the following train. Legacy alpha-only tags with inflated patch numbers do not advance beta/stable numbering.
Do not reuse old alpha branches for a new run. If rerunning the same base SHA, create a new timestamped branch and record why.
## Start
@@ -98,7 +100,7 @@ Tideclaw may run beta releases from `#releases` or mentioned `#maintainers` comm
Accepted shapes:
```text
@Tideclaw beta release from vYYYY.M.D-alpha.N
@Tideclaw beta release from vYYYY.M.PATCH-alpha.N
@Tideclaw beta release from tideclaw/alpha/YYYY-MM-DD-HHMMZ
@Tideclaw beta release from latest proven alpha
```
@@ -110,7 +112,7 @@ Rules:
3. Verify the source alpha first: GitHub release, npm `alpha` package, release CI, recorded state file, and branch/tag SHA.
4. Create a fresh beta branch `tideclaw/beta/YYYY-MM-DD-HHMMZ` from the proven alpha source, not directly from a moving `main`.
5. Reuse/squash only stabilization fixes already proven on alpha. Do not import unrelated alpha release mechanics unless the beta release docs require them.
6. Compute beta as `vYYYY.M.D-beta.N`, matching npm `--tag beta`.
6. Compute beta as `vYYYY.M.PATCH-beta.N`, matching npm `--tag beta`. Ignore alpha-only patch numbers when selecting the beta train.
7. Run beta release validation/preflight/full release CI and fix failures on the beta branch.
8. Publish beta only after green beta gates. Use GitHub Actions/OIDC, never direct npm publish from the host.
9. Final Discord summary must include source alpha, beta tag/version, branch, fix commits, workflow run IDs, npm/GitHub proof, and any skipped/blocked reason.
@@ -165,7 +167,7 @@ git push -u origin "$BRANCH"
After local proof:
1. Compute the next `vYYYY.M.D-alpha.N` from existing git tags, npm versions, and GitHub releases.
1. Compute the next `vYYYY.M.PATCH-alpha.N` from existing git tags, npm versions, and GitHub releases. Select `PATCH` from stable/beta trains, not the date or the highest alpha-only patch. Reuse the same alpha train and increment `alpha.N` until that patch has a beta; after a beta exists, use the following patch for new alpha builds.
2. Make the alpha branch package version and release metadata match that tag, commit it, and push the branch.
3. Run release validation from the alpha branch, using GitHub CLI, not browser/fetch tools. On the Tideclaw host, bare `gh` is a read-only Codex sandbox wrapper; use `/usr/local/bin/gh-tideclaw-write` for write-capable commands such as `workflow run`, `run cancel`, and publish dispatch:

4
.github/labeler.yml vendored
View File

@@ -293,6 +293,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/lobster/**"
"extensions: llama-cpp":
- changed-files:
- any-glob-to-any-file:
- "extensions/llama-cpp/**"
"extensions: memory-core":
- changed-files:
- any-glob-to-any-file:

View File

@@ -2093,7 +2093,7 @@ jobs:
uses: actions/cache@v5
with:
path: ~/.android-sdk
key: ${{ runner.os }}-android-sdk-v1-cmdline-12266719-platform-36-build-tools-36.0.0
key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-37.0-build-tools-36.0.0
restore-keys: |
${{ runner.os }}-android-sdk-v1-
@@ -2101,7 +2101,7 @@ jobs:
run: |
set -euo pipefail
ANDROID_SDK_ROOT="$HOME/.android-sdk"
CMDLINE_TOOLS_VERSION="12266719"
CMDLINE_TOOLS_VERSION="14742923"
ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"
URL="https://dl.google.com/android/repository/${ARCHIVE}"
@@ -2123,7 +2123,7 @@ jobs:
yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null
sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \
"platform-tools" \
"platforms;android-36" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Run Android ${{ matrix.task }}

View File

@@ -35,7 +35,7 @@ jobs:
java-version: "21"
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: java-kotlin
build-mode: manual
@@ -46,6 +46,6 @@ jobs:
run: ./gradlew --no-daemon :app:assemblePlayDebug
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-security/android"

View File

@@ -342,13 +342,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-core-auth-secrets-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/core-auth-secrets"
@@ -365,13 +365,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-config-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/config-boundary"
@@ -388,13 +388,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-gateway-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/gateway-runtime-boundary"
@@ -411,13 +411,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-channel-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/channel-runtime-boundary"
@@ -460,7 +460,7 @@ jobs:
- name: Initialize CodeQL
if: ${{ github.event_name != 'pull_request' }}
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-network-runtime-boundary-critical-quality.yml
@@ -468,7 +468,7 @@ jobs:
- name: Analyze
id: analyze
if: ${{ github.event_name != 'pull_request' }}
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
output: sarif-results
category: "/codeql-critical-quality/network-runtime-boundary"
@@ -518,13 +518,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-agent-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/agent-runtime-boundary"
@@ -541,13 +541,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-mcp-process-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/mcp-process-runtime-boundary"
@@ -564,13 +564,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-memory-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/memory-runtime-boundary"
@@ -587,13 +587,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-session-diagnostics-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/session-diagnostics-boundary"
@@ -610,13 +610,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-plugin-sdk-reply-runtime-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/plugin-sdk-reply-runtime"
@@ -633,13 +633,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-provider-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/provider-runtime-boundary"
@@ -655,13 +655,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-ui-control-plane-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/ui-control-plane"
@@ -677,13 +677,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-web-media-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/web-media-runtime-boundary"
@@ -700,13 +700,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-plugin-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/plugin-boundary"
@@ -723,12 +723,12 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-plugin-sdk-package-contract-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/plugin-sdk-package-contract"

View File

@@ -35,7 +35,7 @@ jobs:
swift --version
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: swift
build-mode: manual
@@ -46,7 +46,7 @@ jobs:
- name: Analyze
id: analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
output: sarif-results
upload: failure-only
@@ -83,7 +83,7 @@ jobs:
done
- name: Upload filtered SARIF
uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
sarif_file: sarif-results-filtered
category: "/codeql-critical-security/macos"

View File

@@ -101,12 +101,12 @@ jobs:
.github/codeql
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: ${{ matrix.language }}
config-file: ${{ matrix.config_file }}
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-security-high/${{ matrix.category }}"

View File

@@ -88,11 +88,30 @@ jobs:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
fetch-depth: 0
- name: Pre-pull BuildKit image
shell: bash
env:
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
run: |
set -euo pipefail
for attempt in 1 2 3 4; do
if docker pull "${BUILDKIT_IMAGE}"; then
exit 0
fi
if [[ "${attempt}" == "4" ]]; then
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
exit 1
fi
sleep_seconds=$((attempt * 10))
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
sleep "${sleep_seconds}"
done
- name: Set up Docker Builder
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
@@ -279,11 +298,30 @@ jobs:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
fetch-depth: 0
- name: Pre-pull BuildKit image
shell: bash
env:
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
run: |
set -euo pipefail
for attempt in 1 2 3 4; do
if docker pull "${BUILDKIT_IMAGE}"; then
exit 0
fi
if [[ "${attempt}" == "4" ]]; then
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
exit 1
fi
sleep_seconds=$((attempt * 10))
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
sleep "${sleep_seconds}"
done
- name: Set up Docker Builder
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
@@ -468,7 +506,7 @@ jobs:
fetch-depth: 0
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
@@ -561,11 +599,30 @@ jobs:
with:
fetch-depth: 1
- name: Pre-pull BuildKit image
shell: bash
env:
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
run: |
set -euo pipefail
for attempt in 1 2 3 4; do
if docker pull "${BUILDKIT_IMAGE}"; then
exit 0
fi
if [[ "${attempt}" == "4" ]]; then
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
exit 1
fi
sleep_seconds=$((attempt * 10))
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
sleep "${sleep_seconds}"
done
- name: Set up Docker Builder
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}

View File

@@ -112,7 +112,7 @@ jobs:
persist-credentials: false
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -223,7 +223,7 @@ jobs:
persist-credentials: false
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -245,7 +245,7 @@ jobs:
- name: Set up Blacksmith Docker Builder
if: steps.existing.outputs.exists != 'true'
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -311,7 +311,7 @@ jobs:
persist-credentials: false
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -417,7 +417,7 @@ jobs:
persist-credentials: false
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -429,7 +429,7 @@ jobs:
run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF"
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -503,7 +503,7 @@ jobs:
persist-credentials: false
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -542,7 +542,7 @@ jobs:
persist-credentials: false
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000

View File

@@ -29,14 +29,14 @@ jobs:
uses: actions/checkout@v6
- name: Login to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000

View File

@@ -37,7 +37,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);

View File

@@ -56,7 +56,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);
@@ -91,7 +91,7 @@ jobs:
steps:
- name: Resolve refs and target PR
id: resolve
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const defaultBaseline = "0bf06e953fdda290799fc9fb9244a8f67fdae593";
@@ -581,7 +581,7 @@ jobs:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const { owner, repo } = context.repo;

View File

@@ -56,7 +56,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);
@@ -91,7 +91,7 @@ jobs:
steps:
- name: Resolve refs and target PR
id: resolve
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const defaultBaseline = "synthetic-reverted-thread-filepath-fix";
@@ -603,7 +603,7 @@ jobs:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const { owner, repo } = context.repo;

View File

@@ -81,7 +81,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);
@@ -180,7 +180,7 @@ jobs:
run: pnpm build
- name: Cache Mantis candidate pnpm store
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.local/share/pnpm/store

View File

@@ -79,7 +79,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
if (context.eventName === "pull_request_target") {
@@ -125,7 +125,7 @@ jobs:
steps:
- name: Resolve refs and target PR
id: resolve
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const eventName = context.eventName;
@@ -709,7 +709,7 @@ jobs:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const { owner, repo } = context.repo;

View File

@@ -68,7 +68,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);
@@ -105,7 +105,7 @@ jobs:
steps:
- name: Resolve refs and target PR
id: resolve
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const eventName = context.eventName;
@@ -327,7 +327,7 @@ jobs:
run: pnpm build
- name: Cache Mantis candidate pnpm store
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.local/share/pnpm/store
@@ -573,7 +573,7 @@ jobs:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const { owner, repo } = context.repo;

View File

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

View File

@@ -1497,37 +1497,72 @@ jobs:
- name: Setup Docker builder
if: steps.image_exists.outputs.needs_build == '1'
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
- name: Build and push bare Docker E2E image
if: steps.plan.outputs.needs_bare_image == '1' && steps.image_exists.outputs.bare_exists != '1'
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: ./scripts/e2e/Dockerfile
target: bare
platforms: linux/amd64
tags: ${{ steps.image.outputs.bare_image }}
sbom: true
provenance: mode=max
push: true
shell: bash
env:
IMAGE_REF: ${{ steps.image.outputs.bare_image }}
run: |
set -euo pipefail
build_cmd=(
docker buildx build
--file ./scripts/e2e/Dockerfile
--target bare
--platform linux/amd64
--tag "$IMAGE_REF"
--sbom=true
--provenance=mode=max
--push
.
)
for attempt in 1 2 3 4; do
if "${build_cmd[@]}"; then
exit 0
fi
if [[ "$attempt" == "4" ]]; then
echo "::error::Failed to build Docker E2E bare image after ${attempt} attempts"
exit 1
fi
sleep_seconds=$((attempt * 20))
echo "Docker E2E bare image build failed; retrying in ${sleep_seconds}s (${attempt}/4)."
sleep "$sleep_seconds"
done
- name: Build and push functional Docker E2E image
if: steps.plan.outputs.needs_functional_image == '1' && steps.image_exists.outputs.functional_exists != '1'
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: ./scripts/e2e/Dockerfile
target: functional
build-contexts: |
openclaw_package=.artifacts/docker-e2e-package
platforms: linux/amd64
tags: ${{ steps.image.outputs.functional_image }}
sbom: true
provenance: mode=max
push: true
shell: bash
env:
IMAGE_REF: ${{ steps.image.outputs.functional_image }}
run: |
set -euo pipefail
build_cmd=(
docker buildx build
--file ./scripts/e2e/Dockerfile
--target functional
--build-context openclaw_package=.artifacts/docker-e2e-package
--platform linux/amd64
--tag "$IMAGE_REF"
--sbom=true
--provenance=mode=max
--push
.
)
for attempt in 1 2 3 4; do
if "${build_cmd[@]}"; then
exit 0
fi
if [[ "$attempt" == "4" ]]; then
echo "::error::Failed to build Docker E2E functional image after ${attempt} attempts"
exit 1
fi
sleep_seconds=$((attempt * 20))
echo "Docker E2E functional image build failed; retrying in ${sleep_seconds}s (${attempt}/4)."
sleep "$sleep_seconds"
done
prepare_live_test_image:
needs: validate_selected_ref
@@ -1558,8 +1593,11 @@ jobs:
run: |
set -euo pipefail
repository="${GITHUB_REPOSITORY,,}"
live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}"
live_image_extensions="matrix,acpx"
live_image_tag_suffix="${live_image_extensions//,/-}"
live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}-${live_image_tag_suffix}"
echo "live_image=${live_image}" >> "$GITHUB_OUTPUT"
echo "live_image_extensions=${live_image_extensions}" >> "$GITHUB_OUTPUT"
echo "Shared live-test image: \`${live_image}\`" >> "$GITHUB_STEP_SUMMARY"
- name: Log in to GHCR
@@ -1582,7 +1620,7 @@ jobs:
- name: Setup Docker builder
if: steps.image_exists.outputs.exists != '1'
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -1594,7 +1632,7 @@ jobs:
file: ./Dockerfile
target: build
build-args: |
OPENCLAW_EXTENSIONS=matrix
OPENCLAW_EXTENSIONS=${{ steps.image.outputs.live_image_extensions }}
platforms: linux/amd64
tags: ${{ steps.image.outputs.live_image }}
sbom: true

View File

@@ -653,6 +653,63 @@ jobs:
done
}
guard_existing_public_release() {
local release_version asset_name release_json is_draft has_sha has_proof has_asset release_url
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
return 0
fi
if ! release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,assets,body,url 2>/dev/null)"; then
return 0
fi
is_draft="$(printf '%s' "${release_json}" | jq -r '.isDraft')"
if [[ "${is_draft}" == "true" ]]; then
return 0
fi
release_version="${RELEASE_TAG#v}"
asset_name="openclaw-${release_version}-dependency-evidence.zip"
has_sha="$(printf '%s' "${release_json}" | jq --arg sha "${TARGET_SHA}" -r '.body | contains($sha)')"
has_proof="$(printf '%s' "${release_json}" | jq -r '.body | contains("### Release verification")')"
has_asset="$(printf '%s' "${release_json}" | jq --arg name "${asset_name}" -r 'any(.assets[]?; .name == $name)')"
release_url="$(printf '%s' "${release_json}" | jq -r '.url')"
if [[ "${has_sha}" == "true" && "${has_proof}" == "true" && "${has_asset}" == "true" ]]; then
return 0
fi
{
echo "Release ${RELEASE_TAG} already has a public GitHub release page without complete postpublish evidence for ${TARGET_SHA}."
echo "Refusing to reuse a public prerelease tag after publication started: ${release_url}"
echo "Create a new beta tag or delete/draft the incomplete public release before retrying."
} >&2
exit 1
}
guard_openclaw_npm_not_already_published() {
local release_version release_url
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
return 0
fi
release_version="${RELEASE_TAG#v}"
if ! npm view "openclaw@${release_version}" version >/dev/null 2>&1; then
return 0
fi
release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}"
{
echo "openclaw@${release_version} is already published on npm."
echo "Refusing to dispatch publish child workflows for an already-published version."
echo "If this is recovery from a failed postpublish evidence or draft-release step, repair/finalize the existing draft or create a correction tag; do not rerun the publish workflow for the same npm version."
echo "Release page, if present: ${release_url}"
} >&2
exit 1
}
create_or_update_github_release() {
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
release_version="${RELEASE_TAG#v}"
@@ -698,11 +755,17 @@ jobs:
else
gh release create "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \
--verify-tag \
--draft \
--title "${title}" \
--notes-file "${notes_file}" \
"${prerelease_args[@]}" \
"${latest_arg}"
fi
echo "- GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
publish_github_release() {
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --draft=false
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
@@ -735,9 +798,11 @@ jobs:
}
verify_published_release() {
local release_version evidence_path
local release_version evidence_path skip_clawhub
local -a verify_args
skip_clawhub="${1:-false}"
release_version="${RELEASE_TAG#v}"
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}"
@@ -754,11 +819,12 @@ jobs:
--plugin-npm-run "${plugin_npm_run_id}"
--openclaw-npm-run "${openclaw_npm_run_id}"
--evidence-out "${evidence_path}"
--skip-github-release
)
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
else
if [[ "${skip_clawhub}" == "true" || "${WAIT_FOR_CLAWHUB}" != "true" ]]; then
verify_args+=(--skip-clawhub)
else
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
fi
if [[ -n "${PLUGINS// }" ]]; then
verify_args+=(--plugins "${PLUGINS}")
@@ -799,6 +865,7 @@ jobs:
RELEASE_NOTES_FILE="${notes_file}" \
RELEASE_VERSION="${release_version}" \
RELEASE_TAG="${RELEASE_TAG}" \
RELEASE_SHA="${TARGET_SHA}" \
RELEASE_REPO="${GITHUB_REPOSITORY}" \
RELEASE_TARBALL="${tarball}" \
RELEASE_INTEGRITY="${integrity}" \
@@ -825,6 +892,7 @@ jobs:
`- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`,
`- registry tarball: ${process.env.RELEASE_TARBALL}`,
`- integrity: \`${process.env.RELEASE_INTEGRITY}\``,
`- release SHA: \`${process.env.RELEASE_SHA}\``,
`- full release CI report: https://github.com/openclaw/releases/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
@@ -863,6 +931,9 @@ jobs:
fi
} >> "$GITHUB_STEP_SUMMARY"
guard_existing_public_release
guard_openclaw_npm_not_already_published
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
if [[ -n "${PLUGINS}" ]]; then
@@ -934,11 +1005,6 @@ jobs:
openclaw_failed=1
fi
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
create_or_update_github_release
upload_dependency_evidence_release_asset
fi
if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then
failed=1
fi
@@ -946,9 +1012,20 @@ jobs:
failed=1
fi
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
verify_published_release
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
if [[ "${failed}" == "0" ]]; then
verify_published_release
else
verify_published_release true
fi
create_or_update_github_release
upload_dependency_evidence_release_asset
append_release_proof_to_github_release
if [[ "${failed}" == "0" ]]; then
publish_github_release
else
echo "- GitHub release: left as draft because a required publish child failed" >> "$GITHUB_STEP_SUMMARY"
fi
fi
if [[ "${failed}" != "0" ]]; then
exit 1

View File

@@ -53,7 +53,7 @@ jobs:
scripts/run-opengrep.sh --sarif --error
- name: Upload SARIF to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v4.36.1
uses: github/codeql-action/upload-sarif@v4.36.2
# Only upload if the scan actually produced a SARIF file.
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
with:

View File

@@ -84,7 +84,7 @@ jobs:
scripts/run-opengrep.sh --changed --sarif --error
- name: Upload SARIF to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v4.36.1
uses: github/codeql-action/upload-sarif@v4.36.2
# Only upload if the scan actually produced a SARIF file.
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
with:

View File

@@ -24,6 +24,11 @@ on:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
dry_run:
description: Validate the full ClawHub artifact handoff without publishing.
required: false
default: false
type: boolean
concurrency:
group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
@@ -35,7 +40,7 @@ env:
CLAWHUB_REGISTRY: "https://clawhub.ai"
CLAWHUB_REPOSITORY: "openclaw/clawhub"
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
CLAWHUB_REF: "facf20ceb6cc459e2872d941e71335a784bbc55c"
CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"
jobs:
preview_plugins_clawhub:
@@ -56,12 +61,6 @@ jobs:
ref: ${{ github.ref }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
- name: Resolve checked-out ref
id: ref
env:
@@ -107,6 +106,12 @@ jobs:
echo "Plugin ClawHub publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
- name: Validate publishable plugin metadata
env:
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
@@ -192,12 +197,6 @@ jobs:
exit 1
fi
- name: Verify OpenClaw ClawHub package ownership
if: steps.plan.outputs.has_candidates == 'true'
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json
validate_release_publish_approval:
name: Validate release publish approval
needs: preview_plugins_clawhub
@@ -326,15 +325,12 @@ jobs:
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
publish_plugins_clawhub:
pack_plugins_clawhub_artifacts:
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
environment: clawhub-plugin-release
permissions:
actions: read
contents: read
id-token: write
strategy:
fail-fast: false
max-parallel: 32
@@ -407,73 +403,7 @@ jobs:
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Write ClawHub token config
env:
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: |
set -euo pipefail
if [[ -z "${CLAWHUB_TOKEN}" ]]; then
echo "No CLAWHUB_TOKEN secret configured; publish will rely on GitHub OIDC trusted publishing."
exit 0
fi
node --input-type=module <<'EOF'
import { writeFileSync } from "node:fs";
import { join } from "node:path";
const path = join(process.env.RUNNER_TEMP, "clawhub-config.json");
writeFileSync(
path,
`${JSON.stringify(
{
registry: process.env.CLAWHUB_REGISTRY,
token: process.env.CLAWHUB_TOKEN,
},
null,
2,
)}\n`,
);
console.log(path);
EOF
echo "CLAWHUB_CONFIG_PATH=${RUNNER_TEMP}/clawhub-config.json" >> "$GITHUB_ENV"
- name: Check ClawHub package version
id: clawhub_package_version
env:
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: |
set -euo pipefail
encoded_name="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_NAME ?? ""))')"
encoded_version="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_VERSION ?? ""))')"
url="${CLAWHUB_REGISTRY%/}/api/v1/packages/${encoded_name}/versions/${encoded_version}"
status=""
for attempt in $(seq 1 8); do
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "${url}")"
if [[ "${status}" == "404" || "${status}" =~ ^2 ]]; then
break
fi
if [[ "${status}" == "429" || "${status}" =~ ^5 ]]; then
echo "ClawHub availability check returned ${status} for ${PACKAGE_NAME}@${PACKAGE_VERSION}; retrying (${attempt}/8)."
sleep 60
continue
fi
break
done
if [[ "${status}" =~ ^2 ]]; then
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub."
echo "already_published=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "${status}" != "404" ]]; then
echo "Unexpected ClawHub response (${status}) for ${PACKAGE_NAME}@${PACKAGE_VERSION}."
exit 1
fi
echo "already_published=false" >> "$GITHUB_OUTPUT"
- name: Publish
if: steps.clawhub_package_version.outputs.already_published != 'true'
- name: Pack ClawHub package artifact
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
@@ -481,8 +411,65 @@ jobs:
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: ${{ runner.temp }}/clawhub-package-artifact
run: bash scripts/plugin-clawhub-publish.sh --pack "${PACKAGE_DIR}"
- name: Upload ClawHub package artifact
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.plugin.artifactName }}
path: ${{ runner.temp }}/clawhub-package-artifact/*.tgz
if-no-files-found: error
retention-days: 7
approve_plugin_clawhub_release:
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts]
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
environment: clawhub-plugin-release
permissions: {}
steps:
- name: Approve ClawHub package publish
run: echo "ClawHub package publish approved."
publish_plugins_clawhub:
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugin_clawhub_release]
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugin_clawhub_release.result == 'success')
permissions:
actions: read
contents: read
id-token: write
strategy:
fail-fast: false
max-parallel: 32
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854
with:
dry_run: ${{ inputs.dry_run }}
json: true
package_artifact_name: ${{ matrix.plugin.artifactName }}
registry: https://clawhub.ai
site: https://clawhub.ai
source_repo: ${{ github.repository }}
source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
source_ref: ${{ github.ref }}
tags: ${{ matrix.plugin.publishTag }}
secrets:
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
verify_published_clawhub_package:
needs: [preview_plugins_clawhub, publish_plugins_clawhub]
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 32
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
- name: Verify published ClawHub package
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}

View File

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

View File

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

View File

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

View File

@@ -251,9 +251,11 @@ Skills own workflows; root owns hard policy and routing.
- Lockfiles/shrinkwrap are security surface: review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, `package-lock.json`; root/plugin npm packages ship shrinkwrap, not package-lock.
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
- Releases/publish/version bumps need explicit approval. Use `$release-openclaw-maintainer`.
- Release versions use `YYYY.M.PATCH`, where `PATCH` is a sequential monthly release-train number, never the calendar day. Stable and beta tags determine the current train; alpha-only tags do not consume or advance the beta/stable patch number. After `2026.6.5`, the next beta train is `2026.6.6-beta.1` even if higher alpha-only tags exist.
- Alpha/nightly versions use the next unreleased train plus an incrementing prerelease number. Repeated nightlies for the same train increment only `alpha.N`; they must not mint a new patch number from the date.
- Backport means apply to newest open `release/` branch unless user names another target.
- GHSA/advisories: `$openclaw-ghsa-maintainer` / `$security-triage`. Secret scanning: `$openclaw-secret-scanning-maintainer`.
- Beta tag/version match: `vYYYY.M.D-beta.N` -> npm `YYYY.M.D-beta.N --tag beta`.
- Beta tag/version match: `vYYYY.M.PATCH-beta.N` -> npm `YYYY.M.PATCH-beta.N --tag beta`.
## Platform / Ops

View File

@@ -2,6 +2,39 @@
Docs: https://docs.openclaw.ai
## 2026.6.6
### Highlights
- Security boundaries are substantially tighter across transcripts, sandbox binds, host environment inheritance, MCP stdio, Codex HTTP access, native search policy, elevated sender checks, deleted-agent ACP bypasses, loopback tools, Discord moderation, and Teams group actions; exec approvals now fail closed on timeout. (#91529, #91618, #91615, #91619, #91741, #91745, #91746, #91748, #91749, #91750, #91751, #91752, #91763, #89938) Thanks @joshavant, @pgondhi987, @mmaps, @eleqtrizit, @shakkernerd, and @drobison00.
- Telegram delivery is safer and more coherent: account-scoped topics route to the right agent, streamed text survives tool calls, `/compact` works on generic ingress, callback handling uses concrete APIs, draft chunking is shared, durable dispatch dedupe moved into the SDK, and unauthorized DM text stays out of cache and prompt context. (#91189, #88682, #89588, #90212, #91876, #91874, #91904, #91478, #91915) Thanks @codysai001, @alexzhu0, @joelnishanth, @snowzlm, @obviyus, and @sallyom.
- iMessage recovery and delivery now cover always-on inbound restart, durable echo markers, block streaming, idle approval discovery, hardened outbound transport, and actionable inbound startup diagnostics. (#91335, #91449, #88969, #88530, #91783, #91785) Thanks @omarshahine, @jmissig, and @colmbrogan.
- Browser and MCP connectivity gained existing-session CDP support, discovered WebSocket validation, default-profile `cdpUrl` handling, safer browser-output boundaries, Streamable HTTP loopback transport, corrected OAuth/SSE authorization handling, and broader schema compatibility. (#91422, #89851, #91736, #91747, #91451, #80143) Thanks @pgondhi987, @anagnorisis2peripeteia, @lifuyue, @eleqtrizit, @LiuwqGit, and @HemantSudarshan.
- Control UI startup and first-reply latency are lower through cached model metadata, removal of the startup catalog wait, lazy slash-command loading, and first-event tracing with slow-reply diagnostics. (#91531, #91538, #91568, #91583, #91598)
- Provider support expands with OpenRouter OAuth onboarding and Claude Fable 5 adaptive thinking, while Codex sessions keep correct compaction ownership, local models skip guardian review, dynamic tool progress normalizes cleanly, and Gemma 4 reasoning replay is preserved. (#91830, #91882, #91590, #88630, #88768, #91696) Thanks @Patrick-Erichsen, @joshavant, @bdjben, and @Coder-Wangyankun.
### Changes
- CLI progress: emit Claude CLI commentary progress events and bridge inter-tool commentary into channel progress without exposing internal protocol scaffolding. (#89834, #90883) Thanks @anagnorisis2peripeteia.
- Observability: allow trusted diagnostics channels to capture tool input/output content, add first-assistant-event traces, and warn on slow initial replies. (#91256, #91568, #91583) Thanks @amknight.
- Plugins/ClawHub: dogfood reusable package publishing, let dry runs skip publish approval, allow declared installed trusted hooks, report managed plugin version drift, and warn instead of failing on retired Skill Workshop configuration. (#91574, #91591, #90004, #90927, #90838) Thanks @Patrick-Erichsen, @brokemac79, and @lonexreb.
- Memory/providers: move the local llama.cpp runtime into its provider plugin, batch embeddings across files, persist the agent model catalog cache, and keep QMD JSON search one-shot while filtering stale REM recall previews. (#91324, #89138, #90457, #91837, #91851) Thanks @osolmaz, @mushuiyu886, @ai-hpc, and @TurboTheTurtle.
- Channels/mobile: add the QQBot group mention toggle, improve iPad and iPhone control surfaces, and expose the active connection host in the TUI footer. (#91423, #91557, #89909) Thanks @cxyhhhhh, @Solvely-Colin, and @baskduf.
- Performance: prewarm TUI runtime plugins, deduplicate plugin auto-enable fanout, trim dense text-delta snapshots, and reuse prepared startup model metadata. (#90782, #89978, #91580, #91531) Thanks @RomneyDa and @ai-hpc.
### Fixes
- Agent/session recovery: drop stale approval follow-ups after session rebind, remove drained reply-queue items by identity, recover stale main and visible replies, preserve Codex context-engine compaction ownership, lower the default compaction timeout to 180 seconds while respecting explicit configuration, and keep provider-failure terminal lifecycle state correct. (#85679, #91450, #91566, #91840, #91590, #91361, #91895) Thanks @openperf, @yetval, @joshavant, @wangmiao0668000666, and @TurboTheTurtle.
- User-visible content boundaries: suppress Codex/Harmony protocol artifacts, neutralize browser and LanceDB memory media directives, redact transcript images, and preserve native `/compact` replies through source suppression. (#89151, #91422, #91425, #91529, #90212) Thanks @joelnishanth, @pgondhi987, @joshavant, and @snowzlm.
- Channel delivery: keep WhatsApp captured replies attached to the successor controller after restart, retry Feishu rate limits, preserve Mattermost thread replies, canonicalize LINE webhook paths, restore Discord reply hydration and runtime timeout exports, and show OpenAI Realtime WebRTC assistant transcripts. (#85823, #89659, #91684, #91649, #90263, #91686, #90426) Thanks @itsuzef, @ladygege, @jacobtomlinson, @fuller-stack-dev, and @shushushv.
- Cron: cancel active task runs cleanly, preserve terminal timeout/cancel state, and recover no-deliver tool warnings instead of silently losing the outcome. (#90666, #90678) Thanks @ai-hpc.
- Gateway/config/auth: share the approval runtime socket token, replace arrays explicitly in `config.patch`, skip the deleted-agent guard only for valid ACP harness sessions, surface headless LaunchAgent state, verify SQLite auth migration before cleanup, and arm QMD startup maintenance. (#87105, #91551, #91219, #91614, #91740, #91978) Thanks @fuller-stack-dev and @scotthuang.
- Providers/Codex: clarify quota errors, restore the Codex synthetic usage line, canonicalize Codex protocol assets, require API-key auth for realtime voice, normalize ACP model refs, preserve Gemma 4 `reasoning_content`, and avoid guardian review for local models. (#91390, #91709, #91507, #91567, #88630, #91696) Thanks @hxy91819, @brokemac79, @RomneyDa, @joshavant, and @Coder-Wangyankun.
- Updates/builds: recover package Gateway restarts after refresh failure, expose plugin convergence repair, fall back to Corepack in PATH-less pnpm environments, seed the correct Docker store packages, and keep ClawHub dry-run and publish paths reusable. (#91581, #91599, #91547, #91591) Thanks @fuller-stack-dev, @sallyom, and @Patrick-Erichsen.
- UI: require explicit user intent before opening chat sessions and drain restored chat queues after session switches. (#91480) Thanks @TurboTheTurtle.
- Android: avoid the `dataSync` foreground-service type for persistent nodes. (#80082) Thanks @davelutztx.
- Native hooks: bound relay lifetimes so abandoned native hook connections cannot linger indefinitely. (#91550) Thanks @joshavant.
## 2026.6.5
### Highlights
@@ -27,9 +60,11 @@ Docs: https://docs.openclaw.ai
- Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward `web_fetch`, clarify legacy `openai-codex` auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev.
- Release/process: switch release trains to `YYYY.M.PATCH` monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at `2026.6.5` after the published beta.
- Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601)
- QQBot: add `/bot-group-allways on|off` slash command (with named-account and default-account support) to toggle whether group messages require an `@mention` before the bot replies, and clear the runtime config snapshot after the write so the new account-level `defaultRequireMention` takes effect immediately without restart. (#91423) Thanks @cxyhhhhh.
### Fixes
- Agents: `sessions_send` now honors an explicit `sessionKey` when stale label metadata is also present, and denied session-id sends no longer echo the resolved canonical session key. Fixes #64699; refs #74009 and #41199. Thanks @Mintalix, @RevisitMoon, and @Mocha-s.
- Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf.
- Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680.
- Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until `message_start`, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii.
@@ -39,10 +74,12 @@ Docs: https://docs.openclaw.ai
- Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, service env planning skips unresolved placeholders that would mask state-dir `.env` values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #90277, #90488) Thanks @MonkeyLeeT and @sallyom.
- Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145)
- Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on `globalThis`; Feishu streaming cards preserve full merged content; voice-call tracks Twilio streams after connect; ClickClack reply tools respect `toolsAllow`. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, and @sahibzada-allahyar.
- Feishu: retry transient send rate-limit errors (HTTP 429, per-chat code 230020, tenant-level code 11232) with linear backoff, including SDK responses that fulfill with rate-limit bodies instead of throwing, and route streaming-card sends through the retry wrapper. (#89659) Thanks @ladygege.
- Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp.
- Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.
- Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.
- Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.
- Memory: keep doctor REM harness previews aligned with live REM by dropping short-term recall snippets whose source files disappeared before rendering preview output. Thanks @samzong and @frankekn.
- Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.
- Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.
- Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,19 @@ import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private fun createDnsResolver(context: Context): DnsResolver =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
createContextDnsResolver(context)
} else {
createLegacyDnsResolver()
}
@TargetApi(Build.VERSION_CODES.CINNAMON_BUN)
private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null)
@Suppress("DEPRECATION")
private fun createLegacyDnsResolver(): DnsResolver = DnsResolver.getInstance()
/**
* Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways.
*/
@@ -58,7 +71,7 @@ class GatewayDiscovery(
) {
private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val dns = DnsResolver.getInstance()
private val dns = createDnsResolver(context)
private val serviceType = "_openclaw-gw._tcp."
private val wideAreaDomain = System.getenv("OPENCLAW_WIDE_AREA_DOMAIN")
private val logTag = "OpenClaw/GatewayDiscovery"

View File

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

View File

@@ -4,7 +4,7 @@ androidx-activity = "1.13.0"
androidx-benchmark = "1.4.1"
androidx-camera = "1.6.0"
androidx-compose-bom = "2026.05.01"
androidx-core = "1.18.0"
androidx-core = "1.19.0"
androidx-exifinterface = "1.4.2"
androidx-lifecycle = "2.10.0"
androidx-security = "1.1.0"
@@ -19,7 +19,7 @@ junit = "4.13.2"
junit-vintage = "6.1.0"
kotest = "6.1.11"
ktlint-gradle = "14.2.0"
kotlin = "2.3.21"
kotlin = "2.4.0"
material = "1.14.0"
okhttp = "5.3.2"
play-services-code-scanner = "16.1.0"

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -329,6 +329,13 @@ struct AgentConfigLite: Decodable {
struct ConfigPatchParams: Encodable {
let raw: String
let baseHash: String
let replacePaths: [String]?
init(raw: String, baseHash: String, replacePaths: [String]? = nil) {
self.raw = raw
self.baseHash = baseHash
self.replacePaths = replacePaths
}
}
enum SkillMutationError: LocalizedError {

View File

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

View File

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

View File

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

View File

@@ -621,7 +621,10 @@ extension AgentProTab {
}
let raw = try Self.agentSkillsPatchRaw(agentId: self.activeAgentID, skills: skills)
let params = ConfigPatchParams(raw: raw, baseHash: baseHash)
let params = ConfigPatchParams(
raw: raw,
baseHash: baseHash,
replacePaths: ["agents.list[].skills"])
let data = try JSONEncoder().encode(params)
guard let json = String(data: data, encoding: .utf8) else {
throw SkillMutationError.invalidPatchPayload

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
{
"originHash" : "a88730a64ccb5fd092108256c37d6c80bc7b92a5b6b563d83a9a26988550234d",
"originHash" : "035a4fe955164c62c1628de75f6437a14443a947eea2a1b0176ba484d6fde6f8",
"pins" : [
{
"identity" : "axorcist",
@@ -42,8 +42,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Peekaboo.git",
"state" : {
"revision" : "faf843032772c2074d834b931911bf0002704136",
"version" : "3.3.0"
"revision" : "3a56ed2aa769bfefb5a78722dfce3c34088cfba1",
"version" : "3.4.0"
}
},
{

View File

@@ -19,7 +19,7 @@ let package = Package(
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"),
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.3.0"),
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.4.0"),
.package(path: "../shared/OpenClawKit"),
.package(path: "../swabble"),
],

View File

@@ -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.schedulerStorePath = status.sqlitePath ?? status.storePath
self.schedulerNextWakeAtMs = status.nextWakeAtMs
}
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)

View File

@@ -1,4 +1,5 @@
import CryptoKit
import Darwin
import Foundation
import OSLog
import Security
@@ -229,6 +230,12 @@ enum ExecApprovalsStore {
private static let secureStateDirPermissions = 0o700
private static let fileLock = NSRecursiveLock()
private enum LegacyMigrationResult {
case notNeeded
case migrated
case blocked
}
private static func withFileLock<T>(_ body: () throws -> T) rethrows -> T {
self.fileLock.lock()
defer { self.fileLock.unlock() }
@@ -243,6 +250,195 @@ enum ExecApprovalsStore {
OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
}
private static func legacyStateDirURLs() -> [URL] {
if let home = OpenClawEnv.path("OPENCLAW_HOME") {
var urls = [
URL(fileURLWithPath: home, isDirectory: true)
.appendingPathComponent(".openclaw", isDirectory: true),
]
let osHomeURL = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".openclaw", isDirectory: true)
if !urls.contains(where: {
$0.standardizedFileURL.path == osHomeURL.standardizedFileURL.path
}) {
urls.append(osHomeURL)
}
return urls
}
return [
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".openclaw", isDirectory: true),
]
}
private static func legacyFileURLIfPending() -> URL? {
guard OpenClawEnv.path("OPENCLAW_STATE_DIR") != nil else { return nil }
let targetURL = self.fileURL()
for stateDirURL in self.legacyStateDirURLs() {
let legacyURL = stateDirURL
.appendingPathComponent("exec-approvals.json", isDirectory: false)
guard legacyURL.standardizedFileURL.path != targetURL.standardizedFileURL.path else {
continue
}
guard FileManager().fileExists(atPath: legacyURL.path) else { continue }
guard !FileManager().fileExists(atPath: targetURL.path) else { return nil }
return legacyURL
}
return nil
}
private static func unmigratedLegacyFallbackFile() -> ExecApprovalsFile {
ExecApprovalsFile(
version: 1,
socket: nil,
defaults: ExecApprovalsDefaults(
security: .deny,
ask: .always,
askFallback: .deny,
autoAllowSkills: nil),
agents: [:])
}
private static func isLegacyDefaultSocketPath(_ raw: String, legacyFileURL: URL) -> Bool {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return true }
let expanded = self.expandPath(trimmed)
let legacySocket = legacyFileURL.deletingLastPathComponent()
.appendingPathComponent("exec-approvals.sock", isDirectory: false)
.path
return URL(fileURLWithPath: expanded).standardizedFileURL.path
== URL(fileURLWithPath: legacySocket).standardizedFileURL.path
}
private static func hasSymlinkParent(_ url: URL) -> Bool {
var cursor = url.deletingLastPathComponent()
let manager = FileManager()
while true {
var isDirectory = ObjCBool(false)
if manager.fileExists(atPath: cursor.path, isDirectory: &isDirectory) {
if (try? manager.destinationOfSymbolicLink(atPath: cursor.path)) != nil {
return true
}
}
let parent = cursor.deletingLastPathComponent()
if parent.path == cursor.path { return false }
cursor = parent
}
}
private static func archiveMigratedLegacyFile(_ legacyURL: URL) throws -> URL {
let manager = FileManager()
var archiveURL = URL(fileURLWithPath: "\(legacyURL.path).migrated")
if manager.fileExists(atPath: archiveURL.path) {
archiveURL = URL(fileURLWithPath: "\(archiveURL.path)-\(UUID().uuidString)")
}
try manager.moveItem(at: legacyURL, to: archiveURL)
return archiveURL
}
private static func writeMigratedFileExclusively(_ data: Data, to targetURL: URL) throws -> Bool {
let tempURL = targetURL.deletingLastPathComponent()
.appendingPathComponent(".exec-approvals.migration.\(UUID().uuidString)")
let fd = open(tempURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR)
if fd == -1 {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
var closed = false
defer {
if !closed { close(fd) }
}
do {
try data.withUnsafeBytes { rawBuffer in
guard let base = rawBuffer.baseAddress else { return }
var offset = 0
while offset < rawBuffer.count {
let written = Darwin.write(
fd,
base.advanced(by: offset),
rawBuffer.count - offset)
if written < 0 {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
offset += written
}
}
close(fd)
closed = true
let copied = copyfile(
tempURL.path,
targetURL.path,
nil,
copyfile_flags_t(COPYFILE_EXCL))
if copied == -1 {
if errno == EEXIST {
try? FileManager().removeItem(at: tempURL)
return false
}
try? FileManager().removeItem(at: targetURL)
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
try? FileManager().removeItem(at: tempURL)
return true
} catch {
try? FileManager().removeItem(at: tempURL)
throw error
}
}
private static func migrateLegacyFileIfNeeded() -> LegacyMigrationResult {
guard let legacyURL = self.legacyFileURLIfPending() else { return .notNeeded }
let targetURL = self.fileURL()
do {
if self.hasSymlinkParent(targetURL) {
throw NSError(domain: "ExecApprovals", code: 10, userInfo: [
NSLocalizedDescriptionKey: "target path has a symlink parent",
])
}
let data = try Data(contentsOf: legacyURL)
var file = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
guard file.version == 1 else {
throw NSError(domain: "ExecApprovals", code: 11, userInfo: [
NSLocalizedDescriptionKey: "unsupported legacy approvals version",
])
}
file = self.normalizeIncoming(file)
let rawSocketPath = file.socket?.path?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if self.isLegacyDefaultSocketPath(rawSocketPath, legacyFileURL: legacyURL) {
if file.socket == nil {
file.socket = ExecApprovalsSocketConfig(path: nil, token: nil)
}
file.socket?.path = self.socketPath()
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let migrated = try encoder.encode(file)
self.ensureSecureStateDirectory()
try FileManager().createDirectory(
at: targetURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
if FileManager().fileExists(atPath: targetURL.path) { return .notNeeded }
let created = try self.writeMigratedFileExclusively(migrated, to: targetURL)
if !created { return .notNeeded }
try? FileManager().setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: targetURL.path)
do {
_ = try self.archiveMigratedLegacyFile(legacyURL)
} catch {
self.logger
.warning(
"exec approvals legacy archive failed: \(error.localizedDescription, privacy: .public)")
}
return .migrated
} catch {
self.logger
.error(
"exec approvals legacy migration failed: \(error.localizedDescription, privacy: .public)")
return .blocked
}
}
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -278,6 +474,14 @@ enum ExecApprovalsStore {
static func readSnapshot() -> ExecApprovalsSnapshot {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
let file = self.unmigratedLegacyFallbackFile()
return ExecApprovalsSnapshot(
path: self.fileURL().path,
exists: false,
hash: self.hashRaw(nil),
file: file)
}
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsSnapshot(
@@ -322,6 +526,14 @@ enum ExecApprovalsStore {
static func loadFile() -> ExecApprovalsFile {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
switch self.migrateLegacyFileIfNeeded() {
case .migrated, .notNeeded:
break
case .blocked:
return self.unmigratedLegacyFallbackFile()
}
}
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
@@ -361,6 +573,14 @@ enum ExecApprovalsStore {
static func ensureFile() -> ExecApprovalsFile {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
switch self.migrateLegacyFileIfNeeded() {
case .migrated, .notNeeded:
break
case .blocked:
return self.unmigratedLegacyFallbackFile()
}
}
self.ensureSecureStateDirectory()
let url = self.fileURL()
let existed = FileManager().fileExists(atPath: url.path)

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ enum HostEnvSecurityPolicy {
"AWS_SESSION_TOKEN",
"AZURE_CLIENT_ID",
"AZURE_CLIENT_SECRET",
"BASHOPTS",
"BASH_ENV",
"BROWSER",
"BUNDLE_GEMFILE",
@@ -36,7 +37,9 @@ enum HostEnvSecurityPolicy {
"BZR_PLUGIN_PATH",
"BZR_SSH",
"CARGO_BUILD_RUSTC",
"CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
"CARGO_BUILD_RUSTC_WRAPPER",
"CARGO_BUILD_RUSTDOC",
"CARGO_HOME",
"CATALINA_OPTS",
"CC",
@@ -70,12 +73,14 @@ enum HostEnvSecurityPolicy {
"ERL_ZFLAGS",
"EXINIT",
"FCEDIT",
"FPATH",
"GCONV_PATH",
"GEM_HOME",
"GEM_PATH",
"GH_TOKEN",
"GITHUB_TOKEN",
"GITLAB_TOKEN",
"GIT_ALLOW_PROTOCOL",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_ASKPASS",
"GIT_COMMON_DIR",
@@ -87,6 +92,7 @@ enum HostEnvSecurityPolicy {
"GIT_INDEX_FILE",
"GIT_NAMESPACE",
"GIT_OBJECT_DIRECTORY",
"GIT_PROTOCOL_FROM_USER",
"GIT_PROXY_COMMAND",
"GIT_SEQUENCE_EDITOR",
"GIT_SSH",
@@ -109,6 +115,8 @@ enum HostEnvSecurityPolicy {
"GVIMINIT",
"HELM_HOME",
"HELM_PLUGINS",
"HGEDITOR",
"HGMERGE",
"HGRCPATH",
"HOSTALIASES",
"IFS",
@@ -116,6 +124,7 @@ enum HostEnvSecurityPolicy {
"JAVA_TOOL_OPTIONS",
"JDK_JAVA_OPTIONS",
"JULIA_EDITOR",
"KSH_ENV",
"LDFLAGS",
"LESSCLOSE",
"LESSOPEN",
@@ -127,6 +136,7 @@ enum HostEnvSecurityPolicy {
"LUA_INIT_5_3",
"LUA_INIT_5_4",
"LUA_PATH",
"MAKE",
"MAKEFLAGS",
"MAVEN_OPTS",
"MFLAGS",
@@ -167,7 +177,10 @@ enum HostEnvSecurityPolicy {
"RUBYLIB",
"RUBYOPT",
"RUBYSHELL",
"RUSTC",
"RUSTC_WORKSPACE_WRAPPER",
"RUSTC_WRAPPER",
"RUSTDOC",
"RUSTFLAGS",
"R_ENVIRON",
"R_ENVIRON_USER",
@@ -183,6 +196,7 @@ enum HostEnvSecurityPolicy {
"SUDO_EDITOR",
"SVN_EDITOR",
"SVN_SSH",
"TCLLIBPATH",
"TF_CLI_CONFIG_FILE",
"TF_PLUGIN_CACHE_DIR",
"UV_DEFAULT_INDEX",
@@ -207,13 +221,16 @@ enum HostEnvSecurityPolicy {
static let blockedKeys: Set<String> = [
"ANT_OPTS",
"BASHOPTS",
"BASH_ENV",
"BROWSER",
"BZR_EDITOR",
"BZR_PLUGIN_PATH",
"BZR_SSH",
"CARGO_BUILD_RUSTC",
"CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
"CARGO_BUILD_RUSTC_WRAPPER",
"CARGO_BUILD_RUSTDOC",
"CATALINA_OPTS",
"CC",
"CMAKE_CXX_COMPILER",
@@ -232,7 +249,9 @@ enum HostEnvSecurityPolicy {
"ERL_FLAGS",
"ERL_ZFLAGS",
"EXINIT",
"FPATH",
"GCONV_PATH",
"GIT_ALLOW_PROTOCOL",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_COMMON_DIR",
"GIT_DIR",
@@ -243,6 +262,7 @@ enum HostEnvSecurityPolicy {
"GIT_INDEX_FILE",
"GIT_NAMESPACE",
"GIT_OBJECT_DIRECTORY",
"GIT_PROTOCOL_FROM_USER",
"GIT_SEQUENCE_EDITOR",
"GIT_SSL_CAINFO",
"GIT_SSL_CAPATH",
@@ -253,6 +273,8 @@ enum HostEnvSecurityPolicy {
"GRADLE_OPTS",
"GVIMINIT",
"HELM_PLUGINS",
"HGEDITOR",
"HGMERGE",
"HGRCPATH",
"HOSTALIASES",
"IFS",
@@ -260,11 +282,13 @@ enum HostEnvSecurityPolicy {
"JAVA_TOOL_OPTIONS",
"JDK_JAVA_OPTIONS",
"JULIA_EDITOR",
"KSH_ENV",
"LUA_INIT",
"LUA_INIT_5_1",
"LUA_INIT_5_2",
"LUA_INIT_5_3",
"LUA_INIT_5_4",
"MAKE",
"MAKEFLAGS",
"MAVEN_OPTS",
"MFLAGS",
@@ -285,7 +309,10 @@ enum HostEnvSecurityPolicy {
"RUBYLIB",
"RUBYOPT",
"RUBYSHELL",
"RUSTC",
"RUSTC_WORKSPACE_WRAPPER",
"RUSTC_WRAPPER",
"RUSTDOC",
"R_ENVIRON",
"R_ENVIRON_USER",
"R_PROFILE",
@@ -297,6 +324,7 @@ enum HostEnvSecurityPolicy {
"SUDO_ASKPASS",
"SVN_EDITOR",
"SVN_SSH",
"TCLLIBPATH",
"VAGRANT_VAGRANTFILE",
"VIMINIT",
"_JAVA_OPTIONS"
@@ -425,6 +453,11 @@ enum HostEnvSecurityPolicy {
"REQUESTS_CA_BUNDLE",
"RUSTC_WRAPPER",
"RUSTFLAGS",
"RUSTUP_DIST_ROOT",
"RUSTUP_DIST_SERVER",
"RUSTUP_HOME",
"RUSTUP_TOOLCHAIN",
"RUSTUP_UPDATE_ROOT",
"R_LIBS_USER",
"SSH_ASKPASS",
"SSH_AUTH_SOCK",

View File

@@ -47,19 +47,20 @@ struct VoiceWakeSettings: View {
private var voiceSummaryPanel: some View {
let enabled = voiceWakeSupported && self.state.swabbleEnabled
let pushToTalk = voiceWakeSupported && self.state.voicePushToTalkEnabled
let statusColor: Color = !voiceWakeSupported ? .orange : enabled || pushToTalk ? .green : .secondary
return HStack(alignment: .center, spacing: 14) {
ZStack {
Circle()
.fill((enabled || pushToTalk ? Color.green : Color.secondary).opacity(0.18))
Image(systemName: enabled ? "waveform.badge.mic" : "mic.slash")
.fill(statusColor.opacity(0.18))
Image(systemName: self.voiceSummaryIconName(enabled: enabled))
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(enabled || pushToTalk ? .green : .secondary)
.foregroundStyle(statusColor)
}
.frame(width: 46, height: 46)
VStack(alignment: .leading, spacing: 4) {
Text(enabled ? "Voice Wake active" : pushToTalk ? "Push-to-talk active" : "Voice controls idle")
Text(self.voiceSummaryTitle(enabled: enabled, pushToTalk: pushToTalk))
.font(.headline)
Text(self.voiceSummarySubtitle)
.font(.footnote)
@@ -84,6 +85,26 @@ struct VoiceWakeSettings: View {
}
}
private func voiceSummaryIconName(enabled: Bool) -> String {
if !voiceWakeSupported {
return "exclamationmark.triangle.fill"
}
return enabled ? "waveform.badge.mic" : "mic.slash"
}
private func voiceSummaryTitle(enabled: Bool, pushToTalk: Bool) -> String {
if !voiceWakeSupported {
return "Voice Wake unavailable"
}
if enabled {
return "Voice Wake active"
}
if pushToTalk {
return "Push-to-talk active"
}
return "Voice controls idle"
}
private var voiceSummarySubtitle: String {
if !voiceWakeSupported {
return "Voice Wake requires macOS 26 or newer."
@@ -98,16 +119,31 @@ struct VoiceWakeSettings: View {
}
private var unsupportedVoiceWakePanel: some View {
HStack(spacing: 10) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
Text("Voice Wake requires macOS 26 or newer.")
.font(.callout.weight(.medium))
.font(.title3.weight(.semibold))
.foregroundStyle(.orange)
.frame(width: 28)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 4) {
Text("Voice Wake requires macOS 26 or newer")
.font(.callout.weight(.semibold))
Text("The Voice Wake and push-to-talk controls are hidden on older macOS versions.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(.yellow.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(.orange.opacity(0.18))
}
}
var body: some View {
@@ -119,72 +155,68 @@ struct VoiceWakeSettings: View {
self.voiceSummaryPanel
SettingsCardGroup("Activation") {
SettingsCardToggleRow(
title: "Enable Voice Wake",
subtitle: "Listen for a wake phrase before running voice commands. Recognition runs fully on-device.",
binding: self.voiceWakeBinding)
.disabled(!voiceWakeSupported)
if voiceWakeSupported {
SettingsCardGroup("Activation") {
SettingsCardToggleRow(
title: "Enable Voice Wake",
subtitle: "Listen for a wake phrase before running voice commands. Recognition runs fully on-device.",
binding: self.voiceWakeBinding)
SettingsCardToggleRow(
title: "Trigger Talk Mode",
subtitle: "Start a full voice conversation when a wake phrase is detected.",
binding: self.$state.voiceWakeTriggersTalkMode)
.disabled(!self.state.swabbleEnabled)
SettingsCardToggleRow(
title: "Trigger Talk Mode",
subtitle: "Start a full voice conversation when a wake phrase is detected.",
binding: self.$state.voiceWakeTriggersTalkMode)
.disabled(!self.state.swabbleEnabled)
SettingsCardToggleRow(
title: "Hold Right Option to talk",
subtitle: "Start listening while you hold the key and show the preview overlay.",
binding: self.$state.voicePushToTalkEnabled)
.disabled(!voiceWakeSupported)
SettingsCardToggleRow(
title: "Hold Right Option to talk",
subtitle: "Start listening while you hold the key and show the preview overlay.",
binding: self.$state.voicePushToTalkEnabled)
if self.state.voicePushToTalkEnabled, self.state.talkEnabled {
SettingsCardRow(
title: "Push-to-talk paused",
subtitle: "Push-to-Talk resumes when Talk Mode is turned off.")
{
Image(systemName: "pause.circle.fill")
.foregroundStyle(.orange)
if self.state.voicePushToTalkEnabled, self.state.talkEnabled {
SettingsCardRow(
title: "Push-to-talk paused",
subtitle: "Push-to-Talk resumes when Talk Mode is turned off.")
{
Image(systemName: "pause.circle.fill")
.foregroundStyle(.orange)
}
}
SettingsCardToggleRow(
title: "Play phase-transition sounds",
subtitle: "Play short sounds when Talk Mode switches between listening, thinking, and speaking.",
binding: self.$state.talkPhaseSoundsEnabled)
SettingsCardToggleRow(
title: "Right Option stops speech",
subtitle: "Tap Right Option to interrupt speech and return to listening.",
binding: self.$state.talkShiftToStopEnabled,
showsDivider: false)
}
SettingsCardToggleRow(
title: "Play phase-transition sounds",
subtitle: "Play short sounds when Talk Mode switches between listening, thinking, and speaking.",
binding: self.$state.talkPhaseSoundsEnabled)
.disabled(!voiceWakeSupported)
SettingsCardGroup("Recognition") {
self.localePicker
self.micPicker
self.levelMeter
}
SettingsCardToggleRow(
title: "Right Option stops speech",
subtitle: "Tap Right Option to interrupt speech and return to listening.",
binding: self.$state.talkShiftToStopEnabled,
showsDivider: false)
.disabled(!voiceWakeSupported)
}
SettingsCardGroup("Test") {
VoiceWakeTestCard(
testState: self.$testState,
isTesting: self.$isTesting,
onToggle: self.toggleTest)
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
if !voiceWakeSupported {
self.chimeSection
self.triggerTable
} else {
self.unsupportedVoiceWakePanel
}
SettingsCardGroup("Recognition") {
self.localePicker
self.micPicker
self.levelMeter
}
SettingsCardGroup("Test") {
VoiceWakeTestCard(
testState: self.$testState,
isTesting: self.$isTesting,
onToggle: self.toggleTest)
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
self.chimeSection
self.triggerTable
Spacer(minLength: 8)
}
.settingsDetailContent()
@@ -217,9 +249,13 @@ struct VoiceWakeSettings: View {
}
private func activateLivePreview() {
self.loadTriggerEntries()
guard voiceWakeSupported else {
self.deactivateLivePreview()
return
}
self.meterStartupTask?.cancel()
self.startMicObserver()
self.loadTriggerEntries()
self.meterStartupTask = Task { @MainActor in
await self.loadMicsIfNeeded()
guard !Task.isCancelled, self.isActive else { return }
@@ -244,6 +280,11 @@ struct VoiceWakeSettings: View {
}
private func scheduleMeterRestart() {
guard voiceWakeSupported else {
self.state.voiceWakeMeterActive = false
Task { await self.meter.stop() }
return
}
self.meterStartupTask?.cancel()
self.meterStartupTask = Task { @MainActor in
guard !Task.isCancelled, self.isActive else { return }
@@ -662,7 +703,7 @@ struct VoiceWakeSettings: View {
@MainActor
private func scheduleMicRefresh() {
guard self.isActive else { return }
guard voiceWakeSupported, self.isActive else { return }
MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) {
await self.loadMicsIfNeeded(force: true)
await self.restartMeter()
@@ -724,6 +765,11 @@ struct VoiceWakeSettings: View {
@MainActor
private func restartMeter() async {
guard voiceWakeSupported else {
self.state.voiceWakeMeterActive = false
await self.meter.stop()
return
}
guard self.isActive else {
self.state.voiceWakeMeterActive = false
await self.meter.stop()
@@ -890,6 +936,7 @@ extension VoiceWakeSettings {
_ = view.levelMeter
_ = view.triggerTable
_ = view.chimeSection
_ = view.unsupportedVoiceWakePanel
view.addWord()
if let entryId = view.triggerEntries.first?.id {

View File

@@ -16,6 +16,23 @@ struct ExecApprovalsStoreRefactorTests {
}
}
private func withTempHomeAndStateDir(
_ body: @escaping @Sendable (URL, URL) async throws -> Void) async throws
{
let root = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-home-state-\(UUID().uuidString)", isDirectory: true)
let home = root.appendingPathComponent("home", isDirectory: true)
let stateDir = root.appendingPathComponent("state", isDirectory: true)
defer { try? FileManager().removeItem(at: root) }
try await TestIsolation.withEnvValues([
"OPENCLAW_HOME": home.path,
"OPENCLAW_STATE_DIR": stateDir.path,
]) {
try await body(home, stateDir)
}
}
@Test
func `ensure file skips rewrite when unchanged`() async throws {
try await self.withTempStateDir { _ in
@@ -30,6 +47,50 @@ struct ExecApprovalsStoreRefactorTests {
}
}
@Test
func `ensure file migrates default approvals into custom state dir`() async throws {
try await self.withTempHomeAndStateDir { home, stateDir in
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
try FileManager().createDirectory(
at: legacyDir,
withIntermediateDirectories: true)
let legacySocket = legacyDir.appendingPathComponent("exec-approvals.sock").path
let legacyFile = legacyDir.appendingPathComponent("exec-approvals.json")
let legacyJson = """
{
"version": 1,
"socket": {
"path": "\(legacySocket)",
"token": "legacy-token"
},
"defaults": {
"security": "deny",
"ask": "always"
},
"agents": {
"main": {
"allowlist": [{ "pattern": "git status" }]
}
}
}
"""
try Data(legacyJson.utf8).write(to: legacyFile)
let file = ExecApprovalsStore.ensureFile()
let targetURL = ExecApprovalsStore.fileURL()
#expect(targetURL.path == stateDir.appendingPathComponent("exec-approvals.json").path)
#expect(FileManager().fileExists(atPath: targetURL.path))
#expect(file.socket?.path == stateDir.appendingPathComponent("exec-approvals.sock").path)
#expect(file.socket?.token == "legacy-token")
#expect(file.defaults?.security == .deny)
#expect(file.defaults?.ask == .always)
#expect(file.agents?["main"]?.allowlist?.map(\.pattern) == ["git status"])
#expect(!FileManager().fileExists(atPath: legacyFile.path))
#expect(FileManager().fileExists(atPath: "\(legacyFile.path).migrated"))
}
}
@Test
func `update allowlist accepts basename pattern`() async throws {
try await self.withTempStateDir { _ in

View File

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

View File

@@ -982,21 +982,25 @@ public struct WakeParams: Codable, Sendable {
public let mode: AnyCodable
public let text: String
public let sessionkey: String?
public let agentid: String?
public init(
mode: AnyCodable,
text: String,
sessionkey: String?)
sessionkey: String?,
agentid: String? = nil)
{
self.mode = mode
self.text = text
self.sessionkey = sessionkey
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case mode
case text
case sessionkey = "sessionKey"
case agentid = "agentId"
}
}
@@ -2773,6 +2777,7 @@ public struct ConfigPatchParams: Codable, Sendable {
public let deliverycontext: [String: AnyCodable]?
public let note: String?
public let restartdelayms: Int?
public let replacepaths: [String]?
public init(
raw: String,
@@ -2780,7 +2785,8 @@ public struct ConfigPatchParams: Codable, Sendable {
sessionkey: String?,
deliverycontext: [String: AnyCodable]?,
note: String?,
restartdelayms: Int?)
restartdelayms: Int?,
replacepaths: [String]?)
{
self.raw = raw
self.basehash = basehash
@@ -2788,6 +2794,7 @@ public struct ConfigPatchParams: Codable, Sendable {
self.deliverycontext = deliverycontext
self.note = note
self.restartdelayms = restartdelayms
self.replacepaths = replacepaths
}
private enum CodingKeys: String, CodingKey {
@@ -2797,6 +2804,7 @@ public struct ConfigPatchParams: Codable, Sendable {
case deliverycontext = "deliveryContext"
case note
case restartdelayms = "restartDelayMs"
case replacepaths = "replacePaths"
}
}

View File

@@ -217,6 +217,16 @@ const config = {
entry: ["index.js!", "scripts/postinstall.js!"],
project: ["index.js!", "scripts/**/*.js!"],
},
[`${BUNDLED_PLUGIN_ROOT_DIR}/llama-cpp`]: {
entry: bundledPluginEntries,
project: ["index.ts!", "src/**/*.{js,mjs,ts}!"],
ignoreDependencies: [
// The provider resolves node-llama-cpp from its own package at runtime
// so local embeddings use the plugin-owned native dependency.
"node-llama-cpp",
...bundledPluginIgnoredRuntimeDependencies,
],
},
[`${BUNDLED_PLUGIN_ROOT_DIR}/*`]: {
// Bundled plugins often load their public surface via string specifiers in
// `index.ts` contracts, so Knip needs these convention-based entry files.

View File

@@ -1,2 +1,2 @@
de06fd99257e4b010e54578ea46605c3bc631c31cac5f68aaed4e301f924f8af plugin-sdk-api-baseline.json
1c7a5420c4bcb1ec08544ff43b83fa4d43f3c0dcda597a5a25aa5f5bab0cb199 plugin-sdk-api-baseline.jsonl
8a2769df428906990ee0d1bf8b0423f2a099b053c64c816d092ff84d61e11633 plugin-sdk-api-baseline.json
28b798973f3fb2a5b33ccbb6e3c1ac0453fa234a3a1c6cdc27935c27639bd104 plugin-sdk-api-baseline.jsonl

File diff suppressed because it is too large Load Diff

View File

@@ -59,6 +59,14 @@ export CLICKCLACK_BOT_TOKEN="ccb_..."
openclaw gateway
```
If `plugins.allow` is a non-empty restrictive list, explicitly selecting
ClickClack in channel setup or running `openclaw plugins enable clickclack`
appends `clickclack` to that list. Onboarding installation uses the same
explicit-selection behavior. These paths do not override `plugins.deny` or a
global `plugins.enabled: false` setting. Direct `openclaw plugins install
clickclack` follows the normal plugin-install policy and also records ClickClack
in an existing allowlist.
## Multiple bots
Each account opens its own ClickClack realtime connection and uses its own bot token.

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ Use it when you want to:
- inspect the local requested policy, host approvals file, and effective merge
- apply a local preset such as YOLO or deny-all
- synchronize local `tools.exec.*` and local `~/.openclaw/exec-approvals.json`
- synchronize local `tools.exec.*` and the local host approvals file
Examples:
@@ -83,7 +83,7 @@ Precedence is intentional:
```bash
openclaw approvals set --file ./exec-approvals.json
openclaw approvals set --stdin <<'EOF'
{ version: 1, defaults: { security: "full", ask: "off" } }
{ version: 1, defaults: { security: "full", ask: "off", askFallback: "full" } }
EOF
openclaw approvals set --node <id|name|ip> --file ./exec-approvals.json
openclaw approvals set --gateway --file ./exec-approvals.json
@@ -137,7 +137,8 @@ Why `tools.exec.host=gateway` in this example:
- YOLO is about approvals, not routing.
- If you want host exec even when a sandbox is configured, make the host choice explicit with `gateway` or `/exec host=gateway`.
This matches the current host-default YOLO behavior. Tighten it if you want approvals.
Omitted `askFallback` defaults to `deny`. Set `askFallback: "full"`
explicitly when upgrading a no-UI host that should keep never-prompt behavior.
Local shortcut:
@@ -182,7 +183,9 @@ Targeting notes:
- `--node` uses the same resolver as `openclaw nodes` (id, name, ip, or id prefix).
- `--agent` defaults to `"*"`, which applies to all agents.
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
- Approvals files are stored per host at `~/.openclaw/exec-approvals.json`.
- Approvals files are stored per host in the OpenClaw state dir
(`$OPENCLAW_STATE_DIR/exec-approvals.json`, or
`~/.openclaw/exec-approvals.json` when the variable is unset).
## Related

View File

@@ -194,11 +194,14 @@ openclaw browser select <ref> OptionA OptionB
openclaw browser fill --fields '[{"ref":"1","value":"Ada"}]'
openclaw browser wait --text "Done"
openclaw browser evaluate --fn '(el) => el.textContent' --ref <ref>
openclaw browser evaluate --fn 'const title = document.title; return title;'
openclaw browser evaluate --timeout-ms 30000 --fn 'async () => { await window.ready; return true; }'
```
Use `evaluate --timeout-ms <ms>` when the page-side function may need longer
than the default evaluate timeout.
`evaluate --fn` accepts a function source, an expression, or a statement body.
Statement bodies are wrapped as async functions, so use `return` for the value
you want back. Use `evaluate --timeout-ms <ms>` when the page-side function may
need longer than the default evaluate timeout.
Action responses return the current raw `targetId` after action-triggered page
replacement when OpenClaw can prove the replacement tab. Scripts should still
@@ -277,10 +280,14 @@ Use the built-in `user` profile, or create your own `existing-session` profile:
openclaw browser --browser-profile user tabs
openclaw browser create-profile --name chrome-live --driver existing-session
openclaw browser create-profile --name brave-live --driver existing-session --user-data-dir "~/Library/Application Support/BraveSoftware/Brave-Browser"
openclaw browser create-profile --name chrome-port --driver existing-session --cdp-url http://127.0.0.1:9222
openclaw browser --browser-profile chrome-live tabs
```
This path is host-only. For Docker, headless servers, Browserless, or other remote setups, use a CDP profile instead.
The default existing-session path is host-only Chrome MCP auto-connect. If the browser is already
running with a DevTools endpoint, pass `--cdp-url` so Chrome MCP attaches to that endpoint instead.
For Docker, Browserless, or other remote setups where Chrome MCP semantics are not needed, use a
CDP profile.
Current existing-session limits:

View File

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

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