Compare commits

..

25 Commits

Author SHA1 Message Date
Tideclaw
5bd92eed29 fix: add alpha changelog section 2026-06-09 20:59:56 +00:00
Tideclaw
7c16b0219f fix: refresh alpha config baseline 2026-06-09 20:41:38 +00:00
Tideclaw
33366f51af chore: prepare alpha 2026.6.9-alpha.3 2026-06-09 20:20:21 +00:00
Tideclaw
d564223cbd fix: repair stale sqlite session paths 2026-06-09 20:16:31 +00:00
Tideclaw
f1008bf948 fix: refresh pre-migrated session metadata paths 2026-06-09 20:16:31 +00:00
Tideclaw
b13c54a342 fix: refresh migrated session metadata paths 2026-06-09 20:16:31 +00:00
Tideclaw
797b33128e fix: stabilize alpha release preflight 2026-06-09 20:16:31 +00:00
Tideclaw
1022e49adc test: stabilize imessage monitor release shard 2026-06-09 20:16:31 +00:00
Tideclaw
e0c378d046 test: stabilize release tooling tests on root hosts 2026-06-09 20:16:31 +00:00
Tideclaw
9c08945c36 fix: refresh alpha release generated metadata 2026-06-09 20:16:31 +00:00
Tideclaw
3af4991fd2 test: keep task maintenance stale clocks deterministic 2026-06-09 20:16:31 +00:00
Tideclaw
59ecff6d92 fix: stabilize alpha QA image run waits 2026-06-09 20:16:31 +00:00
Tideclaw
f5ed45174d test: retry codex context-engine temp cleanup 2026-06-09 20:16:31 +00:00
Tideclaw
1e74b8c081 test: register matrix fixture for cross-agent acp spawn 2026-06-09 20:16:31 +00:00
Tideclaw
9d38be36b4 fix: ignore docker post-exit zero stats 2026-06-09 20:16:31 +00:00
Tideclaw
17a4a7f7d7 test: stabilize acp spawn channel fixtures 2026-06-09 20:16:31 +00:00
Tideclaw
d5ba0c25cb fix: tolerate legacy kitchen sink ui descriptor probes 2026-06-09 20:16:31 +00:00
Tideclaw
9afb3acff7 test: use sqlite session stores in release fixtures 2026-06-09 20:16:31 +00:00
Tideclaw
6a06a36f26 test: seed telegram approval stores through sdk 2026-06-09 20:16:31 +00:00
Tideclaw
aea32e6b6c test: seed remaining plugin stores through sdk 2026-06-09 20:16:31 +00:00
Tideclaw
08f4ff46da test: use valid slack session entries 2026-06-09 20:16:31 +00:00
Tideclaw
5ed103e79d test: satisfy channel fixture lint 2026-06-09 20:16:31 +00:00
Tideclaw
1c2c301701 fix(ui): localize cron command labels 2026-06-09 20:16:31 +00:00
Tideclaw
066ed9dd84 test: seed channel session stores through sdk 2026-06-09 20:16:31 +00:00
Tideclaw
a7a5d28b57 fix: allow current release package scan size 2026-06-09 20:16:31 +00:00
1710 changed files with 18437 additions and 86496 deletions

View File

@@ -19,7 +19,7 @@ attribution.
## Inputs
- Target base version: `YYYY.M.PATCH`, without beta suffix.
- Target base version: `YYYY.M.D`, 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.PATCH`
- use `## YYYY.M.D`
- 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.PATCH notes" CHANGELOG.md`
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.D 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.PATCH` for private mac preflight/validation when building that branch variation.
- Keep `tag=vYYYY.M.PATCH` pointing at the original stable release commit.
- 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.
- 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.PATCH \
-f source_ref=release/YYYY.M.PATCH \
-f tag=vYYYY.M.D \
-f source_ref=release/YYYY.M.D \
-f preflight_only=true \
-f smoke_test_only=false \
-f allow_late_calver_recovery=false \
-f public_release_branch=release/YYYY.M.PATCH
-f public_release_branch=release/YYYY.M.D
```
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.PATCH \
-f source_ref=release/YYYY.M.PATCH
-f tag=vYYYY.M.D \
-f source_ref=release/YYYY.M.D
```
Real publish:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.PATCH \
-f tag=vYYYY.M.D \
-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.PATCH
-f public_release_branch=release/YYYY.M.D
```
## Verify
- `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`.
- `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`.
- Appcast entry has `sparkle:version`, `sparkle:shortVersionString`, length, and `sparkle:edSignature`.

View File

@@ -10,15 +10,12 @@ 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.PATCH` for the branch name.
`main`. Use `release/YYYY.M.D` 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.
@@ -95,7 +92,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.PATCH-beta.N`, with npm dist-tag `beta`
- `beta`: prerelease tags like `vYYYY.M.D-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
@@ -111,7 +108,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.PATCH-N`, the repo version locations still stay at `YYYY.M.PATCH`.
- For fallback correction tags like `vYYYY.M.D-N`, the repo version locations still stay at `YYYY.M.D`.
- “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
@@ -132,19 +129,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.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`;
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`;
provenance checks must prove the source SHA descends from the tag and
validation/preflight use the same source. Reserve `vYYYY.M.PATCH-N` correction
validation/preflight use the same source. Reserve `vYYYY.M.D-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.PATCH-N`, the repo version still stays
at `YYYY.M.PATCH`, but the mac release must use a strictly higher numeric
- 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
`APP_BUILD` / Sparkle build than the original release so existing installs
see it as newer.
- Stable Windows Hub release closeout requires the signed
@@ -154,7 +151,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.PATCH/...` assets for the current
`openclaw/openclaw/releases/download/vYYYY.M.D/...` 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.
@@ -168,7 +165,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.PATCH` section
rebase or backport must refresh the same stable-base `## YYYY.M.D` 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,
@@ -191,11 +188,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.PATCH` through the line before the
or editing a release, extract from `## YYYY.M.D` 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.PATCH --jq .id`, then
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.D --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
@@ -208,10 +205,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.PATCH-beta.N` from the release commit
- create a prerelease titled `openclaw YYYY.M.PATCH-beta.N`
- tag `vYYYY.M.D-beta.N` from the release commit
- create a prerelease titled `openclaw YYYY.M.D-beta.N`
- use release notes from the stable base `CHANGELOG.md` version section
(`## YYYY.M.PATCH`), not a beta-specific heading
(`## YYYY.M.D`), 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
@@ -221,10 +218,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.PATCH 🦞` or `🦞 OpenClaw YYYY.M.PATCH is live`, blank line,
- Format: `OpenClaw YYYY.M.D 🦞` or `🦞 OpenClaw YYYY.M.D 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.PATCH-beta.N 🦞` or `OpenClaw YYYY.M.PATCH beta N is
- For beta: say `OpenClaw YYYY.M.D-beta.N 🦞` or `OpenClaw YYYY.M.D 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
@@ -317,23 +314,6 @@ 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
@@ -352,8 +332,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.PATCH-N`, it also verifies the
upgrade path from `YYYY.M.PATCH` to `YYYY.M.PATCH-N` so a correction publish cannot
- 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
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
@@ -500,7 +480,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.PATCH latest --otp "$OTP"`.
`npm dist-tag add openclaw@YYYY.M.D 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`.
@@ -526,7 +506,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.PATCH`.
use only `main` or `release/YYYY.M.D`.
- `.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
@@ -551,7 +531,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.PATCH` branch. For release-branch runs, the tag must be contained
`release/YYYY.M.D` 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
@@ -579,11 +559,7 @@ 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.
- 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.
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
## Fallback local mac publish
@@ -623,8 +599,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.PATCH`, not `## YYYY.M.PATCH-beta.N`.
6. Create `release/YYYY.M.PATCH` from that post-changelog `main` commit.
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.
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
@@ -640,9 +616,7 @@ 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. 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.
14. Create or refresh the matching GitHub release.
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.
@@ -665,29 +639,20 @@ 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 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.
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.
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. 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:
24. Run postpublish verification:
`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,11 +37,9 @@ 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.PATCH-alpha.N`
- Release tag: `vYYYY.M.D-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
@@ -100,7 +98,7 @@ Tideclaw may run beta releases from `#releases` or mentioned `#maintainers` comm
Accepted shapes:
```text
@Tideclaw beta release from vYYYY.M.PATCH-alpha.N
@Tideclaw beta release from vYYYY.M.D-alpha.N
@Tideclaw beta release from tideclaw/alpha/YYYY-MM-DD-HHMMZ
@Tideclaw beta release from latest proven alpha
```
@@ -112,7 +110,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.PATCH-beta.N`, matching npm `--tag beta`. Ignore alpha-only patch numbers when selecting the beta train.
6. Compute beta as `vYYYY.M.D-beta.N`, matching npm `--tag beta`.
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.
@@ -167,7 +165,7 @@ git push -u origin "$BRANCH"
After local proof:
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.
1. Compute the next `vYYYY.M.D-alpha.N` from existing git tags, npm versions, and GitHub releases.
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:

View File

@@ -1,61 +0,0 @@
name: openclaw-codeql-process-exec-boundary-critical-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
- include:
precision:
- high
- very-high
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- src/process
- src/tui/tui-local-shell.ts
- src/tui/tui.ts
- src/plugin-sdk/windows-spawn.ts
- packages/agent-core/src/harness/env
- packages/memory-host-sdk/src/host
- extensions/acpx/src
- extensions/bonjour/src/advertiser.ts
- extensions/browser/src/browser/chrome-mcp.ts
- extensions/browser/src/browser/chrome.executables.ts
- extensions/browser/src/browser/chrome.ts
- extensions/codex/src/app-server/sandbox-exec-server
- extensions/codex/src/app-server/transport-stdio.ts
- extensions/codex/src/node-cli-sessions.ts
- extensions/codex-supervisor/src/json-rpc-client.ts
- extensions/file-transfer/src
- extensions/google-meet/src
- extensions/imessage/src
- extensions/memory-core/src/memory/qmd-manager.ts
- extensions/memory-wiki/src/obsidian.ts
- extensions/microsoft-foundry/cli.ts
- extensions/ollama/src/wsl2-crash-loop-check.ts
- extensions/qa-lab/src
- extensions/signal/src/daemon.ts
- extensions/tts-local-cli/speech-provider.ts
- extensions/voice-call/src
- scripts
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.spec.ts"
- "**/*.spec.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"

View File

@@ -17,28 +17,7 @@ on:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "extensions/acpx/src/**"
- "extensions/bonjour/src/advertiser.ts"
- "extensions/browser/src/browser/chrome-mcp.ts"
- "extensions/browser/src/browser/chrome.executables.ts"
- "extensions/browser/src/browser/chrome.ts"
- "extensions/codex/src/app-server/sandbox-exec-server/**"
- "extensions/codex/src/app-server/transport-stdio.ts"
- "extensions/codex/src/node-cli-sessions.ts"
- "extensions/codex-supervisor/src/json-rpc-client.ts"
- "extensions/file-transfer/src/**"
- "extensions/google-meet/src/**"
- "extensions/imessage/src/**"
- "extensions/memory-core/src/memory/qmd-manager.ts"
- "extensions/memory-wiki/src/obsidian.ts"
- "extensions/microsoft-foundry/cli.ts"
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
- "extensions/qa-lab/src/**"
- "extensions/signal/src/daemon.ts"
- "extensions/tts-local-cli/speech-provider.ts"
- "extensions/voice-call/src/**"
- "packages/**"
- "scripts/**"
- "src/**"
push:
branches:
@@ -47,28 +26,7 @@ on:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "extensions/acpx/src/**"
- "extensions/bonjour/src/advertiser.ts"
- "extensions/browser/src/browser/chrome-mcp.ts"
- "extensions/browser/src/browser/chrome.executables.ts"
- "extensions/browser/src/browser/chrome.ts"
- "extensions/codex/src/app-server/sandbox-exec-server/**"
- "extensions/codex/src/app-server/transport-stdio.ts"
- "extensions/codex/src/node-cli-sessions.ts"
- "extensions/codex-supervisor/src/json-rpc-client.ts"
- "extensions/file-transfer/src/**"
- "extensions/google-meet/src/**"
- "extensions/imessage/src/**"
- "extensions/memory-core/src/memory/qmd-manager.ts"
- "extensions/memory-wiki/src/obsidian.ts"
- "extensions/microsoft-foundry/cli.ts"
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
- "extensions/qa-lab/src/**"
- "extensions/signal/src/daemon.ts"
- "extensions/tts-local-cli/speech-provider.ts"
- "extensions/voice-call/src/**"
- "packages/**"
- "scripts/**"
- "src/**"
schedule:
- cron: "0 6 * * *"
@@ -115,11 +73,6 @@ jobs:
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
- language: javascript-typescript
category: process-exec-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-process-exec-boundary-critical-security.yml
- language: javascript-typescript
category: plugin-trust-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404

View File

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

View File

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

View File

@@ -437,17 +437,8 @@ jobs:
echo "::warning::Could not generate motion-trimmed desktop previews; continuing with screenshots and full MP4 links."
fi
read_discord_status_reaction_status() {
local lane="$1"
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
jq -r '.entries[0].result.status' "$root/$lane/qa-evidence.json"
return
fi
jq -r '.scenarios[0].status' "$root/$lane/discord-qa-summary.json"
}
baseline_status="$(read_discord_status_reaction_status baseline)"
candidate_status="$(read_discord_status_reaction_status candidate)"
baseline_status="$(jq -r '.scenarios[0].status' "$root/baseline/discord-qa-summary.json")"
candidate_status="$(jq -r '.scenarios[0].status' "$root/candidate/discord-qa-summary.json")"
jq -n \
--arg baseline_status "$baseline_status" \

View File

@@ -451,17 +451,8 @@ jobs:
capture_candidate_discord_web
read_discord_thread_attachment_status() {
local lane="$1"
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
jq -r '.entries[] | select(.test.id == "discord-thread-reply-filepath-attachment") | .result.status' "$root/$lane/qa-evidence.json"
return
fi
jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/$lane/discord-qa-summary.json"
}
baseline_status="$(read_discord_thread_attachment_status baseline)"
candidate_status="$(read_discord_thread_attachment_status candidate)"
baseline_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/baseline/discord-qa-summary.json")"
candidate_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/candidate/discord-qa-summary.json")"
comparison_status="fail"
if [[ "$baseline_status" == "fail" && "$candidate_status" == "pass" ]]; then
comparison_status="pass"

View File

@@ -445,8 +445,8 @@ jobs:
telegram_exit=$?
set -e
if [[ ! -f "$root/qa-evidence.json" && ! -f "$root/telegram-qa-summary.json" ]]; then
echo "Telegram live QA did not produce an evidence summary." >&2
if [[ ! -f "$root/telegram-qa-summary.json" ]]; then
echo "Telegram live QA did not produce a summary." >&2
exit "$telegram_exit"
fi
echo "telegram_exit=${telegram_exit}" >> "$GITHUB_OUTPUT"

View File

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

View File

@@ -1497,7 +1497,7 @@ jobs:
- name: Setup Docker builder
if: steps.image_exists.outputs.needs_build == '1'
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
with:
max-cache-size-mb: 800000
@@ -1593,11 +1593,8 @@ jobs:
run: |
set -euo pipefail
repository="${GITHUB_REPOSITORY,,}"
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}"
live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}"
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
@@ -1620,7 +1617,7 @@ jobs:
- name: Setup Docker builder
if: steps.image_exists.outputs.exists != '1'
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
with:
max-cache-size-mb: 800000
@@ -1632,7 +1629,7 @@ jobs:
file: ./Dockerfile
target: build
build-args: |
OPENCLAW_EXTENSIONS=${{ steps.image.outputs.live_image_extensions }}
OPENCLAW_EXTENSIONS=matrix
platforms: linux/amd64
tags: ${{ steps.image.outputs.live_image }}
sbom: true
@@ -1748,7 +1745,6 @@ jobs:
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
minimax) require_any MiniMax MINIMAX_API_KEY ;;
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
openai) require_any OpenAI OPENAI_API_KEY ;;
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;
@@ -1837,7 +1833,7 @@ jobs:
run: |
set -euo pipefail
all_providers=(anthropic google minimax moonshot openai opencode-go openrouter xai zai fireworks)
all_providers=(anthropic google minimax openai opencode-go openrouter xai zai fireworks)
normalize_provider() {
local value="${1,,}"
@@ -1923,7 +1919,6 @@ jobs:
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
minimax) require_any MiniMax MINIMAX_API_KEY ;;
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
openai) require_any OpenAI OPENAI_API_KEY ;;
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;

View File

@@ -527,13 +527,6 @@ jobs:
cleanup_gateway
trap - EXIT
if node -e "const fs=require('node:fs'); const scripts=require('./package.json').scripts||{}; process.exit(scripts['test:sqlite:perf:smoke'] && fs.existsSync('scripts/bench-sqlite-state.ts') ? 0 : 1)"; then
pnpm test:sqlite:perf:smoke
cp .artifacts/sqlite-perf/smoke.json "$SOURCE_PERF_DIR/sqlite-perf-smoke.json"
else
echo "SQLite state smoke probe is not available in ${TESTED_REF}; continuing with the remaining source probes." >> "$GITHUB_STEP_SUMMARY"
fi
summary_args=(node "$PERFORMANCE_HELPER_DIR/scripts/openclaw-performance-source-summary.mjs" \
--source-dir "$SOURCE_PERF_DIR" \
--output "$SOURCE_PERF_DIR/index.md")
@@ -611,7 +604,7 @@ jobs:
## Source probes
Additional gateway boot, memory, plugin pressure, mock hello-loop, CLI startup, and SQLite state smoke numbers are in [source/index.md](source/index.md).
Additional gateway boot, memory, plugin pressure, mock hello-loop, and CLI startup numbers are in [source/index.md](source/index.md).
EOF
fi
fi

View File

@@ -387,9 +387,7 @@ jobs:
run: |
set -euo pipefail
dispatch_workflow_at_ref() {
local workflow_ref="$1"
shift
dispatch_workflow() {
local workflow="$1"
shift
@@ -399,7 +397,7 @@ jobs:
-F per_page=100 \
--jq '[.workflow_runs[].id]')"
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$workflow_ref" "$@" 2>&1)"
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
printf '%s\n' "$dispatch_output" >&2
run_id="$(
printf '%s\n' "$dispatch_output" |
@@ -434,10 +432,6 @@ jobs:
printf '%s\n' "${run_id}"
}
dispatch_workflow() {
dispatch_workflow_at_ref "$CHILD_WORKFLOW_REF" "$@"
}
print_pending_deployments() {
local workflow="$1"
local run_id="$2"
@@ -659,128 +653,6 @@ 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
}
resolve_clawhub_release_plan() {
local -a plan_args
clawhub_plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json"
plan_args=(
--release-tag "${RELEASE_TAG}"
--release-publish-branch "${CHILD_WORKFLOW_REF}"
--release-publish-run-id "${GITHUB_RUN_ID}"
--plugin-publish-scope "${PLUGIN_PUBLISH_SCOPE}"
)
if [[ -n "${PLUGINS// }" ]]; then
plan_args+=(--plugins "${PLUGINS}")
fi
CLAWHUB_REGISTRY="${CLAWHUB_REGISTRY:-https://clawhub.ai}" \
node --import tsx scripts/openclaw-release-clawhub-plan.ts "${plan_args[@]}" > "${clawhub_plan_path}"
echo "Resolved OpenClaw release ClawHub dispatch plan:"
cat "${clawhub_plan_path}"
clawhub_workflow_ref="$(jq -r '.clawHubWorkflowRef' "${clawhub_plan_path}")"
normal_plugins="$(jq -r '.summary.normalPlugins' "${clawhub_plan_path}")"
bootstrap_plugins="$(jq -r '.summary.bootstrapPlugins' "${clawhub_plan_path}")"
missing_trusted_plugins="$(jq -r '.summary.missingTrustedPlugins' "${clawhub_plan_path}")"
normal_plugin_count="$(jq -r '.summary.normalCount' "${clawhub_plan_path}")"
bootstrap_plugin_count="$(jq -r '.summary.bootstrapCount' "${clawhub_plan_path}")"
missing_trusted_plugin_count="$(jq -r '.summary.missingTrustedPublisherCount' "${clawhub_plan_path}")"
{
echo "### ClawHub release plan"
echo
echo "- Normal OIDC candidates: \`${normal_plugin_count}\`"
echo "- Bootstrap/repair candidates: \`${bootstrap_plugin_count}\`"
echo "- Existing-package trusted-publisher repairs: \`${missing_trusted_plugin_count}\`"
if [[ -n "${normal_plugins}" ]]; then
echo "- Normal plugins: \`${normal_plugins}\`"
fi
if [[ -n "${bootstrap_plugins}" ]]; then
echo "- Bootstrap/repair plugins: \`${bootstrap_plugins}\`"
fi
if [[ -n "${missing_trusted_plugins}" ]]; then
echo "- Trusted-publisher repair plugins: \`${missing_trusted_plugins}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
}
append_clawhub_dispatch_args() {
local target="$1"
while IFS=$'\t' read -r key value; do
clawhub_dispatch_args+=(-f "${key}=${value}")
done < <(jq -r --arg target "${target}" '.[$target].inputs | to_entries[] | [.key, .value] | @tsv' "${clawhub_plan_path}")
}
write_clawhub_runtime_state() {
local force_skip_clawhub="$1"
local output_path="$2"
node --import tsx scripts/openclaw-release-clawhub-runtime-state.ts \
--repository "${GITHUB_REPOSITORY}" \
--wait-for-clawhub "${WAIT_FOR_CLAWHUB}" \
--force-skip-clawhub "${force_skip_clawhub}" \
--normal-run-id "${plugin_clawhub_run_id:-}" \
--bootstrap-run-id "${plugin_clawhub_bootstrap_run_id:-}" \
--bootstrap-completed "${plugin_clawhub_bootstrap_completed:-false}" > "${output_path}"
}
create_or_update_github_release() {
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
release_version="${RELEASE_TAG#v}"
@@ -826,17 +698,11 @@ 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"
}
@@ -869,11 +735,9 @@ jobs:
}
verify_published_release() {
local release_version evidence_path skip_clawhub clawhub_runtime_state_path
local release_version evidence_path
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}"
@@ -886,18 +750,16 @@ jobs:
--dist-tag "${RELEASE_NPM_DIST_TAG}"
--repo "${GITHUB_REPOSITORY}"
--workflow-ref "${CHILD_WORKFLOW_REF}"
--clawhub-workflow-ref "${clawhub_workflow_ref}"
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
--plugin-npm-run "${plugin_npm_run_id}"
--openclaw-npm-run "${openclaw_npm_run_id}"
--evidence-out "${evidence_path}"
--skip-github-release
)
clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-verify.json"
write_clawhub_runtime_state "${skip_clawhub}" "${clawhub_runtime_state_path}"
while IFS= read -r arg; do
verify_args+=("${arg}")
done < <(jq -r '.verifierArgs[]' "${clawhub_runtime_state_path}")
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
else
verify_args+=(--skip-clawhub)
fi
if [[ -n "${PLUGINS// }" ]]; then
verify_args+=(--plugins "${PLUGINS}")
fi
@@ -913,7 +775,7 @@ jobs:
}
append_release_proof_to_github_release() {
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path
local release_version body_file notes_file tarball integrity telegram_line clawhub_line
release_version="${RELEASE_TAG#v}"
body_file="${RUNNER_TEMP}/release-body.md"
@@ -927,16 +789,16 @@ jobs:
else
telegram_line="- npm Telegram beta E2E: not supplied"
fi
clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-proof.json"
write_clawhub_runtime_state false "${clawhub_runtime_state_path}"
clawhub_line="$(jq -r '.proofLines.normal' "${clawhub_runtime_state_path}")"
clawhub_bootstrap_line="$(jq -r '.proofLines.bootstrap' "${clawhub_runtime_state_path}")"
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
else
clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
fi
RELEASE_BODY_FILE="${body_file}" \
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}" \
@@ -946,7 +808,6 @@ jobs:
PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \
OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \
CLAWHUB_LINE="${clawhub_line}" \
CLAWHUB_BOOTSTRAP_LINE="${clawhub_bootstrap_line}" \
TELEGRAM_LINE="${telegram_line}" \
node --input-type=module <<'NODE'
import { readFileSync, writeFileSync } from "node:fs";
@@ -964,14 +825,12 @@ 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}`,
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,
`- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`,
process.env.CLAWHUB_LINE,
process.env.CLAWHUB_BOOTSTRAP_LINE,
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
process.env.TELEGRAM_LINE,
].join("\n");
@@ -988,7 +847,6 @@ jobs:
echo "### Publish sequence"
echo
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
echo "- ClawHub workflow ref: release tag \`${RELEASE_TAG}\`"
echo "- Release tag: \`${RELEASE_TAG}\`"
echo "- Release SHA: \`${TARGET_SHA}\`"
echo "- Release approval: this workflow job"
@@ -1005,68 +863,26 @@ jobs:
fi
} >> "$GITHUB_STEP_SUMMARY"
guard_existing_public_release
guard_openclaw_npm_not_already_published
resolve_clawhub_release_plan
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
npm_args+=(-f plugins="${PLUGINS}")
clawhub_args+=(-f plugins="${PLUGINS}")
fi
plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")"
plugin_clawhub_run_id=""
if [[ "$(jq -r '.normal.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
clawhub_dispatch_args=()
append_clawhub_dispatch_args normal
plugin_clawhub_run_id="$(dispatch_workflow_at_ref \
"$(jq -r '.normal.ref' "${clawhub_plan_path}")" \
"$(jq -r '.normal.workflow' "${clawhub_plan_path}")" \
"${clawhub_dispatch_args[@]}")"
else
echo "- plugin-clawhub-release.yml: no normal OIDC candidates" >> "$GITHUB_STEP_SUMMARY"
fi
plugin_clawhub_bootstrap_run_id=""
plugin_clawhub_bootstrap_completed="false"
if [[ "$(jq -r '.bootstrap.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
clawhub_dispatch_args=()
append_clawhub_dispatch_args bootstrap
plugin_clawhub_bootstrap_run_id="$(dispatch_workflow_at_ref \
"$(jq -r '.bootstrap.ref' "${clawhub_plan_path}")" \
"$(jq -r '.bootstrap.workflow' "${clawhub_plan_path}")" \
"${clawhub_dispatch_args[@]}")"
else
echo "- plugin-clawhub-new.yml: no bootstrap candidates" >> "$GITHUB_STEP_SUMMARY"
fi
plugin_clawhub_run_id="$(dispatch_workflow plugin-clawhub-release.yml "${clawhub_args[@]}")"
{
echo "- Plugin npm run ID: \`${plugin_npm_run_id}\`"
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id:-none}\`"
echo "- Plugin ClawHub bootstrap run ID: \`${plugin_clawhub_bootstrap_run_id:-none}\`"
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id}\`"
} >> "$GITHUB_STEP_SUMMARY"
if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}"; then
echo "Plugin npm publish failed; cancelling dispatched ClawHub child workflows." >&2
if [[ -n "${plugin_clawhub_run_id}" ]]; then
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_bootstrap_run_id}" >/dev/null 2>&1 || true
fi
echo "Plugin npm publish failed; cancelling ClawHub publish child ${plugin_clawhub_run_id}." >&2
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
exit 1
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" && "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
echo "Waiting for plugin-clawhub-new.yml bootstrap to finish before continuing release publish."
if wait_for_run plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
plugin_clawhub_bootstrap_completed="true"
else
if [[ -n "${plugin_clawhub_run_id}" ]]; then
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
fi
exit 1
fi
fi
openclaw_npm_run_id=""
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \
@@ -1083,52 +899,19 @@ jobs:
clawhub_result=""
clawhub_pid=""
clawhub_bootstrap_result=""
clawhub_bootstrap_pid=""
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
if [[ -n "${plugin_clawhub_run_id}" ]]; then
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
wait_run_pid=""
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
clawhub_pid="${wait_run_pid}"
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
else
clawhub_bootstrap_result="$RUNNER_TEMP/clawhub-bootstrap-result.txt"
wait_run_pid=""
wait_for_run_background plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "${clawhub_bootstrap_result}"
clawhub_bootstrap_pid="${wait_run_pid}"
fi
fi
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
wait_run_pid=""
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
clawhub_pid="${wait_run_pid}"
else
if [[ -n "${plugin_clawhub_run_id}" ]]; then
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
:
else
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
:
else
echo "- plugin-clawhub-release.yml: no normal OIDC publish to await" >> "$GITHUB_STEP_SUMMARY"
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
else
wait_for_job_success plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
:
else
echo "- plugin-clawhub-new.yml: child environment gate not ready; bootstrap was left dispatched (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-new.yml: bootstrap not awaited (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
else
echo "- plugin-clawhub-new.yml: no bootstrap publish to await" >> "$GITHUB_STEP_SUMMARY"
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
openclaw_result=""
@@ -1151,33 +934,21 @@ 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
if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then
failed=1
fi
if [[ -n "${clawhub_bootstrap_pid}" ]] && ! wait "${clawhub_bootstrap_pid}"; then
failed=1
fi
if [[ -f "${clawhub_bootstrap_result}" && "$(cat "${clawhub_bootstrap_result}")" != "success" ]]; then
failed=1
fi
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
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
verify_published_release
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

@@ -1,504 +0,0 @@
name: Plugin ClawHub New
on:
workflow_dispatch:
inputs:
plugins:
description: Comma-separated plugin package names to bootstrap on ClawHub
required: true
type: string
ref:
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
required: false
default: ""
type: string
release_publish_run_id:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
release_publish_branch:
description: Branch name of the approving OpenClaw Release Publish workflow run
required: false
type: string
dry_run:
description: Validate the token-gated ClawHub bootstrap handoff without publishing.
required: false
default: false
type: boolean
concurrency:
group: plugin-clawhub-new-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.15.0"
CLAWHUB_REGISTRY: "https://clawhub.ai"
CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"
jobs:
resolve_bootstrap_plan:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
ref_revision: ${{ steps.ref.outputs.sha }}
has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }}
bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }}
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref }}
fetch-depth: 0
- name: Resolve checked-out ref
id: ref
env:
TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
if [[ -n "${TARGET_REF}" ]]; then
if git rev-parse --verify --quiet "${TARGET_REF}^{commit}" >/dev/null; then
target_sha="$(git rev-parse "${TARGET_REF}^{commit}")"
elif git rev-parse --verify --quiet "origin/${TARGET_REF}^{commit}" >/dev/null; then
target_sha="$(git rev-parse "origin/${TARGET_REF}^{commit}")"
else
echo "Unable to resolve requested publish ref: ${TARGET_REF}" >&2
exit 1
fi
git checkout --detach "${target_sha}"
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate ref is on a trusted publish branch
env:
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if git merge-base --is-ancestor HEAD origin/main; then
exit 0
fi
while IFS= read -r release_ref; do
if git merge-base --is-ancestor HEAD "${release_ref}"; then
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${TRUSTED_PUBLISH_BRANCH}"
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
exit 0
fi
fi
echo "Plugin ClawHub bootstraps 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:
RELEASE_PLUGINS: ${{ inputs.plugins }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PLUGINS// }" ]]; then
echo "Plugin ClawHub bootstrap requires at least one package name in plugins." >&2
exit 1
fi
pnpm release:plugins:clawhub:check -- --selection-mode selected --plugins "${RELEASE_PLUGINS}"
- name: Resolve plugin bootstrap plan
id: plan
env:
RELEASE_PLUGINS: ${{ inputs.plugins }}
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: |
set -euo pipefail
mkdir -p .local
node --import tsx scripts/plugin-clawhub-release-plan.ts \
--selection-mode selected \
--plugins "${RELEASE_PLUGINS}" > .local/plugin-clawhub-release-plan.json
cat .local/plugin-clawhub-release-plan.json
bootstrap_candidate_count="$(jq -r '(.bootstrapCandidates | length) + (.missingTrustedPublisher | length)' .local/plugin-clawhub-release-plan.json)"
selected_count="$(jq -r '.all | length' .local/plugin-clawhub-release-plan.json)"
matrix_json="$(
jq -c '
[
.bootstrapCandidates[]? + {
bootstrapMode: "publish",
requiresManualOverride: false
},
.missingTrustedPublisher[]? + {
bootstrapMode: (if .alreadyPublished then "configure-only" else "publish" end),
requiresManualOverride: true
}
]
' .local/plugin-clawhub-release-plan.json
)"
has_bootstrap_candidates="false"
if [[ "${bootstrap_candidate_count}" != "0" ]]; then
has_bootstrap_candidates="true"
fi
invalid_scope="$(
jq -r '
(.bootstrapCandidates[]?, .missingTrustedPublisher[]?)
| select(.packageName | startswith("@openclaw/") | not)
| "- \(.packageName)@\(.version)"
' .local/plugin-clawhub-release-plan.json
)"
if [[ -n "${invalid_scope}" ]]; then
echo "Plugin ClawHub bootstrap only supports @openclaw/* packages." >&2
printf '%s\n' "${invalid_scope}" >&2
exit 1
fi
not_bootstrap="$(
jq -r '
(.bootstrapCandidates | map(.packageName)) as $bootstrapNames
| (.missingTrustedPublisher | map(.packageName)) as $repairNames
| .all[]?
| select(.packageName as $name | ($bootstrapNames + $repairNames | index($name) | not))
| "- \(.packageName)@\(.version)"
' .local/plugin-clawhub-release-plan.json
)"
if [[ -n "${not_bootstrap}" ]]; then
echo "Selected packages must all be first-publish bootstrap candidates or trusted-publisher repair candidates." >&2
printf '%s\n' "${not_bootstrap}" >&2
exit 1
fi
if [[ "${selected_count}" == "0" || "${bootstrap_candidate_count}" == "0" ]]; then
echo "No selected packages require ClawHub bootstrap." >&2
exit 1
fi
{
echo "bootstrap_candidate_count=${bootstrap_candidate_count}"
echo "has_bootstrap_candidates=${has_bootstrap_candidates}"
echo "matrix=${matrix_json}"
} >> "$GITHUB_OUTPUT"
echo "ClawHub bootstrap candidates:"
jq -r '
.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"
' .local/plugin-clawhub-release-plan.json
echo "ClawHub trusted-publisher repair candidates:"
jq -r '
.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir), alreadyPublished=\(.alreadyPublished)"
' .local/plugin-clawhub-release-plan.json
- name: Validate Tideclaw alpha plugin channels
env:
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
exit 0
fi
invalid="$(
jq -r '
(.bootstrapCandidates[]?, .missingTrustedPublisher[]?)
| select(.publishTag != "alpha" or .channel != "alpha")
| "- \(.packageName)@\(.version) [\(.publishTag)]"
' .local/plugin-clawhub-release-plan.json
)"
if [[ -n "${invalid}" ]]; then
echo "Tideclaw alpha ClawHub bootstraps may only publish alpha plugin versions." >&2
printf '%s\n' "${invalid}" >&2
exit 1
fi
validate_release_publish_approval:
name: Validate release publish approval
needs: resolve_bootstrap_plan
if: github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
echo "Plugin ClawHub bootstrap dispatched by another workflow must include release_publish_run_id." >&2
exit 1
fi
echo "Direct Plugin ClawHub New dispatch; relying on this workflow's clawhub-plugin-bootstrap environment approval."
exit 0
fi
direct_recovery=false
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
direct_recovery=true
echo "Direct Plugin ClawHub New recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-bootstrap environment approval."
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
validate_bootstrap_trusted_publisher_cli:
needs: [resolve_bootstrap_plan, validate_release_publish_approval]
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate pinned ClawHub trusted publisher CLI support
env:
CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }}
run: |
set -euo pipefail
help_output="$(
npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \
clawhub package trusted-publisher set --help 2>&1 || true
)"
printf '%s\n' "${help_output}"
if ! grep -Fq "Usage: clawhub package trusted-publisher set" <<<"${help_output}"; then
echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} to expose 'package trusted-publisher set' before token bootstrap publish can run. The pinned CLI returned parent help or no set command, so this workflow is stopping before creating a ClawHub package row."
exit 1
fi
for required_flag in --repository --workflow-filename; do
if ! grep -Fq -- "${required_flag}" <<<"${help_output}"; then
echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} trusted-publisher set help to include ${required_flag}."
exit 1
fi
done
publish_bootstrap_plugins:
needs:
[
resolve_bootstrap_plan,
validate_release_publish_approval,
validate_bootstrap_trusted_publisher_cli,
]
if: always() && github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success' && (inputs.dry_run == true || needs.validate_bootstrap_trusted_publisher_cli.result == 'success')
runs-on: ubuntu-latest
environment: clawhub-plugin-bootstrap
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 8
matrix:
plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref }}
fetch-depth: 0
- name: Checkout target revision
env:
TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
git checkout --detach "${TARGET_SHA}"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
install-deps: "true"
- name: Verify package-local runtime build
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
- name: Install pinned ClawHub CLI wrapper
run: |
set -euo pipefail
cat > "${RUNNER_TEMP}/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"
EOF
chmod +x "${RUNNER_TEMP}/clawhub"
echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}"
- name: Write ClawHub token config
if: inputs.dry_run != true
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
run: |
set -euo pipefail
config_path="${RUNNER_TEMP}/clawhub-config.json"
CONFIG_PATH="${config_path}" node --input-type=module <<'NODE'
import { writeFileSync } from "node:fs";
const registry = process.env.CLAWHUB_REGISTRY?.trim();
const token = process.env.CLAWHUB_TOKEN?.trim();
const configPath = process.env.CONFIG_PATH;
if (!registry) {
throw new Error("CLAWHUB_REGISTRY is required for token-gated ClawHub bootstrap.");
}
if (!token) {
throw new Error("CLAWHUB_TOKEN is required for token-gated ClawHub bootstrap.");
}
if (!configPath) {
throw new Error("CONFIG_PATH is required.");
}
writeFileSync(configPath, `${JSON.stringify({ registry, token }, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
NODE
echo "CLAWHUB_CONFIG_PATH=${config_path}" >> "${GITHUB_ENV}"
- name: Publish ClawHub bootstrap package
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
BOOTSTRAP_MODE: ${{ matrix.plugin.bootstrapMode }}
REQUIRES_MANUAL_OVERRIDE: ${{ matrix.plugin.requiresManualOverride && 'true' || 'false' }}
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0"
run: |
set -euo pipefail
if [[ "${BOOTSTRAP_MODE}" == "configure-only" ]]; then
echo "Skipping bootstrap publish because ${PACKAGE_DIR} version is already present on ClawHub; configuring trusted publisher only."
elif [[ "${DRY_RUN}" == "true" ]]; then
bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
else
if [[ "${REQUIRES_MANUAL_OVERRIDE}" == "true" ]]; then
export OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON="GitHub Actions trusted publisher repair before OIDC migration"
fi
bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
fi
- name: Configure trusted publisher for normal OIDC releases
if: inputs.dry_run != true
env:
CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }}
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
run: |
set -euo pipefail
npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \
clawhub package trusted-publisher set "${PACKAGE_NAME}" \
--repository openclaw/openclaw \
--workflow-filename plugin-clawhub-release.yml
verify_bootstrap_clawhub_package:
needs: [resolve_bootstrap_plan, publish_bootstrap_plugins]
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 8
matrix:
plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }}
steps:
- name: Verify bootstrap ClawHub package and trusted publisher
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
run: |
set -euo pipefail
node --input-type=module <<'EOF'
const registry = (process.env.CLAWHUB_REGISTRY ?? "https://clawhub.ai").replace(/\/+$/, "");
const packageName = process.env.PACKAGE_NAME;
const packageVersion = process.env.PACKAGE_VERSION;
const packageTag = process.env.PACKAGE_TAG;
if (!packageName || !packageVersion || !packageTag) {
throw new Error("Missing ClawHub bootstrap verification env.");
}
const encodedName = encodeURIComponent(packageName);
const encodedVersion = encodeURIComponent(packageVersion);
const detailUrl = `${registry}/api/v1/packages/${encodedName}`;
const trustedPublisherUrl = `${detailUrl}/trusted-publisher`;
const versionUrl = `${detailUrl}/versions/${encodedVersion}`;
const artifactUrl = `${versionUrl}/artifact/download`;
async function fetchWithRetry(url, options = {}) {
let lastStatus = "unknown";
for (let attempt = 1; attempt <= 12; attempt += 1) {
try {
const response = await fetch(url, { redirect: "manual", ...options });
lastStatus = response.status;
if (response.status !== 429 && response.status < 500) {
return response;
}
} catch (error) {
lastStatus = error instanceof Error ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, attempt * 5000));
}
throw new Error(`${url} did not stabilize; last status ${lastStatus}.`);
}
const detailResponse = await fetchWithRetry(detailUrl, {
headers: { accept: "application/json" },
});
if (!detailResponse.ok) {
throw new Error(`${detailUrl} returned HTTP ${detailResponse.status}.`);
}
const detail = await detailResponse.json();
const tags = detail?.package?.tags ?? {};
if (tags[packageTag] !== packageVersion) {
throw new Error(
`${packageName}: ClawHub tag ${packageTag} points to ${tags[packageTag] ?? "<missing>"}, expected ${packageVersion}.`,
);
}
const trustedPublisherResponse = await fetchWithRetry(trustedPublisherUrl, {
headers: { accept: "application/json" },
});
if (!trustedPublisherResponse.ok) {
throw new Error(`${trustedPublisherUrl} returned HTTP ${trustedPublisherResponse.status}.`);
}
const trustedPublisherDetail = await trustedPublisherResponse.json();
const trustedPublisher = trustedPublisherDetail?.trustedPublisher;
if (
trustedPublisher?.repository !== "openclaw/openclaw" ||
trustedPublisher?.workflowFilename !== "plugin-clawhub-release.yml" ||
trustedPublisher?.environment != null
) {
throw new Error(
`${packageName}: trusted publisher config did not match openclaw/openclaw plugin-clawhub-release.yml without an environment pin.`,
);
}
const versionResponse = await fetchWithRetry(versionUrl);
if (!versionResponse.ok) {
throw new Error(`${versionUrl} returned HTTP ${versionResponse.status}.`);
}
const artifactResponse = await fetchWithRetry(artifactUrl, { method: "HEAD" });
if (artifactResponse.status < 200 || artifactResponse.status >= 400) {
throw new Error(`${artifactUrl} returned HTTP ${artifactResponse.status}.`);
}
console.log(`${packageName}@${packageVersion} bootstrap verified on ClawHub.`);
EOF

View File

@@ -16,7 +16,7 @@ on:
required: false
type: string
ref:
description: Dry-run target ref to validate; real OIDC publishes must dispatch the workflow with --ref set to the target release tag/ref
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
required: false
default: ""
type: string
@@ -24,10 +24,6 @@ on:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
release_publish_branch:
description: Branch name of the approving OpenClaw Release Publish workflow run
required: false
type: string
dry_run:
description: Validate the full ClawHub artifact handoff without publishing.
required: false
@@ -42,7 +38,9 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.15.0"
CLAWHUB_REGISTRY: "https://clawhub.ai"
CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"
CLAWHUB_REPOSITORY: "openclaw/clawhub"
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"
jobs:
preview_plugins_clawhub:
@@ -52,15 +50,9 @@ jobs:
outputs:
ref_revision: ${{ steps.ref.outputs.sha }}
has_candidates: ${{ steps.plan.outputs.has_candidates }}
has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }}
has_missing_trusted_publisher: ${{ steps.plan.outputs.has_missing_trusted_publisher }}
candidate_count: ${{ steps.plan.outputs.candidate_count }}
bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }}
missing_trusted_publisher_count: ${{ steps.plan.outputs.missing_trusted_publisher_count }}
skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }}
matrix: ${{ steps.plan.outputs.matrix }}
bootstrap_matrix: ${{ steps.plan.outputs.bootstrap_matrix }}
missing_trusted_publisher_matrix: ${{ steps.plan.outputs.missing_trusted_publisher_matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -91,27 +83,9 @@ jobs:
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate OIDC source matches workflow ref
env:
TARGET_SHA: ${{ steps.ref.outputs.sha }}
WORKFLOW_SHA: ${{ github.sha }}
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
run: |
set -euo pipefail
if [[ "${TARGET_SHA}" != "${WORKFLOW_SHA}" ]]; then
if [[ "${DRY_RUN}" == "true" ]]; then
echo "Dry-run publish target differs from workflow ref; allowing validation-only dispatch."
exit 0
fi
echo "Plugin ClawHub OIDC publishes must run from the same ref that is being published." >&2
echo "The ref input is only supported for dry_run=true." >&2
echo "For real publishes, dispatch this workflow with --ref pointing at the target release tag/ref and omit the ref input." >&2
exit 1
fi
- name: Validate ref is on a trusted publish branch
env:
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if git merge-base --is-ancestor HEAD origin/main; then
@@ -122,8 +96,8 @@ jobs:
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${TRUSTED_PUBLISH_BRANCH}"
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${WORKFLOW_REF#refs/heads/}"
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
exit 0
@@ -184,78 +158,36 @@ jobs:
cat .local/plugin-clawhub-release-plan.json
candidate_count="$(jq -r '.candidates | length' .local/plugin-clawhub-release-plan.json)"
bootstrap_candidate_count="$(jq -r '.bootstrapCandidates | length' .local/plugin-clawhub-release-plan.json)"
missing_trusted_publisher_count="$(jq -r '.missingTrustedPublisher | length' .local/plugin-clawhub-release-plan.json)"
skipped_published_count="$(jq -r '.skippedPublished | length' .local/plugin-clawhub-release-plan.json)"
has_candidates="false"
if [[ "${candidate_count}" != "0" ]]; then
has_candidates="true"
fi
has_bootstrap_candidates="false"
if [[ "${bootstrap_candidate_count}" != "0" ]]; then
has_bootstrap_candidates="true"
fi
has_missing_trusted_publisher="false"
if [[ "${missing_trusted_publisher_count}" != "0" ]]; then
has_missing_trusted_publisher="true"
fi
matrix_json="$(jq -c '.candidates' .local/plugin-clawhub-release-plan.json)"
bootstrap_matrix_json="$(jq -c '.bootstrapCandidates' .local/plugin-clawhub-release-plan.json)"
missing_trusted_publisher_matrix_json="$(jq -c '.missingTrustedPublisher' .local/plugin-clawhub-release-plan.json)"
{
echo "candidate_count=${candidate_count}"
echo "bootstrap_candidate_count=${bootstrap_candidate_count}"
echo "missing_trusted_publisher_count=${missing_trusted_publisher_count}"
echo "skipped_published_count=${skipped_published_count}"
echo "has_candidates=${has_candidates}"
echo "has_bootstrap_candidates=${has_bootstrap_candidates}"
echo "has_missing_trusted_publisher=${has_missing_trusted_publisher}"
echo "matrix=${matrix_json}"
echo "bootstrap_matrix=${bootstrap_matrix_json}"
echo "missing_trusted_publisher_matrix=${missing_trusted_publisher_matrix_json}"
} >> "$GITHUB_OUTPUT"
echo "Plugin release candidates:"
jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
echo "Bootstrap candidates requiring token bootstrap:"
jq -r '.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
echo "Missing trusted publisher candidates:"
jq -r '.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
echo "Already published / skipped:"
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-clawhub-release-plan.json
- name: Fail when trusted publisher is missing
if: steps.plan.outputs.missing_trusted_publisher_count != '0'
run: |
echo "::error::One or more ClawHub packages exist but do not have trusted publishing configured. Configure trusted publishing before running the normal OIDC publish workflow."
jq -r '.missingTrustedPublisher[]? | "::error::Missing trusted publisher: \(.packageName)@\(.version). Configure trusted publishing for openclaw/openclaw, workflow plugin-clawhub-release.yml."' .local/plugin-clawhub-release-plan.json
exit 1
- name: Fail normal publish when bootstrap is required
if: steps.plan.outputs.bootstrap_candidate_count != '0'
run: |
echo "::error::One or more ClawHub packages do not exist yet and require the token-gated Plugin ClawHub New bootstrap workflow before normal OIDC publish can run."
jq -r '.bootstrapCandidates[]? | "::error::Bootstrap required: \(.packageName)@\(.version). Dispatch plugin-clawhub-new.yml for this package, then rerun the normal release."' .local/plugin-clawhub-release-plan.json
exit 1
- name: Fail manual publish when target versions already exist
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
if: github.event_name == 'workflow_dispatch' && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
run: |
echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish."
exit 1
- name: Validate Tideclaw alpha plugin channels
env:
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
run: |
set -euo pipefail
if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
exit 0
fi
invalid="$(
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-clawhub-release-plan.json
)"
@@ -265,6 +197,12 @@ 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
@@ -283,7 +221,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
@@ -302,8 +240,99 @@ jobs:
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
preview_plugin_pack:
needs: preview_plugins_clawhub
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 12
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref }}
fetch-depth: 0
- name: Checkout target revision
env:
TARGET_SHA: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
git checkout --detach "${TARGET_SHA}"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
install-deps: "true"
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
persist-credentials: false
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: main
path: clawhub-source
fetch-depth: 0
- name: Checkout pinned ClawHub CLI revision
working-directory: clawhub-source
env:
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
run: git checkout --detach "${CLAWHUB_REF}"
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
run: |
set -euo pipefail
for attempt in 1 2 3; do
if bun install --frozen-lockfile; then
exit 0
fi
status="$?"
if [[ "${attempt}" == "3" ]]; then
exit "${status}"
fi
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
sleep $((attempt * 15))
done
- name: Bootstrap ClawHub CLI
run: |
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
EOF
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Verify package-local runtime build
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
- name: Preview publish command
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
pack_plugins_clawhub_artifacts:
needs: [preview_plugins_clawhub, validate_release_publish_approval]
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
permissions:
@@ -338,19 +367,47 @@ jobs:
install-bun: "true"
install-deps: "true"
- name: Verify package-local runtime build
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
persist-credentials: false
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: main
path: clawhub-source
fetch-depth: 0
- name: Install pinned ClawHub CLI wrapper
- name: Checkout pinned ClawHub CLI revision
working-directory: clawhub-source
env:
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
run: git checkout --detach "${CLAWHUB_REF}"
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
run: |
set -euo pipefail
cat > "${RUNNER_TEMP}/clawhub" <<'EOF'
for attempt in 1 2 3; do
if bun install --frozen-lockfile; then
exit 0
fi
status="$?"
if [[ "${attempt}" == "3" ]]; then
exit "${status}"
fi
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
sleep $((attempt * 15))
done
- name: Bootstrap ClawHub CLI
run: |
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
EOF
chmod +x "${RUNNER_TEMP}/clawhub"
echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}"
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Pack ClawHub package artifact
env:
@@ -371,23 +428,19 @@ jobs:
if-no-files-found: error
retention-days: 7
approve_plugins_clawhub_release:
approve_plugin_clawhub_release:
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts]
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success'
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:
contents: read
permissions: {}
steps:
- name: Approve Plugin ClawHub release publish
run: |
echo "Approved CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows release publish gate."
- name: Approve ClawHub package publish
run: echo "ClawHub package publish approved."
publish_plugins_clawhub:
needs:
[preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugins_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_plugins_clawhub_release.result == 'success')
uses: openclaw/clawhub/.github/workflows/package-publish.yml@9d49df109d4ad3dc8a6ecf05d26b39f46d294721
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
@@ -397,18 +450,19 @@ jobs:
max-parallel: 32
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854
with:
package_artifact_name: ${{ matrix.plugin.artifactName }}
dry_run: ${{ inputs.dry_run }}
json: true
package_artifact_name: ${{ matrix.plugin.artifactName }}
registry: https://clawhub.ai
site: https://clawhub.ai
tags: ${{ matrix.plugin.publishTag }}
source_repo: ${{ github.repository }}
source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
source_ref: ${{ github.ref }}
source_path: ${{ matrix.plugin.packageDir }}
inspector_artifact_name: ${{ matrix.plugin.artifactName }}-inspector
publish_json_artifact_name: ${{ matrix.plugin.artifactName }}-publish-json
tags: ${{ matrix.plugin.publishTag }}
secrets:
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
verify_published_clawhub_package:
needs: [preview_plugins_clawhub, publish_plugins_clawhub]

View File

@@ -288,7 +288,6 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
OPENCLAW_NPM_PUBLISH_AUTH_MODE: trusted-publisher
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
- name: Verify published runtime

View File

@@ -159,7 +159,7 @@ jobs:
run_mock_parity:
name: Run QA Lab mock parity lane
needs: [validate_selected_ref]
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-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=12288
NODE_OPTIONS: --max-old-space-size=8192
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-16vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-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=12288
NODE_OPTIONS: --max-old-space-size=8192
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-16vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-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=12288
NODE_OPTIONS: --max-old-space-size=8192
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-16vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-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=12288
NODE_OPTIONS: --max-old-space-size=8192
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-16vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-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=12288
NODE_OPTIONS: --max-old-space-size=8192
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-16vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-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=12288
NODE_OPTIONS: --max-old-space-size=8192
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-16vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-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=12288
NODE_OPTIONS: --max-old-space-size=8192
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-16vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-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=12288
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run Slack live lane

View File

@@ -509,62 +509,60 @@ jobs:
let locked = 0;
let inspected = 0;
let cursor = null;
let page = 1;
while (true) {
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;
const { data: issues } = await github.rest.issues.listForRepo({
owner,
repo,
state: "closed",
sort: "updated",
direction: "desc",
per_page: perPage,
page,
});
for (const issue of issues.nodes) {
if (issue.locked || !issue.closedAt) {
if (issues.length === 0) {
break;
}
for (const issue of issues) {
if (issue.pull_request) {
continue;
}
if (issue.locked) {
continue;
}
if (!issue.closed_at) {
continue;
}
inspected += 1;
const closedAtMs = Date.parse(issue.closedAt);
if (!Number.isFinite(closedAtMs) || closedAtMs > cutoffMs) {
const closedAtMs = Date.parse(issue.closed_at);
if (!Number.isFinite(closedAtMs)) {
continue;
}
if (closedAtMs > cutoffMs) {
continue;
}
const lastComment = issue.comments.nodes[0];
const lastCommentMs = lastComment ? Date.parse(lastComment.createdAt) : 0;
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 lastActivityMs = Math.max(closedAtMs, lastCommentMs || 0);
if (lastActivityMs > cutoffMs) {
continue;
@@ -580,10 +578,7 @@ jobs:
locked += 1;
}
if (!issues.pageInfo.hasNextPage || !issues.pageInfo.endCursor) {
break;
}
cursor = issues.pageInfo.endCursor;
page += 1;
}
core.info(`Inspected ${inspected} closed issues; locked ${locked}.`);

View File

@@ -251,11 +251,9 @@ 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.PATCH-beta.N` -> npm `YYYY.M.PATCH-beta.N --tag beta`.
- Beta tag/version match: `vYYYY.M.D-beta.N` -> npm `YYYY.M.D-beta.N --tag beta`.
## Platform / Ops

View File

@@ -2,38 +2,17 @@
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.
## 2026.6.9-alpha.3
### 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.
- Alpha/nightly package acceptance refreshes migrated SQLite session metadata when legacy transcript files move during doctor preflight, keeping upgrade-survivor package checks aligned with the migrated session store.
## 2026.6.9-alpha.2
### Fixes
- Alpha/nightly release validation carries release-branch stabilization for package metadata, channel and plugin fixture isolation, Docker and QA waits, iMessage monitor retry tests, memory startup catch-up test coverage, and release prepack changelog packaging.
## 2026.6.5
@@ -64,7 +43,6 @@ Docs: https://docs.openclaw.ai
### 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.
@@ -79,7 +57,6 @@ Docs: https://docs.openclaw.ai
- Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.
- Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.
- Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.
- Memory: keep doctor REM harness previews aligned with live REM by dropping short-term recall snippets whose source files disappeared before rendering preview output. Thanks @samzong and @frankekn.
- Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.
- Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.
- Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)

View File

@@ -116,19 +116,11 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \
echo "/* A2UI bundle unavailable in this build */" > extensions/canvas/src/host/a2ui/a2ui.bundle.js && \
echo "stub" > extensions/canvas/src/host/a2ui/.bundle.hash && \
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \
fi && \
NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
pnpm_config_verify_deps_before_run=false pnpm qa:lab:build && \
mkdir -p dist/extensions/qa-lab/web && \
rm -rf dist/extensions/qa-lab/web/dist && \
cp -R extensions/qa-lab/web/dist dist/extensions/qa-lab/web/dist; \
fi
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
# Prune dev dependencies, omitted plugin runtime packages, and build-only
# metadata before copying runtime assets into the final image.

View File

@@ -48,7 +48,6 @@ 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.
@@ -104,7 +103,6 @@ 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.
@@ -163,7 +161,6 @@ 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).
@@ -184,7 +181,6 @@ 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -6,12 +6,10 @@ extension AgentProTab {
@ViewBuilder
func destination(for route: AgentRoute) -> some View {
switch route {
case .agents:
self.agentsDestination
case .skills:
self.skillsDestination
case .instances:
self.instancesDestination
case .nodes:
self.nodesDestination
case .cron:
self.cronDestination
case .usage:
@@ -21,26 +19,6 @@ 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()
@@ -68,9 +46,8 @@ extension AgentProTab {
.navigationBarTitleDisplayMode(.inline)
}
var instancesDestination: some View {
var nodesDestination: some View {
AgentProNodesDestination(
headerLeadingAction: self.directHeaderLeadingAction(for: .instances),
overview: self.overview,
gatewayConnected: self.gatewayConnected,
agentCount: self.appModel.gatewayAgents.count,
@@ -87,10 +64,6 @@ 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",
@@ -116,10 +89,6 @@ 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",
@@ -142,7 +111,6 @@ extension AgentProTab {
var dreamingDestination: some View {
AgentProDreamingDestination(
headerLeadingAction: self.directHeaderLeadingAction(for: .dreaming),
overview: self.overview,
gatewayConnected: self.gatewayConnected,
overviewLoading: self.overviewLoading,
@@ -154,27 +122,6 @@ 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,19 +5,18 @@ import SwiftUI
extension AgentProTab {
var rosterHeader: some View {
VStack(alignment: .leading, spacing: 10) {
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)
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)
}
} accessory: {
Spacer(minLength: 8)
HStack(spacing: 10) {
self.gatewayPillButton
self.headerIconButton(
systemName: "magnifyingglass",
label: "Search agents",
@@ -57,19 +56,6 @@ 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) {
@@ -154,7 +140,7 @@ extension AgentProTab {
value: self.instancesValue,
detail: self.instancesDetail,
color: self.instancesColor,
route: .instances)
route: .nodes)
self.metricTile(
icon: "clock.arrow.circlepath",
title: "Cron",

View File

@@ -6,12 +6,6 @@ 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
@@ -37,9 +31,8 @@ struct AgentProTab: View {
@State var cronActionStatusText: String?
enum AgentRoute: Hashable {
case agents
case skills
case instances
case nodes
case cron
case usage
case dreaming
@@ -126,42 +119,8 @@ 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 {
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) {
NavigationStack {
ZStack {
OpenClawProBackground()
ScrollView {
@@ -184,22 +143,15 @@ struct AgentProTab: View {
self.destination(for: route)
}
}
.onAppear {
self.applyInitialRouteIfNeeded()
.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 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,25 +7,6 @@ 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 {
@@ -86,30 +67,7 @@ struct ChatProTab: View {
}
private var header: some View {
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 {
HStack(spacing: 11) {
Text(self.agentBadge)
.font(.system(size: self.agentBadge.count > 2 ? 13 : 16, weight: .bold, design: .rounded))
.foregroundStyle(.white)
@@ -128,9 +86,24 @@ 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)
} else {
ProIconBadge(systemName: "bubble.left", color: OpenClawBrand.accent)
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
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 8)
.padding(.bottom, 4)
}
private func syncChatViewModel() {
@@ -189,93 +162,37 @@ 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.gatewayPillColor)
Text(Self.gatewayPillTitle(state: self.gatewayDisplayState, isGatewayUsable: self.gatewayConnected))
ProStatusDot(color: self.gatewayConnected ? OpenClawBrand.ok : .orange)
Text(self.gatewayConnected ? "Connected" : "Connecting")
.font(.caption.weight(.semibold))
.lineLimit(1)
}
.foregroundStyle(self.gatewayPillColor)
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .orange)
.padding(.horizontal, 10)
.frame(height: 30)
.background {
Capsule()
.fill(self.gatewayPillColor.opacity(0.11))
.fill((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.11))
}
.overlay {
Capsule()
.strokeBorder(self.gatewayPillColor.opacity(0.16), lineWidth: 1)
.strokeBorder((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.16), lineWidth: 1)
}
}
private var gatewayConnected: Bool {
guard self.gatewayDisplayState == .connected else {
guard GatewayStatusBuilder.build(appModel: self.appModel) == .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: OpenClawProMetric.cardRadius)
radius: 12)
{
self.content
}
@@ -34,15 +34,40 @@ struct CommandControlBackground: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
LinearGradient(
colors: self.colorScheme == .dark ? self.darkColors : self.lightColors,
startPoint: .top,
endPoint: .bottom)
.overlay(alignment: .top) {
if self.colorScheme == .light {
Color.white.opacity(0.20)
.frame(height: 140)
LinearGradient(
colors: [
Color.white.opacity(0.34),
Color.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
.frame(height: 260)
}
}
.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 {
@@ -89,12 +114,12 @@ struct CommandSessionRow: View {
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.padding(.vertical, 9)
.background {
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.rowFill)
.overlay {
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(self.rowBorder, lineWidth: 1)
}
}
@@ -111,11 +136,11 @@ struct CommandSessionRow: View {
}
private var rowFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
}
private var rowBorder: Color {
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
}
}
@@ -129,21 +154,21 @@ struct CommandViewMoreRow: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background {
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.rowFill)
.overlay {
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(self.rowBorder, lineWidth: 1)
}
}
}
private var rowFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
}
private var rowBorder: Color {
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
}
}
@@ -174,13 +199,13 @@ struct CommandEmptyStateRow: View {
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 8)
.padding(.vertical, 9)
.background {
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.fill(Color(uiColor: .systemBackground))
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.black.opacity(0.06))
.overlay {
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.strokeBorder(Color(uiColor: .separator).opacity(0.22), lineWidth: 1)
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color.primary.opacity(0.055), lineWidth: 1)
}
}
}

View File

@@ -2,17 +2,13 @@ import OpenClawChatUI
import SwiftUI
struct CommandCenterTab: View {
static let recentSessionsFetchLimit = 200
fileprivate 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
@@ -35,37 +31,20 @@ struct CommandCenterTab: View {
var body: some View {
NavigationStack {
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)
ZStack {
CommandControlBackground()
self.commandAmbientOverlay
ScrollView {
VStack(alignment: .leading, spacing: 10) {
self.header
self.gatewayCard
self.defaultChatSessionSection
self.recentSessions
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
.padding(.top, 16)
.padding(.bottom, 18)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationBarHidden(true)
}
@@ -74,47 +53,12 @@ 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 {
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")
HStack(alignment: .center, spacing: 11) {
OpenClawProMark(size: 31, shadowRadius: 9)
Text("OpenClaw")
.font(.system(size: 27, weight: .bold, design: .rounded))
Spacer()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@@ -142,7 +86,7 @@ struct CommandCenterTab: View {
title: "Gateway",
value: self.gatewayStateText,
color: self.gatewayStatusColor,
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
icon: self.gatewayConnected ? "hourglass" : "wifi.slash")
HStack(spacing: 0) {
self.gatewayFact(
@@ -216,6 +160,7 @@ struct CommandCenterTab: View {
.buttonStyle(.plain)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var recentSessions: some View {
@@ -255,6 +200,7 @@ struct CommandCenterTab: View {
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func cardHeader(
@@ -267,8 +213,7 @@ struct CommandCenterTab: View {
{
HStack(spacing: 8) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
.font(.subheadline.weight(.bold))
if let badgeValue {
Text(badgeValue)
.font(.caption2.weight(.bold))
@@ -458,7 +403,7 @@ struct CommandCenterTab: View {
return result
}
static func sessionWorkItem(
fileprivate static func sessionWorkItem(
for session: OpenClawChatSessionEntry,
currentSessionKey: String) -> WorkItem
{
@@ -613,20 +558,14 @@ struct CommandCenterTab: View {
}
}
struct CommandSessionsScreen: View {
private 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()
@@ -648,18 +587,12 @@ struct CommandSessionsScreen: View {
}
private var header: some View {
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)
}
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

@@ -1,244 +0,0 @@
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

@@ -1,672 +0,0 @@
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

@@ -1,17 +0,0 @@
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

@@ -1,71 +0,0 @@
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

@@ -1,129 +0,0 @@
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,23 +1,33 @@
import SwiftUI
enum OpenClawProMetric {
static let pagePadding: CGFloat = 18
static let cardRadius: CGFloat = 10
static let controlRadius: CGFloat = 8
static let pagePadding: CGFloat = 20
static let cardRadius: CGFloat = 14
static let controlRadius: CGFloat = 12
static let bottomScrollInset: CGFloat = 96
static let heroRadius: CGFloat = 12
static let heroRadius: CGFloat = 22
}
struct OpenClawProBackground: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
LinearGradient(
colors: OpenClawBrand.canvasColors(for: self.colorScheme),
startPoint: .top,
endPoint: .bottom)
.ignoresSafeArea()
.overlay(alignment: .top) {
if self.colorScheme == .light {
Color.white.opacity(0.22)
.frame(height: 140)
LinearGradient(
colors: [
OpenClawBrand.accent.opacity(0.05),
OpenClawBrand.accent.opacity(0.02),
.clear,
],
startPoint: .topTrailing,
endPoint: .bottomLeading)
.frame(height: 620)
.ignoresSafeArea()
}
}
@@ -56,7 +66,7 @@ struct ProSectionHeader: View {
struct ProCard<Content: View>: View {
var tint: Color?
var isProminent: Bool = false
var padding: CGFloat = 12
var padding: CGFloat = 14
var radius: CGFloat = OpenClawProMetric.cardRadius
@ViewBuilder var content: Content
@@ -81,39 +91,78 @@ 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 {
if self.isProminent {
shape.strokeBorder(
OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.12 : 0.07),
lineWidth: 1)
.padding(1)
}
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)
}
}
}
private var fill: AnyShapeStyle {
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)
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)
}
return AnyShapeStyle(base)
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)
}
private var borderStyle: AnyShapeStyle {
AnyShapeStyle(Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.26 : 0.30))
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))
}
}
}
}
}
@@ -202,9 +251,9 @@ private struct ProPanelSurfaceModifier: ViewModifier {
}
.modifier(ProLightGlassModifier(radius: self.radius))
.shadow(
color: self.colorScheme == .dark ? .black.opacity(0.22) : .black.opacity(0.028),
radius: self.isProminent ? 9 : 4,
y: self.isProminent ? 4 : 1)
color: self.colorScheme == .dark ? .black.opacity(0.60) : .black.opacity(0.045),
radius: self.isProminent ? 20 : 12,
y: self.isProminent ? 10 : 6)
}
}
@@ -214,160 +263,16 @@ struct ProIconBadge: View {
var body: some View {
Image(systemName: self.systemName)
.font(.caption.weight(.semibold))
.font(.subheadline.weight(.semibold))
.foregroundStyle(self.color)
.frame(width: 30, height: 30)
.frame(width: 34, height: 34)
.background {
RoundedRectangle(cornerRadius: 8, style: .continuous)
RoundedRectangle(cornerRadius: 10, 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
@@ -375,6 +280,7 @@ struct ProStatusDot: View {
Circle()
.fill(self.color)
.frame(width: 8, height: 8)
.shadow(color: self.color.opacity(0.35), radius: 4)
}
}
@@ -406,7 +312,7 @@ struct OpenClawProMark: View {
.resizable()
.scaledToFit()
.frame(width: self.size, height: self.size)
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: self.shadowRadius, y: self.shadowRadius / 3)
.shadow(color: OpenClawBrand.accent.opacity(0.28), radius: self.shadowRadius, y: self.shadowRadius / 2)
.accessibilityLabel("OpenClaw")
}
}
@@ -484,10 +390,7 @@ 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)
@@ -502,57 +405,6 @@ 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]
@@ -679,120 +531,28 @@ 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: .top, spacing: 12) {
HStack(alignment: .center, 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(2)
.lineLimit(1)
}
Spacer(minLength: 8)
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)
}
}
ProValuePill(value: self.value, color: self.color)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.padding(.vertical, 11)
}
}

View File

@@ -1,417 +0,0 @@
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

@@ -1,726 +0,0 @@
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,38 +58,9 @@ 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 {
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) {
NavigationStack {
ZStack {
OpenClawProBackground()
ScrollView {
@@ -107,17 +78,11 @@ 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 {
@@ -154,76 +119,66 @@ struct SettingsProTab: View {
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.applyPendingGatewaySetupLinkIfNeeded()
}
}
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.$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()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This disconnects, clears saved gateway credentials, and reopens onboarding.")
}
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button("Reset", role: .destructive) {
self.resetOnboarding()
}
.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]
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 ?? "")
}
}
}

View File

@@ -495,7 +495,6 @@ extension SettingsProTab {
case .gateway: "Gateway"
case .approvals: "Approvals"
case .permissions: "Permissions"
case .channels: "Channels"
case .voice: "Voice & Talk"
case .diagnostics: "Diagnostics"
case .privacy: "Privacy"
@@ -504,20 +503,6 @@ 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,20 +3,10 @@ import SwiftUI
extension SettingsProTab {
var settingsHeader: some View {
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)
Text("Settings")
.font(.system(size: 28, weight: .bold))
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 6)
}
var appearanceSection: some View {
@@ -141,11 +131,6 @@ 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",
@@ -214,9 +199,6 @@ extension SettingsProTab {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
if self.headerLeadingAction != nil {
self.routeHeader(for: route)
}
switch route {
case .gateway:
self.gatewayDestination
@@ -224,8 +206,6 @@ extension SettingsProTab {
self.approvalsDestination
case .permissions:
self.permissionsDestination
case .channels:
SettingsChannelsDestination()
case .voice:
self.voiceDestination
case .diagnostics:
@@ -244,24 +224,6 @@ 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,12 +1,10 @@
import Darwin
import OpenClawKit
import SwiftUI
enum SettingsRoute: Hashable {
case gateway
case approvals
case permissions
case channels
case voice
case diagnostics
case privacy
@@ -152,176 +150,3 @@ 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,17 +9,8 @@ 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,
@@ -94,9 +85,6 @@ 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,8 +116,6 @@ final class NodeAppModel {
self.operatorConnected
}
private(set) var hasOperatorAdminScope: Bool = false
var gatewayServerName: String?
var gatewayRemoteAddress: String?
var connectedGatewayID: String?
@@ -299,7 +297,6 @@ 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)
@@ -2760,15 +2757,6 @@ 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
}
}
@@ -4648,10 +4636,6 @@ extension NodeAppModel {
self.gatewayConnected
}
func _test_refreshOperatorAdminScopeFromStore() {
self.refreshOperatorAdminScopeFromStore()
}
func _test_applyPendingForegroundNodeActions(
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
{

View File

@@ -8,8 +8,6 @@ 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
@@ -23,14 +21,10 @@ 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
@@ -40,6 +34,14 @@ 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 {
@@ -64,28 +66,6 @@ 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 {
@@ -107,11 +87,45 @@ struct RootTabs: View {
}
}
static func shouldUseSidebarTabs(
idiom: UIUserInterfaceIdiom,
horizontalSizeClass _: UserInterfaceSizeClass?) -> Bool
enum StartupPresentationRoute: Equatable {
case none
case onboarding
case settings
}
static func startupPresentationRoute(
gatewayConnected: Bool,
hasConnectedOnce: Bool,
onboardingComplete: Bool,
hasExistingGatewayConfig: Bool,
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
{
idiom == .pad
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
}
var body: some View {
@@ -122,30 +136,20 @@ 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) {
RootTabsPhoneControlHub(
groups: Self.phoneControlGroups,
initialDestination: Self.requestedInitialSidebarDestination,
openRootDestination: { self.selectSidebarDestination($0) })
.tabItem { Label("Control", systemImage: "square.grid.2x2") }
CommandCenterTab(
openChat: { self.selectedTab = .chat },
openSettings: { self.selectedTab = .settings })
.tabItem { Label("Command", systemImage: "target") }
.badge(self.appModel.pendingExecApprovalPrompt == nil ? 0 : 1)
.tag(AppTab.control)
ChatProTab(openSettings: { self.selectSidebarDestination(.gateway) })
ChatProTab()
.tabItem { Label("Chat", systemImage: "bubble.left.fill") }
.tag(AppTab.chat)
TalkProTab(openSettings: { self.selectSidebarDestination(.gateway) })
TalkProTab(openSettings: { self.selectedTab = .settings })
.tabItem {
Label(
"Talk",
@@ -153,394 +157,16 @@ struct RootTabs: View {
}
.tag(AppTab.talk)
NavigationStack {
AgentProTab(
directRoute: .agents,
openSettings: { self.selectSidebarDestination(.gateway) })
}
.tabItem { Label("Agent", systemImage: "person.2.fill") }
.tag(AppTab.agent)
AgentProTab()
.tabItem { Label("Agent", systemImage: "person.2.fill") }
.tag(AppTab.agent)
SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)
.id(self.selectedSidebarDestination.settingsRoute.map { "\($0)" } ?? "settings")
SettingsProTab()
.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) {
@@ -569,7 +195,6 @@ struct RootTabs: View {
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.overlay {
if self.appModel.cameraFlashNonce != 0 {
RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
@@ -700,7 +325,7 @@ struct RootTabs: View {
self.evaluateOnboardingPresentation(force: true)
}
.onChange(of: self.appModel.openChatRequestID) { _, _ in
self.selectSidebarDestination(.chat)
self.selectedTab = .chat
}
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.maybeOpenSettingsForGatewaySetup()
@@ -709,6 +334,10 @@ 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(
@@ -875,50 +504,6 @@ 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,
@@ -953,7 +538,7 @@ struct RootTabs: View {
} else if problem.retryable {
Task { await self.gatewayController.connectLastKnown() }
} else {
self.selectSidebarDestination(.gateway)
self.selectedTab = .settings
}
}
@@ -980,7 +565,7 @@ struct RootTabs: View {
self.showOnboarding = true
case .settings:
self.didAutoOpenSettings = true
self.selectSidebarDestination(.gateway)
self.selectedTab = .settings
}
}
@@ -1006,7 +591,7 @@ struct RootTabs: View {
shouldPresentOnLaunch: false)
guard route == .settings else { return }
self.didAutoOpenSettings = true
self.selectSidebarDestination(.gateway)
self.selectedTab = .settings
}
private func maybeOpenSettingsForGatewaySetup() {
@@ -1016,7 +601,7 @@ struct RootTabs: View {
self.showOnboarding = false
self.presentedSheet = nil
self.didAutoOpenSettings = true
self.selectSidebarDestination(.gateway)
self.selectedTab = .settings
}
private func applyInitialChatSessionIfNeeded() {
@@ -1096,120 +681,3 @@ 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

@@ -1,308 +0,0 @@
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

@@ -0,0 +1,25 @@
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,15 +31,6 @@ 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
@@ -82,7 +73,6 @@ 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
@@ -96,6 +86,7 @@ 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

@@ -1,27 +0,0 @@
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,12 +33,4 @@ 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,38 +1025,6 @@ 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,6 +1,4 @@
import SwiftUI
import Testing
import UIKit
@testable import OpenClaw
@MainActor
@@ -40,432 +38,4 @@ import UIKit
#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

@@ -1,65 +0,0 @@
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

@@ -1,812 +0,0 @@
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,9 +5,8 @@ import UIKit
@testable import OpenClaw
@Suite struct SwiftUIRenderSmokeTests {
@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)
@MainActor private static func host(_ view: some View) -> UIWindow {
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UIHostingController(rootView: view)
window.makeKeyAndVisible()
window.rootViewController?.view.setNeedsLayout()
@@ -42,102 +41,18 @@ import UIKit
}
}
@Test @MainActor func rootTabsBuildsDeviceOrientationShellMatrix() {
for scenario in Self.rootTabsShellScenarios() {
let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
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() {
@Test @MainActor func rootTabsBuildAViewHierarchy() {
let appModel = NodeAppModel()
let root = RootTabsPhoneControlHub(
groups: RootTabs.phoneControlGroups,
initialDestination: nil,
openRootDestination: { _ in })
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = RootTabs()
.environment(appModel)
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .compact)
.environment(appModel.voiceWake)
.environment(gatewayController)
_ = 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() }
@@ -149,51 +64,4 @@ 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,2"
TARGETED_DEVICE_FAMILY: "1"
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,2"
TARGETED_DEVICE_FAMILY: "1"
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,2"
TARGETED_DEVICE_FAMILY: "1"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
SUPPORTS_LIVE_ACTIVITIES: YES

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

View File

@@ -1,5 +1,4 @@
import CryptoKit
import Darwin
import Foundation
import OSLog
import Security
@@ -230,12 +229,6 @@ 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() }
@@ -250,195 +243,6 @@ 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) ?? ""
@@ -474,14 +278,6 @@ 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(
@@ -526,14 +322,6 @@ 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: [:])
@@ -573,14 +361,6 @@ 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,7 +775,6 @@ extension GatewayConnection {
struct CronSchedulerStatus: Decodable {
let enabled: Bool
let storePath: String
let sqlitePath: String?
let jobs: Int
let nextWakeAtMs: Int?
}

View File

@@ -27,8 +27,6 @@ enum HostEnvSanitizer {
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",
@@ -48,12 +46,6 @@ 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) })
}

View File

@@ -37,9 +37,7 @@ 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",
@@ -115,8 +113,6 @@ enum HostEnvSecurityPolicy {
"GVIMINIT",
"HELM_HOME",
"HELM_PLUGINS",
"HGEDITOR",
"HGMERGE",
"HGRCPATH",
"HOSTALIASES",
"IFS",
@@ -136,7 +132,6 @@ enum HostEnvSecurityPolicy {
"LUA_INIT_5_3",
"LUA_INIT_5_4",
"LUA_PATH",
"MAKE",
"MAKEFLAGS",
"MAVEN_OPTS",
"MFLAGS",
@@ -177,10 +172,7 @@ enum HostEnvSecurityPolicy {
"RUBYLIB",
"RUBYOPT",
"RUBYSHELL",
"RUSTC",
"RUSTC_WORKSPACE_WRAPPER",
"RUSTC_WRAPPER",
"RUSTDOC",
"RUSTFLAGS",
"R_ENVIRON",
"R_ENVIRON_USER",
@@ -228,9 +220,7 @@ enum HostEnvSecurityPolicy {
"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",
@@ -273,8 +263,6 @@ enum HostEnvSecurityPolicy {
"GRADLE_OPTS",
"GVIMINIT",
"HELM_PLUGINS",
"HGEDITOR",
"HGMERGE",
"HGRCPATH",
"HOSTALIASES",
"IFS",
@@ -288,7 +276,6 @@ enum HostEnvSecurityPolicy {
"LUA_INIT_5_2",
"LUA_INIT_5_3",
"LUA_INIT_5_4",
"MAKE",
"MAKEFLAGS",
"MAVEN_OPTS",
"MFLAGS",
@@ -309,10 +296,7 @@ enum HostEnvSecurityPolicy {
"RUBYLIB",
"RUBYOPT",
"RUBYSHELL",
"RUSTC",
"RUSTC_WORKSPACE_WRAPPER",
"RUSTC_WRAPPER",
"RUSTDOC",
"R_ENVIRON",
"R_ENVIRON_USER",
"R_PROFILE",

View File

@@ -47,20 +47,19 @@ 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(statusColor.opacity(0.18))
Image(systemName: self.voiceSummaryIconName(enabled: enabled))
.fill((enabled || pushToTalk ? Color.green : Color.secondary).opacity(0.18))
Image(systemName: enabled ? "waveform.badge.mic" : "mic.slash")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(statusColor)
.foregroundStyle(enabled || pushToTalk ? .green : .secondary)
}
.frame(width: 46, height: 46)
VStack(alignment: .leading, spacing: 4) {
Text(self.voiceSummaryTitle(enabled: enabled, pushToTalk: pushToTalk))
Text(enabled ? "Voice Wake active" : pushToTalk ? "Push-to-talk active" : "Voice controls idle")
.font(.headline)
Text(self.voiceSummarySubtitle)
.font(.footnote)
@@ -85,26 +84,6 @@ 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."
@@ -119,31 +98,16 @@ struct VoiceWakeSettings: View {
}
private var unsupportedVoiceWakePanel: some View {
HStack(alignment: .top, spacing: 12) {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.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)
}
.foregroundStyle(.yellow)
Text("Voice Wake requires macOS 26 or newer.")
.font(.callout.weight(.medium))
Spacer()
}
.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))
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(.yellow.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
var body: some View {
@@ -155,68 +119,72 @@ struct VoiceWakeSettings: View {
self.voiceSummaryPanel
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)
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)
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)
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)
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)
}
SettingsCardGroup("Recognition") {
self.localePicker
self.micPicker
self.levelMeter
}
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("Test") {
VoiceWakeTestCard(
testState: self.$testState,
isTesting: self.$isTesting,
onToggle: self.toggleTest)
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
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)
}
self.chimeSection
self.triggerTable
} else {
if !voiceWakeSupported {
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()
@@ -249,13 +217,9 @@ 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 }
@@ -280,11 +244,6 @@ 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 }
@@ -703,7 +662,7 @@ struct VoiceWakeSettings: View {
@MainActor
private func scheduleMicRefresh() {
guard voiceWakeSupported, self.isActive else { return }
guard self.isActive else { return }
MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) {
await self.loadMicsIfNeeded(force: true)
await self.restartMeter()
@@ -765,11 +724,6 @@ 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()
@@ -936,7 +890,6 @@ extension VoiceWakeSettings {
_ = view.levelMeter
_ = view.triggerTable
_ = view.chimeSection
_ = view.unsupportedVoiceWakePanel
view.addWord()
if let entryId = view.triggerEntries.first?.id {

View File

@@ -16,23 +16,6 @@ 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
@@ -47,50 +30,6 @@ 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

@@ -1,256 +0,0 @@
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,25 +982,21 @@ 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?,
agentid: String? = nil)
sessionkey: String?)
{
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"
}
}

View File

@@ -1,4 +1,4 @@
37b56008790612b8293930b6a29d74490e98daa90f954fca9d133fcc28645c4c config-baseline.json
75b64c2ea081369ba4306493313a8a4cd48b784145f92fed995e6b77a5df350d config-baseline.core.json
17d64c9799dfa239a49493413f1100bdd9237e9b67aaeae331a4604dbc227023 config-baseline.channel.json
f9d1f50bfa8403891e76cd99dc1357cdece4a71e8ae18a39b190c2a14e6f97b0 config-baseline.plugin.json
36e44e38957b56b7b0782e5f14add447870d578248d3bd0ebf9a7e884c406a0f config-baseline.json
7b2f99fdd52527397bf3520d923e7b4eec6b0091a7a7a006f6f39bedb931404c config-baseline.core.json
1d1cd6d624d7f4b8137b8a945c7734530aa31170983c3b216de27e6e10126034 config-baseline.channel.json
b0dec5acfe60557e728e5ad03cc36d19d2432d51f755656c97846afa7fbe374a config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
2c783beea6b3cda3d79060739a923f9f39e7e8b5942123dd6b08a09143a587ca plugin-sdk-api-baseline.json
0b33af2cffb42abb46682fb71c8f214da220793f13d10a34d332e75ff99e8ce9 plugin-sdk-api-baseline.jsonl
ae06e87a060aaa9618e2b245553d90402c0fbbe1ebc864928dc7f771cede7c6d plugin-sdk-api-baseline.json
8ae4665726d0a8e2e80587ab0b98afce6718861a996daef2fac207066c29dd4f plugin-sdk-api-baseline.jsonl

View File

@@ -59,14 +59,6 @@ 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

@@ -763,31 +763,6 @@ 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

@@ -183,7 +183,7 @@ The workflow installs OCM from a pinned release and Kova from `openclaw/Kova` at
- `mock-deep-profile`: CPU/heap/trace profiling for startup, gateway, and agent-turn hotspots.
- `live-openai-candidate`: a real OpenAI `openai/gpt-5.5` agent turn, skipped when `OPENAI_API_KEY` is unavailable.
The mock-provider lane also runs OpenClaw-native source probes after the Kova pass: gateway boot timing and memory across default, hook, and 50-plugin startup cases; bundled plugin import RSS, repeated mock-OpenAI `channel-chat-baseline` hello loops, CLI startup commands against the booted gateway, and the SQLite state smoke performance probe. When the previous published mock-provider source report is available for the tested ref, the source summary compares current RSS and heap values against that baseline and marks large RSS increases as `watch`. The source probe Markdown summary lives at `source/index.md` in the report bundle, with raw JSON beside it.
The mock-provider lane also runs OpenClaw-native source probes after the Kova pass: gateway boot timing and memory across default, hook, and 50-plugin startup cases; bundled plugin import RSS, repeated mock-OpenAI `channel-chat-baseline` hello loops, and CLI startup commands against the booted gateway. When the previous published mock-provider source report is available for the tested ref, the source summary compares current RSS and heap values against that baseline and marks large RSS increases as `watch`. The source probe Markdown summary lives at `source/index.md` in the report bundle, with raw JSON beside it.
Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured, the workflow also commits `report.json`, `report.md`, bundles, `index.md`, and source-probe artifacts into `openclaw/clawgrit-reports` under `openclaw-performance/<tested-ref>/<run-id>-<attempt>/<lane>/`. The current tested-ref pointer is written as `openclaw-performance/<tested-ref>/latest-<lane>.json`.
@@ -452,7 +452,7 @@ For normal PRs, follow scoped CI/check evidence instead of treating parity as a
The `CodeQL` workflow is intentionally a narrow first-pass security scanner, not the full repository sweep. Daily, manual, and non-draft pull request guard runs scan Actions workflow code plus the highest-risk JavaScript/TypeScript surfaces with high-confidence security queries filtered to high/critical `security-severity`.
The pull request guard stays light: it only starts for changes under `.github/actions`, `.github/codeql`, `.github/workflows`, `packages`, `scripts`, `src`, or process-owning bundled plugin runtime paths, and it runs the same high-confidence security matrix as the scheduled workflow. Android and macOS CodeQL stay out of PR defaults.
The pull request guard stays light: it only starts for changes under `.github/actions`, `.github/codeql`, `.github/workflows`, `packages`, or `src`, and it runs the same high-confidence security matrix as the scheduled workflow. Android and macOS CodeQL stay out of PR defaults.
### Security categories
@@ -462,7 +462,6 @@ The pull request guard stays light: it only starts for changes under `.github/ac
| `/codeql-security-high/channel-runtime-boundary` | Core channel implementation contracts plus the channel plugin runtime, gateway, Plugin SDK, secrets, audit touchpoints |
| `/codeql-security-high/network-ssrf-boundary` | Core SSRF, IP parsing, network guard, web-fetch, and Plugin SDK SSRF policy surfaces |
| `/codeql-security-high/mcp-process-tool-boundary` | MCP servers, process execution helpers, outbound delivery, and agent tool-execution gates |
| `/codeql-security-high/process-exec-boundary` | Local shell, process spawn helpers, subprocess-owning bundled plugin runtimes, and workflow script glue |
| `/codeql-security-high/plugin-trust-boundary` | Plugin install, loader, manifest, registry, package-manager install, source-loading, and Plugin SDK package contract trust surfaces |
### Platform-specific security shards

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 the local host approvals file
- synchronize local `tools.exec.*` and local `~/.openclaw/exec-approvals.json`
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", askFallback: "full" } }
{ version: 1, defaults: { security: "full", ask: "off" } }
EOF
openclaw approvals set --node <id|name|ip> --file ./exec-approvals.json
openclaw approvals set --gateway --file ./exec-approvals.json
@@ -137,8 +137,7 @@ 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`.
Omitted `askFallback` defaults to `deny`. Set `askFallback: "full"`
explicitly when upgrading a no-UI host that should keep never-prompt behavior.
This matches the current host-default YOLO behavior. Tighten it if you want approvals.
Local shortcut:
@@ -183,9 +182,7 @@ 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 in the OpenClaw state dir
(`$OPENCLAW_STATE_DIR/exec-approvals.json`, or
`~/.openclaw/exec-approvals.json` when the variable is unset).
- Approvals files are stored per host at `~/.openclaw/exec-approvals.json`.
## Related

View File

@@ -280,14 +280,10 @@ 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
```
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.
This path is host-only. For Docker, headless servers, Browserless, or other remote setups, use a CDP profile instead.
Current existing-session limits:

View File

@@ -162,8 +162,7 @@ The node host stores its node id, token, display name, and gateway connection in
`system.run` is gated by local exec approvals:
- `$OPENCLAW_STATE_DIR/exec-approvals.json`, or
`~/.openclaw/exec-approvals.json` when the variable is unset
- `~/.openclaw/exec-approvals.json`
- [Exec approvals](/tools/exec-approvals)
- `openclaw approvals --node <id|name|ip>` (edit from the Gateway)

View File

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

View File

@@ -19,7 +19,6 @@ updates happen via the package-manager flow in [Updating](/install/updating).
```bash
openclaw update
openclaw update status
openclaw update repair
openclaw update wizard
openclaw update --channel beta
openclaw update --channel dev
@@ -77,36 +76,6 @@ Options:
- `--json`: print machine-readable status JSON.
- `--timeout <seconds>`: timeout for checks (default is 3s).
## `update repair`
Rerun update finalization after the core package already changed but later
repair work did not finish cleanly. This is the supported recovery path when
`openclaw update` installed the new core package but post-core plugin sync,
managed npm plugin metadata, registry refresh, or doctor repair still needs to
converge.
```bash
openclaw update repair
openclaw update repair --channel beta
openclaw update repair --json
```
Options:
- `--channel <stable|beta|dev>`: persist the update channel before repair and
run plugin convergence against that channel.
- `--json`: print machine-readable finalization JSON.
- `--timeout <seconds>`: timeout for repair steps (default `1800`).
- `--yes`: skip confirmation prompts.
- `--no-restart`: accepted for update command parity; repair never restarts the
Gateway.
`openclaw update repair` runs `openclaw doctor --fix`, reloads the repaired
config and install records, syncs tracked plugins for the active update channel,
updates managed npm plugin installs, repairs missing configured plugin payloads,
refreshes the plugin registry, and writes the converged install-record metadata.
It does not install a new core package and does not restart the Gateway.
## `update wizard`
Interactive flow to pick an update channel and confirm whether to restart the Gateway
@@ -130,14 +99,12 @@ install method aligned:
missing or older than the current stable release.
The Gateway core auto-updater (when enabled via config) launches the CLI update path
outside the live Gateway request handler. Control-plane `update.run`
package-manager updates and supervised git-checkout updates also use a
managed-service handoff instead of replacing the package tree or rebuilding
`dist/` inside the live Gateway process. The Gateway starts a detached helper,
exits, and the helper runs the normal `openclaw update --yes --json` CLI path
from outside the Gateway process tree. If that handoff is unavailable,
`update.run` returns a structured response with the safe shell command to run
manually.
outside the live Gateway request handler. Control-plane `update.run` package-manager
updates also use a managed-service handoff instead of replacing the package tree
inside the live Gateway process. The Gateway starts a detached helper, exits,
and the helper runs the normal `openclaw update --yes --json` CLI path from
outside the Gateway process tree. If that handoff is unavailable, `update.run`
returns a structured response with the safe shell command to run manually.
For package-manager installs, `openclaw update` resolves the target package
version before invoking the package manager. npm global installs use a staged
@@ -152,33 +119,29 @@ installed OpenClaw build while leaving full plugin-command completion rebuilds t
explicit `openclaw completion --write-state` runs.
When a local managed Gateway service is installed and restart is enabled,
package-manager and git-checkout updates stop the running service before
replacing the package tree or mutating the checkout/build output. The updater
then refreshes the service metadata from the updated install, restarts the
service, and verifies the restarted Gateway before reporting
`Gateway: restarted and verified.`. Package-manager updates additionally verify
the restarted Gateway reports the expected package version; git-checkout updates
verify gateway health and service readiness after the rebuild. On macOS, the
post-update check also verifies the LaunchAgent is loaded/running for the active
profile and the configured loopback port is healthy. If the plist is installed
but launchd is not supervising it, OpenClaw re-bootstraps the LaunchAgent
automatically, then reruns the health/version/channel readiness checks. A fresh
bootstrap loads the RunAtLoad job directly, so update recovery does not
immediately `kickstart -k` the newly spawned Gateway. If the Gateway still does
not become healthy, the command exits non-zero and prints the restart log path
plus explicit restart, reinstall, and package rollback instructions. If restart
cannot run, the command prints `Gateway: restart skipped (...)` or
`Gateway: restart failed: ...` with a manual `openclaw gateway restart` hint.
With `--no-restart`, package replacement or git rebuild still runs but the
managed service is not stopped or restarted, so the running Gateway may keep old
code until you restart it manually.
package-manager updates stop the running service before replacing the package
tree, then refresh the service metadata from the updated install, restart the
service, and verify the restarted Gateway reports the expected version before
reporting `Gateway: restarted and verified.`. On macOS, the post-update check
also verifies the LaunchAgent is loaded/running for the active profile and the
configured loopback port is healthy. If the plist is installed but launchd is
not supervising it, OpenClaw re-bootstraps the LaunchAgent automatically, then
reruns the health/version/channel readiness checks. A fresh bootstrap loads the
RunAtLoad job directly, so update recovery does not immediately `kickstart -k`
the newly spawned Gateway. If the Gateway still does not become healthy, the
command exits non-zero and prints the restart log path plus explicit restart,
reinstall, and package rollback instructions. If restart cannot run, the command
prints `Gateway: restart skipped (...)` or `Gateway: restart failed: ...` with a
manual `openclaw gateway restart` hint. With `--no-restart`,
package replacement still runs but the managed service is not stopped or
restarted, so the running Gateway may keep old code until you restart it
manually.
### Control-plane response shape
When `update.run` is invoked through the Gateway control plane on a
package-manager install or supervised git checkout, the handler reports the
handoff initiation separately from the CLI update that continues after the
Gateway exits:
package-manager install, the handler reports the handoff initiation separately
from the CLI update that continues after the Gateway exits:
- `ok: true`, `result.status: "skipped"`,
`result.reason: "managed-service-handoff-started"`, and
@@ -187,11 +150,8 @@ Gateway exits:
`openclaw update --yes --json` outside the live service process.
- `ok: false`, `result.reason: "managed-service-handoff-unavailable"`, and
`handoff.status: "unavailable"` mean OpenClaw could not find a supervising
service boundary and durable service identity for a safe handoff. For
example, systemd handoff requires the OpenClaw unit identity
(`OPENCLAW_SYSTEMD_UNIT`), not only ambient systemd process markers. The
response includes `handoff.command`, the shell command to run from outside the
Gateway.
service boundary for a safe handoff. The response includes
`handoff.command`, the shell command to run from outside the Gateway.
- `ok: false`, `result.reason: "managed-service-handoff-failed"` means the
Gateway tried to create the handoff but could not spawn the detached helper.
@@ -202,8 +162,8 @@ health checks complete. During the handoff, the sentinel can carry
restarted Gateway keeps polling it and only fires the continuation after the CLI
has verified service health and rewritten the sentinel with the final `ok`
result. `openclaw status` and `openclaw status --all` show an `Update restart`
row while that sentinel is pending or failed, and `update.status` refreshes and
returns the latest sentinel.
row while that sentinel is pending or failed, and `update.status` returns the
latest cached sentinel.
## Git checkout flow
@@ -258,9 +218,9 @@ If an exact pinned npm plugin update resolves to an artifact whose integrity dif
</Warning>
<Note>
Post-update plugin sync failures that are scoped to a managed plugin and that the sync path can route around (e.g. an unreachable npm registry for a non-essential plugin) are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw update repair` and `openclaw plugins inspect <id> --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw update repair`.
Post-update plugin sync failures that are scoped to a managed plugin and that the sync path can route around (e.g. an unreachable npm registry for a non-essential plugin) are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw doctor --fix` and `openclaw plugins inspect <id> --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw doctor --fix` or `openclaw update`.
After the per-plugin sync step, `openclaw update` runs a mandatory **post-core convergence** pass before the gateway is restarted: it repairs missing configured plugin payloads, validates each _active_ tracked install record on disk, and statically verifies its `package.json` is parseable (and any explicitly-declared `main` exists). Failures from this pass — and an invalid OpenClaw config snapshot — return `postUpdate.plugins.status: "error"` and flip the top-level update `status` to `"error"`, so `openclaw update` exits non-zero and the gateway is _not_ restarted with an unverified plugin set. The error includes structured `postUpdate.plugins.warnings[].guidance` lines pointing at `openclaw update repair` and `openclaw plugins inspect <id> --runtime --json` for follow-up. Disabled plugin entries and records that are not trusted-source-linked official sync targets are skipped here, mirroring the `skipDisabledPlugins` policy used by the missing-payload check, so a stale disabled plugin record cannot block an otherwise valid update.
After the per-plugin sync step, `openclaw update` runs a mandatory **post-core convergence** pass before the gateway is restarted: it repairs missing configured plugin payloads, validates each _active_ tracked install record on disk, and statically verifies its `package.json` is parseable (and any explicitly-declared `main` exists). Failures from this pass — and an invalid OpenClaw config snapshot — return `postUpdate.plugins.status: "error"` and flip the top-level update `status` to `"error"`, so `openclaw update` exits non-zero and the gateway is _not_ restarted with an unverified plugin set. The error includes structured `postUpdate.plugins.warnings[].guidance` lines pointing at `openclaw doctor --fix` and `openclaw plugins inspect <id> --runtime --json` for follow-up. Disabled plugin entries and records that are not trusted-source-linked official sync targets are skipped here, mirroring the `skipDisabledPlugins` policy used by the missing-payload check, so a stale disabled plugin record cannot block an otherwise valid update.
When the updated Gateway starts, plugin loading is verify-only: startup does not
run package managers or mutate dependency trees. Package-manager `update.run`

View File

@@ -35,7 +35,6 @@ openclaw wiki status
openclaw wiki doctor
openclaw wiki init
openclaw wiki ingest ./notes/alpha.md
openclaw wiki okf import ./knowledge-catalog/okf/bundles/ga4
openclaw wiki compile
openclaw wiki lint
openclaw wiki search "alpha"
@@ -105,31 +104,6 @@ Notes:
- imported source pages keep provenance in frontmatter
- auto-compile can run after ingest when enabled
### `wiki okf import <path>`
Import an unpacked Open Knowledge Format bundle into wiki concept pages.
The importer reads every non-reserved `.md` concept document in the OKF
directory tree, requires a non-empty `type` field, and treats unknown OKF
`type` values as generic concepts. Reserved OKF `index.md` and `log.md` files
are not imported as concepts.
Imported pages are flattened under `concepts/` so existing wiki compile,
search, get, digest, and dashboard flows see them immediately. The original OKF
concept ID, `type`, `resource`, `tags`, timestamp, source path, and full
frontmatter are preserved in the page frontmatter. Internal OKF markdown links
are rewritten to the generated wiki pages; broken or external links are left
unchanged.
Examples:
```bash
openclaw wiki okf import ./bundles/ga4
openclaw wiki okf import ./bundles/ga4 --json
openclaw wiki search "BigQuery Table" --mode source-evidence --json
openclaw wiki get <path-from-json-result>
```
### `wiki compile`
Rebuild indexes, related blocks, dashboards, and compiled digests.
@@ -259,8 +233,6 @@ These require the official `obsidian` CLI on `PATH` when
- Use `wiki lint` before trusting contradictory or low-confidence content.
- Use `wiki compile` after bulk imports or source changes when you want fresh
dashboards and compiled digests immediately.
- Use `wiki okf import` when a data catalog, documentation export, or agent
enrichment pipeline already emits OKF markdown bundles.
- Use `wiki bridge import` when bridge mode depends on newly exported memory
artifacts.

View File

@@ -62,12 +62,10 @@ present.
`build`. Gateway startup does not initialize QMD by default, so cold boot
avoids importing the memory runtime or creating the long-lived watcher before
memory is first used.
- If you want QMD initialized at gateway start anyway, set
`memory.qmd.update.startup` to `idle` or `immediate`. With
`memory.qmd.update.onBoot: true`, startup runs the initial refresh. With
`onBoot: false`, startup skips that immediate refresh but still opens the
long-lived manager when update or embed intervals are configured, so QMD can
own its regular watcher and timers.
- If you want a gateway-start refresh anyway, set
`memory.qmd.update.startup` to `idle` or `immediate`. The opt-in startup
refresh uses a one-shot QMD subprocess path instead of creating the full
long-lived in-process watcher.
- Searches use the configured `searchMode` (default: `search`; also supports
`vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic
vector readiness probes and embedding maintenance in that mode. If a mode

View File

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

View File

@@ -374,7 +374,7 @@ The implicit default set always covers canary, mention gating, native command re
Output artifacts:
- `telegram-qa-report.md`
- `qa-evidence.json` - evidence entries for the live transport checks, including profile, coverage, provider, channel, artifacts, result, and RTT fields.
- `telegram-qa-summary.json` - includes per-reply RTT (driver send → observed SUT reply) starting with the canary.
- `telegram-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT=1`.
Package RTT comparison uses the same Telegram credential contract while keeping
@@ -447,7 +447,7 @@ pnpm openclaw qa discord \
Output artifacts:
- `discord-qa-report.md`
- `qa-evidence.json` - evidence entries for the live transport checks.
- `discord-qa-summary.json`
- `discord-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_DISCORD_CAPTURE_CONTENT=1`.
- `discord-qa-reaction-timelines.json` and `discord-status-reactions-tool-only-timeline.png` when the status-reaction scenario runs.
@@ -495,7 +495,7 @@ Scenarios (`extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts`):
Output artifacts:
- `slack-qa-report.md`
- `qa-evidence.json` - evidence entries for the live transport checks.
- `slack-qa-summary.json`
- `slack-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_SLACK_CAPTURE_CONTENT=1`.
- `approval-checkpoints/` - only when Mantis sets
`OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_DIR`; contains checkpoint JSON,
@@ -740,7 +740,7 @@ poll and upload-file coverage run through deterministic gateway `poll` and
Output artifacts:
- `whatsapp-qa-report.md`
- `qa-evidence.json` - evidence entries for the live transport checks.
- `whatsapp-qa-summary.json`
- `whatsapp-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT=1`.
### Convex credential pool
@@ -787,10 +787,9 @@ the source of truth for one test run and should define:
- docs and code refs
- optional plugin requirements
- optional gateway config patch
- an executable `qa-flow` block for flow scenarios, or `execution.kind`/`execution.path`
for Vitest and Playwright scenarios
- the executable `qa-flow`
The reusable runtime surface that backs `qa-flow` blocks is allowed to stay generic
The reusable runtime surface that backs `qa-flow` is allowed to stay generic
and cross-cutting. For example, markdown scenarios can combine transport-side
helpers with browser-side helpers that drive the embedded Control UI through the
Gateway `browser.request` seam without adding a special-case runner.
@@ -916,7 +915,6 @@ The report should answer:
For the inventory of available scenarios - useful when sizing follow-up work or wiring a new transport - run `pnpm openclaw qa coverage` (add `--json` for machine-readable output).
When choosing focused proof for a touched behavior or file path, run `pnpm openclaw qa coverage --match <query>`.
The match report searches scenario metadata, docs refs, code refs, coverage IDs, plugins, and provider requirements, then prints matching `qa suite --scenario ...` targets.
Every `qa suite` scenario execution writes a `qa-evidence.json` artifact. Flow scenarios also write `qa-suite-summary.json` for existing suite/report tooling; scenarios that declare `execution.kind: vitest` or `execution.kind: playwright` run the matching test path and write `qa-vitest-report.md` or `qa-playwright-report.md` plus per-scenario logs.
Treat it as a discovery aid, not a gate replacement; the selected scenario still needs the right provider mode, live transport, Multipass, Testbox, or release lane for the behavior under test.
For character and style checks, run the same scenario across multiple live model

View File

@@ -161,7 +161,6 @@ Telegram:
- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics.
- Final text edits the active preview in place; long finals reuse that message for the first chunk and send only the remaining chunks.
- `block` mode rotates the preview into a new message at `streaming.preview.chunk.maxChars` (default 800, capped at Telegram's 4096 edit limit); other modes grow one preview up to 4096 characters.
- `progress` mode keeps tool progress in an editable status draft, clears that draft at completion, and sends the final answer through normal delivery.
- If the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview.
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).

View File

@@ -30,23 +30,6 @@ title: "Usage tracking"
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
- macOS menu bar: "Usage" section under Context (only if available).
## Custom `/usage full` footer
Set `messages.usageTemplate` to customize the per-response `/usage full`
footer. The value can be an inline template object or a JSON file path:
```json
{
"messages": {
"usageTemplate": "~/.openclaw/usage-footer.json"
}
}
```
Templates read the `openclaw.usageLine.v1` contract and can use `scales`,
`aliases`, and `output.surfaces` to render channel-specific footers. Missing,
unreadable, invalid, or empty templates fall back to the built-in usage line.
## Providers + credentials
- **Anthropic (Claude)**: OAuth tokens in auth profiles.

View File

@@ -1375,6 +1375,7 @@
"clawhub/cli",
"clawhub/publishing",
"clawhub/skill-format",
"clawhub/soul-format",
"clawhub/auth",
"clawhub/telemetry",
"clawhub/troubleshooting"

View File

@@ -634,7 +634,7 @@ Periodic heartbeat runs.
compaction: {
mode: "safeguard", // default | safeguard
provider: "my-provider", // id of a registered compaction provider plugin (optional)
timeoutSeconds: 180,
timeoutSeconds: 900,
reserveTokensFloor: 24000,
keepRecentTokens: 50000,
identifierPolicy: "strict", // strict | off | custom
@@ -661,7 +661,7 @@ Periodic heartbeat runs.
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
- `provider`: id of a registered compaction provider plugin. When set, the provider's `summarize()` is called instead of built-in LLM summarization. Falls back to built-in on failure. Setting a provider forces `mode: "safeguard"`. See [Compaction](/concepts/compaction).
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `180`.
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `900`.
- `keepRecentTokens`: agent cut-point budget for keeping the most recent transcript tail verbatim. Manual `/compact` honors this when explicitly set; otherwise manual compaction is a hard checkpoint.
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.

View File

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

View File

@@ -493,8 +493,6 @@ example `~/.agents/skills/manager -> ~/Projects/manager/skills`.
- `extraDirs` scans the sibling repo as an explicit skill root.
- `allowSymlinkTargets` lets symlinked skill folders resolve into that trusted
real target root without allowing arbitrary symlink escapes.
- To let Skill Workshop apply write through the same trusted symlink target,
set `skills.workshop.allowSymlinkTargetWrites: true`.
## Common patterns

View File

@@ -200,9 +200,6 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
nodeManager: "npm", // npm | pnpm | yarn | bun
allowUploadedArchives: false,
},
workshop: {
allowSymlinkTargetWrites: false,
},
entries: {
"image-lab": {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string
@@ -219,8 +216,6 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
- `load.extraDirs`: extra shared skill roots (lowest precedence).
- `load.allowSymlinkTargets`: trusted real target roots that skill symlinks may
resolve into when the link lives outside its configured source root.
- `workshop.allowSymlinkTargetWrites`: allows Skill Workshop apply to write
through already-trusted symlink targets (default: false).
- `install.preferBrew`: when true, prefer Homebrew installers when `brew` is
available before falling back to other installer kinds.
- `install.nodeManager`: node installer preference for `metadata.openclaw.install`
@@ -447,17 +442,12 @@ See [Inferred commitments](/concepts/commitments).
the selected host or through a connected browser node.
- `existing-session` profiles can set `userDataDir` to target a specific
Chromium-based browser profile such as Brave or Edge.
- `existing-session` profiles can set `cdpUrl` when Chrome is already running
behind a DevTools HTTP(S) discovery endpoint or direct WS(S) endpoint. In that
mode OpenClaw passes the endpoint to Chrome MCP instead of using auto-connect;
`userDataDir` is ignored for Chrome MCP launch arguments.
- `existing-session` profiles keep the current Chrome MCP route limits:
snapshot/ref-driven actions instead of CSS-selector targeting, one-file upload
hooks, no dialog timeout overrides, no `wait --load networkidle`, and no
`responsebody`, PDF export, download interception, or batch actions.
- Local managed `openclaw` profiles auto-assign `cdpPort` and `cdpUrl`; set
`cdpUrl` explicitly only for remote CDP profiles or existing-session endpoint
attach.
- Local managed `openclaw` profiles auto-assign `cdpPort` and `cdpUrl`; only
set `cdpUrl` explicitly for remote CDP.
- Local managed profiles can set `executablePath` to override the global
`browser.executablePath` for that profile. Use this to run one profile in
Chrome and another in Brave.

View File

@@ -42,21 +42,6 @@ health commands above for live connectivity checks.
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: multi-account override that wins over the channel-level setting.
- These per-channel overrides apply to the built-in channel monitors that expose them today: Discord, Google Chat, iMessage, Microsoft Teams, Signal, Slack, Telegram, and WhatsApp.
## Uptime monitoring
External uptime monitoring services should use the dedicated `/health` endpoint, not `/v1/chat/completions`.
- **DO use:** `GET /health` — instant response, no session created, no LLM call, returns `{"ok":true,"status":"live"}`
- **DON'T use:** `/v1/chat/completions` for health checks — each request creates a full agent session with skill snapshot, context assembly, and LLM calls
When no `x-openclaw-session-key` header or `user` field is provided, `/v1/chat/completions` generates a new random session for each request. Monitoring services that ping every 15 minutes create ~96 sessions/day, each consuming 422KB. Over time this causes session store bloat and can lead to context window overflow.
### Monitoring service setup examples
- **BetterStack:** Set health check URL to `https://<your-gateway-host>:<port>/health`
- **UptimeRobot:** Add a new HTTP monitor with URL `https://<your-gateway-host>:<port>/health`
- **Generic:** Any HTTP GET to `/health` returns 200 with `{"ok":true}` when the gateway is healthy
## When something fails
- `logged out` or status 409515 → relink with `openclaw channels logout` then `openclaw channels login`.

View File

@@ -75,7 +75,6 @@ Auth matrix:
- honor `x-openclaw-scopes` when the header is present
- fall back to the normal operator default scope set when the header is absent
- only lose owner semantics when the caller explicitly narrows scopes and omits `operator.admin`
- require `operator.admin` for owner-level request controls such as `x-openclaw-model`
See [Security](/gateway/security) and [Remote access](/gateway/remote).
@@ -97,7 +96,7 @@ OpenClaw treats the OpenAI `model` field as an **agent target**, not a raw provi
Optional request headers:
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent. Shared-secret bearer callers can use this header. Identity-bearing callers, such as trusted-proxy or private no-auth ingress requests with `x-openclaw-scopes`, need `operator.admin`; write-only callers get `403 missing scope: operator.admin`.
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent.
- `x-openclaw-agent-id: <agentId>` remains supported as a compatibility override.
- `x-openclaw-session-key: <sessionKey>` fully controls session routing.
- `x-openclaw-message-channel: <channel>` sets the synthetic ingress channel context for channel-aware prompts and policies.
@@ -179,7 +178,7 @@ This is the highest-leverage compatibility set for self-hosted frontends and too
</Accordion>
<Accordion title="How do I override the backend model?">
Use `x-openclaw-model`. This is an owner-level override: it works with the Gateway shared-secret bearer token/password path, and it requires `operator.admin` on identity-bearing HTTP paths such as trusted proxy auth.
Use `x-openclaw-model`.
Examples:
`x-openclaw-model: openai/gpt-5.4`
@@ -192,7 +191,7 @@ This is the highest-leverage compatibility set for self-hosted frontends and too
`/v1/embeddings` uses the same agent-target `model` ids.
Use `model: "openclaw/default"` or `model: "openclaw/<agentId>"`.
When you need a specific embedding model, send it in `x-openclaw-model` from a shared-secret caller or an identity-bearing caller with `operator.admin`.
When you need a specific embedding model, send it in `x-openclaw-model`.
Without that header, the request passes through to the selected agent's normal embedding setup.
</Accordion>
@@ -286,7 +285,7 @@ Expected behavior:
- `GET /v1/models` should list `openclaw/default`
- Open WebUI should use `openclaw/default` as the chat model id
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model` from a shared-secret caller or an identity-bearing caller with `operator.admin`
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model`
Quick smoke:
@@ -371,7 +370,7 @@ Notes:
- `/v1/models` returns OpenClaw agent targets, not raw provider catalogs.
- `openclaw/default` is always present so one stable id works across environments.
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field. On identity-bearing HTTP auth paths, this header requires `operator.admin`.
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field.
- `/v1/embeddings` supports `input` as a string or array of strings.
## Related

View File

@@ -411,8 +411,8 @@ enumeration of `src/gateway/server-methods/*.ts`.
- `config.apply` validates + replaces the full config payload.
- `config.schema` returns the live config schema payload used by Control UI and CLI tooling: schema, `uiHints`, version, and generation metadata, including plugin + channel schema metadata when the runtime can load it. The schema includes field `title` / `description` metadata derived from the same labels and help text used by the UI, including nested object, wildcard, array-item, and `anyOf` / `oneOf` / `allOf` composition branches when matching field documentation exists.
- `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, optional `reloadKind`, and immediate child summaries for UI/CLI drill-down. `reloadKind` is one of `restart`, `hot`, or `none` and mirrors the Gateway config reload planner for the requested path. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, and flags like `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, optional `reloadKind`, plus the matched `hint` / `hintPath`.
- `update.run` runs the gateway update flow and schedules a restart only when the update itself succeeded; callers with a session can include `continuationMessage` so startup resumes one follow-up agent turn through the restart continuation queue. Package-manager updates and supervised git-checkout updates from the control plane use a detached managed-service handoff instead of replacing the package tree or mutating checkout/build output inside the live Gateway. A started handoff returns `ok: true` with `result.reason: "managed-service-handoff-started"` and `handoff.status: "started"`; unavailable or failed handoffs return `ok: false` with `managed-service-handoff-unavailable` or `managed-service-handoff-failed`, plus `handoff.command` when a manual shell update is required. An unavailable handoff means OpenClaw lacks a safe supervisor boundary or durable service identity, such as `OPENCLAW_SYSTEMD_UNIT` for systemd. During a started handoff, the restart sentinel may briefly report `stats.reason: "restart-health-pending"`; the continuation is delayed until the CLI verifies the restarted Gateway and writes the final `ok` sentinel.
- `update.status` refreshes and returns the latest update restart sentinel, including the post-restart running version when available.
- `update.run` runs the gateway update flow and schedules a restart only when the update itself succeeded; callers with a session can include `continuationMessage` so startup resumes one follow-up agent turn through the restart continuation queue. Package-manager updates from the control plane use a detached managed-service handoff instead of replacing the package tree inside the live Gateway. A started handoff returns `ok: true` with `result.reason: "managed-service-handoff-started"` and `handoff.status: "started"`; unavailable or failed handoffs return `ok: false` with `managed-service-handoff-unavailable` or `managed-service-handoff-failed`, plus `handoff.command` when a manual shell update is required. During a started handoff, the restart sentinel may briefly report `stats.reason: "restart-health-pending"`; the continuation is delayed until the CLI verifies the restarted Gateway and writes the final `ok` sentinel.
- `update.status` returns the latest cached update restart sentinel, including the post-restart running version when available.
- `wizard.start`, `wizard.next`, `wizard.status`, and `wizard.cancel` expose the onboarding wizard over WS RPC.
</Accordion>

View File

@@ -93,7 +93,7 @@ exhaustive):
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` fails closed when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
| `tools.exec.security_full_configured` | warn/critical | Host exec is running with `security="full"` | `tools.exec.security`, `agents.list[].tools.exec.security` | no |
| `tools.exec.fs_tools_disabled_but_exec_enabled` | warn | Filesystem tool policy does not make shell execution read-only | `tools.deny`, `agents.list[].tools.deny`, `agents.*.sandbox.workspaceAccess` | no |
| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | host approvals file | no |
| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | `~/.openclaw/exec-approvals.json` | no |
| `tools.exec.allowlist_interpreter_without_strict_inline_eval` | warn | Interpreter allowlists permit inline eval without forced reapproval | `tools.exec.strictInlineEval`, `agents.list[].tools.exec.strictInlineEval`, exec approvals allowlist | no |
| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |
| `tools.exec.safe_bins_broad_behavior` | warn | Broad-behavior tools in `safeBins` weaken the low-risk stdin-filter trust model | `tools.exec.safeBins`, `agents.list[].tools.exec.safeBins` | no |

View File

@@ -951,7 +951,7 @@ Important boundary note:
- Treat credentials that can call `/v1/chat/completions`, `/v1/responses`, plugin routes such as `/api/v1/admin/rpc`, or `/api/channels/*` as full-access operator secrets for that gateway.
- On the OpenAI-compatible HTTP surface, shared-secret bearer auth restores the full default operator scopes (`operator.admin`, `operator.approvals`, `operator.pairing`, `operator.read`, `operator.talk.secrets`, `operator.write`) and owner semantics for agent turns; narrower `x-openclaw-scopes` values do not reduce that shared-secret path.
- Per-request scope semantics on HTTP only apply when the request comes from an identity-bearing mode such as trusted proxy auth, or from an explicitly no-auth private ingress.
- In those identity-bearing modes, omitting `x-openclaw-scopes` falls back to the normal operator default scope set; send the header explicitly when you want a narrower scope set. Owner-level OpenAI-compatible headers such as `x-openclaw-model` require `operator.admin` when scopes are narrowed.
- In those identity-bearing modes, omitting `x-openclaw-scopes` falls back to the normal operator default scope set; send the header explicitly when you want a narrower scope set.
- `/tools/invoke` and HTTP session history endpoints follow the same shared-secret rule: token/password bearer auth is treated as full operator access there too, while identity-bearing modes still honor declared scopes.
- Do not share these credentials with untrusted callers; prefer separate gateways per trust boundary.

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