Compare commits

..

33 Commits

Author SHA1 Message Date
Vincent Koc
7c86d8d501 feat(memory): support global baselines and agent overrides 2026-06-19 10:00:02 +08:00
Vincent Koc
f15a70be21 fix(memory): exclude peer dream artifacts from extra paths 2026-06-19 02:44:09 +08:00
Vincent Koc
a31204ac6c fix(ui): scope dreaming wiki data by agent 2026-06-19 02:37:25 +08:00
Vincent Koc
4838915c95 test(gateway): scope qmd startup fixtures by agent 2026-06-19 02:33:02 +08:00
Vincent Koc
e14b2d9ba8 test(memory): align fixtures with agent config 2026-06-19 02:26:39 +08:00
Vincent Koc
a43ed080f3 fix(memory): preserve legacy dream diary content 2026-06-19 02:10:58 +08:00
Vincent Koc
bb44b27c2a fix(memory): protect agent dream diary reads 2026-06-19 02:07:19 +08:00
Vincent Koc
223b643112 fix(memory): migrate shared state to owner workspace 2026-06-19 02:03:10 +08:00
Vincent Koc
bda5ccf1c8 fix(memory): preserve shared legacy dreaming state 2026-06-19 01:59:15 +08:00
Vincent Koc
8b90900b8d fix(ui): preserve configured agent ids in dreaming patches 2026-06-19 01:54:07 +08:00
Vincent Koc
29a01b86c2 fix(memory): validate agent-scoped maintenance targets 2026-06-19 01:48:03 +08:00
Vincent Koc
6efd70ea20 fix(memory): start explicitly configured qmd agents 2026-06-19 01:42:49 +08:00
Vincent Koc
b85ba100b7 fix(ui): scope dreaming config by agent 2026-06-19 01:37:05 +08:00
Vincent Koc
d8b88c35c2 fix(memory): guard dreaming repair paths 2026-06-19 01:31:37 +08:00
Vincent Koc
1aa9837321 fix(config): prune relocated memory index paths 2026-06-19 01:26:53 +08:00
Vincent Koc
a064e11269 fix(memory): harden agent dreaming artifacts 2026-06-19 01:21:24 +08:00
Vincent Koc
01cdf9ca63 fix(memory-wiki): reject unknown gateway agent IDs 2026-06-19 01:21:24 +08:00
Vincent Koc
71168a2ae5 fix(memory): preserve QMD private artifact ignores 2026-06-19 01:21:24 +08:00
Vincent Koc
55f2ab04f0 fix(memory-wiki): type shared vault agent ids 2026-06-19 01:21:24 +08:00
Vincent Koc
80d2f54d31 fix(memory-wiki): retain shared vault bridge artifacts 2026-06-19 01:21:23 +08:00
Vincent Koc
021ae312a7 fix(memory): lock shared promotions by physical workspace 2026-06-19 01:21:23 +08:00
Vincent Koc
6c639c739c fix(codex): bind citations to resolved session agent 2026-06-19 01:21:23 +08:00
Vincent Koc
516263eefd fix(memory-wiki): retain shared vault bridge pages 2026-06-19 01:21:23 +08:00
Vincent Koc
86b2d0a569 fix(memory): preserve shared legacy dreaming state 2026-06-19 01:21:23 +08:00
Vincent Koc
8b14f45bae fix(memory-wiki): canonicalize gateway agent ids 2026-06-19 01:21:23 +08:00
Vincent Koc
b4bc84caa9 fix(memory): harden agent-scoped memory isolation 2026-06-19 01:21:23 +08:00
Vincent Koc
bf8c975cea fix(memory-wiki): type agent config fixture 2026-06-19 01:21:23 +08:00
Vincent Koc
44725f80c7 fix(memory): align agent-scoped runtime contracts 2026-06-19 01:21:23 +08:00
Vincent Koc
d38c702221 fix(memory): hide unscoped private artifacts 2026-06-19 01:21:23 +08:00
Vincent Koc
647869d425 feat(memory): unify agent-scoped memory configuration 2026-06-19 01:21:23 +08:00
Aaron Wong
1690c3f0dd fix(docs): oxfmt trailing comma in memory-config.md
(cherry picked from commit 7cd774a665)
2026-06-19 01:20:47 +08:00
Aaron Wong
7fe54772a9 feat(memory): add per-agent dreaming control
Add ability to selectively enable/disable dreaming for specific agents.

- Add 'dreaming.enabled' option to AgentConfig type
- Add corresponding Zod schema for config validation
- Modify resolveMemoryDreamingWorkspaces() to skip agents with dreaming.enabled = false
- Add test coverage for the filtering logic
- Update memory-config.md documentation

Fixes #67413

(cherry picked from commit 17f1d61d98)
2026-06-19 01:20:47 +08:00
Aaron Wong
740237831f feat(memory): add per-agent dreaming control
Add ability to selectively enable/disable dreaming for specific agents.

- Add 'dreaming.enabled' option to AgentConfig type
- Modify resolveMemoryDreamingWorkspaces() to skip agents with dreaming.disabled = false
- Addresses GitHub issue #67413

(cherry picked from commit 06814bcb42)
2026-06-19 01:20:47 +08:00
3054 changed files with 48101 additions and 113956 deletions

View File

@@ -1,34 +1,44 @@
---
name: channel-message-flows
description: "Use when running QA Lab channel message flow evidence."
description: "Use when previewing local channel message flow fixtures."
---
# Channel Message Flows
Use this from the OpenClaw repo root to run the QA Lab evidence for Telegram
draft/final delivery sequencing. This skill no longer launches a standalone
script; the behavior is owned by the QA scenario and its Vitest-backed e2e test.
Use this from the OpenClaw repo root to send canned channel preview flows while iterating on message UX. These are real sends/edits/deletes against the configured channel target.
## QA Scenario
## Telegram
Run the scenario through QA Lab:
Native Telegram `sendMessageDraft` tool progress, then a final answer:
```bash
pnpm openclaw qa suite --scenario channel-message-flows
node --import tsx scripts/dev/channel-message-flows.ts \
--channel telegram \
--target <telegram-chat-id> \
--flow working-final \
--duration-ms 20000
```
Run the focused e2e test directly in a Codex worktree:
Thinking preview, then a final answer:
```bash
node scripts/run-vitest.mjs extensions/telegram/src/channel-message-flows.qa.e2e.test.ts
node --import tsx scripts/dev/channel-message-flows.ts \
--channel telegram \
--target <telegram-chat-id> \
--flow thinking-final
```
## References
## Options
- `qa/scenarios/channels/channel-message-flows.yaml`
- `extensions/telegram/src/channel-message-flows.qa.e2e.test.ts`
- `extensions/telegram/src/test-support/channel-message-flows.ts`
- `--account <accountId>`: Telegram account id when not using the default.
- `--thread-id <id>`: Telegram forum topic/message thread id.
- `--delay-ms <ms>`: Override preview update cadence.
- `--duration-ms <ms>`: Simulated working duration for `working-final`.
- `--final-text <text>`: Override the durable final message.
The scenario covers `channels.streaming` as primary evidence and records
secondary coverage for thread preservation, delivery ordering, and reasoning
preview visibility.
## Notes
- `--target` is the numeric Telegram chat id.
- `working-final` exercises native Telegram `sendMessageDraft` with static `Working` status and sample tool progress.
- `thinking-final` exercises formatted `Thinking` reasoning preview clearing before the final answer.
- Only `--channel telegram` is implemented for now.

View File

@@ -16,8 +16,11 @@ This skill owns the operational workflow for:
- `taxonomy.yaml`
- `docs/maturity-scores.yaml`
- `docs/concepts/qa-e2e-automation.md`
- `qa/scenarios/index.yaml`
- `docs/maturity-scorecard.md`
- `docs/taxonomy.md`
- `docs/taxonomy-outline.md`
- `scripts/render-maturity-docs.mjs`
- `.github/workflows/maturity-scorecard.yml`
Keep person-specific, maintainer-private, Discord archive, and discrawl facts
out of this repo. If a score needs private evidence, use the redacted
@@ -28,21 +31,12 @@ out of this repo. If a score needs private evidence, use the redacted
- `taxonomy.yaml` is the hand-edited source of truth for surfaces, levels,
QA profiles, categories, feature coverage IDs, docs refs, LTS overrides, and
completeness-instruction paths.
- Feature `coverageIds` are ANDed proof targets, not aliases. A feature may
list multiple IDs when each ID proves part of one capability.
- Coverage IDs use dotted `namespace.behavior` form, with lowercase
alphanumeric/dash segments. Profile, surface, and category IDs may remain
dashed or dotted.
- Keep categories and feature names unique, product-shaped, and broader than raw
coverage IDs. Do not promote generic IDs into standalone feature names.
- Avoid duplicate coverage-ID bundles under different feature names in one
category.
- `docs/maturity-scores.yaml` is the aggregate score source committed in this
repo. It is the only committed score data; do not add generated inventory
directories.
- There is no committed maturity-doc renderer or `pnpm maturity:*` script in
this repo. Do not invent generated scorecard files; update the source YAML
and current docs directly.
- `docs/maturity-scorecard.md`, `docs/taxonomy.md`, and
`docs/taxonomy-outline.md` are deterministic docs generated from the root
taxonomy and aggregate score source.
- `qa-evidence.json` artifacts provide per-run QA scorecard evidence. They can
enrich generated artifact docs, but they are not committed as inventory.
@@ -50,28 +44,22 @@ out of this repo. If a score needs private evidence, use the redacted
Run from the openclaw repo root.
Validate YAML structure after source edits:
Render committed docs:
```bash
node <<'NODE'
const fs = require("node:fs");
const YAML = require("yaml");
for (const file of ["taxonomy.yaml", "docs/maturity-scores.yaml", "qa/scenarios/index.yaml"]) {
YAML.parse(fs.readFileSync(file, "utf8"));
}
NODE
pnpm maturity:render
```
Check docs when touching docs prose:
Check generated docs are current:
```bash
pnpm check:docs
pnpm maturity:check
```
Run focused QA/profile checks when changing coverage IDs or profile membership:
Render an evidence-enriched docs artifact from downloaded QA artifacts:
```bash
pnpm openclaw qa coverage --json
pnpm maturity:render -- --evidence-dir .artifacts/maturity-evidence --output-dir .artifacts/maturity-docs
```
## Scoring Workflow
@@ -87,13 +75,13 @@ When asked to score or refresh a surface:
discrawl or unredacted private archives.
5. Update `docs/maturity-scores.yaml` only when the score change is backed by
public or redacted artifact evidence.
6. Run the YAML validation command from this skill.
7. Run `pnpm check:docs` if docs prose changed, and focused QA coverage checks
if coverage IDs or profile membership changed.
6. Run `pnpm maturity:render`.
7. Run `pnpm maturity:check`.
For subjective score changes, make the smallest defensible edit and leave the
evidence path in the PR or task summary. Keep manual prose in current docs and
keep score data in `docs/maturity-scores.yaml`.
evidence path in the PR or task summary. The deterministic renderer owns
Markdown structure; manual prose tweaks belong in taxonomy, score source, or
the renderer rather than in generated docs.
## Default Completeness Process
@@ -170,9 +158,13 @@ Bands:
- `Alpha`: 50-70
- `Experimental`: 0-50
## Artifacts
## GitHub Action
The `Maturity scorecard` workflow verifies committed generated docs on PRs and
pushes. Manual dispatch can also download QA artifacts from another workflow run
with `source_run_id` and `artifact_pattern`, render evidence-enriched docs into
`.artifacts/maturity-docs`, and upload them as a GitHub artifact.
Do not add the maintainer repo's `docs/kevinslin/maturity-scorecard/inventory/`
tree to openclaw. Evidence-enriched scorecard outputs belong in short-lived
artifacts, not committed generated docs, unless this repo adds an explicit
renderer/check workflow first.
tree to openclaw. Those generated reports are intentionally replaced here by
short-lived artifact docs and the committed aggregate scorecard pages.

View File

@@ -4,7 +4,6 @@ import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
const repo = "openclaw/openclaw";
const commitAssociationQueryBatchSize = 20;
const excludedHandles = new Set(["openclaw", "clawsweeper", "claude", "codex", "steipete"]);
const nonEditorialTypes = new Set([
"build",
@@ -619,25 +618,13 @@ function graphql(query) {
let lastError;
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const response = githubApi(["graphql", "-f", `query=${query}`]);
if (response?.data && typeof response.data === "object") {
return response.data;
}
const errors = Array.isArray(response?.errors)
? response.errors.map((error) => error?.message).filter(Boolean)
: [];
const detail = [...errors, response?.message].filter(Boolean).join("\n");
throw new Error(
detail
? `GitHub GraphQL response did not include data:\n${detail}`
: "GitHub GraphQL response did not include data.",
);
return githubApi(["graphql", "-f", `query=${query}`]).data;
} catch (error) {
lastError = error;
const message = [error?.message, error?.stdout, error?.stderr].filter(Boolean).join("\n");
// Historical ranges batch hundreds of objects; only retry transient transport failures.
if (
!/(?:operation timed out|ECONNRESET|ETIMEDOUT|EAI_AGAIN|TLS handshake timeout|stream error: .*CANCEL|unexpected end of JSON input|upstream connect error|connection termination|connection reset by peer|error connecting to api\.github\.com|Unexpected token '<'|something went wrong|temporarily unavailable|internal server error|rate limit)/i.test(
!/(?:operation timed out|ECONNRESET|ETIMEDOUT|EAI_AGAIN|TLS handshake timeout|stream error: .*CANCEL|unexpected end of JSON input|upstream connect error|connection termination|error connecting to api\.github\.com|Unexpected token '<')/i.test(
message,
)
) {
@@ -670,8 +657,8 @@ function resolveAssociatedPullRequests(commitHashes, targetTimestamp) {
pending.push({ commitHash, cursor: connection.pageInfo.endCursor });
}
}
for (let index = 0; index < commitHashes.length; index += commitAssociationQueryBatchSize) {
const chunk = commitHashes.slice(index, index + commitAssociationQueryBatchSize);
for (let index = 0; index < commitHashes.length; index += 40) {
const chunk = commitHashes.slice(index, index + 40);
const fields = chunk
.map(
(hash, offset) =>

View File

@@ -107,9 +107,16 @@ Reject:
## PR Body Proof
Use the repo PR template. Include authored `## What Problem This Solves` and
`## Evidence` sections. Keep the body focused on intent and the most useful
validation evidence; inspect the code, tests, and CI before judging correctness.
Use the repo PR template. Include these exact labels:
```text
Behavior addressed:
Real environment tested:
Exact steps or command run after this patch:
Evidence after fix:
Observed result after fix:
What was not tested:
```
## Existing PR Rules

View File

@@ -4,14 +4,6 @@ set -euo pipefail
repo="openclaw/openclaw"
months="12"
include_global="0"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(git -C "$script_dir/../../../.." rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "$repo_root" ]; then
repo_root="$(cd "$script_dir/../../../.." && pwd)"
fi
# shellcheck disable=SC1091
source "$repo_root/scripts/lib/plain-gh.sh"
usage() {
printf 'Usage: %s [--repo owner/repo] [--months N] [--global] <github-login> [login...]\n' "$0"
@@ -26,10 +18,6 @@ need() {
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
}
gh() {
gh_plain "$@"
}
date_utc_relative_months() {
local count="$1"
if date -u -v-"${count}"m +%Y-%m-%dT00:00:00Z >/dev/null 2>&1; then
@@ -143,8 +131,7 @@ done
exit 2
}
OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)" || die "missing required command: gh"
export OPENCLAW_GH_BIN
need gh
need jq
since_ts=$(date_utc_relative_months "$months")

View File

@@ -4,12 +4,12 @@
* Usage: node secret-scanning.mjs <command> [options]
*/
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { spawnPlainGh } from "../../../../scripts/lib/plain-gh.mjs";
const REPO = "openclaw/openclaw";
const REPO_URL = `https://github.com/${REPO}`;
@@ -29,7 +29,7 @@ function tmpFile(purpose) {
}
function gh(args, { json = true, allowFailure = false } = {}) {
const proc = spawnPlainGh(args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
const proc = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
if (proc.status !== 0 && !allowFailure) {
fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`);
}

View File

@@ -5,7 +5,6 @@
*/
import { execFileSync } from "node:child_process";
import process from "node:process";
import { plainGhEnv, resolvePlainGhBin } from "../../../../scripts/lib/plain-gh.mjs";
const runId = process.argv[2];
const repo = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw";
@@ -16,9 +15,8 @@ if (!runId) {
}
function gh(args) {
return execFileSync(resolvePlainGhBin(), args, {
return execFileSync("gh", args, {
encoding: "utf8",
env: plainGhEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
}
@@ -34,15 +32,14 @@ function githubRestJson(pathSuffix) {
"-lc",
[
"set -euo pipefail",
'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"',
'token="$(gh auth token)"',
'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"',
].join("\n"),
],
{
encoding: "utf8",
env: {
...plainGhEnv(),
OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(),
...process.env,
OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`,
},
maxBuffer: 16 * 1024 * 1024,

View File

@@ -22,7 +22,7 @@ paths:
- src/plugins/memory-*.ts
- src/gateway/server-startup-memory.ts
- src/commands/doctor-memory-search.ts
- src/commands/doctor/cron/dreaming-payload-migration.ts
- src/commands/doctor-cron-dreaming-payload-migration.ts
paths-ignore:
- "**/node_modules"

View File

@@ -19,6 +19,7 @@ paths:
- src/plugins/bundled-compat.ts
- src/plugins/bundled-dir.ts
- src/plugins/bundled-plugin-metadata.ts
- src/plugins/bundled-public-surface-runtime-root.ts
- src/plugins/plugin-sdk-dist-alias.ts
- src/plugins/captured-registration.ts
- src/plugins/config-activation-shared.ts
@@ -45,6 +46,7 @@ paths:
- src/plugins/runtime-state.ts
- src/plugins/runtime.ts
- src/plugins/sdk-alias.ts
- src/plugins/source-loader.ts
- src/plugins/types.ts
- src/plugins/validation-diagnostics.ts
- src/plugins/web-provider-public-artifacts*.ts

View File

@@ -51,6 +51,7 @@ paths:
- src/plugins/runtime
- src/plugins/runtime-state.ts
- src/plugins/runtime.ts
- src/plugins/source-loader.ts
- src/plugins/update.ts
- src/plugins/validation-diagnostics.ts
- src/plugin-sdk/*entry*.ts

21
.github/labeler.yml vendored
View File

@@ -41,6 +41,12 @@
- any-glob-to-any-file:
- "extensions/google-meet/**"
- "docs/plugins/google-meet.md"
"plugin: meeting-notes":
- changed-files:
- any-glob-to-any-file:
- "extensions/meeting-notes/**"
- "docs/plugins/meeting-notes.md"
- "src/meeting-notes/**"
"plugin: workboard":
- changed-files:
- any-glob-to-any-file:
@@ -103,11 +109,6 @@
- any-glob-to-any-file:
- "extensions/qqbot/**"
- "docs/channels/qqbot.md"
"channel: raft":
- changed-files:
- any-glob-to-any-file:
- "extensions/raft/**"
- "docs/channels/raft.md"
"channel: qa-channel":
- changed-files:
- any-glob-to-any-file:
@@ -170,10 +171,6 @@
- any-glob-to-any-file:
- "extensions/zalo/**"
- "docs/channels/zalo.md"
"channel: zaloclawbot":
- changed-files:
- any-glob-to-any-file:
- "docs/channels/zaloclawbot.md"
"channel: zalouser":
- changed-files:
- any-glob-to-any-file:
@@ -251,12 +248,12 @@
- "src/agents/sandbox*.ts"
- "src/commands/sandbox*.ts"
- "src/cli/sandbox-cli.ts"
- "src/docker-setup.e2e.test.ts"
- "src/docker-setup.test.ts"
- "src/config/**/*sandbox*"
- "docs/cli/sandbox.md"
- "docs/gateway/sandbox*.md"
- "docs/install/docker.md"
- "docs/tools/multi-agent-sandbox-tools.md"
- "docs/multi-agent-sandbox-tools.md"
"agents":
- changed-files:
@@ -269,7 +266,7 @@
- ".github/workflows/opengrep-*.yml"
- ".semgrepignore"
- "docs/cli/security.md"
- "docs/gateway/security/**"
- "docs/gateway/security.md"
- "security/**"
"extensions: admin-http-rpc":

View File

@@ -1,57 +1,118 @@
<!--
Optional linked context:
Add a visible `Closes #<issue-number>` or `Related: #<issue-number>` line
below this comment.
## Summary
Required PR title:
type: user-facing description
Use a parenthesized scope only when it adds clarity:
fix(auth): login redirect loops when session cookie is expired
What problem does this PR solve?
Types: feat, fix, improve, refactor, docs, chore.
For fixes, describe the user-visible symptom and trigger:
fix: task list fails to load when user has no environments
Avoid implementation details such as:
fix: add null check to task query
-->
Why does this matter now?
## What Problem This Solves
What is the intended outcome?
<!--
Describe the concrete user, product, or operational problem.
For fixes, begin with:
"Fixes an issue where users <do X> would <experience Y> when <condition>."
or:
"Resolves a problem where..."
What is intentionally out of scope?
Name the affected UI surface or workflow. Do not describe the code-level cause here.
-->
What does success look like?
## Why This Change Was Made
What should reviewers focus on?
<!--
In one or two sentences, explain the complete shipped solution, key design
decisions, and relevant boundaries or non-goals. Include implementation detail
only when it helps reviewers understand user-visible behavior or risk.
Avoid file-by-file narration.
-->
<details>
<summary>Summary guidance</summary>
## User Impact
This PR description is the contributor's durable explanation of the change. Write it for human maintainers first; ClawSweeper and Barnacle use the same text to understand intent, proof, risk, and current review state.
<!--
State what users, operators, or developers can now do or expect. Lead with the
concrete benefit and use user-facing language. If there is no user-visible
impact, say so plainly.
-->
Describe the intent and outcome in 2-5 bullets. Avoid restating the diff; reviewers and bots can read the changed files.
## Evidence
If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta blocker - <summary>` and link the matching `Beta blocker: <plugin-name> - <summary>` issue labeled `beta-blocker`. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation.
<!--
Show the most useful proof that this change works. Screenshots, screencasts,
terminal output, focused tests, CI results, live observations, redacted logs,
and artifact links are all useful. Include before/after evidence for visual
changes when it clarifies the result.
</details>
Reviewers will inspect the code, tests, and CI. Use this section to make the
validation easy to understand, not to restate the diff.
-->
## Linked context
Which issue does this close?
Closes #
Which issues, PRs, or discussions are related?
Related #
Was this requested by a maintainer or owner?
<details>
<summary>Linked context guidance</summary>
Link the issue, PR, discussion, maintainer request, or owner request that explains why this PR should exist. Maintainer context helps reviewers and automation distinguish intended work from drive-by churn.
</details>
## Real behavior proof (required for external PRs)
- Behavior or issue addressed:
- Real environment tested:
- Exact steps or command run after this patch:
- Evidence after fix (screenshot, recording, terminal capture, console output, redacted runtime log, linked artifact, or copied live output):
- Observed result after fix:
- What was not tested:
- Proof limitations or environment constraints:
- Before evidence (optional but encouraged):
<details>
<summary>Real behavior proof guidance</summary>
External contributors must show after-fix evidence from a real OpenClaw setup. Unit tests, mocks, lint, typechecks, snapshots, and CI are supplemental only.
Screenshots are encouraged even for CLI, console, text, or log changes. Terminal screenshots, copied live output, redacted runtime logs, recordings, and linked artifacts count.
If your environment cannot produce the ideal proof, explain that under `Proof limitations or environment constraints` so reviewers and ClawSweeper can direct the next step properly.
Be mindful of private information like IP addresses, API keys, phone numbers, non-public endpoints, or other private details when providing evidence.
</details>
## Tests and validation
Which commands did you run?
What regression coverage was added or updated?
What failed before this fix, if known?
If no test was added, why not?
<details>
<summary>Testing guidance</summary>
List focused commands, not every incidental check. CI is useful support, but external PRs still need real behavior proof above when behavior changes.
</details>
## Risk checklist
Did user-visible behavior change? (`Yes/No`)
Did config, environment, or migration behavior change? (`Yes/No`)
Did security, auth, secrets, network, or tool execution behavior change? (`Yes/No`)
What is the highest-risk area?
How is that risk mitigated?
<details>
<summary>Risk guidance</summary>
Use this for author judgment that is not obvious from the diff. ClawSweeper can see touched files, but it cannot know which behavior you think is risky, why the risk is acceptable, or what mitigation reviewers should verify.
</details>
## Current review state
What is the next action?
What is still waiting on author, maintainer, CI, or external proof?
Which bot or reviewer comments were addressed?
<details>
<summary>Review state guidance</summary>
Keep this as the durable state for review progress. If useful information appears in comments, fold the current next action or blocker back here so maintainers and ClawSweeper do not need to reconstruct state from comment history.
</details>

View File

@@ -14,10 +14,6 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -214,49 +210,24 @@ jobs:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox

View File

@@ -13,10 +13,6 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
@@ -132,10 +128,8 @@ jobs:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
@@ -143,38 +137,16 @@ jobs:
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox

View File

@@ -17,10 +17,6 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
@@ -121,10 +117,8 @@ jobs:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
@@ -132,38 +126,16 @@ jobs:
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox

View File

@@ -41,32 +41,11 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
# Keep the canonical main queue quiet long enough for a follow-up push to
# cancel this run before it registers the Blacksmith matrix.
runner-admission:
permissions:
contents: read
runs-on: ubuntu-24.04
timeout-minutes: 3
env:
OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS: "90"
steps:
- name: Debounce canonical main pushes
if: github.event_name == 'push' && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main'
run: |
set -euo pipefail
echo "Waiting ${OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS}s for a superseding main push before Blacksmith admission"
sleep "${OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS}"
- name: Admit non-main CI runs immediately
if: github.event_name != 'push' || github.repository != 'openclaw/openclaw' || github.ref != 'refs/heads/main'
run: echo "No canonical main debounce required"
# Preflight: establish routing truth and job matrices once, then let real
# work fan out from a single source of truth.
preflight:
permissions:
contents: read
needs: [runner-admission]
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
@@ -218,7 +197,7 @@ jobs:
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import {
createNodeTestShardBundles,
createNodeTestShards,
} from "./scripts/lib/ci-node-test-plan.mjs";
import {
createChannelContractTestShards,
@@ -293,22 +272,18 @@ jobs:
}
}
const compactPullRequest = isCanonicalRepository && eventName === "pull_request";
const nodeTestShards = runNodeFull
? createNodeTestShardBundles({
? createNodeTestShards({
includeReleaseOnlyPluginShards: false,
compact: compactPullRequest,
}).map((shard) => ({
check_name: shard.checkName,
runtime: "node",
task: "test-shard",
shard_name: shard.shardName,
groups: shard.groups,
configs: shard.configs,
includePatterns: shard.includePatterns,
requires_dist: shard.requiresDist,
runner: shard.runner,
timeout_minutes: shard.timeoutMinutes,
}))
: [];
const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist);
@@ -345,14 +320,7 @@ jobs:
run_checks_windows: runWindows,
checks_windows_matrix: createMatrix(
runWindows
? [
{
check_name: "checks-windows-node-test",
runtime: "node",
task: "test",
runner: "blacksmith-8vcpu-windows-2025",
},
]
? [{ check_name: "checks-windows-node-test", runtime: "node", task: "test" }]
: [],
),
run_macos_node: runMacos,
@@ -386,7 +354,6 @@ jobs:
security-fast:
permissions:
contents: read
needs: [runner-admission]
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
@@ -591,7 +558,7 @@ jobs:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_build_artifacts == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
outputs:
channels-result: ${{ steps.built_artifact_checks.outputs['channels-result'] }}
@@ -852,7 +819,6 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
steps:
- name: Checkout
@@ -942,7 +908,6 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }}
steps:
- name: Checkout
@@ -1023,7 +988,6 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }}
steps:
- name: Checkout
@@ -1172,11 +1136,10 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 12
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
steps:
- name: Checkout
@@ -1235,7 +1198,6 @@ jobs:
- name: Run Node test shard
env:
NODE_OPTIONS: --max-old-space-size=8192
OPENCLAW_NODE_TEST_GROUPS_JSON: ${{ toJson(matrix.groups || null) }}
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
@@ -1250,47 +1212,28 @@ jobs:
import { writeFileSync } from "node:fs";
import { join } from "node:path";
const groups = JSON.parse(process.env.OPENCLAW_NODE_TEST_GROUPS_JSON ?? "null");
const plans = Array.isArray(groups) && groups.length > 0
? groups
: [{
configs: JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]"),
includePatterns: JSON.parse(
process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null",
),
shard_name: process.env.OPENCLAW_VITEST_SHARD_NAME,
}];
for (const plan of plans) {
const configs = plan.configs;
if (!Array.isArray(configs) || configs.length === 0) {
console.error("Missing node test shard configs");
process.exit(1);
}
const childEnv = {
...process.env,
...(plan.shard_name ? { OPENCLAW_VITEST_SHARD_NAME: plan.shard_name } : {}),
};
if (Array.isArray(plan.includePatterns) && plan.includePatterns.length > 0) {
const includeFile = join(
process.env.RUNNER_TEMP ?? ".",
`node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`,
);
writeFileSync(includeFile, JSON.stringify(plan.includePatterns), "utf8");
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
} else {
delete childEnv.OPENCLAW_VITEST_INCLUDE_FILE;
}
const result = spawnSync(
"pnpm",
["exec", "node", "scripts/test-projects.mjs", ...configs],
{
env: childEnv,
stdio: "inherit",
},
const configs = JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]");
if (!Array.isArray(configs) || configs.length === 0) {
console.error("Missing node test shard configs");
process.exit(1);
}
const includePatterns = JSON.parse(process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null");
const childEnv = { ...process.env };
if (Array.isArray(includePatterns) && includePatterns.length > 0) {
const includeFile = join(
process.env.RUNNER_TEMP ?? ".",
`node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`,
);
if ((result.status ?? 1) !== 0) {
process.exit(result.status ?? 1);
}
writeFileSync(includeFile, JSON.stringify(includePatterns), "utf8");
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
}
const result = spawnSync("pnpm", ["exec", "node", "scripts/test-projects.mjs", ...configs], {
env: childEnv,
stdio: "inherit",
});
if ((result.status ?? 1) !== 0) {
process.exit(result.status ?? 1);
}
EOF
@@ -1305,7 +1248,6 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 8
matrix:
include:
- check_name: check-guards
@@ -1322,7 +1264,7 @@ jobs:
runner: blacksmith-16vcpu-ubuntu-2404
- check_name: check-dependencies
task: dependencies
runner: blacksmith-4vcpu-ubuntu-2404
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-test-types
task: test-types
runner: blacksmith-4vcpu-ubuntu-2404
@@ -1443,39 +1385,30 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 8
matrix:
include:
- check_name: check-additional-boundaries-a
group: boundaries
boundary_shard: 1/4
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-additional-boundaries-bcd
group: boundaries
boundary_shard: 2/4,3/4,4/4
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-session-accessor-boundary
group: session-accessor-boundary
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-session-transcript-reader-boundary
group: session-transcript-reader-boundary
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-additional-extension-channels
group: extension-channels
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-additional-extension-bundled
group: extension-bundled
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-additional-extension-package-boundary
group: extension-package-boundary
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-additional-runtime-topology-architecture
group: runtime-topology-architecture
runner: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
shell: bash
@@ -1818,7 +1751,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_windows == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'windows-2025' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-windows-2025') || 'windows-2025') }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'windows-2025' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-windows-2025' || 'windows-2025') }}
timeout-minutes: 60
env:
NODE_OPTIONS: --max-old-space-size=8192
@@ -1830,7 +1763,6 @@ jobs:
shell: bash
strategy:
fail-fast: false
max-parallel: 2
matrix: ${{ fromJson(needs.preflight.outputs.checks_windows_matrix) }}
steps:
- name: Checkout
@@ -2160,7 +2092,6 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 2
matrix: ${{ fromJson(needs.preflight.outputs.android_matrix) }}
steps:
- name: Checkout

View File

@@ -18,16 +18,15 @@ permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'push' && format('clawsweeper-dispatch-{0}-{1}', github.repository, github.ref) || format('clawsweeper-dispatch-{0}-{1}', github.repository, github.event.issue.number || github.event.pull_request.number || github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'push' || github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
jobs:
dispatch:
runs-on: ubuntu-latest
if: >-
${{
(github.event_name != 'issue_comment' ||
(github.actor != 'clawsweeper[bot]' && github.actor != 'openclaw-clawsweeper[bot]')) &&
github.event_name == 'issue_comment' ||
!(
endsWith(github.actor, '[bot]') &&
(github.event.action == 'labeled' || github.event.action == 'unlabeled')
@@ -42,34 +41,6 @@ jobs:
if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }}
run: sleep 20
- name: Debounce main push dispatch
if: ${{ github.event_name == 'push' }}
run: sleep 45
- name: Install GitHub API backoff helper
run: |
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
gh_api_with_retry() {
local attempt output status lower_output
for attempt in 1 2 3 4 5; do
if output="$(gh api "$@" 2>&1)"; then
printf '%s\n' "$output"
return 0
fi
status=$?
lower_output="${output,,}"
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
printf '%s\n' "$output" >&2
return "$status"
fi
echo "::warning::GitHub API throttled ClawSweeper dispatch on attempt ${attempt}; retrying after backoff." >&2
sleep $((attempt * attempt * 5))
done
printf '%s\n' "$output" >&2
return "$status"
}
BASH
- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
@@ -81,27 +52,9 @@ jobs:
repositories: clawsweeper
permission-contents: write
- name: Pre-filter ClawSweeper comment
id: comment_filter
if: ${{ github.event_name == 'issue_comment' }}
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
if grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|autoclose|auto([[:space:]]+|-)?merge)\b' <<< "$COMMENT_BODY"; then
echo "is_command=true" >> "$GITHUB_OUTPUT"
else
echo "is_command=false" >> "$GITHUB_OUTPUT"
fi
- name: Create target comment token
id: target_token
if: >-
${{
github.event_name == 'issue_comment' &&
steps.comment_filter.outputs.is_command == 'true' &&
env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true'
}}
if: ${{ github.event_name == 'issue_comment' && env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
@@ -124,7 +77,6 @@ jobs:
echo "::notice::Skipping GitHub activity dispatch because no ClawSweeper app token is configured."
exit 0
fi
. "$RUNNER_TEMP/github-api-backoff.sh"
activity="$(jq -c \
--arg target_repo "$TARGET_REPO" \
--arg event_name "$SOURCE_EVENT" \
@@ -191,7 +143,7 @@ jobs:
' "$GITHUB_EVENT_PATH")"
payload="$(jq -nc --argjson activity "$activity" \
'{event_type:"github_activity",client_payload:{activity:$activity}}')"
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
if gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched GitHub activity to ClawSweeper."
@@ -213,7 +165,6 @@ jobs:
echo "::notice::Skipping ClawSweeper dispatch because no ClawSweeper app token is configured. Not falling back to a maintainer token."
exit 0
fi
. "$RUNNER_TEMP/github-api-backoff.sh"
payload="$(jq -nc \
--arg target_repo "$TARGET_REPO" \
--argjson item_number "$ITEM_NUMBER" \
@@ -222,7 +173,7 @@ jobs:
--arg source_action "$SOURCE_ACTION" \
--argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress}}')"
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
if gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched ClawSweeper review."
@@ -231,11 +182,7 @@ jobs:
fi
- name: Acknowledge and dispatch ClawSweeper comment
if: >-
${{
github.event_name == 'issue_comment' &&
steps.comment_filter.outputs.is_command == 'true'
}}
if: ${{ github.event_name == 'issue_comment' }}
env:
DISPATCH_TOKEN: ${{ steps.token.outputs.token }}
TARGET_TOKEN: ${{ steps.target_token.outputs.token }}
@@ -251,12 +198,15 @@ jobs:
echo "::notice::Skipping ClawSweeper comment dispatch because no ClawSweeper app token is configured."
exit 0
fi
. "$RUNNER_TEMP/github-api-backoff.sh"
body_file="$RUNNER_TEMP/clawsweeper-comment-body.txt"
printf '%s\n' "$COMMENT_BODY" > "$body_file"
if ! grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|automerge|autoclose)\b' "$body_file"; then
echo "No ClawSweeper command found in comment."
exit 0
fi
if [ -n "$TARGET_TOKEN" ]; then
err="$(mktemp)"
if GH_TOKEN="$TARGET_TOKEN" gh_api_with_retry -X POST \
if GH_TOKEN="$TARGET_TOKEN" gh api -X POST \
-H "Accept: application/vnd.github+json" \
"repos/$TARGET_REPO/issues/comments/$COMMENT_ID/reactions" \
-f content="eyes" 2>"$err" >/dev/null; then
@@ -283,7 +233,7 @@ jobs:
"Command router queued. I will update this comment with the next step.")"
status_payload="$(jq -nc --arg body "$status_body" '{body:$body}')"
status_err="$(mktemp)"
if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh_api_with_retry \
if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh api \
"repos/$TARGET_REPO/issues/$ITEM_NUMBER/comments" \
--method POST \
--input - <<< "$status_payload" 2>"$status_err")"; then
@@ -304,7 +254,7 @@ jobs:
--arg source_event "issue_comment" \
--arg source_action "$SOURCE_ACTION" \
'{event_type:"clawsweeper_comment",client_payload:({target_repo:$target_repo,item_number:$item_number,comment_id:$comment_id,source_event:$source_event,source_action:$source_action,max_comments:"1"} + (if $status_comment_id != "" then {status_comment_id:($status_comment_id|tonumber)} else {} end))}')"
if GH_TOKEN="$DISPATCH_TOKEN" gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
if GH_TOKEN="$DISPATCH_TOKEN" gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched ClawSweeper comment router."
@@ -326,7 +276,6 @@ jobs:
echo "::notice::Skipping ClawSweeper commit dispatch because no ClawSweeper app token is configured. Not falling back to a maintainer token."
exit 0
fi
. "$RUNNER_TEMP/github-api-backoff.sh"
case "$CREATE_CHECKS" in
true|TRUE|1|yes|YES|on|ON) create_checks=true ;;
*) create_checks=false ;;
@@ -338,7 +287,7 @@ jobs:
--arg ref "$SOURCE_REF" \
--argjson create_checks "$create_checks" \
'{event_type:"clawsweeper_commit_review",client_payload:{target_repo:$target_repo,before_sha:$before_sha,after_sha:$after_sha,ref:$ref,enabled:true,create_checks:$create_checks}}')"
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
if gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched ClawSweeper commit review."

View File

@@ -6,7 +6,7 @@ on:
- cron: "0 7 * * *"
concurrency:
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || format('ref-{0}', github.ref) }}
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
cancel-in-progress: false
env:

View File

@@ -96,7 +96,7 @@ on:
- "src/auto-reply/reply/post-compaction-context.ts"
- "src/auto-reply/reply/queue/**"
- "src/auto-reply/reply/startup-context.ts"
- "src/commands/doctor/cron/dreaming-payload-migration.ts"
- "src/commands/doctor-cron-dreaming-payload-migration.ts"
- "src/commands/doctor-memory-search.ts"
- "src/commands/doctor-session-*.ts"
- "src/commands/session-store-targets.ts"
@@ -136,7 +136,7 @@ on:
- cron: "30 6 * * *"
concurrency:
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ref-{0}', github.ref) }}
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
@@ -257,7 +257,7 @@ jobs:
packages/gateway-protocol/src/*|packages/gateway-protocol/src/**/*|src/gateway/method-scopes.ts|src/gateway/server-methods/*|src/gateway/server-methods.ts|src/gateway/server-methods-list.ts)
gateway=true
;;
packages/memory-host-sdk/*|src/commands/doctor/cron/dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/gateway/server-startup-memory.ts|src/memory/*|src/memory-host-sdk/*)
packages/memory-host-sdk/*|src/commands/doctor-cron-dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/gateway/server-startup-memory.ts|src/memory/*|src/memory-host-sdk/*)
memory=true
;;
src/infra/outbound/base-session-key.ts|src/infra/outbound/delivery-queue*.ts|src/infra/outbound/outbound-session.ts|src/infra/outbound/session-binding*.ts|src/infra/outbound/session-context.ts|src/infra/outbound/targets-session.ts)
@@ -295,7 +295,7 @@ jobs:
src/model-catalog/*|src/plugins/*provider*.ts|src/plugins/capability-provider-runtime.ts|src/plugins/compaction-provider.ts|src/plugins/memory-embedding-provider*.ts|src/plugins/memory-embedding-providers*.ts|src/plugins/migration-provider-runtime.ts|src/plugins/synthetic-auth.runtime.ts|src/plugins/web-fetch-providers*.ts|src/plugins/web-search-providers*.ts)
provider=true
;;
src/plugins/activation-planner.ts|src/plugins/api-builder.ts|src/plugins/bundled-*.ts|src/plugins/captured-registration.ts|src/plugins/config-*.ts|src/plugins/discovery.ts|src/plugins/effective-plugin-ids.ts|src/plugins/externalized-bundled-plugins.ts|src/plugins/installed-plugin-index*.ts|src/plugins/loader*.ts|src/plugins/manifest*.ts|src/plugins/module-export.ts|src/plugins/package-entrypoints.ts|src/plugins/plugin-registry*.ts|src/plugins/public-surface*.ts|src/plugins/registry.ts|src/plugins/registry-types.ts|src/plugins/runtime|src/plugins/runtime/*|src/plugins/runtime-state.ts|src/plugins/runtime.ts|src/plugins/sdk-alias.ts|src/plugins/types.ts|src/plugins/validation-diagnostics.ts)
src/plugins/activation-planner.ts|src/plugins/api-builder.ts|src/plugins/bundled-*.ts|src/plugins/captured-registration.ts|src/plugins/config-*.ts|src/plugins/discovery.ts|src/plugins/effective-plugin-ids.ts|src/plugins/externalized-bundled-plugins.ts|src/plugins/installed-plugin-index*.ts|src/plugins/loader*.ts|src/plugins/manifest*.ts|src/plugins/module-export.ts|src/plugins/package-entrypoints.ts|src/plugins/plugin-registry*.ts|src/plugins/public-surface*.ts|src/plugins/registry.ts|src/plugins/registry-types.ts|src/plugins/runtime|src/plugins/runtime/*|src/plugins/runtime-state.ts|src/plugins/runtime.ts|src/plugins/sdk-alias.ts|src/plugins/source-loader.ts|src/plugins/types.ts|src/plugins/validation-diagnostics.ts)
plugin=true
;;
packages/plugin-package-contract/*|packages/plugin-sdk/*)

View File

@@ -6,7 +6,7 @@ on:
- cron: "0 8 * * 1"
concurrency:
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || format('ref-{0}', github.ref) }}
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
cancel-in-progress: false
env:

View File

@@ -32,8 +32,8 @@ on:
- cron: "0 6 * * *"
concurrency:
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ref-{0}', github.ref) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -23,8 +23,8 @@ permissions:
contents: write
concurrency:
group: control-ui-locale-refresh-${{ github.event_name == 'push' && github.ref || github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'release' && format('release-{0}', github.event.release.tag_name) || format('{0}-{1}', github.event_name, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
group: control-ui-locale-refresh
cancel-in-progress: false
jobs:
plan:

View File

@@ -663,10 +663,8 @@ jobs:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
@@ -674,38 +672,16 @@ jobs:
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Mark Crabbox ready

View File

@@ -13,10 +13,6 @@ on:
permissions:
contents: read
concurrency:
group: docs-sync-publish-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
jobs:
sync-publish-repo:
runs-on: ubuntu-latest

View File

@@ -70,7 +70,7 @@ on:
default: ""
type: string
npm_telegram_package_spec:
description: Optional published package spec for the focused package Telegram E2E rerun
description: Optional published package spec for the package Telegram E2E lane
required: false
default: ""
type: string
@@ -95,7 +95,7 @@ on:
default: ""
type: string
npm_telegram_provider_mode:
description: Provider mode for the focused package Telegram E2E rerun
description: Provider mode for the package Telegram E2E lane
required: false
default: mock-openai
type: choice
@@ -103,7 +103,7 @@ on:
- mock-openai
- live-frontier
npm_telegram_scenario:
description: Optional comma-separated Telegram scenario ids for the focused package Telegram E2E rerun
description: Optional comma-separated Telegram scenario ids for the package Telegram lane
required: false
default: ""
type: string
@@ -200,16 +200,14 @@ jobs:
if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
echo "- Published release package: \`${RELEASE_PACKAGE_SPEC}\`"
fi
if [[ "$RERUN_GROUP" == "npm-telegram" && -n "${NPM_TELEGRAM_PACKAGE_SPEC// }" ]]; then
if [[ -n "${NPM_TELEGRAM_PACKAGE_SPEC// }" ]]; then
echo "- Published-package Telegram E2E: \`${NPM_TELEGRAM_PACKAGE_SPEC}\`"
elif [[ "$RERUN_GROUP" == "npm-telegram" && -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
elif [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
echo "- Published-package Telegram E2E: \`${RELEASE_PACKAGE_SPEC}\`"
elif [[ "$RERUN_GROUP" == "npm-telegram" ]]; then
echo "- Package Telegram E2E: focused rerun requires \`release_package_spec\` or \`npm_telegram_package_spec\`"
elif [[ "$RERUN_GROUP" == "all" || "$RERUN_GROUP" == "release-checks" || "$RERUN_GROUP" == "package" ]]; then
echo "- Package Telegram E2E: OpenClaw Release Checks Package Acceptance"
elif [[ "$RERUN_GROUP" == "all" && "$RELEASE_PROFILE" == "full" ]]; then
echo "- Package Telegram E2E: parent \`release-package-under-test\` artifact"
else
echo "- Package Telegram E2E: skipped by rerun group"
echo "- Package Telegram E2E: skipped unless \`release_profile=full\`, \`release_package_spec\`, or \`npm_telegram_package_spec\` is provided"
fi
if [[ -n "${EVIDENCE_PACKAGE_SPEC// }" ]]; then
echo "- Private evidence package proof: \`${EVIDENCE_PACKAGE_SPEC}\`"
@@ -766,13 +764,83 @@ jobs:
dispatch_and_wait openclaw-release-checks.yml "${args[@]}"
prepare_release_package:
name: Prepare release package artifact
needs: [resolve_target, docker_runtime_assets_preflight]
if: ${{ always() && needs.resolve_target.result == 'success' && inputs.npm_telegram_package_spec == '' && inputs.release_package_spec == '' && inputs.rerun_group == 'all' && inputs.release_profile == 'full' && needs.docker_runtime_assets_preflight.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
packages: write
outputs:
artifact_name: ${{ steps.artifact.outputs.name }}
package_sha256: ${{ steps.package.outputs.sha256 }}
package_version: ${{ steps.package.outputs.package_version }}
source_sha: ${{ steps.package.outputs.source_sha }}
steps:
- name: Checkout trusted workflow ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
ref: ${{ github.ref_name }}
fetch-depth: 0
- name: Set artifact metadata
id: artifact
run: echo "name=release-package-under-test" >> "$GITHUB_OUTPUT"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
install-deps: "false"
- name: Resolve release package artifact
id: package
shell: bash
env:
PACKAGE_REF: ${{ needs.resolve_target.outputs.sha }}
run: |
set -euo pipefail
node scripts/resolve-openclaw-package-candidate.mjs \
--source ref \
--package-ref "$PACKAGE_REF" \
--output-dir .artifacts/docker-e2e-package \
--output-name openclaw-current.tgz \
--metadata .artifacts/docker-e2e-package/package-candidate.json \
--github-output "$GITHUB_OUTPUT"
digest="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).sha256")"
version="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).version")"
source_sha="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).packageSourceSha")"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
{
echo "## Release package artifact"
echo
echo "- Artifact: \`release-package-under-test\`"
echo "- Package ref: \`$PACKAGE_REF\`"
echo "- SHA-256: \`$digest\`"
echo "- Version: \`$version\`"
echo "- Source SHA: \`$source_sha\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload release package artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-package-under-test
path: |
.artifacts/docker-e2e-package/openclaw-current.tgz
.artifacts/docker-e2e-package/package-candidate.json
if-no-files-found: error
npm_telegram:
name: Run package Telegram E2E
needs: [resolve_target]
if: ${{ always() && needs.resolve_target.result == 'success' && inputs.rerun_group == 'npm-telegram' && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '') }}
needs: [resolve_target, prepare_release_package]
if: ${{ always() && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group) && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '' || (inputs.rerun_group == 'all' && inputs.release_profile == 'full')) }}
continue-on-error: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile == 'full' && 360 || 60 }}
timeout-minutes: ${{ inputs.release_profile == 'full' && 120 || 60 }}
outputs:
run_id: ${{ steps.dispatch.outputs.run_id }}
url: ${{ steps.dispatch.outputs.url }}
@@ -785,6 +853,8 @@ jobs:
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
PACKAGE_SPEC: ${{ inputs.npm_telegram_package_spec || inputs.release_package_spec }}
PACKAGE_ARTIFACT_NAME: ${{ needs.prepare_release_package.outputs.artifact_name }}
PREPARE_PACKAGE_RESULT: ${{ needs.prepare_release_package.result }}
PROVIDER_MODE: ${{ inputs.npm_telegram_provider_mode }}
SCENARIO: ${{ inputs.npm_telegram_scenario }}
run: |
@@ -813,7 +883,18 @@ jobs:
return "$status"
}
args=(-f package_spec="$PACKAGE_SPEC" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
args=(-f package_spec="${PACKAGE_SPEC:-openclaw@beta}" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
if [[ -z "${PACKAGE_SPEC// }" ]]; then
if [[ "$PREPARE_PACKAGE_RESULT" != "success" || -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then
echo "Full release Telegram requires either npm_telegram_package_spec or a prepared release-package-under-test artifact." >&2
exit 1
fi
args+=(
-f package_artifact_name="$PACKAGE_ARTIFACT_NAME"
-f package_artifact_run_id="${GITHUB_RUN_ID}"
-f package_label="full-release-${TARGET_SHA:0:12}"
)
fi
if [[ -n "${SCENARIO// }" ]]; then
args+=(-f scenario="$SCENARIO")
fi
@@ -890,7 +971,7 @@ jobs:
needs: [resolve_target, docker_runtime_assets_preflight]
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","performance"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile == 'full' && 360 || 120 }}
timeout-minutes: 120
outputs:
run_id: ${{ steps.dispatch.outputs.run_id }}
url: ${{ steps.dispatch.outputs.url }}

View File

@@ -1686,8 +1686,7 @@ jobs:
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
OPENCLAW_LIVE_PROVIDERS: ${{ matrix.providers }}
OPENCLAW_LIVE_IMAGE: ${{ needs.prepare_live_test_image.outputs.live_image }}
OPENCLAW_LIVE_MODELS: ${{ matrix.models || 'modern' }}
OPENCLAW_LIVE_MAX_MODELS: ${{ matrix.max_models || '6' }}
OPENCLAW_LIVE_MAX_MODELS: "6"
OPENCLAW_LIVE_MODEL_TIMEOUT_MS: "45000"
OPENCLAW_SKIP_DOCKER_BUILD: "1"
OPENCLAW_VITEST_MAX_WORKERS: "2"
@@ -2001,7 +2000,7 @@ jobs:
profiles: stable full
- suite_id: native-live-src-gateway-profiles-minimax
label: Native live gateway profiles MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 60
profile_env_only: false
profiles: stable full
@@ -2304,7 +2303,7 @@ jobs:
profiles: stable full
- suite_id: live-gateway-minimax-docker
label: Docker live gateway MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: stable full

View File

@@ -45,7 +45,7 @@ on:
kova_ref:
description: openclaw/Kova Git ref to install
required: false
default: 4f146016583018bad9e24f8e64a6af5f963bb7ee
default: b63b6f9e20efb23641df00487e982230d81a90ac
type: string
dispatch_id:
description: Optional parent workflow dispatch identifier
@@ -66,7 +66,6 @@ env:
OCM_LINUX_X64_SHA256: b849b8de5d77e97e0df9319703254ae95e29d7f26a7552ea79bf173ff110ea0a
KOVA_REPOSITORY: openclaw/Kova
PERFORMANCE_MODEL_ID: gpt-5.5
KOVA_SCENARIO_TIMEOUT_MS: "300000"
jobs:
kova:
@@ -99,7 +98,7 @@ jobs:
live: "true"
include_filters: "scenario:agent-cold-warm-message"
env:
KOVA_REF: ${{ inputs.kova_ref || '4f146016583018bad9e24f8e64a6af5f963bb7ee' }}
KOVA_REF: ${{ inputs.kova_ref || 'b63b6f9e20efb23641df00487e982230d81a90ac' }}
KOVA_HOME: ${{ github.workspace }}/.artifacts/kova/home/${{ matrix.lane }}
PERFORMANCE_HELPER_DIR: ${{ github.workspace }}/.artifacts/performance-workflow
REPORT_DIR: ${{ github.workspace }}/.artifacts/kova/reports/${{ matrix.lane }}
@@ -292,7 +291,6 @@ jobs:
--auth "$AUTH_MODE"
--parallel 1
--repeat "$repeat"
--timeout-ms "$KOVA_SCENARIO_TIMEOUT_MS"
--report-dir "$REPORT_DIR"
--execute
--json
@@ -363,7 +361,6 @@ jobs:
- Kova repository: ${KOVA_REPOSITORY}
- Kova ref: ${KOVA_REF}
- Kova profile: ${PROFILE}
- Kova scenario timeout: ${KOVA_SCENARIO_TIMEOUT_MS}ms
- Lane auth: ${AUTH_MODE}
- Lane model: ${PERFORMANCE_MODEL_ID}
- Lane repeat: ${repeat}

View File

@@ -717,6 +717,7 @@ jobs:
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
telegram_mode: mock-openai
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-long-final-reuses-preview,telegram-mention-gating
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}

View File

@@ -519,7 +519,12 @@ jobs:
local workflow="$1"
shift
local dispatch_output run_id
local before_json dispatch_output run_id
before_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \
-F event=workflow_dispatch \
-F per_page=100 \
--jq '[.workflow_runs[].id]')"
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$workflow_ref" "$@" 2>&1)"
printf '%s\n' "$dispatch_output" >&2
run_id="$(
@@ -529,7 +534,22 @@ jobs:
)"
if [[ -z "$run_id" ]]; then
echo "gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \
-F event=workflow_dispatch \
-F per_page=50 \
--jq '.workflow_runs | map({databaseId:.id, createdAt:.created_at}) | map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
fi
if [[ -z "${run_id:-}" ]]; then
echo "Could not find dispatched run for ${workflow}." >&2
exit 1
fi

View File

@@ -23,8 +23,8 @@ permissions:
contents: write
concurrency:
group: openclaw-stable-main-closeout-${{ github.event_name == 'workflow_dispatch' && (inputs.tag || github.run_id) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
group: openclaw-stable-main-closeout
cancel-in-progress: false
jobs:
resolve:
@@ -43,30 +43,6 @@ jobs:
should_closeout: ${{ steps.inputs.outputs.should_closeout }}
tag: ${{ steps.inputs.outputs.tag }}
steps:
- name: Install GitHub API backoff helper
run: |
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
gh_with_retry() {
local attempt output status lower_output
for attempt in 1 2 3 4 5; do
if output="$(gh "$@" 2>&1)"; then
printf '%s\n' "$output"
return 0
fi
status=$?
lower_output="${output,,}"
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
printf '%s\n' "$output" >&2
return "$status"
fi
echo "::warning::GitHub API throttled stable closeout on attempt ${attempt}; retrying after backoff." >&2
sleep $((attempt * attempt * 5))
done
printf '%s\n' "$output" >&2
return "$status"
}
BASH
- name: Checkout pushed main
if: ${{ github.event_name == 'push' }}
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
@@ -86,13 +62,9 @@ jobs:
TRIGGER_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == "push" ]]; then
sleep 45
fi
. "$RUNNER_TEMP/github-api-backoff.sh"
if [[ "$EVENT_NAME" == "push" ]]; then
main_ref="$TRIGGER_SHA"
tag="$(gh_with_retry release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \
tag="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \
--json tagName,isPrerelease,publishedAt \
--jq '[.[] | select(.isPrerelease | not) | select(.tagName | test("^v[0-9]{4}\\.[0-9]+\\.[0-9]+(-[0-9]+)?$"))] | sort_by(.publishedAt) | last | .tagName // empty')"
if [[ -z "$tag" ]]; then
@@ -116,27 +88,8 @@ jobs:
if [[ "$release_package_version" =~ ^(.+)-[0-9]+$ ]]; then
fallback_package_version="${BASH_REMATCH[1]}"
fi
tag_package_content="$RUNNER_TEMP/tag-package-content.b64"
tag_package_read=false
for attempt in 1 2 3; do
if gh_with_retry api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
--jq '.content' > "$tag_package_content"; then
tag_package_read=true
break
fi
if [[ "$attempt" != "3" ]]; then
sleep $((attempt * 5))
fi
done
if [[ "$tag_package_read" != "true" ]]; then
echo "Stable closeout could not read package.json for $tag from GitHub API." >&2
exit 1
fi
if ! tag_package_json="$(tr -d '\n' < "$tag_package_content" | base64 --decode)"; then
echo "Stable closeout package.json content for $tag was not valid base64." >&2
exit 1
fi
tag_package_version="$(jq -r '.version // empty' <<<"$tag_package_json")"
tag_package_version="$(gh api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
--jq '.content' | tr -d '\n' | base64 --decode | jq -r '.version // empty')"
fallback_correction=false
evidence_source_tag="$tag"
if [[ "$release_package_version" != "$fallback_package_version" &&
@@ -154,7 +107,7 @@ jobs:
closeout_checksum_asset="${closeout_asset}.sha256"
closeout_dir="$RUNNER_TEMP/release-closeout-evidence"
mkdir -p "$closeout_dir"
gh_with_retry release download "$tag" --repo "$GITHUB_REPOSITORY" \
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$closeout_asset" --pattern "$closeout_checksum_asset" --dir "$closeout_dir" || true
closeout_json_path="$closeout_dir/$closeout_asset"
closeout_checksum_path="$closeout_dir/$closeout_checksum_asset"
@@ -210,11 +163,8 @@ jobs:
fi
evidence_dir="$RUNNER_TEMP/release-postpublish-evidence"
mkdir -p "$evidence_dir"
gh_with_retry release download "$evidence_source_tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$evidence_asset" --pattern "$evidence_checksum_asset" --dir "$evidence_dir" || true
evidence_path="$evidence_dir/$evidence_asset"
evidence_checksum_path="$evidence_dir/$evidence_checksum_asset"
if [[ ! -f "$evidence_path" || ! -f "$evidence_checksum_path" ]]; then
if ! gh release download "$evidence_source_tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$evidence_asset" --pattern "$evidence_checksum_asset" --dir "$evidence_dir"; then
if [[ "$EVENT_NAME" == "push" ]]; then
echo "Stable closeout skipped: $evidence_source_tag predates immutable postpublish evidence." >&2
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
@@ -223,6 +173,7 @@ jobs:
echo "Stable closeout is required for $tag, but immutable postpublish evidence from $evidence_source_tag is missing." >&2
exit 1
fi
evidence_path="$evidence_dir/$evidence_asset"
if ! (
cd "$evidence_dir"
sha256sum --strict --status -c "$evidence_checksum_asset"
@@ -244,11 +195,6 @@ jobs:
exit 1
fi
if [[ -z "$ROLLBACK_DRILL_ID" || -z "$ROLLBACK_DRILL_DATE" ]]; then
if [[ "$EVENT_NAME" == "push" ]]; then
echo "::warning::Stable closeout skipped: rollback drill repository variables are missing; manual dispatch remains required to complete closeout."
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Stable closeout requires repository variables RELEASE_ROLLBACK_DRILL_ID and RELEASE_ROLLBACK_DRILL_DATE, or explicit manual overrides." >&2
exit 1
fi
@@ -307,30 +253,6 @@ jobs:
exit 1
fi
- name: Install GitHub API backoff helper
run: |
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
gh_with_retry() {
local attempt output status lower_output
for attempt in 1 2 3 4 5; do
if output="$(gh "$@" 2>&1)"; then
printf '%s\n' "$output"
return 0
fi
status=$?
lower_output="${output,,}"
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
printf '%s\n' "$output" >&2
return "$status"
fi
echo "::warning::GitHub API throttled stable closeout on attempt ${attempt}; retrying after backoff." >&2
sleep $((attempt * attempt * 5))
done
printf '%s\n' "$output" >&2
return "$status"
}
BASH
- name: Verify release workflow evidence
env:
GH_TOKEN: ${{ github.token }}
@@ -338,8 +260,7 @@ jobs:
RELEASE_PUBLISH_RUN_ID: ${{ needs.resolve.outputs.release_publish_run_id }}
run: |
set -euo pipefail
. "$RUNNER_TEMP/github-api-backoff.sh"
gh_with_retry run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
--json workflowName,event,status,conclusion \
> "$RUNNER_TEMP/full-release-validation-run.json"
node --input-type=module - "$RUNNER_TEMP/full-release-validation-run.json" <<'NODE'
@@ -356,7 +277,7 @@ jobs:
}
}
NODE
gh_with_retry run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" \
gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" \
--json workflowName,event,status,conclusion \
> "$RUNNER_TEMP/release-publish-run.json"
node --input-type=module - "$RUNNER_TEMP/release-publish-run.json" <<'NODE'
@@ -377,7 +298,7 @@ jobs:
manifest_dir="$RUNNER_TEMP/full-release-validation-manifest"
rm -rf "$manifest_dir"
mkdir -p "$manifest_dir"
gh_with_retry run download "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
gh run download "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
--name "full-release-validation-${FULL_RELEASE_VALIDATION_RUN_ID}" \
--dir "$manifest_dir"
tag_sha="$(git -C "$GITHUB_WORKSPACE/release-tag" rev-parse HEAD)"
@@ -406,8 +327,7 @@ jobs:
run: |
set -euo pipefail
mkdir -p "$CLOSEOUT_DIR"
. "$RUNNER_TEMP/github-api-backoff.sh"
gh_with_retry release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets \
> "$CLOSEOUT_DIR/github-release.json"
node scripts/verify-stable-main-closeout.mjs \
@@ -433,23 +353,21 @@ jobs:
CLOSEOUT_DIR: ${{ runner.temp }}/openclaw-stable-main-closeout
run: |
set -euo pipefail
. "$RUNNER_TEMP/github-api-backoff.sh"
release_version="${RELEASE_TAG#v}"
attach_or_verify() {
local source_path="$1"
local asset_name="$2"
local existing_dir="$CLOSEOUT_DIR/existing-${asset_name}"
mkdir -p "$existing_dir"
gh_with_retry release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--pattern "$asset_name" --dir "$existing_dir" || true
if [[ -f "$existing_dir/$asset_name" ]]; then
if gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--pattern "$asset_name" --dir "$existing_dir"; then
cmp --silent "$source_path" "$existing_dir/$asset_name" || {
echo "Existing release asset $asset_name differs from closeout evidence." >&2
exit 1
}
return
fi
gh_with_retry release upload "$RELEASE_TAG" "$source_path#$asset_name" --repo "$GITHUB_REPOSITORY"
gh release upload "$RELEASE_TAG" "$source_path#$asset_name" --repo "$GITHUB_REPOSITORY"
}
attach_or_verify \
"$CLOSEOUT_DIR/stable-main-closeout.json" \

View File

@@ -38,8 +38,8 @@ on:
type: string
concurrency:
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -532,7 +532,6 @@ jobs:
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }}
run: |
set -euo pipefail
@@ -625,7 +624,6 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_DISCORD_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.discord_scenario || '' }}
@@ -723,7 +721,6 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.whatsapp_scenario || '' }}
@@ -818,7 +815,6 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_SLACK_CAPTURE_CONTENT: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"

View File

@@ -19,7 +19,7 @@ permissions:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -57,10 +57,6 @@ jobs:
echo "could not read required Blacksmith metadata" >&2
exit 1
fi
if ! jq -e 'type == "number"' <<<"$installation_model_id" >/dev/null; then
echo "invalid Blacksmith installation model id: ${installation_model_id}" >&2
exit 1
fi
if [ -n "${BLACKSMITH_HOSTNAME:-}" ]; then
runner_host="$BLACKSMITH_HOSTNAME"
@@ -69,32 +65,21 @@ jobs:
fi
runner_ssh_port="${BLACKSMITH_SSH_PORT:-22}"
hydrating_body="$RUNNER_TEMP/testbox-hydrating.json"
hydrating_response="$RUNNER_TEMP/testbox-hydrating.response"
jq -n \
--arg testbox_id "$TESTBOX_ID" \
--argjson installation_model_id "$installation_model_id" \
--arg status "hydrating" \
--arg ip_address "$runner_host" \
--arg ssh_port "$runner_ssh_port" \
--arg working_directory "$GITHUB_WORKSPACE" \
--arg adopted_run_id "$GITHUB_RUN_ID" \
'{
testbox_id: $testbox_id,
installation_model_id: $installation_model_id,
status: $status,
ip_address: $ip_address,
ssh_port: $ssh_port,
working_directory: $working_directory,
adopted_run_id: $adopted_run_id,
metadata: {}
}' > "$hydrating_body"
hydrating_http_code="$(curl -sS -L --post302 --post303 -o "$hydrating_response" -w '%{http_code}' \
-X POST "${api_url}/api/testbox/phone-home" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${auth_token}" \
--data-binary @"$hydrating_body" || true)"
-d "{
\"testbox_id\": \"${TESTBOX_ID}\",
\"installation_model_id\": ${installation_model_id},
\"status\": \"hydrating\",
\"ip_address\": \"${runner_host}\",
\"ssh_port\": \"${runner_ssh_port}\",
\"working_directory\": \"${GITHUB_WORKSPACE}\",
\"adopted_run_id\": \"${GITHUB_RUN_ID}\",
\"metadata\": {}
}" || true)"
echo "phone_home_hydrating_http=${hydrating_http_code}"
if [[ ! "$hydrating_http_code" =~ ^2 ]]; then
@@ -167,30 +152,20 @@ jobs:
runner_ssh_port="$(cat "$state/runner_ssh_port")"
working_directory="$(cat "$state/working_directory")"
adopted_run_id="$(cat "$state/adopted_run_id")"
if ! jq -e 'type == "number"' <<<"$installation_model_id" >/dev/null; then
echo "invalid Blacksmith installation model id: ${installation_model_id}" >&2
exit 1
fi
ready_body="$RUNNER_TEMP/testbox-ready.json"
jq -n \
--arg testbox_id "$testbox_id" \
--argjson installation_model_id "$installation_model_id" \
--arg status "ready" \
--arg ip_address "$runner_host" \
--arg ssh_port "$runner_ssh_port" \
--arg working_directory "$working_directory" \
--arg adopted_run_id "$adopted_run_id" \
'{
testbox_id: $testbox_id,
installation_model_id: $installation_model_id,
status: $status,
ip_address: $ip_address,
ssh_port: $ssh_port,
working_directory: $working_directory,
adopted_run_id: $adopted_run_id,
metadata: {}
}' > "$ready_body"
cat > "$ready_body" <<JSON
{
"testbox_id": "${testbox_id}",
"installation_model_id": ${installation_model_id},
"status": "ready",
"ip_address": "${runner_host}",
"ssh_port": "${runner_ssh_port}",
"working_directory": "${working_directory}",
"adopted_run_id": "${adopted_run_id}",
"metadata": {}
}
JSON
http_code="$(curl -sS -L --post302 --post303 -o "$RUNNER_TEMP/testbox-ready.response" -w '%{http_code}' \
-X POST "${api_url}/api/testbox/phone-home" \

View File

@@ -85,22 +85,12 @@ jobs:
env:
ENABLE_WSL2_FEATURES: ${{ inputs.enable_wsl2_features }}
IMPORT_UBUNTU_WSL2: ${{ inputs.import_ubuntu_wsl2 }}
UBUNTU_WSL_ROOTFS_URL: https://cloud-images.ubuntu.com/wsl/releases/24.04/current/ubuntu-noble-wsl-amd64-wsl.rootfs.tar.gz
run: |
$ErrorActionPreference = "Continue"
$ok = $false
$restartRequired = $false
function Resolve-UbuntuWslRootfsUrl {
$osArch = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture).ToString().ToLowerInvariant()
switch ($osArch) {
"x64" { $wslArch = "amd64" }
"arm64" { $wslArch = "arm64" }
default { throw "Unsupported Windows architecture for Ubuntu WSL rootfs: $osArch" }
}
Write-Host "ubuntu_wsl_rootfs_arch=$wslArch"
"https://cloud-images.ubuntu.com/wsl/releases/24.04/current/ubuntu-noble-wsl-$wslArch-wsl.rootfs.tar.gz"
}
function Invoke-WslText {
param([string[]] $Arguments)
$output = & wsl.exe @Arguments 2>&1
@@ -153,9 +143,8 @@ jobs:
Write-Host "import_ubuntu_wsl2=true"
$wslRoot = "C:\wsl\UbuntuProbe"
$rootfs = "C:\wsl\ubuntu-noble-wsl.rootfs.tar.gz"
$rootfsUrl = Resolve-UbuntuWslRootfsUrl
New-Item -ItemType Directory -Force -Path @((Split-Path -Parent $rootfs), $wslRoot) | Out-Null
Invoke-WebRequest -Uri $rootfsUrl -OutFile $rootfs -UseBasicParsing
Invoke-WebRequest -Uri $env:UBUNTU_WSL_ROOTFS_URL -OutFile $rootfs -UseBasicParsing
$import = Invoke-WslText -Arguments @("--import", "UbuntuProbe", $wslRoot, $rootfs, "--version", "2")
Write-Host $import.Text
Write-Host "wsl_import_exit=$($import.Code)"

View File

@@ -129,28 +129,11 @@ jobs:
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
trusted_zizmor_config="$RUNNER_TEMP/zizmor-base.yml"
fetch_base_ref() {
local ref="$1"
local target="$2"
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+${ref}:${target}" && return 0
fetch_status="$?"
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
return "$fetch_status"
fi
if [ "$attempt" = "3" ]; then
return "$fetch_status"
fi
echo "::warning::trusted base fetch for '$ref' timed out on attempt $attempt; retrying"
sleep 5
done
}
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
fetch_base_ref "$BASE_SHA" "refs/remotes/origin/security-base" ||
fetch_base_ref "refs/heads/${BASE_REF}" "refs/remotes/origin/${BASE_REF}"
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+${BASE_SHA}:refs/remotes/origin/security-base" ||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
fi
if git cat-file -e "${BASE_SHA}:.pre-commit-config.yaml" 2>/dev/null; then

View File

@@ -35,7 +35,7 @@ Skills own workflows; root owns hard policy and routing.
- One-sided fixes need sibling-surface proof, an explanation for why siblings are unaffected, or explicit follow-up work.
- Changelog findings: see Docs / Changelog.
- Public ClawSweeper comments prefer `https://docs.openclaw.ai/...` when a public docs page exists; structured evidence still cites repo files, lines, SHAs.
- Findings need current source, shipped/current behavior, tests/CI evidence, and dependency contract proof when dependency-backed behavior is involved. Validation is judged against touched and sibling surfaces plus this file's commands; clear evidence matters for user-visible changes, with Telegram/Desktop proof for Telegram-visible behavior when feasible.
- Findings need current source, shipped/current behavior, tests/CI evidence, and dependency contract proof when dependency-backed behavior is involved. Validation is judged against touched and sibling surfaces plus this file's commands; real behavior proof matters for user-visible changes, with Telegram/Desktop proof for Telegram-visible behavior when feasible.
- Prefer findings for concrete behavior regressions, missing changed-surface proof, owner-boundary violations, security/API contract issues, or docs/config mismatches.
- Do not file findings for repo policy preference when changed code follows the relevant scoped guide and no user-visible, runtime, security, or maintainer-risk impact is shown.
@@ -165,12 +165,13 @@ Skills own workflows; root owns hard policy and routing.
- Representing user: if user already has a comment/thread for the point, update/reply there when possible; avoid duplicate PR/issue comments.
- No surprise GH writes: chat must mention every posted/updated public comment with URL.
- GH comments with backticks, `$`, or shell snippets: use heredoc/body file, not inline double-quoted `--body`.
- PR create: real body required. Use the current template: `What Problem This Solves`, `Why This Change Was Made`, `User Impact`, and `Evidence`; include visible refs, behavior, and validation.
- PR create: real body required. Include Summary + Verification; mention refs, behavior, and proof.
- PR create/refresh: keep PR branches takeover-ready. Use a branch maintainers can push to, or for fork PRs ensure `maintainer_can_modify` / GitHub's `Allow edits by maintainers` is enabled unless explicitly told otherwise or GitHub's Actions/secrets warning makes that unsafe.
- GitHub issue/PR create: read `$agent-transcript`; ask about sanitized transcript logs when available.
- Contributor PRs: parsed context requires authored `What Problem This Solves` and `Evidence` sections. Do not require field-level proof forms; reviewers inspect code, tests, and CI for correctness.
- Contributor PRs: parsed `Real behavior proof` uses exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`.
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
- OpenClaw write-access maintainers may skip `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
- Agent PR landing to `main`: use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`; do not idle on `auto-response` or `check-docs`.
## Code

View File

@@ -2,465 +2,6 @@
Docs: https://docs.openclaw.ai
## 2026.6.9
### Highlights
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, normalizes HTML tables safely, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93002, #93088, #93281, #94891, #94856) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, @aaajiao, @zhangguiping-xydt, @zhangqueping, and @jairrab.
- **More dependable agent recovery:** retries, terminal outcomes, usage after compaction, session history repair, and reply reconciliation now keep more interrupted or partial turns moving toward a visible final result. (#92191, #93073, #93228, #93084, #93469, #93291, #90943) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @yetval, @sandieman2, and @vincentkoc.
- **A stronger Codex integration:** Codex gains automatic plugin approvals, GPT-5.3 Spark OAuth routing, remote-node `exec` as a dynamic tool, and more reliable app-server teardown and terminal outcomes. (#92625, #89133, #93654, #91767, #93287) Thanks @kevinslin, @VACInc, @vincentkoc, @JPKay-AI, and @aliahnaf2013-max.
- **Standalone official provider plugins:** external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is available from npm and ClawHub. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
- **More capable web and native clients:** the Control UI adds a session workspace rail and extension health, iOS adds Watch controls, and Android shows chat context. (#92856, #91952, #93387, #92837) Thanks @Solvely-Colin, @jalehman, @joshavant, and @Tosko4.
- **More useful search and skills:** Codex Hosted Search is available, key-free search providers remain deliberate opt-ins, and ClawHub skill installs retain verified source provenance. (#93446, #93616, #93283, #93506) Thanks @fuller-stack-dev, @davemorin, @momothemage, @nmccready-tars, and @vincentkoc.
### Changes
- Providers and auth: add Codex Hosted Search, improve Gemini CLI OAuth behind proxies, and keep external provider onboarding on current choices and package metadata. (#93446, #92815) Thanks @fuller-stack-dev, @yetval, @EvetteYoung, and @vincentkoc.
- Plugins and installs: externalized official providers publish as independent npm packages, Gateway discovers installed channel plugins at startup, and StepFun installs from npm or ClawHub. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
- Dashboard and mobile: add a session workspace rail, plugin health in status, compact cron lists, and iOS Watch controls. (#92856, #91952, #93395, #93387) Thanks @Solvely-Colin, @jalehman, @yu-xin-c, @centralpc, @joshavant, and @vincentkoc.
- Codex, observability, and skills: add automatic plugin approvals and SecretRefs, preserve ClawHub skill provenance, add OpenTelemetry log export, and expose remote-node execution to Codex when a node is connected. (#92625, #94324, #93283, #94561, #93654) Thanks @kevinslin, @kevinlin-openai, @momothemage, @nmccready-tars, @jesse-merhi, @vincentkoc, and @JPKay-AI.
- QA and release engineering: QA scenarios now use YAML, with broader profile evidence and release coverage for the plugin and channel matrix. Thanks @vincentkoc.
### Fixes
- Security and privacy: redact secrets from debug/config output, block internal HTTP session overrides, audit open-DM tool exposure, and retain plugin write ownership checks. (#93333, #88496, #93443, #92883, #93353) Thanks @Alix-007, @jason-allen-oneal, @coygeek, @RichardCao, @yu-xin-c, @cjg20ss, @eleqtrizit, and @vincentkoc.
- Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve pending subagent delivery, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469, #94349, #92383, #94257) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @vincentkoc, @sallyom, @oiGaDio, @Hidetsugu55, and @Nas01010101.
- Channels and replies: fix Telegram rich delivery, table rendering, action-error handling, progress draft cleanup before visible tool output, and ingress recovery; preserve command progress detail across channel adapters; retain WhatsApp opening text after a media failure; keep Mattermost thread replies intact; and harden Discord action handling. (#93286, #93364, #93281, #93002, #93076, #93334, #93424, #93488, #94868, #94891, #94856, #94810, #93823) Thanks @obviyus, @NianJiuZst, @mcaxtr, @zhangguiping-xydt, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, @vincentkoc, @zhangqueping, @jairrab, @ZOOWH, @parveshsaini, and @yetval.
- Storage and migrations: avoid SQLite WAL on network filesystems, clean reindex artifacts, keep setup state out of workspace dot-directories, and import default-agent auth profiles into SQLite. (#93454, #92891, #93182, #93295, #93520, #93156) Thanks @vincentkoc, @ZengWen-DT, @Zeng-wen, @potterdigital, @Alix-007, @Pick-cat, @sallyom, @1qh, and @Tazio7.
- Provider and model behavior: fix Gemini CLI proxy OAuth, restore Codex Spark OAuth routing, correct Bedrock embedding model IDs, and preserve configured defaults in embedded runs. (#92815, #89133, #93452, #93428) Thanks @yetval, @EvetteYoung, @VACInc, @LiuwqGit, @aleck31, @zenglingbiao, @danielgerlag, and @vincentkoc.
- CLI, TUI, and apps: accept global flags after subcommands, keep terminal output and activity indicators visible, preserve CJK IME composition, and refresh stale UI state. (#93455, #93460, #93006, #93427, #93498, #93606) Thanks @ooiuuii, @Alix-007, @ZengWen-DT, @Zeng-wen, @AlethiaQuizForge, @Zhaoqj2016, @liuhao1024, @BrianClaw1955, @vincentkoc, and @NicoBoom13.
- Operations and updates: harden official plugin recovery, restart managed Gateways after failed update handoff, keep safe cron delivery defaults, avoid Node-specific npm prefixes, and keep package validation paths reliable. (#93325, #92111, #93650, #94453, #91685) Thanks @vincentkoc, @yetval, @ofan, @yaanfpv, @jincheng-xydt, @sallyom, @davectr, and @nxmxbbd.
### Complete contribution record
This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
#### Pull requests
- **PR #90463** refactor: add session accessor seam with gateway consumer. Thanks @jalehman.
- **PR #88656** Drop reasoning-only length turns from replay. Thanks @abel-zer0.
- **PR #92856** feat(webui): add session workspace rail. Thanks @Solvely-Colin.
- **PR #92845** docs(browser-control): document OPENCLAW_EAGER_BROWSER_CONTROL_SERVER requirement. Related #92841. Thanks @liuhao1024 and @jeugregg.
- **PR #82366** fix: use passive periodic sqlite wal checkpoints. Related #81715. Thanks @honor2030 and @KrasimirKralev.
- **PR #92815** fix(google): route Gemini CLI OAuth through the env proxy (#46184). Thanks @yetval and @EvetteYoung.
- **PR #91331** fix(mattermost): merge progress preview lines by identity. Related #89761. Thanks @iloveleon19 and @leonthe8th and @vincentkoc.
- **PR #92909** fix(tui): keep spinner active when toggling tools. Related #49763. Thanks @ZengWen-DT and @Zeng-wen and @vincentkoc and @CrimsonDump.
- **PR #92904** fix(elevenlabs): use current TTS model ids. Thanks @vortexopenclaw and @vincentkoc.
- **PR #92642** fix #86872: Subagent run reports success but fails to write output file. Thanks @zhangguiping-xydt and @vincentkoc and @zapper35.
- **PR #89122** refactor: route command session reads through seam. Thanks @jalehman.
- **PR #90943** fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread. Thanks @sandieman2 and @vincentkoc.
- **PR #92894** fix(skills): keep managed prompt paths readable. Related #92875. Thanks @kesslerio and @sallyom.
- **PR #39617** fix: reload config in slash command routing so dmScope is respected. Related #39605. Thanks @Ciward.
- **PR #92191** fix(agents): retry thinking-only errored turns. Related #91953. Thanks @ai-hpc and @lml2468.
- **PR #92891** fix(memory): clean stale reindex temp files. Related #92874. Thanks @ZengWen-DT and @Zeng-wen and @vincentkoc and @potterdigital.
- **PR #93005** Add OpenRouter Fusion guidance and prompt context. Related #92984. Thanks @sallyom.
- **PR #88792** fix(state): harden sqlite path caching. Thanks @vincentkoc.
- **PR #93022** fix(gateway): repair usage cost aggregation across agents. Thanks @luke-skywalker-open-claw and @stablegenius49.
- **PR #93020** fix(telegram): cool down transient sendChatAction failures. Related #56096. Thanks @Boulea7 and @sumaiazaman and @Pick-cat and @cal-rufus.
- **PR #93002** fix(telegram): clear progress drafts before visible tool output. Thanks @zhangguiping-xydt.
- **PR #89160** fix(agents): detect truncated API responses to prevent silent session hang. Related #89051. Thanks @joelnishanth and @ArthurusDent.
- **PR #93009** fix(agents): make wrapToolWithBeforeToolCallHook idempotent to prevent double hook execution (fixes #92973). Thanks @zenglingbiao and @dertbv.
- **PR #92991** fix(agents): tolerate missing attribution baseUrl. Related #92974. Thanks @samrusani and @Haderach-Ram.
- **PR #92913** fix(opencode-go): register model catalog to fix context window detection. Related #92912. Thanks @kumaxs.
- **PR #89129** refactor: route bundled plugin session callers through seam. Thanks @jalehman.
- **PR #93084** fix(agents): preserve fresh usage after compaction. Related #50795. Thanks @Hollychou924 and @leno23 and @de1tydev and @425072024 and @vincentkoc and @wuwahe3.
- **PR #92869** fix #90333: [Bug]: Discord image build aborts at step 66 — openclaw-build-messaging-plugins.py exits 1. Thanks @zhangguiping-xydt and @vincentkoc and @chriskosys.
- **PR #93011** fix(gateway): accept file-only input on /v1/responses (parity with image-only). Thanks @yetval and @vincentkoc.
- **PR #92915** Convert QA scenarios to YAML files. Thanks @RomneyDa.
- **PR #91767** Fix one-shot Codex app-server teardown. Thanks @aliahnaf2013-max.
- **PR #92625** feat(codex): add auto plugin approvals. Thanks @kevinslin.
- **PR #91587** test(qa): add qa run --qa-profile and unified output summary/evidence. Thanks @RomneyDa.
- **PR #93104** test(reply): seed channel fixtures for dedupe tests. Thanks @RomneyDa.
- **PR #93107** test(reply): preserve telegram dedupe fallback. Thanks @RomneyDa.
- **PR #92954** fix(memory): accept local default model path migration. Thanks @mushuiyu886 and @vincentkoc.
- **PR #90936** fix(agents): do not misclassify client-disconnect abort as run timeout. Related #90764. Thanks @openperf and @reginaldomarcilon.
- **PR #90812** fix(voice-call): preserve live Twilio streams in stale reaper. Related #79121. Thanks @Takhoffman and @sahibzada-allahyar and @donkeykong91.
- **PR #93094** fix(whatsapp): bound socket operations. Thanks @mcaxtr.
- **PR #91629** fix(scripts): add database-first legacy store guard. Related #91628. Thanks @galiniliev.
- **PR #93124** fix(telegram): render progress drafts as rich previews. Thanks @Marvinthebored.
- **PR #93109** test(qa): embed profile scorecard evidence. Thanks @RomneyDa.
- **PR #87298** test: add temp directory helper guidance. Thanks @hxy91819.
- **PR #92318** fix(cron): require explicit message target proof. Thanks @hxy91819.
- **PR #93137** fix(imessage): honor disabled reply actions. Related #92142. Thanks @omarshahine and @dprev.
- **PR #93134** fix(feishu): pass card_msg_content_type to get full card content (fixes #78289). Thanks @liuhao1024 and @vincentkoc and @longdoubled7.
- **PR #93138** fix(agents): preserve literal current session resolution. Thanks @liuhao1024 and @vincentkoc.
- **PR #91225** fix #83830: [Bug]: Dreaming diary repeats "first day" narrative every sweep — same early memories dominate snippets. Thanks @mushuiyu886 and @YinLiuLiu66.
- **PR #93153** simplify QA evidence profile and mappings/coverage shape. Thanks @RomneyDa.
- **PR #93164** fix(telegram): preserve rich markdown line breaks. Thanks @vincentkoc.
- **PR #93119** fix: accept mixed source/dist bundled roots. Related #87730. Thanks @arkyu2077 and @vincentkoc and @jasonftl.
- **PR #93130** fix(telegram): preserve sticker media paths. Related #83748. Thanks @goutamadwant and @vincentkoc and @aaajiao.
- **PR #93073** fix(agents): retry empty post-tool final turns. Thanks @fuller-stack-dev.
- **PR #91784** fix(voice-call): require realtime websocket path boundary. Thanks @jason-allen-oneal.
- **PR #89133** Restore GPT-5.3 Codex Spark OAuth routing. Thanks @VACInc.
- **PR #91996** refactor: prune unused iOS code. Thanks @zats.
- **PR #90231** fix #69443: [Bug] Subagent RPC callback to WeChat session key routed to main session instead. Thanks @zhangguiping-xydt and @sliverp and @chen11221.
- **PR #89920** fix(matrix): replace recovered command progress lines. Thanks @bdjben and @jesse-merhi.
- **PR #93159** fix(tui): keep parent stdin paused after exit. Thanks @fuller-stack-dev.
- **PR #93201** fix(auto-reply): clear pending-final state before honoring post-send abort (#89115). Thanks @amknight and @danashburn.
- **PR #93228** fix(agents): replace prose terminal classifiers. Thanks @fuller-stack-dev.
- **PR #93231** fix(status): correct pinned model clear hint. Thanks @hxy91819.
- **PR #92428** fix(qqbot): keep markdown table chunks valid. Thanks @sliverp.
- **PR #93220** fix(status): avoid stale session context windows. Thanks @hxy91819.
- **PR #91957** perf(sessions): share one enumeration across archive retention sweeps. Thanks @amknight.
- **PR #93281** fix(telegram): recover pid-reused ingress claims. Thanks @obviyus.
- **PR #93287** fix(codex): preserve terminal outcome ordering.
- **PR #93182** fix(memory): clean rollback-journal reindex temp sidecar on NFS stores. Thanks @Alix-007.
- **PR #93283** Persist ClawHub skill install provenance. Related #92077. Thanks @momothemage and @nmccready-tars.
- **PR #88872** fix: attribute spawned task runs to child agent. Related #66670. Thanks @Alix-007 and @Neomail2.
- **PR #92837** fix(android): show live chat context usage. Thanks @Tosko4.
- **PR #93325** fix(cli): harden official plugin recovery. Thanks @vincentkoc.
- **PR #93286** feat(telegram): send rich messages as rich html. Thanks @obviyus.
- **PR #92910** fix(memory-core): safely refresh qmd index during collection repair.
- **PR #93329** fix(cli): allow zero Discord timeout duration. Related #93327. Thanks @rohitjavvadi.
- **PR #91625** fix(cron): add cron edit --clear-model to clear a job's model override. Thanks @ly-wang19.
- **PR #91691** [AI] fix(memory): prevent empty-string expectedModel in resolveMemory…. Thanks @xydt-tanshanshan.
- **PR #93006** fix(tui): keep stderr visible when local shell stdout fills the output cap. Thanks @Alix-007.
- **PR #93001** fix(daemon): prefer stderr over stale stdout in gateway restart diagnostics. Thanks @Alix-007.
- **PR #91117** refactor: remove dead code and improve string concatenation. Thanks @Pommelle.
- **PR #90893** fix(models): mask paste-token input in CLI auth prompt. Thanks @anurag-bg-neu.
- **PR #90571** fix(configure): mask gateway password input in CLI wizard prompt. Thanks @anurag-bg-neu.
- **PR #91768** fix(ios): respect chat header safe area. Thanks @zats.
- **PR #93245** fix(cron): resolve lastRunStatus in cron list/show human output. Thanks @ly-wang19.
- **PR #78765** fix(tui): avoid inserting spaces into long CJK text. Thanks @hpt.
- **PR #91776** fix(ios): refresh permission rows after grants. Thanks @zats.
- **PR #92817** fix(cron): trust agent output when channel is unresolved without explicit delivery. Related #90664. Thanks @fsdwen and @dertbv.
- **PR #93297** fix(control-ui): respect agents.defaults.timeFormat for timestamps. Related #58147. Thanks @ZengWen-DT and @Zeng-wen and @TommoT2.
- **PR #93364** Fix Telegram rich progress command output. Thanks @obviyus.
- **PR #91952** feat(status): surface plugin health. Thanks @jalehman.
- **PR #75025** fix(heartbeat): refresh stale Current time line on every helper call (#44993). Thanks @MoerAI and @mclee1975.
- **PR #90992** docs(windows): fix WSL gateway-autostart recipe for WSL ≥ 2.6.1.0 idle-termination. Thanks @spencer2211.
- **PR #86544** fix(cli): show Gemini CLI runtime auth status. Related #79585. Thanks @giodl73-repo and @fabricefoy.
- **PR #88945** fix(plugins): serialize binding approval saves. Related #64065. Thanks @Alix-007 and @lihaokun.
- **PR #90115** fix(gateway): pass managed inbound PDFs through chat.send. Related #90097. Thanks @harjothkhara and @joeykrug.
- **PR #74613** docs(cli): add agent selector to CLI backend quick start. Related #68940. Thanks @vyctorbrzezowski and @drmarcopapa.
- **PR #89121** refactor: add transcript reader seam. Thanks @jalehman.
- **PR #84434** fix(cli): disable ScheduleWakeup/CronCreate in --print claude runs. Thanks @SkyWolfDreamer.
- **PR #66985** fix(agents): resolve requestedNode to canonical ID before boundNode comparison. Related #87213. Thanks @mujiannan.
- **PR #91488** fix(reply): project preflight compaction gate by next-input size on fresh tokens. Thanks @yetval.
- **PR #93353** fix(plugins): require owner for plugin writes. Thanks @eleqtrizit.
- **PR #91499** fix(cron): preserve scheduled turn tool policy [AI]. Thanks @mmaps.
- **PR #90412** fix(sessions): cache warm transcript reads to avoid per-turn re-parse. Related #83943. Thanks @Alix-007 and @yyds-xxxx.
- **PR #93118** fix(gateway): guard fast-path startup migrations. Related #93032. Thanks @openperf and @Haderach-Ram.
- **PR #93355** fix(ci): verify performance workflow downloads. Thanks @eleqtrizit.
- **PR #93358** fix(outbound): guard cross-context message mutations. Thanks @eleqtrizit.
- **PR #93362** fix(flock): bind allow-always to wrapped command. Thanks @eleqtrizit.
- **PR #92578** refactor(whatsapp): add inbound admission foundation. Thanks @mcaxtr.
- **PR #89547** Control Telegram group history context. Thanks @mmaps.
- **PR #89201** refactor: add transcript runtime identity contract. Thanks @jalehman.
- **PR #93357** fix(plugins): enforce install policy in wrappers. Thanks @eleqtrizit.
- **PR #93156** fix(doctor): import default-agent auth profiles into sqlite. Related #93145. Thanks @Pick-cat and @sallyom and @Tazio7.
- **PR #93179** Add slim evidence mode for QA profile evidence. Thanks @RomneyDa.
- **PR #93349** fix(control-ui): keep workboard card titles visible in overflowing columns (fixes #91717). Thanks @Pick-cat and @NicoBoom13.
- **PR #93324** fix(cli): accept --no-color after subcommands. Thanks @ooiuuii.
- **PR #89621** Return Google Chat thread metadata from message sends. Thanks @franco-viotti.
- **PR #82458** fix(infra): drop duplicated "restart" word in restart-sentinel summary. Thanks @jameswniu.
- **PR #85471** Suppress cron announce control replies. Related #85421. Thanks @TurboTheTurtle and @leatherneck-33.
- **PR #85316** fix(auth): keep alias-compatible auth-profile overrides instead of clearing them. Thanks @SkyWolfDreamer.
- **PR #89260** fix(doctor): separate platform-incompatible skills from missing requirements. Related #89232. Thanks @Alix-007 and @CameronWeller.
- **PR #90846** fix(media): stop pruning media on write; let the configured timer do it. Thanks @lundog.
- **PR #88062** fix(logging): avoid stalled warnings for active model calls. Thanks @litang9.
- **PR #93308** fix(discord): reject malformed realtime consult calls. Thanks @khoek.
- **PR #93334** fix(whatsapp): notify user when trailing media send fails instead of silent drop. Thanks @rushindrasinha.
- **PR #92575** fix(sessions): preserve user behavior overrides across daily/idle rollover (#92562) [AI-assisted]. Thanks @harjothkhara and @civiltox.
- **PR #89124** refactor: route auto-reply sessions through session seam. Thanks @jalehman.
- **PR #93431** fix: stabilize transcript cache and CLI env isolation. Thanks @shakkernerd.
- **PR #93412** fix(discord): suppress tool progress for message-tool replies. Thanks @mgunnin and @vincentkoc.
- **PR #93409** fix(whatsapp): stop markdownToWhatsApp dropping code spans followed by a digit. Thanks @rushindrasinha.
- **PR #93295** fix(memory): swap rollback-journal sidecar during atomic reindex. Thanks @Alix-007.
- **PR #93076** fix(whatsapp): preserve auth on terminal disconnects. Thanks @mcaxtr.
- **PR #93435** fix(agents): bound autoreview scope. Thanks @vincentkoc.
- **PR #93279** fix(telegram): restore readable default text sends. Related #93263. Thanks @NianJiuZst and @SweetSophia.
- **PR #93429** fix(line): cap carousel column text at 60 chars when a title or image is set. Thanks @harjothkhara and @vincentkoc.
- **PR #93428** fix(agents): resolve configured default model in runEmbeddedAgent (fixes #93419). Thanks @zenglingbiao and @vincentkoc and @danielgerlag.
- **PR #93427** fix(tui): show activity indicator for system-injected runs. Related #51825. Thanks @ZengWen-DT and @vincentkoc and @Zeng-wen and @AlethiaQuizForge.
- **PR #90003** feat(policy): cover exec approvals artifact. Thanks @giodl73-repo.
- **PR #93448** fix(guards): allow auth profile sqlite reader. Thanks @amknight.
- **PR #93424** fix(mattermost): keep message tool replies in threads. Thanks @amknight and @vincentkoc.
- **PR #93418** fix(telegram): forward Bot API 10.1 rich_message content to agent. Related #93410. Thanks @xzh-icenter and @vincentkoc and @0pen7ech.
- **PR #93175** test(qa): taxonomy profiles: includeAllCategories for release profile, update some coverage. Thanks @RomneyDa.
- **PR #93456** fix(agents): handle string assistant message content. Thanks @vincentkoc.
- **PR #93441** fix(outbound): ignore schema-padded poll metadata on send. Related #43015. Thanks @weichengdeng and @charzhou.
- **PR #93443** fix(gateway): block internal HTTP session overrides. Thanks @RichardCao.
- **PR #93454** fix(sqlite): disable WAL on network filesystems. Thanks @vincentkoc.
- **PR #90275** test: make install-safe-path symlink tests compatible with Windows. Thanks @aniruddhaadak80.
- **PR #93464** fix(qa): suppress empty WhatsApp debug artifacts. Thanks @vincentkoc.
- **PR #90861** fix(cli): preserve sessions_yield over MCP. Related #77426. Thanks @zhangguiping-xydt and @jarvisagimuspicard-hub.
- **PR #90946** fix(infra): preserve inherited gateway PID across reparent during cleanup. Thanks @amittell.
- **PR #92220** fix(media): extract large managed inbound PDFs via media-understanding. Related #90096, #90097. Thanks @amknight and @joeykrug.
- **PR #91208** fix #91047: Plugin session-extension registry not pinned; sessions.pluginPatch fails after agent/subagent plugin-load churn. Thanks @mushuiyu886 and @teamadams.
- **PR #92111** fix(update): restart managed gateway when update handoff fails after stop. Related #92088. Thanks @yetval and @ofan.
- **PR #93238** fix(agents): honor disabled envelope timestamps at model boundary. Thanks @osolmaz.
- **PR #93343** fix(codex): de-duplicate commentary notes across the raw response lane. Related #93296. Thanks @Marvinthebored and @Peetiegonzalez.
- **PR #93361** fix(openshell): pin mirror remote mutations. Thanks @eleqtrizit.
- **PR #93354** fix(discord): block cross-provider guild admin actions. Thanks @eleqtrizit.
- **PR #92178** fix(gateway): normalize malformed paired access lists. Related #90654. Thanks @wangmiao0668000666 and @EmilioNicolas.
- **PR #85254** perf(plugins): thread prepared manifestPlugins through runtime model-id normalize chain. Thanks @zeroaltitude.
- **PR #93489** Add ClawHub content rights docs to sidebar. Thanks @Patrick-Erichsen.
- **PR #93466** [AI] fix(feishu): guard against missing inbound in channelRuntime fallback. Thanks @xydt-tanshanshan.
- **PR #93460** fix(cli): honor --log-level in route-first commands. Related #93457. Thanks @ooiuuii.
- **PR #93495** fix(cron): clear delivery routing fields from cron edit. Thanks @ly-wang19 and @vincentkoc.
- **PR #93494** docs: point PR landing at maintainer workflow. Thanks @fuller-stack-dev and @vincentkoc.
- **PR #93487** fix(ui): add agent selector to skills page. Related #78553. Thanks @goutamadwant and @vincentkoc and @xiaobu1112.
- **PR #93488** fix(discord): apply tool status emojis immediately to avoid override by thinking reactions. Related #92715. Thanks @lzyyzznl and @vincentkoc and @darealgege.
- **PR #93055** fix(ui): restore provider usage pill in desktop chat composer [AI]. Thanks @harjothkhara.
- **PR #83156** fix(matrix): accept bracketed display-name mentions. Related #83142. Thanks @wdx-agent-io and @wdongxv.
- **PR #93333** fix(auto-reply): redact secrets in /debug show and /debug set output. Thanks @Alix-007.
- **PR #88496** fix(auto-reply): redact secrets in config show output. Related #65623. Thanks @jason-allen-oneal and @coygeek.
- **PR #93105** fix(doctor): repair null agents.list[].workspace values. Related #77718. Thanks @xydigit-sj and @slideshow-dingo.
- **PR #73923** fix(ui): preserve gateway token during safe websocket url edits. Related #41545. Thanks @wsyjh8.
- **PR #88970** fix #85871: [Bug]: Heartbeat scheduler silently fails to fire on 5.20 and all 5.x versions (regression from 4.23). Thanks @zhangguiping-xydt and @vincentkoc and @carlbjson.
- **PR #93511** fix(imessage): normalize leading NUL echo-cache prefixes. Thanks @vincentkoc and @drvoss.
- **PR #92594** [Bug]: ollama-cloud runtime fails DNS lookup for ai.ollama.com, while ollama/<model>:cloud works. Related #92391. Thanks @zhangguiping-xydt and @vincentkoc and @kvzsolt.
- **PR #93512** build(docs): finish PowerShell-safe docs formatting. Related #44293. Thanks @vincentkoc and @yil337 and @aniruddhaadak80.
- **PR #93513** fix(skills): refresh persisted snapshots after restart. Thanks @vincentkoc and @fif911 and @skadauke.
- **PR #93517** fix(skills): quote skill-creator template description. Thanks @vincentkoc and @parubets.
- **PR #73976** fix(memory): use per-keyword FTS search in hybrid mode #39484. Thanks @joshuakeithpa-sudo.
- **PR #93520** fix(workspace): store setup state outside workspace dot-dir. Thanks @vincentkoc and @1qh.
- **PR #93521** fix(onboard): skip Homebrew prompt on unsupported platforms. Related #68893. Thanks @vincentkoc and @yurivict.
- **PR #93522** fix(feishu): send post mentions as native at elements. Thanks @vincentkoc and @gavin-ali and @YizukiAme and @Panniantong.
- **PR #93496** fix(gateway): rotate already-stale generated transcript filename on /reset. Thanks @harjothkhara and @vincentkoc.
- **PR #93471** fix(cron): preserve aborted isolated-run failure. Thanks @BhargavSatya and @vincentkoc.
- **PR #93473** fix(memory): report skipped QMD embedding probe. Related #77645. Thanks @TurboTheTurtle and @vincentkoc and @aderius.
- **PR #93498** fix(ui): preserve CJK IME composition. Related #86035. Thanks @Zhaoqj2016 and @vincentkoc.
- **PR #93088** fix(telegram): bind bot mentions to assistant identity. Thanks @kesslerio and @vincentkoc.
- **PR #93499** fix(nodes): return screen snapshots as media. Related #90126. Thanks @zenglingbiao and @vincentkoc and @JeffSteinbok.
- **PR #93506** fix(skills): trust verified ClawHub source provenance. Thanks @vincentkoc.
- **PR #93525** agents: notify chat exec empty-success completions. Thanks @vincentkoc and @wenkang-xie.
- **PR #93446** feat: add Codex hosted web search. Thanks @fuller-stack-dev.
- **PR #92883** fix(security): audit open dm tool exposure. Related #55612. Thanks @yu-xin-c and @vincentkoc and @cjg20ss.
- **PR #93476** fix(mattermost): preserve Codex progress preview. Related #88766. Thanks @goutamadwant and @vincentkoc and @KelTech-Services.
- **PR #93395** feat(cron): add compact list responses. Related #93366. Thanks @yu-xin-c and @vincentkoc and @centralpc.
- **PR #93527** fix(cron): preserve model overrides for text payloads. Thanks @vincentkoc and @liaoandi.
- **PR #90487** fix: harden ChatGPT Responses missing content-type streams. Thanks @anyech and @vincentkoc.
- **PR #93528** fix(gateway): tolerate transient pre-hello clean closes. Thanks @vincentkoc and @ruanrrn.
- **PR #93529** fix(auto-reply): allow message tool for group attachments. Related #43146. Thanks @vincentkoc and @Robcis.
- **PR #93291** fix(reply): preserve pending thread evidence when reconciling partial send results. Thanks @yetval and @vincentkoc.
- **PR #90572** fix(feishu): drop self-authored receive echoes. Thanks @baskduf.
- **PR #93455** fix(cli): accept --log-level after subcommands. Thanks @ooiuuii and @vincentkoc.
- **PR #93452** fix(bedrock): strip inference profile prefix from model ID in embedding adapter. Related #79212. Thanks @LiuwqGit and @vincentkoc and @aleck31.
- **PR #89799** fix(cli): skip compile cache on early Node 24.x to avoid startup deadlock. Related #86550. Thanks @zhangguiping-xydt and @vincentkoc and @renyuliang000.
- **PR #93469** fix(agents): drop partialJson streaming artifacts from session history repair. Thanks @drvoss and @vincentkoc.
- **PR #93463** fix(codex): log app-server compaction completion. Related #83932. Thanks @goutamadwant and @vincentkoc and @aounakram.
- **PR #93562** fix(tui): refresh after external session reset. Related #38966. Thanks @vincentkoc and @wsyjh8 and @yizhanzjz.
- **PR #93470** fix(plugins): load externally-installed channel plugins at gateway startup. Related #93219. Thanks @sunlit-deng and @vincentkoc and @cxdnicole.
- **PR #88796** fix(discord): resolve guildId from session channel for search actions. Related #88790. Thanks @SebTardif and @vincentkoc and @mugabuga.
- **PR #93194** fix(agents): preserve prompt-released session metadata. Related #93193. Thanks @snowzlm.
- **PR #89483** fix(gateway): project failed agent turns in chat history. Related #89197. Thanks @IWhatsskill and @vincentkoc and @yangiit.
- **PR #93434** fix: avoid parent group allowlist false positive. Related #92684. Thanks @kingrubic and @vincentkoc and @motteman.
- **PR #93449** fix(feishu): dedupe redelivered text by stable retry identity. Related #46778. Thanks @ZengWen-DT and @vincentkoc and @kingcuty.
- **PR #93407** AGT-80 AGT-81 Fix Discord ingress ack ordering. Thanks @mgunnin and @vincentkoc.
- **PR #93439** fix(agents): honor embedded run default model. Related #93419. Thanks @harjothkhara and @vincentkoc and @danielgerlag.
- **PR #93565** fix(cli): summarize cleanup dry-run by label. Related #76826. Thanks @AgentArcLab and @vincentkoc and @renatomaluhy.
- **PR #93509** fix(skills): clear orphaned idempotency pointer on corrupt-metadata re-begin. Thanks @Alix-007 and @vincentkoc.
- **PR #93274** Clarify plugin channel config additional-property errors. Thanks @zhangguiping-xydt and @vincentkoc.
- **PR #93555** fix(read): route text decoding through shared Windows codepage fallba…. Thanks @zhanxingxin1998 and @vincentkoc.
- **PR #93314** fix(skills): preserve ClawHub origin provenance on readback. Thanks @Alix-007 and @vincentkoc.
- **PR #93573** fix(acp): keep bridge sessions out of stale ACP classification [AI-assisted]. Related #38907. Thanks @eldar702 and @vincentkoc and @ninaopenclaw.
- **PR #93398** fix(cron): emit isolated model usage diagnostics. Related #92338. Thanks @849261680 and @vincentkoc and @niks999.
- **PR #93367** Fix SSH sandbox remote directory args. Related #93344. Thanks @dmorn and @vincentkoc.
- **PR #93574** fix(feishu): suppress log noise for bot_p2p_chat_entered_v1 event [AI-assisted]. Related #42351. Thanks @eldar702 and @vincentkoc and @sunking0223.
- **PR #93269** Fix tokenjuice bash results without details. Thanks @moeedahmed and @vincentkoc.
- **PR #93575** fix(telegram): hydrate group reply-chain media into model context [AI-assisted]. Thanks @eldar702 and @vincentkoc.
- **PR #93261** fix(plugins): resolve provider policy surface for plugin-owned CLI backends. Related #93259. Thanks @BitmapAsset and @vincentkoc.
- **PR #93303** fix(whatsapp): bound stalled read-receipt socket operations. Thanks @Alix-007 and @vincentkoc.
- **PR #93242** fix(mattermost): keep bare @mention with empty body instead of dropping it. Related #93205. Thanks @iloveleon19 and @vincentkoc.
- **PR #93606** fix(ui): clear stale Talk error when session transitions to non-error state (fixes #88176). Thanks @liuhao1024 and @vincentkoc and @BrianClaw1955.
- **PR #93607** perf(tasks): memoize reconcileInspectableTasks for same-tick calls (fixes #73531). Thanks @liuhao1024 and @vincentkoc and @slideshow-dingo.
- **PR #93612** fix(gateway): compute sessions.usage aggregate totals from all sessions, not just the limited page (fixes #76496). Thanks @liuhao1024 and @vincentkoc and @bobsahur-robot.
- **PR #93615** fix(telegram): recover lone active spooled handler on timeout (#84158). Thanks @0xghost42 and @vincentkoc and @crash2kx.
- **PR #93616** Keep key-free web search providers opt-in. Thanks @davemorin and @vincentkoc.
- **PR #93298** fix #93044: control-ui webchat double-renders agent replies when dmScope=main. Thanks @zhangguiping-xydt and @vincentkoc and @cfmilam.
- **PR #93618** fix(feishu): filter temporary card-action-c-\* IDs from reply target to prevent Invalid open_message_id errors (fixes #56818). Thanks @liuhao1024 and @vincentkoc and @SwordImmortal.
- **PR #93387** feat(ios): add watch action surface. Thanks @Solvely-Colin and @joshavant.
- **PR #93648** fix(doctor): archive superseded plugin install index conflicts. Related #90418. Thanks @vincentkoc and @ramitrkar-hash.
- **PR #93649** fix(qwen): place DashScope image prompts in user content. Related #92688. Thanks @vincentkoc and @Yachiyo404.
- **PR #93650** fix(update): avoid per-Node npm prefixes during self-update. Related #80387. Thanks @vincentkoc and @yaanfpv.
- **PR #93653** fix(skill-workshop): skip helper sessions during auto-capture. Thanks @vincentkoc and @zhangguiping-xydt.
- **PR #93654** fix(codex): expose remote node exec as a Codex dynamic tool. Related #92141. Thanks @vincentkoc and @JPKay-AI.
- **PR #93662** fix(discord): protect mention aliases in code fences. Thanks @vincentkoc and @rohitjavvadi.
- **PR #93663** fix(clawdock): open dashboard on published port without starting deps. Related #77344. Thanks @vincentkoc and @dhoman.
- **PR #93670** fix(browser): recover stale managed Chrome CDP listener. Related #41750. Thanks @vincentkoc and @rohitjavvadi and @kissman911.
- **PR #93672** fix(commands): preserve multiline slash skill args. Related #79155. Thanks @vincentkoc and @web3blind.
- **PR #93674** fix(browser): accept top-level act fields with nested requests. Related #38762. Thanks @vincentkoc and @angelusbr and @Lumos-789.
- **PR #93678** fix(plugins): allow Dreaming sidecar through restrictive memory allowlists. Related #92536. Thanks @vincentkoc and @pradeep7127 and @resYuto.
- **PR #93306** fix(status): ignore stale context after model switch. Thanks @hxy91819.
- **PR #93666** fix(control-ui): copy code blocks over plain HTTP via clipboard fallback. Related #93628. Thanks @Pick-cat and @pjq2926.
- **PR #93629** fix(reply): preserve unsent text-only finals after block pipeline streamed partial content (fixes #81078). Thanks @liuhao1024 and @Jackten.
- **PR #93690** fix(telegram): dispatch MEDIA directives as attachments. Related #77702. Thanks @vincentkoc and @butttersbot.
- **PR #93693** fix(gateway): ignore stale sudo scope for root user services. Related #81410. Thanks @vincentkoc and @Ericksza.
- **PR #93646** fix(agents): return string assistant content in getLastAssistantText. Thanks @Alix-007 and @vincentkoc.
- **PR #93687** fix(i18n): retain Codex error tails in logs. Thanks @hxy91819.
- **PR #93630** fix(heartbeat): bootstrap plugin session targets. Thanks @ZengWen-DT and @vincentkoc.
- **PR #93658** fix(wizard): preserve existing default model during setup auth choice [AI-assisted]. Related #64129. Thanks @ml12580 and @vegapunk9527.
- **PR #93671** fix(respawn): rewrite pnpm versioned entry paths to stable wrapper (fixes #52313). Thanks @liuhao1024 and @vincentkoc and @RichardCao.
- **PR #93698** Fix Telegram rich progress detail updates. Thanks @obviyus.
- **PR #93656** fix(gateway): send approval route notices with write scope. Related #93563. Thanks @mushuiyu886 and @vincentkoc and @clawbot247-commits.
- **PR #93665** fix(gateway): surface codex app-server returned failures. Thanks @litang9 and @vincentkoc.
- **PR #93727** fix(context-engine): avoid turn-maintenance lane livelock. Related #77340. Thanks @vincentkoc and @baghvn and @Veda-openclaw.
- **PR #93681** fix(llm): handle string assistant content on the OpenAI-compatible completion path. Thanks @Alix-007.
- **PR #93722** chore(release): update appcast for 2026.6.8. Thanks @vincentkoc.
- **PR #93677** fix(google-meet): declare realtime provider secret inputs. Related #81891. Thanks @goutamadwant and @vincentkoc and @chachi-max.
- **PR #92947** fix(qqbot): deliver cron auto-TTS voice by trusting OpenClaw temp root. Related #92816. Thanks @ZengWen-DT and @Zeng-wen and @lewiswu1209.
- **PR #93679** fix(whatsapp): extract GIF metadata and distinguish gifPlayback in media placeholders (fixes #49099). Thanks @liuhao1024 and @vincentkoc and @bugkill3r.
- **PR #93688** fix(minimax): check base_resp envelope errors in TTS provider. Related #76904. Thanks @dwc1997 and @najef1979-code.
- **PR #93714** fix: isolate async model resolution mock from sync mock in flaky test. Related #92117. Thanks @lsr911 and @wangwllu.
- **PR #93705** test(macos): cover root command dispatch. Related #83879. Thanks @markoub and @vincentkoc and @davinci282828.
- **PR #93711** Keep command text in progress drafts. Thanks @keshavbotagent and @vincentkoc.
- **PR #93712** fix: scope assistant avatar override to agent ID. Related #90890. Thanks @lsr911 and @vincentkoc and @najef1979-code.
- **PR #93725** fix(usage): prune stale usage cache temp files. Related #78939. Thanks @markoub and @Tramsrepus.
- **PR #93726** fix(typing): start typing on reasoning deltas in thinking mode before visible text. Related #79681. Thanks @xialonglee and @novaflash82.
- **PR #93716** fix(discord): propagate timeout through channel capabilities diagnostics. Related #77040. Thanks @xialonglee and @vincentkoc and @unicebondoc.
- **PR #93729** fix(ollama): preserve configured API during discovery. Related #93710. Thanks @zhangguiping-xydt and @vincentkoc and @obnoxious2011-cmd.
- **PR #93719** fix: pin plugin workspace dir for sessions.list to avoid O(rows) memo busting. Related #90814. Thanks @lsr911 and @vincentkoc and @k-l-lambda.
- **PR #93732** fix(agents): preserve re-sent user prompt during compaction transcript rotation. Thanks @yetval.
- **PR #93738** fix: break plugin registry type import cycle. Thanks @giodl73-repo.
- **PR #93740** fix(sessions): release retained locks after takeover. Thanks @TurboTheTurtle.
- **PR #93745** fix(usage): reject invalid explicit dates in usage RPC date parsing. Thanks @harjothkhara and @vincentkoc.
- **PR #93746** fix(ui): populate realtime talk provider and transport options from talk.catalog. Thanks @shushushv and @vincentkoc.
- **PR #93751** fix(ios): fix quick setup sheet layout design. Thanks @zats.
- **PR #93749** fix(compaction): ignore stale persisted totalTokens in preflight gate. Thanks @yetval.
- **PR #93753** fix: correct tautological uppercase check in tool description summarizer. Thanks @GautamKumarOffical.
- **PR #89123** refactor: route transcript writers through session seam. Thanks @jalehman.
- **PR #93758** feat(memory): apply outputDimensionality truncation to local GGUF embeddings (fixes #58765). Thanks @liuhao1024 and @vincentkoc and @losz5000.
- **PR #93754** feat(inbound-meta): expose per-turn source modality. Related #50482. Thanks @liuhao1024 and @vincentkoc and @JTOrca.
- **PR #93767** fix(reasoning-tags): strip MiniMax `mm:` namespaced reasoning tags. Thanks @DrHack1 and @vincentkoc.
- **PR #93772** fix(feishu): recover CJK filenames from JSON file_name field (fixes #81103). Thanks @liuhao1024 and @vincentkoc and @pjuneye.
- **PR #93773** fix(ui): scope Skill Workshop proposals to selected agent. Related #93760. Thanks @TurboTheTurtle and @vincentkoc and @hannesrudolph.
- **PR #88750** feat(context-engine): pass runtime settings into lifecycle. Thanks @ragesaq and @jalehman.
- **PR #93763** fix(agents): use neutral billing copy for subscription auth. Related #80877. Thanks @eldar702 and @vincentkoc and @22kyasue.
- **PR #93818** List all ClawHub docs in sidebar. Thanks @Patrick-Erichsen.
- **PR #93779** fix(webchat): skip textarea resize during IME composition to eliminate typing lag. Related #90800. Thanks @joelnishanth and @vincentkoc and @w10497-create.
- **PR #93786** fix(plugins): treat refreshable catalogs as requiring runtime discovery (fixes #93775). Thanks @liuhao1024 and @St0rmz1.
- **PR #93791** fix(memory): await search-sync before returning results to prevent stale index (fixes #52115). Thanks @liuhao1024 and @vincentkoc and @FicheallADa.
- **PR #93780** fix(google): keep parallel Gemini tool responses in the turn after the model. Thanks @yetval and @vincentkoc.
- **PR #93789** fix(agents): make lane suspension consistent across cooldown-precheck and embedded-runner paths. Related #93036. Thanks @joelnishanth and @vincentkoc and @kumaxs.
- **PR #93798** fix(status): show 0 (not ?) for fresh-session context tokens. Related #93771. Thanks @Alix-007 and @vincentkoc and @anarchia-99.
- **PR #93810** fix(cron): preserve startup overflow catch-up deferrals in start() maintenance pass. Thanks @yetval.
- **PR #93811** Strip UTF-8 BOM when reading SKILL.md in quick_validate. Thanks @HrachShah.
- **PR #93803** fix(ui): preserve WebChat visible messages across session switches. Related #80855. Thanks @LiuwqGit and @vincentkoc and @viagarsuker.
- **PR #93792** fix(android): wait for node capability approval before onboarding. Thanks @Solvely-Colin and @vincentkoc.
- **PR #93796** fix(feishu): paginate wiki node and space listing (#37626). Thanks @ZengWen-DT and @vincentkoc and @ritou11.
- **PR #93797** fix(browser): use openTab return value to prevent wsUrl race in ensureTabAvailable (fixes #63343). Thanks @liuhao1024 and @vincentkoc and @OpenCodeEngineer.
- **PR #93806** fix(reasoning-tags): strip MiniMax mm: tags on silent-reply and streaming paths missed by #93767. Thanks @Alix-007 and @vincentkoc.
- **PR #93691** refactor: add gateway sessions.create lifecycle seam. Thanks @jalehman.
- **PR #88748** fix(gemini): bridge OAuth profiles into CLI runtime. Related #88742. Thanks @jason-allen-oneal.
- **PR #93857** fix(deps): remediate Dependabot alerts. Thanks @vincentkoc.
- **PR #93874** fix(slack): recognize MiniMax mm: namespaced reasoning tags in monitor preview. Thanks @Alix-007.
- **PR #93832** feat(providers): add ClawRouter managed proxy. Thanks @vincentkoc.
- **PR #93880** fix(macos): preserve approvals migration data. Thanks @vincentkoc.
- **PR #93903** fix(cron): reject invalid absolute timestamps. Thanks @Alix-007 and @vincentkoc.
- **PR #93879** fix(update): use configured npm registry for update metadata. Related #79140. Thanks @vincentkoc and @sixerLiu.
- **PR #93924** revert(providers): remove ClawRouter provider. Thanks @vincentkoc.
- **PR #93955** fix(telegram): surface rich-message disabled state. Thanks @obviyus.
- **PR #93881** fix(agents): route BTW through canonical Codex runtime. Related #88902. Thanks @vincentkoc and @TurboTheTurtle and @khalil-omer.
- **PR #90192** fix(feishu): fetch quoted content before empty-message guard. Related #90177. Thanks @bladin and @sliverp and @lkxlaz.
- **PR #93237** Fix Mattermost open DM validation. Thanks @amknight.
- **PR #93945** feat(diagnostics): add SIEM security events. Thanks @vincentkoc.
- **PR #87487** fix(cli): clarify mcp list registry scope. Related #65209. Thanks @Alix-007 and @slideshow-dingo.
- **PR #24661** feat(cohere): add provider plugin. Thanks @vincentkoc.
- **PR #93532** Expose verified ClawHub source in skill verify output. Thanks @momothemage.
- **PR #93538** feat(codex): support app-server network proxy profiles. Thanks @vincentkoc.
- **PR #93938** fix(telegram): guard UTF-16 surrogate pairs in outbound chunkers. Related #93921. Thanks @Nas01010101 and @vincentkoc.
- **PR #94104** feat(agents): trace compaction summarization model calls. Thanks @amknight.
- **PR #94108** Fix package Telegram temp root. Thanks @obviyus.
- **PR #94113** Fix Telegram package output mount. Thanks @obviyus.
- **PR #89062** feat(docker): support offline setup reruns. Related #70443. Thanks @Alix-007 and @safrano9999.
- **PR #93929** fix(secrets): explicitly pass BWS_SERVER_URL to resolver for self-hosted instances. Related #93851. Thanks @Pandah97 and @vincentkoc and @AdoShan.
- **PR #90057** Polish Workboard operations view. Thanks @fuller-stack-dev.
- **PR #89396** fix(doctor): drop inert legacy cron notify when cron.webhook is unset. Related #44460. Thanks @Alix-007.
- **PR #94138** fix(session): prevent stale finalizer from recreating deleted session rows. Related #40840. Thanks @xialonglee and @vincentkoc and @AL-knows.
- **PR #93739** refactor: add session patch projection seam. Thanks @jalehman.
- **PR #94178** fix(workspace): skip optional bootstrap files when workspace setup is already completed. Related #83593. Thanks @dwc1997 and @jsompis.
- **PR #93363** fix(feishu): enforce account tool family gates. Thanks @eleqtrizit.
- **PR #93813** fix(codex): keep message registered for internal turns. Related #93750. Thanks @jalehman and @hannesrudolph.
- **PR #93659** refactor: add session reset delete lifecycle seam. Thanks @jalehman.
- **PR #93852** ci(release): harden release controls. Thanks @vincentkoc.
- **PR #94203** feat(codex): support remote app-server plugins. Thanks @kevinslin.
- **PR #94263** chore: migrate claw-score skill. Thanks @RomneyDa and @kevinslin.
- **PR #93695** refactor: add compact trim lifecycle seam. Thanks @jalehman.
- **PR #93114** test: fold lifecycle and package proof into QA Lab. Thanks @RomneyDa.
- **PR #93181** test: fold otel smoke into qa e2e. Thanks @RomneyDa.
- **PR #93178** test: fold gateway smoke into qa e2e. Thanks @RomneyDa.
- **PR #94276** qa-lab: support script-backed evidence scenarios. Thanks @Solvely-Colin and @RomneyDa.
- **PR #94282** Support owner-qualified ClawHub skill installs. Thanks @Patrick-Erichsen.
- **PR #93704** refactor: add session cleanup lifecycle seam. Thanks @jalehman.
- **PR #94296** fix: require all taxonomy coverage ids for a feature - AND not OR. Thanks @RomneyDa.
- **PR #92016** fix(plugins): compose live hook registry view for tool-call hooks. Related #91918. Thanks @amknight and @vokaplok.
- **PR #89596** fix(policy): recognize declared tool allowlists. Thanks @giodl73-repo.
- **PR #93713** fix: route deleted-agent session purge through lifecycle seam. Thanks @jalehman.
- **PR #84172** fix(exec): rebuild command authorization on the Tree-sitter command planner. Thanks @jesse-merhi.
- **PR #94332** docs: add ClawHub namespace claims to sidebar. Thanks @Patrick-Erichsen.
- **PR #86360** fix(codex): honor bound agent exec host policy. Thanks @jesse-merhi.
- **PR #73162** fix(slack): remove socket reconnect attempt cap so gateway stays connected indefinitely. Related #72808. Thanks @suboss87 and @tleyden.
- **PR #94156** fix: expose OpenAI image quality and moderation CLI options. Thanks @lastguru-net and @fuller-stack-dev.
- **PR #94350** feat: externalize GMI provider plugin. Thanks @Patrick-Erichsen and @vincentkoc.
- **PR #94543** fix(gateway): bound config.get middleware results. Related #94265. Thanks @vincentkoc and @v-s-gusev.
- **PR #91409** fix(update): run plugin convergence after RPC git updates. Thanks @masatohoshino.
- **PR #94556** chore(extensions): bump tokenjuice to 0.8.1. Thanks @vincentkoc.
- **PR #94580** fix(ci): stabilize update run gates.
- **PR #94394** fix(infra): probe 127.0.0.1 in ensurePortAvailable to detect IPv4-only occupants. Related #94379. Thanks @Pandah97 and @wangwllu.
- **PR #94421** fix(agents): preserve active compaction retries. Related #94391. Thanks @dexiosmb.
- **PR #94428** fix(feishu): preserve replies before error finals. Related #94360. Thanks @xunx33.
- **PR #93735** refactor: add restart recovery lifecycle seam. Thanks @jalehman.
- **PR #94591** docs(release): backfill complete contribution records. Thanks @vincentkoc.
- **PR #94588** fix(cron): retry isolated setup timeouts. Thanks @aaroneden.
- **PR #94082** fix(cron): prevent lane timeout during long tool execution. Related #94033. Thanks @ajwan8998 and @JingWang-Star996.
- **PR #94551** feat(firecrawl): add keyless scrape support. Thanks @vincentkoc and @developersdigest.
- **PR #94619** test(ci): stabilize timeout-sensitive shards. Thanks @vincentkoc.
- **PR #94048** fix(telegram): set richMessages default to false explicitly in schema. Related #93770, #93794. Thanks @Monkey-wusky and @obviyus and @Nardoa375 and @laurenceputra.
- **PR #94118** [codex] Fix Telegram rich local Markdown link hrefs. Related #94117. Thanks @dankarization and @obviyus.
- **PR #94646** refactor(sqlite): land database-first memory and proxy alignment. Thanks @vincentkoc.
- **PR #94658** test(sqlite): use shared temp directory helper. Thanks @vincentkoc.
- **PR #92135** fix(openai-embedding): preserve openai/ prefix for non-native base URLs. Related #92124. Thanks @xialonglee and @Kambrian.
- **PR #93737** refactor: add session maintenance transaction seam. Thanks @jalehman.
- **PR #93685** refactor(auto-reply): add lifecycle storage seams. Thanks @jalehman.
- **PR #94349** fix(agents): preserve pending subagent completion announces. Related #93323. Thanks @sallyom and @oiGaDio.
- **PR #93174** test: fold channel message flows into qa e2e. Thanks @RomneyDa.
- **PR #94093** Prevent Codex thread rotation from losing next-step context. Thanks @VACInc.
- **PR #53920** fix(scripts): avoid mutating tracked auth-monitor template during setup. Thanks @JackWuGlobal.
- **PR #94702** Standardize QA coverage IDs on dotted names. Thanks @RomneyDa.
- **PR #81825** fix(skills/1password): stop forcing tmux for desktop app auth (#52540). Thanks @koshaji and @tylerbittner.
- **PR #94725** fix(doctor): warn on volatile SQLite state. Thanks @vincentkoc.
- **PR #88551** fix(agents): skip auth gate for CLI-owned transport. Thanks @yu-xin-c.
- **PR #88581** feat(commands): add /name to rename the current session from chat. Thanks @BSG2000.
- **PR #94324** feat(codex): support app-server SecretRefs. Thanks @kevinlin-openai and @kevinslin.
- **PR #90882** fix: add self-knowledge docs rule to system prompt. Related #90713. Thanks @SutraHsing.
- **PR #94684** fix: #80507 show dry-run output for message send/poll. Thanks @lzyyzznl and @YB0y.
- **PR #93823** fix(whatsapp): keep opening text chunk when first media fails on multi-chunk reply. Thanks @yetval.
- **PR #89203** refactor: route SDK session compatibility through seam. Thanks @jalehman.
- **PR #94453** fix: default cron runMode to "due" instead of "force" (#94270). Thanks @jincheng-xydt and @sallyom and @davectr.
- **PR #94746** fix(note): prevent clack from re-breaking copy-sensitive tokens. Related #94730. Thanks @xzh-icenter and @berkgungor.
- **PR #89904** refactor: route sdk session compatibility through accessor. Thanks @jalehman.
- **PR #86719** fix(skills): retarget stale plugin skill symlinks. Related #85925. Thanks @stevenepalmer and @shakkernerd.
- **PR #94337** fix(tui): show 0 not ? for fresh-session context tokens in footer. Thanks @mushuiyu886.
- **PR #94539** fix(android): group settings by intent. Thanks @Tosko4.
- **PR #92383** fix(gateway): never return an empty chat.history transcript. Thanks @Hidetsugu55.
- **PR #92574** test(browser): cover action-input CLI request bodies. Related #83877. Thanks @yu-xin-c and @davinci282828.
- **PR #92873** test(diffs): add viewerState, toolbar toggle, shadow root, and hydrateProps tests (fixes #83915). Thanks @liuhao1024 and @davinci282828.
- **PR #94257** fix(sessions): preserve Media\* index alignment when reading user-turn fields. Thanks @Nas01010101.
- **PR #94756** fix(codex): bound turn/start text when context budget is non-positive. Related #94748. Thanks @Nas01010101.
- **PR #94729** fix(skills/trello): add curl to requires.bins to match body examples (fixes #94727). Thanks @liuhao1024 and @berkgungor.
- **PR #94790** feat(slack): log INFO receipt for inbound app_mention events. Related #94691. Thanks @ZengWen-DT and @BryceMurray.
- **PR #81696** fix: guard tool event callbacks (AI-assisted). Thanks @enjoylife1243.
- **PR #94809** chore: forward-port alpha release fixes.
- **PR #94612** fix(macos): open NSOpenPanel for embedded Control UI file inputs (#94468). Thanks @bbblending and @DINGDANGMAOUP.
- **PR #89806** fix(feishu): avoid axios interceptor internals. Related #83913. Thanks @sweetcornna and @davinci282828.
- **PR #91923** fix(ios): clean up notification settings state. Thanks @zats.
- **PR #91345** fix: suggest close CLI commands. Related #83999. Thanks @glenn-agent and @HannesOberreiter.
- **PR #94561** Add stdout diagnostics OTEL log exporter. Thanks @jesse-merhi.
- **PR #91013** fix(gateway): ignore stale abort markers for fresh chat events. Related #91012. Thanks @nxmxbbd.
- **PR #89279** fix(tasks): deliver ACP completions to bound Discord threads. Related #84022. Thanks @anyech and @h-mascot.
- **PR #91656** test(cron): expand parseAbsoluteTimeMs test coverage to 39 cases. Related #91654. Thanks @SpecialLeon.
- **PR #94810** fix(telegram): classify sendChatAction 401 by structured error_code, not bare substring match. Related #94787. Thanks @ZOOWH and @parveshsaini.
- **PR #94737** fix(reply): clarify provider internal error copy. Thanks @snowzlmbot.
- **PR #94868** fix(channels): preserve command progress detail. Thanks @vincentkoc.
- **PR #94891** fix(telegram): send progress previews as html text. Thanks @obviyus.
- **PR #94683** fix(outbound): keep direct-only targets out of group sessions. Related #92384. Thanks @scotthuang and @haiwei01.
- **PR #92477** fix: migrate watch app to single-target app (Xcode 27+ compat). Thanks @zats and @joshavant.
- **PR #94812** test(perf): compare saved CLI startup benchmarks. Thanks @FelixIsaac.
- **PR #94856** fix(telegram): normalize all HTML tables before entity-escaping in rich messages. Related #94317. Thanks @zhangqueping and @jairrab.
- **PR #91685** fix(cron): refuse keyless implicit isolated cron delivery inherited from shared agent-main bucket. Thanks @nxmxbbd.
## 2026.6.8
### Highlights
@@ -693,7 +234,6 @@ This audited record covers the complete v2026.6.6..v2026.6.8 history: 192 merged
- **PR #93159** fix(tui): keep parent stdin paused after exit. Thanks @fuller-stack-dev.
- **PR #93616** Keep key-free web search providers opt-in. Thanks @davemorin and @vincentkoc.
- **PR #93164** fix(telegram): preserve rich markdown line breaks. Thanks @vincentkoc.
## 2026.6.7
### Highlights
@@ -780,7 +320,6 @@ This audited record covers the complete v2026.6.6..v2026.6.7-beta.1 history: 59
- **PR #92605** fix(docs): pin Windows Hub download links to v2026.6.5. Related #92470. Thanks @lzyyzznl and @arjkul.
- **PR #92593** #92589: fix(internal-runtime-context): wrap prompt-preface runtime context body in delimiters. Thanks @zhangqueping and @jovi2014-cyber.
- **PR #92606** Run Vitest and Playwright scenarios from qa suite. Thanks @RomneyDa.
## 2026.6.6
### Highlights
@@ -1018,7 +557,6 @@ This audited record covers the complete v2026.6.5..v2026.6.6 history: 198 merged
- **PR #92150** fix(release): gate beta publish on plugin verification. Thanks @vincentkoc.
- **PR #92158** fix(cli): validate gateway RPC timeout inputs. Thanks @ruanrrn and @comeran.
- **PR #91911** fix(agents): retry same model across short rate-limit windows. Thanks @lanzhi-lee.
## 2026.6.5
### Highlights
@@ -1203,7 +741,6 @@ This audited record covers the complete v2026.6.2-beta.1..v2026.6.5 history: 142
- **PR #89659** fix(feishu): retry on send rate-limit errors (230020/230006). Related #70879. Thanks @ladygege and @marshallm-create and @sliverp and @AxelHu.
- **PR #91547** Fix Docker store seed target packages. Related #91035. Thanks @sallyom and @laurenceputra.
- **PR #91423** feat(qqbot): add /bot-group-allways command to toggle mention requirement. Thanks @cxyhhhhh.
## 2026.6.2
### Highlights
@@ -1296,7 +833,6 @@ This audited record covers the complete v2026.6.1..v2026.6.2-beta.1 history: 57
- **PR #89176** fix(browser): honor tab timeout for Chrome MCP. Related #88213. Thanks @MonkeyLeeT and @lamkan0210.
- **PR #90043** fix: restore Skill Workshop current chat toggle. Thanks @shakkernerd.
- **PR #81422** fix(update): surface plugin channel fallbacks. Thanks @BKF-Gitty.
## 2026.6.1
### Highlights
@@ -1511,7 +1047,6 @@ This audited record covers the complete v2026.5.31-beta.4..v2026.6.1 history: 11
- **PR #88288** fix(config): skip state-dir dotenv values that are unresolved shell references. Related #88274. Thanks @Alix-007 and @mathias15010.
- **PR #88305** fix(browser): isolate Chrome MCP pending attach aborts. Related #88304. Thanks @rohitjavvadi.
- **PR #74089** fix(openai/tts): handle [[tts:speed]] directive in OpenAI speech provider (#12163). Thanks @stainlu and @useramuser.
## 2026.5.31
### Highlights
@@ -1642,7 +1177,7 @@ This audited record covers the complete v2026.5.28..v2026.5.31-beta.4 history: 4
- **PR #88346** refactor: extract web content core package.
- **PR #71280** test(gateway): avoid brittle shutdown timer assertion. Thanks @hansolo949.
- **PR #80686** fix(agents): extend session-write-lock payload-less orphan grace from 5s to 30s. Thanks @wAngByg.
- **PR #88067** fix(responses): drop orphaned assistant msg\_\* id when reasoning is dropped (#88019). Thanks @BSG2000.
- **PR #88067** fix(responses): drop orphaned assistant msg_* id when reasoning is dropped (#88019). Thanks @BSG2000.
- **PR #88417** [codex] Route denied exec approval followups to sessions. Related #88167. Thanks @brokemac79 and @jhartman00.
- **PR #85996** fix #85782: surface terminal TUI lifecycle errors. Thanks @zhangguiping-xydt and @vincentkoc and @shakkernerd.
- **PR #88445** refactor: source model catalog types from core.
@@ -1941,7 +1476,6 @@ This audited record covers the complete v2026.5.28..v2026.5.31-beta.4 history: 4
- **PR #88978** perf(ui): skip closed slash menu rerenders. Thanks @vincentkoc.
- **PR #88982** fix(test): wait for telegram timer flushes. Thanks @vincentkoc.
- **PR #88989** perf(ui): guard chat transcript rerenders. Thanks @vincentkoc.
## 2026.5.28
### Highlights

View File

@@ -106,8 +106,7 @@ For coordinated change sets that genuinely need more than 20 PRs, join the **#cl
## Before You PR
- Test locally with your OpenClaw instance
- External PRs must describe the user, product, or operational problem in **What Problem This Solves** and include useful validation in **Evidence**. Focused tests, CI results, screenshots, recordings, terminal output, live observations, redacted logs, and artifact links all count. Reviewers will inspect the code, tests, and CI; use the PR body to explain intent and make validation easy to understand.
- When ClawSweeper, Codex, Barnacle, or a maintainer asks for more context or evidence, edit the PR description instead of only replying in a new comment. Keep **What Problem This Solves**, **Why This Change Was Made**, **User Impact**, and **Evidence** current; a short comment can point reviewers to the update, but the PR body should remain the durable explanation for maintainers and bots.
- External PRs must include a filled **Real behavior proof** section in the PR body. Show the real setup you tested, the exact command or steps you ran after the patch, after-fix evidence, the observed result, and anything you did not test. Screenshots, recordings, terminal screenshots, console output, copied live output, linked artifacts, and redacted runtime logs all count. Unit tests, mocks, snapshots, lint, typechecks, and CI are useful but do not satisfy this requirement by themselves. Maintainers may apply `proof: override` only when the proof gate should not apply.
- Keep PRs takeover-ready: open them from a branch maintainers can push to. For fork PRs, leave GitHub's **Allow edits by maintainers** option enabled so maintainers can finish urgent fixes, changelog entries, or merge prep when needed. If GitHub shows **Allow edits and access to secrets by maintainers**, enable it only when that workflow/secrets access is acceptable and say so in the PR.
- Do not edit `CHANGELOG.md` in contributor PRs. Maintainers or ClawSweeper add the changelog entry when landing user-facing changes.
- Run tests: `pnpm build && pnpm check && pnpm test`
@@ -170,7 +169,7 @@ Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
Please include in your PR:
- [ ] Mark as AI-assisted in the PR title or description
- [ ] Include a concise **Evidence** section with the most useful validation. Reviewers will inspect the code, tests, and CI rather than relying on the PR body alone.
- [ ] Include human-run real behavior proof from your own setup. AI-generated tests, mocks, lint, typechecks, and CI output are supplemental only; they do not prove the fix works for users.
- [ ] Include prompts or session logs if possible (super helpful!)
- [ ] Confirm you understand what the code does
- [ ] If you have access to Codex, run `codex review --base origin/main` locally and address the findings before asking for review

View File

@@ -2,5 +2,5 @@
# Source of truth: apps/android/version.json
# Generated by scripts/android-sync-versioning.ts.
OPENCLAW_ANDROID_VERSION_NAME=2026.6.9
OPENCLAW_ANDROID_VERSION_CODE=2026060901
OPENCLAW_ANDROID_VERSION_NAME=2026.6.2
OPENCLAW_ANDROID_VERSION_CODE=2026060201

View File

@@ -898,38 +898,32 @@ private fun SettingsShellScreen(
ProfilePanel(displayName = displayName.ifBlank { "OpenClaw" }, onClick = { onRouteChange(SettingsRoute.Profile) })
}
val settingsRows =
listOf(
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
SettingsRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, status = nodesDevicesStatus(nodesDevicesSummary), route = SettingsRoute.NodesDevices),
SettingsRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, status = channelsStatus(channelsSummary), route = SettingsRoute.Channels),
SettingsRow("Agents", if (agents.isEmpty()) "Load from gateway" else "${agents.size} available", Icons.Default.Person, status = agents.isNotEmpty(), route = SettingsRoute.Agents),
SettingsRow("Approvals", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, status = approvalsStatus(pendingToolCalls.size), route = SettingsRoute.Approvals),
SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = SettingsRoute.CronJobs),
SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage),
SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills),
SettingsRow("Dreaming", dreamingSummaryText(dreamingSummary), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming),
SettingsRow("Voice", if (speakerEnabled) "Speaker on" else "Speaker muted", Icons.Default.Mic, route = SettingsRoute.Voice),
SettingsRow("Canvas", "Screen surface", Icons.AutoMirrored.Filled.ScreenShare, status = isConnected, route = SettingsRoute.Canvas),
SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications),
SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities),
SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance),
SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About),
SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health),
)
settingsSections(settingsRows).forEach { section ->
item {
SettingsSectionTitle(section.title)
}
item {
SettingsGroup(rows = section.rows, onOpen = onRouteChange)
}
}
item {
SettingsSectionTitle("Account")
SettingsGroup(
rows =
listOf(
SettingsRow("Profile", displayName.ifBlank { "Local device" }, Icons.Default.Person, route = SettingsRoute.Profile),
SettingsRow("Voice", if (speakerEnabled) "Speaker on" else "Speaker muted", Icons.Default.Mic, route = SettingsRoute.Voice),
SettingsRow("Agents", if (agents.isEmpty()) "Load from gateway" else "${agents.size} available", Icons.Default.Person, status = agents.isNotEmpty(), route = SettingsRoute.Agents),
SettingsRow("Approvals", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, status = approvalsStatus(pendingToolCalls.size), route = SettingsRoute.Approvals),
SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = SettingsRoute.CronJobs),
SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage),
SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills),
SettingsRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, status = nodesDevicesStatus(nodesDevicesSummary), route = SettingsRoute.NodesDevices),
SettingsRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, status = channelsStatus(channelsSummary), route = SettingsRoute.Channels),
SettingsRow("Dreaming", dreamingSummaryText(dreamingSummary), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming),
SettingsRow("Canvas", "Screen surface", Icons.AutoMirrored.Filled.ScreenShare, status = isConnected, route = SettingsRoute.Canvas),
SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications),
SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities),
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance),
SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health),
SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About),
),
onOpen = onRouteChange,
)
}
item {
SettingsGroup(
rows = listOf(SettingsRow("Sign Out", "Disconnect", Icons.AutoMirrored.Filled.ExitToApp)),
@@ -1063,7 +1057,7 @@ private fun dreamingStatus(summary: GatewayDreamingSummary): Boolean? =
else -> null
}
internal data class SettingsRow(
private data class SettingsRow(
val title: String,
val value: String,
val icon: ImageVector,
@@ -1071,65 +1065,6 @@ internal data class SettingsRow(
val route: SettingsRoute? = null,
)
internal data class SettingsSection(
val title: String,
val rows: List<SettingsRow>,
)
internal fun settingsSections(rows: List<SettingsRow>): List<SettingsSection> =
settingsSectionOrder.mapNotNull { title ->
val sectionRows = rows.filter { row -> row.route?.let(::settingsSectionTitleForRoute) == title }
if (sectionRows.isEmpty()) null else SettingsSection(title = title, rows = sectionRows)
}
private val settingsSectionOrder =
listOf(
"Connection",
"Agents & automation",
"Phone context & privacy",
"Profile & device",
"Diagnostics",
)
internal fun settingsSectionTitleForRoute(route: SettingsRoute): String =
when (route) {
SettingsRoute.Gateway,
SettingsRoute.NodesDevices,
SettingsRoute.Channels,
-> "Connection"
SettingsRoute.Agents,
SettingsRoute.Approvals,
SettingsRoute.CronJobs,
SettingsRoute.Usage,
SettingsRoute.Skills,
SettingsRoute.Dreaming,
-> "Agents & automation"
SettingsRoute.Voice,
SettingsRoute.Canvas,
SettingsRoute.Notifications,
SettingsRoute.PhoneCapabilities,
-> "Phone context & privacy"
SettingsRoute.Profile,
SettingsRoute.Appearance,
SettingsRoute.About,
-> "Profile & device"
SettingsRoute.Health -> "Diagnostics"
SettingsRoute.Home -> "Diagnostics"
}
@Composable
private fun SettingsSectionTitle(title: String) {
Text(
text = title.uppercase(),
style = ClawTheme.type.caption.copy(fontSize = 12.sp, lineHeight = 16.sp),
color = ClawTheme.colors.textMuted,
)
}
@Composable
private fun ProfilePanel(
displayName: String,

View File

@@ -7,8 +7,6 @@ import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPendingDeviceSummary
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -157,46 +155,7 @@ class ShellScreenLogicTest {
assertEquals("Node approval pending", rows.single().subtitle)
}
@Test
fun settingsSectionTitlesGroupPowerSettingsByMeaning() {
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.Gateway))
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.NodesDevices))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.Approvals))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.CronJobs))
assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.PhoneCapabilities))
assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.Notifications))
assertEquals("Profile & device", settingsSectionTitleForRoute(SettingsRoute.Appearance))
assertEquals("Diagnostics", settingsSectionTitleForRoute(SettingsRoute.Health))
}
@Test
fun settingsSectionsPreserveMeaningfulOrder() {
val sections =
settingsSections(
listOf(
settingsRow(SettingsRoute.Voice),
settingsRow(SettingsRoute.Agents),
settingsRow(SettingsRoute.Gateway),
settingsRow(SettingsRoute.Appearance),
settingsRow(SettingsRoute.Health),
),
)
assertEquals(
listOf(
"Connection",
"Agents & automation",
"Phone context & privacy",
"Profile & device",
"Diagnostics",
),
sections.map { it.title },
)
}
private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList())
private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())
private fun settingsRow(route: SettingsRoute): SettingsRow = SettingsRow(route.name, "Value", Icons.Default.Settings, route = route)
}

View File

@@ -1 +1,3 @@
Maintenance update for the current OpenClaw Android release.
OpenClaw is now available on Android.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.

View File

@@ -1,4 +1,4 @@
{
"version": "2026.6.9",
"versionCode": 2026060901
"version": "2026.6.2",
"versionCode": 2026060201
}

View File

@@ -12,7 +12,7 @@ report_include:
- Sources/**
- ShareExtension/**
- ActivityWidget/**
- WatchApp/Sources/**
- WatchExtension/Sources/**
build_arguments:
- -destination
- generic/platform=iOS Simulator

View File

@@ -1,13 +1,5 @@
# OpenClaw iOS Changelog
## 2026.6.9 - 2026-06-20
Maintenance update for the current OpenClaw release.
- Added Apple Watch controls for common agent actions.
- Improved Gateway setup, notification settings, and share-extension identity handling.
- Updated the Watch app integration for current Xcode compatibility.
## 2026.6.2 - 2026-06-02
OpenClaw is now available on iPhone.

View File

@@ -3,7 +3,6 @@
"signingRepo": "git@github.com:openclaw/apps-signing.git",
"signingBranch": "main",
"profileType": "appstore",
"appGroupId": "group.ai.openclawfoundation.app.shared",
"targets": [
{
"target": "OpenClaw",
@@ -12,8 +11,7 @@
"platform": "IOS",
"profileKey": "OPENCLAW_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
"capabilities": ["PUSH_NOTIFICATIONS"]
},
{
"target": "OpenClawShareExtension",
@@ -22,8 +20,7 @@
"platform": "IOS",
"profileKey": "OPENCLAW_SHARE_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.share",
"capabilities": ["APP_GROUPS"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
"capabilities": []
},
{
"target": "OpenClawActivityWidget",
@@ -42,6 +39,15 @@
"profileKey": "OPENCLAW_WATCH_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
"capabilities": []
},
{
"target": "OpenClawWatchExtension",
"displayName": "OpenClaw Watch Extension",
"bundleId": "ai.openclawfoundation.app.watchkitapp.extension",
"platform": "IOS",
"profileKey": "OPENCLAW_WATCH_EXTENSION_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
"capabilities": []
}
]
}

View File

@@ -7,11 +7,12 @@ OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
// Local contributors can override this by running scripts/ios-configure-signing.sh.
// Keep include after defaults: xcconfig is evaluated top-to-bottom.

View File

@@ -2,8 +2,8 @@
// Source of truth: apps/ios/version.json
// Generated by scripts/ios-sync-versioning.ts.
OPENCLAW_IOS_VERSION = 2026.6.9
OPENCLAW_MARKETING_VERSION = 2026.6.9
OPENCLAW_IOS_VERSION = 2026.6.2
OPENCLAW_MARKETING_VERSION = 2026.6.2
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

@@ -7,12 +7,13 @@ OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
// Leave empty with automatic signing.
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =

View File

@@ -101,7 +101,6 @@ Release-owner secrets:
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`.
- The share sheet requires the Apple Developer App Group in `apps/ios/Config/AppStoreSigning.json` to be associated with both the app and share-extension bundle IDs before App Store profiles are regenerated.
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
@@ -156,8 +155,7 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
- `ai.openclawfoundation.app.share`
- `ai.openclawfoundation.app.activitywidget`
- `ai.openclawfoundation.app.watchkitapp`
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`.
- `ai.openclawfoundation.app.watchkitapp.extension`
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.

View File

@@ -41,7 +41,5 @@
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
<key>OpenClawAppGroupIdentifier</key>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</dict>
</plist>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -184,8 +184,7 @@ final class ShareViewController: UIViewController {
clientId: clientId,
clientMode: "node",
clientDisplayName: "OpenClaw Share",
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true)
includeDeviceIdentity: false)
}
do {

View File

@@ -10,8 +10,8 @@ OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
@@ -19,6 +19,7 @@ OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
// so later assignments in local files override the defaults above.

View File

@@ -53,9 +53,8 @@ struct SettingsProTab: View {
@State var suppressCredentialPersist = false
@State var locationStatusText: String?
@State var previousLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@State var notificationStatus: SettingsNotificationStatus = .checking
@State var isRequestingNotificationAuthorization = false
@State var showNotificationRelayDisclosure = false
@State var notificationStatusText = "Checking"
@State var notificationActionText = "Request Access"
@State var diagnosticsLastRunText = "Not run"
@State var diagnosticsIssueCount: Int?
@State var showTalkIssueDetails = false
@@ -63,18 +62,15 @@ struct SettingsProTab: View {
let initialRoute: SettingsRoute?
let directRoute: SettingsRoute?
let headerLeadingAction: OpenClawSidebarHeaderAction?
let onRouteChange: ((SettingsRoute?) -> Void)?
init(
initialRoute: SettingsRoute? = nil,
directRoute: SettingsRoute? = nil,
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
onRouteChange: ((SettingsRoute?) -> Void)? = nil)
headerLeadingAction: OpenClawSidebarHeaderAction? = nil)
{
self.initialRoute = initialRoute
self.directRoute = directRoute
self.headerLeadingAction = headerLeadingAction
self.onRouteChange = onRouteChange
}
var body: some View {
@@ -122,7 +118,6 @@ struct SettingsProTab: View {
self.refreshNotificationSettings()
self.applyPendingGatewaySetupLinkIfNeeded()
self.applyInitialRouteIfNeeded()
self.notifyRouteChange()
}
.onChange(of: self.scenePhase) { _, phase in
if phase == .active {
@@ -159,9 +154,6 @@ struct SettingsProTab: View {
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.applyPendingGatewaySetupLinkIfNeeded()
}
.onChange(of: self.navigationPath) { _, _ in
self.notifyRouteChange()
}
}
private func settingsModalPresentation(_ content: some View) -> some View {
@@ -226,19 +218,6 @@ struct SettingsProTab: View {
} message: {
Text(self.scannerError ?? "")
}
.alert("Enable OpenClaw Hosted Push Relay?", isPresented: self.$showNotificationRelayDisclosure) {
Button("Continue") {
self.requestNotificationAuthorizationFromSettings()
}
Button("Not Now", role: .cancel) {}
} message: {
Text(self.notificationRelayDisclosureMessage)
}
}
func openNotificationsRouteFromApprovals() {
guard self.directRoute == nil else { return }
self.navigationPath = [.notifications]
}
private func applyInitialRouteIfNeeded() {
@@ -247,12 +226,4 @@ struct SettingsProTab: View {
guard self.navigationPath != [initialRoute] else { return }
self.navigationPath = [initialRoute]
}
private func notifyRouteChange() {
if let directRoute {
self.onRouteChange?(directRoute)
return
}
self.onRouteChange?(self.navigationPath.last)
}
}

View File

@@ -65,7 +65,7 @@ extension SettingsProTab {
title: "Notifications",
detail: "Approval and event alert channel",
value: self.notificationStatusText,
color: self.notificationStatus.color)
color: self.notificationStatusText == "Allowed" ? OpenClawBrand.ok : .secondary)
Divider().padding(.leading, 60)
self.diagnosticCheckRow(
icon: "rectangle.on.rectangle",
@@ -157,7 +157,7 @@ extension SettingsProTab {
gatewayConnected: self.gatewayDiagnosticConnected,
discoveredGatewayCount: self.gatewayController.gateways.count,
talkConfigLoaded: self.gatewayDiagnosticTalkConfigLoaded,
notificationsAllowed: self.notificationStatus == .allowed)
notificationStatusText: self.notificationStatusText)
self.diagnosticsIssueCount = issueCount
self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date())
}
@@ -422,41 +422,40 @@ extension SettingsProTab {
}
func handleNotificationAction() {
if self.notificationStatus.shouldOpenNotificationSettings {
self.openNotificationSettings()
if self.notificationStatusText == "Allowed" || self.notificationStatusText == "Not Allowed" {
self.openSystemSettings()
return
}
guard self.notificationStatus == .notSet else { return }
if PushBuildConfig.current.usesOpenClawHostedRelay {
self.showNotificationRelayDisclosure = true
return
}
self.requestNotificationAuthorizationFromSettings()
}
func requestNotificationAuthorizationFromSettings() {
guard !self.isRequestingNotificationAuthorization else { return }
self.isRequestingNotificationAuthorization = true
Task {
let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [
.alert,
.badge,
.sound,
])) ?? false
let settings = await UNUserNotificationCenter.current().notificationSettings()
await MainActor.run {
self.isRequestingNotificationAuthorization = false
self.notificationStatus = SettingsNotificationStatus(settings.authorizationStatus)
guard granted, self.notificationStatus.allowsNotifications else { return }
UIApplication.shared.registerForRemoteNotifications()
self.notificationStatusText = granted ? "Allowed" : "Not Allowed"
self.notificationActionText = granted ? "Open System Settings" : "Open System Settings"
}
}
}
@MainActor
func applyNotificationStatus(_ status: UNAuthorizationStatus) {
self.notificationStatus = SettingsNotificationStatus(status)
switch status {
case .authorized, .provisional, .ephemeral:
self.notificationStatusText = "Allowed"
self.notificationActionText = "Open System Settings"
case .denied:
self.notificationStatusText = "Not Allowed"
self.notificationActionText = "Open System Settings"
case .notDetermined:
self.notificationStatusText = "Not Set"
self.notificationActionText = "Request Access"
@unknown default:
self.notificationStatusText = "Unknown"
self.notificationActionText = "Open System Settings"
}
}
func persistGatewayToken(_ value: String) {
@@ -477,8 +476,8 @@ extension SettingsProTab {
instanceId: instanceId)
}
func openNotificationSettings() {
guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else { return }
func openSystemSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
@@ -676,9 +675,6 @@ extension SettingsProTab {
if self.appModel.isAppleReviewDemoModeEnabled {
return "Live gateway requests are disabled in demo mode."
}
if self.notificationsNeedAttention {
return "Foreground approvals still appear while OpenClaw is connected."
}
return self.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway."
}
@@ -718,19 +714,7 @@ extension SettingsProTab {
}
var approvalsDetail: String {
if self.notificationsNeedAttention {
return self.pendingApproval == nil ? "Notifications off" : "1 waiting, notifications off"
}
return self.pendingApproval == nil ? "No approvals waiting" : "1 request waiting"
}
var notificationsNeedAttention: Bool {
switch self.notificationStatus {
case .allowed, .checking:
false
case .notAllowed, .notSet, .unknown:
true
}
self.pendingApproval == nil ? "No approvals waiting" : "1 request waiting"
}
var approvalItems: [SettingsApprovalItem] {
@@ -793,41 +777,4 @@ extension SettingsProTab {
case .always: "Always"
}
}
var notificationStatusText: String {
self.notificationStatus.text
}
var notificationActionText: String {
self.notificationStatus.actionTitle
}
var notificationStatusDetail: String {
switch self.notificationStatus {
case .checking:
"Checking iOS notification permission."
case .allowed:
"OpenClaw can show approval prompts and event alerts when the app is not active."
case .notAllowed:
"Notifications have been denied. Enable them in iOS Settings."
case .notSet:
"Enable notifications to receive approval prompts and event alerts outside the app."
case .unknown:
"OpenClaw cannot determine the current notification permission state."
}
}
var notificationRelayDetail: String {
if PushBuildConfig.current.usesOpenClawHostedRelay {
return """
This build uses OpenClaw's hosted push relay at ios-push-relay.openclaw.ai for notification \
delivery data.
"""
}
return "This build is not configured to use OpenClaw's hosted push relay."
}
var notificationRelayDisclosureMessage: String {
"Enabling this sends delivery data through OpenClaw's hosted push relay."
}
}

View File

@@ -308,57 +308,15 @@ extension SettingsProTab {
self.detailStatusCard(
icon: "checkmark.shield.fill",
title: "Approvals",
detail: self.notificationsNeedAttention
? "Out-of-app approval alerts need notification permission."
: (self.pendingApproval == nil ? "No gateway actions are waiting for review." :
"Review the pending gateway action."),
value: self.notificationsNeedAttention
? "Alerts Off"
: (self.pendingApproval == nil ? "clear" : "1 waiting"),
color: self.notificationsNeedAttention ? OpenClawBrand.warn :
(self.pendingApproval == nil ? OpenClawBrand.ok : OpenClawBrand.warn))
if self.notificationsNeedAttention {
self.approvalNotificationsWarningCard
}
detail: self.pendingApproval == nil ? "No gateway actions are waiting for review." :
"Review the pending gateway action.",
value: self.pendingApproval == nil ? "clear" : "1 waiting",
color: self.pendingApproval == nil ? OpenClawBrand.ok : OpenClawBrand.warn)
self.approvalsReviewCard
}
}
var approvalNotificationsWarningCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: "bell.slash.fill", color: OpenClawBrand.warn)
VStack(alignment: .leading, spacing: 4) {
Text("Notifications are off")
.font(.subheadline.weight(.semibold))
Text(
"""
Enable Notifications to receive approval notifications while OpenClaw is not open.
""")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
if self.directRoute == nil {
Button {
self.openNotificationsRouteFromApprovals()
} label: {
Label("Open Notifications", systemImage: "bell.badge")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var approvalsReviewCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
@@ -532,9 +490,9 @@ extension SettingsProTab {
self.detailStatusCard(
icon: "bell",
title: "Notifications",
detail: self.notificationStatusDetail,
detail: "Approvals and event alerts from OpenClaw.",
value: self.notificationStatusText,
color: self.notificationStatus.color)
color: self.notificationStatusText == "Allowed" ? OpenClawBrand.ok : .secondary)
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
@@ -543,30 +501,15 @@ extension SettingsProTab {
} label: {
Label(
self.notificationActionText,
systemImage: self.notificationStatus.actionIcon)
systemImage: self.notificationStatusText == "Allowed" ? "gear" : "bell.badge")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(self.notificationStatus == .checking || self.isRequestingNotificationAuthorization)
Text(self.notificationStatusDetail)
Text("OpenClaw uses notifications for approval prompts and mirrored event alerts.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Divider()
HStack(alignment: .top, spacing: 10) {
Image(systemName: "network")
.font(.caption.weight(.semibold))
.foregroundStyle(OpenClawBrand.accent)
.frame(width: 22, height: 22)
Text(self.notificationRelayDetail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)

View File

@@ -1,7 +1,6 @@
import Darwin
import OpenClawKit
import SwiftUI
import UserNotifications
enum SettingsRoute: Hashable {
case gateway
@@ -66,87 +65,6 @@ struct SettingsApprovalRow: View {
}
}
enum SettingsNotificationStatus: Equatable {
case checking
case allowed
case notAllowed
case notSet
case unknown
init(_ status: UNAuthorizationStatus) {
switch status {
case .authorized, .provisional, .ephemeral:
self = .allowed
case .denied:
self = .notAllowed
case .notDetermined:
self = .notSet
@unknown default:
self = .unknown
}
}
var text: String {
switch self {
case .checking: "Checking"
case .allowed: "Enabled"
case .notAllowed: "Denied"
case .notSet: "Not Enabled"
case .unknown: "Unknown"
}
}
var actionTitle: String {
switch self {
case .notSet:
"Enable Notifications"
case .checking:
"Checking"
case .allowed:
"Manage in iOS Settings"
case .notAllowed, .unknown:
"Open iOS Settings"
}
}
var actionIcon: String {
switch self {
case .allowed:
"gear"
case .notAllowed, .unknown:
"gear.badge"
case .checking:
"hourglass"
case .notSet:
"bell.badge"
}
}
var color: Color {
switch self {
case .allowed:
OpenClawBrand.ok
case .notAllowed, .unknown:
OpenClawBrand.warn
case .checking, .notSet:
.secondary
}
}
var shouldOpenNotificationSettings: Bool {
switch self {
case .allowed, .notAllowed, .unknown:
true
case .checking, .notSet:
false
}
}
var allowsNotifications: Bool {
self == .allowed
}
}
enum SettingsDiagnosticIssue: String, Equatable, CaseIterable {
case gatewayOffline
case discoveryUnavailable
@@ -159,13 +77,13 @@ enum SettingsDiagnostics {
gatewayConnected: Bool,
discoveredGatewayCount: Int,
talkConfigLoaded: Bool,
notificationsAllowed: Bool) -> [SettingsDiagnosticIssue]
notificationStatusText: String) -> [SettingsDiagnosticIssue]
{
var issues: [SettingsDiagnosticIssue] = []
if !gatewayConnected { issues.append(.gatewayOffline) }
if discoveredGatewayCount == 0 { issues.append(.discoveryUnavailable) }
if gatewayConnected, !talkConfigLoaded { issues.append(.talkConfigMissing) }
if !notificationsAllowed { issues.append(.notificationsUnavailable) }
if notificationStatusText != "Allowed" { issues.append(.notificationsUnavailable) }
return issues
}
@@ -173,13 +91,13 @@ enum SettingsDiagnostics {
gatewayConnected: Bool,
discoveredGatewayCount: Int,
talkConfigLoaded: Bool,
notificationsAllowed: Bool) -> Int
notificationStatusText: String) -> Int
{
self.issues(
gatewayConnected: gatewayConnected,
discoveredGatewayCount: discoveredGatewayCount,
talkConfigLoaded: talkConfigLoaded,
notificationsAllowed: notificationsAllowed).count
notificationStatusText: notificationStatusText).count
}
static func timestamp(_ date: Date) -> String {

View File

@@ -2,14 +2,11 @@ import SwiftUI
private struct ExecApprovalPromptDialogModifier: ViewModifier {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
let suppressedApprovalID: String?
func body(content: Content) -> some View {
content
.overlay {
if let prompt = self.appModel.pendingExecApprovalPrompt,
prompt.id != self.suppressedApprovalID
{
if let prompt = self.appModel.pendingExecApprovalPrompt {
ZStack {
Color.black.opacity(0.38)
.ignoresSafeArea()
@@ -61,7 +58,7 @@ private struct ExecApprovalPromptCard: View {
VStack(alignment: .leading, spacing: 6) {
Text("Exec approval required")
.font(.headline)
Text("Review this exec request before continuing. Your decision will be sent back to the gateway.")
Text("OpenClaw opened from a notification. Review this exec request before continuing.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
@@ -191,7 +188,7 @@ private struct ExecApprovalPromptMetadataRow: View {
}
extension View {
func execApprovalPromptDialog(suppressedApprovalID: String? = nil) -> some View {
self.modifier(ExecApprovalPromptDialogModifier(suppressedApprovalID: suppressedApprovalID))
func execApprovalPromptDialog() -> some View {
self.modifier(ExecApprovalPromptDialogModifier())
}
}

View File

@@ -62,7 +62,6 @@ struct GatewayConnectConfig {
lhs.clientId == rhs.clientId &&
lhs.clientMode == rhs.clientMode &&
lhs.clientDisplayName == rhs.clientDisplayName &&
lhs.deviceIdentityProfile == rhs.deviceIdentityProfile &&
lhs.includeDeviceIdentity == rhs.includeDeviceIdentity &&
lhsScopes == rhsScopes &&
lhsCaps == rhsCaps &&

View File

@@ -1,102 +0,0 @@
import SwiftUI
private struct NotificationPermissionGuidanceDialogModifier: ViewModifier {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
let openNotifications: (String) -> Void
func body(content: Content) -> some View {
content
.overlay {
if let prompt = self.appModel.pendingNotificationPermissionGuidancePrompt {
ZStack {
Color.black.opacity(0.38)
.ignoresSafeArea()
NotificationPermissionGuidanceCard(
onOpenNotifications: {
let approvalId = prompt.approvalId
self.appModel.dismissNotificationPermissionGuidancePrompt(
suppressFuture: false)
self.openNotifications(approvalId)
},
onDismiss: {
self.appModel.dismissNotificationPermissionGuidancePrompt(
suppressFuture: false)
},
onSuppressFuture: {
self.appModel.dismissNotificationPermissionGuidancePrompt(
suppressFuture: true)
})
.padding(.horizontal, 20)
.frame(maxWidth: 460)
.transition(.scale(scale: 0.98).combined(with: .opacity))
}
.zIndex(2)
.id(prompt.id)
}
}
.animation(
.easeInOut(duration: 0.18),
value: self.appModel.pendingNotificationPermissionGuidancePrompt?.id)
}
}
private struct NotificationPermissionGuidanceCard: View {
let onOpenNotifications: () -> Void
let onDismiss: () -> Void
let onSuppressFuture: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("Notifications are off")
.font(.headline)
Text(
"""
Exec approvals can only be reviewed while OpenClaw is open and connected.
Enable Notifications to receive approval notifications while OpenClaw is not open.
""")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
VStack(spacing: 10) {
Button {
self.onOpenNotifications()
} label: {
Text("Open Notifications Settings")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button(role: .cancel) {
self.onDismiss()
} label: {
Text("Not Now")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button {
self.onSuppressFuture()
} label: {
Text("Don't show again")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
.controlSize(.large)
.frame(maxWidth: .infinity)
}
.padding(18)
.proPanelSurface(tint: OpenClawBrand.warn, radius: 20, isProminent: true)
}
}
extension View {
func notificationPermissionGuidanceDialog(openNotifications: @escaping (String) -> Void) -> some View {
self.modifier(NotificationPermissionGuidanceDialogModifier(openNotifications: openNotifications))
}
}

View File

@@ -78,8 +78,6 @@
<string>OpenClaw uses on-device speech recognition for talk mode and voice wake.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>OpenClawAppGroupIdentifier</key>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
<key>OpenClawCanonicalVersion</key>
<string>$(OPENCLAW_IOS_VERSION)</string>
<key>OpenClawPushAPNsEnvironment</key>

View File

@@ -23,10 +23,6 @@ private struct WatchChatPreview {
var statusText: String?
}
private struct ExecApprovalGatewayEventPayload: Decodable {
var id: String
}
/// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
private let lock = NSLock()
@@ -87,11 +83,6 @@ final class NodeAppModel {
}
}
struct NotificationPermissionGuidancePrompt: Identifiable, Equatable {
let id = UUID()
let approvalId: String
}
private enum ExecApprovalResolutionOutcome {
case resolved
case stale
@@ -105,8 +96,6 @@ final class NodeAppModel {
}
private let deepLinkLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "DeepLink")
private nonisolated static let execApprovalNotificationGuidanceSuppressedKey =
"notifications.execApprovalGuidance.suppressed"
private let pushWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PushWake")
private let pendingActionLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PendingAction")
private let locationWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "LocationWake")
@@ -167,7 +156,6 @@ final class NodeAppModel {
private(set) var pendingExecApprovalPromptResolving: Bool = false
private(set) var pendingExecApprovalPromptErrorText: String?
private var pendingExecApprovalPromptRequestGeneration: Int = 0
private(set) var pendingNotificationPermissionGuidancePrompt: NotificationPermissionGuidancePrompt?
private var queuedAgentDeepLinkPrompt: AgentDeepLinkPrompt?
private var lastAgentDeepLinkPromptAt: Date = .distantPast
@ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?
@@ -907,50 +895,26 @@ final class NodeAppModel {
for await evt in stream {
if Task.isCancelled { return }
guard let payload = evt.payload else { continue }
await self.handleOperatorGatewayServerEvent(evt)
switch evt.event {
case "voicewake.changed":
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
case "talk.mode":
struct Payload: Decodable {
var enabled: Bool
var phase: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
default:
continue
}
}
}
}
private func handleOperatorGatewayServerEvent(_ evt: EventFrame) async {
guard let payload = evt.payload else { return }
switch evt.event {
case "voicewake.changed":
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
case "talk.mode":
struct Payload: Decodable {
var enabled: Bool
var phase: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
case ExecApprovalNotificationBridge.requestedKind:
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
await self.presentNotificationPermissionGuidanceForExecApprovalIfNeeded(approvalId: approvalId)
await self.presentExecApprovalNotificationPrompt(
ExecApprovalNotificationPrompt(approvalId: approvalId))
case ExecApprovalNotificationBridge.resolvedKind:
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
await self.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
default:
return
}
}
private nonisolated static func execApprovalEventID(from payload: AnyCodable) -> String? {
guard let decoded = try? GatewayPayloadDecoding.decode(
payload,
as: ExecApprovalGatewayEventPayload.self)
else {
return nil
}
let approvalId = decoded.id.trimmingCharacters(in: .whitespacesAndNewlines)
return approvalId.isEmpty ? nil : approvalId
}
private func applyTalkModeSync(enabled: Bool, phase: String?) {
_ = phase
guard self.talkMode.isEnabled != enabled else { return }
@@ -1368,8 +1332,8 @@ final class NodeAppModel {
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty notification"))
}
let status = await self.notificationAuthorizationStatus()
guard Self.isNotificationAuthorizationAllowed(status) else {
let finalStatus = await self.requestNotificationAuthorizationIfNeeded()
guard finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
@@ -1421,18 +1385,9 @@ final class NodeAppModel {
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty chat.push text"))
}
let shouldSpeak = params.speak ?? true
let status = await self.notificationAuthorizationStatus()
let notificationsAllowed = Self.isNotificationAuthorizationAllowed(status)
if !notificationsAllowed, !shouldSpeak {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications"))
}
let finalStatus = await self.requestNotificationAuthorizationIfNeeded()
let messageId = UUID().uuidString
if notificationsAllowed {
if finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral {
let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
let content = UNMutableNotificationContent()
content.title = "OpenClaw"
@@ -1453,7 +1408,7 @@ final class NodeAppModel {
}
}
if shouldSpeak {
if params.speak ?? true {
let toSpeak = text
Task { @MainActor in
try? await TalkSystemSpeechSynthesizer.shared.speak(text: toSpeak)
@@ -1465,6 +1420,26 @@ final class NodeAppModel {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
}
private func requestNotificationAuthorizationIfNeeded() async -> NotificationAuthorizationStatus {
let status = await self.notificationAuthorizationStatus()
guard status == .notDetermined else { return status }
// Avoid hanging invoke requests if the permission prompt is never answered.
_ = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
_ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
}
let updatedStatus = await self.notificationAuthorizationStatus()
if Self.isNotificationAuthorizationAllowed(updatedStatus) {
// Refresh APNs registration immediately after the first permission grant so the
// gateway can receive a push registration without requiring an app relaunch.
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
return updatedStatus
}
private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
let result = await self.runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in
await notificationCenter.authorizationStatus()
@@ -1488,29 +1463,6 @@ final class NodeAppModel {
}
}
private func presentNotificationPermissionGuidanceForExecApprovalIfNeeded(approvalId: String) async {
guard !self.execApprovalNotificationGuidanceSuppressed else { return }
let status = await self.notificationAuthorizationStatus()
guard !Self.isNotificationAuthorizationAllowed(status) else { return }
self.pendingNotificationPermissionGuidancePrompt =
NotificationPermissionGuidancePrompt(approvalId: approvalId)
}
var execApprovalNotificationGuidanceSuppressed: Bool {
UserDefaults.standard.bool(forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
}
func dismissNotificationPermissionGuidancePrompt(suppressFuture: Bool) {
if suppressFuture {
UserDefaults.standard.set(true, forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
}
self.pendingNotificationPermissionGuidancePrompt = nil
}
func resetExecApprovalNotificationGuidanceSuppression() {
UserDefaults.standard.removeObject(forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
}
private func runNotificationCall<T: Sendable>(
timeoutSeconds: Double,
operation: @escaping @Sendable () async throws -> T) async -> Result<T, NotificationCallError>
@@ -2383,6 +2335,10 @@ extension NodeAppModel {
nodeOptions: nodeOptions,
sessionBox: sessionBox)
}
// QR bootstrap onboarding should surface the system notification permission
// prompt immediately so visible APNs alerts work without a second manual step.
_ = await self.requestNotificationAuthorizationIfNeeded()
}
private func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
@@ -3960,15 +3916,11 @@ extension NodeAppModel {
let hadWatchPrompt = self.watchExecApprovalPromptsByID[normalizedApprovalID] != nil
let hadPendingPrompt = self.pendingExecApprovalPrompt?.id == normalizedApprovalID
let hadPendingRecoveryID = self.pendingWatchExecApprovalRecoveryIDs.contains(normalizedApprovalID)
let hadGuidancePrompt = self.pendingNotificationPermissionGuidancePrompt?.approvalId == normalizedApprovalID
let hadApprovalSurface = hadWatchPrompt || hadPendingPrompt || hadPendingRecoveryID
guard hadApprovalSurface || hadGuidancePrompt else {
guard hadWatchPrompt || hadPendingPrompt || hadPendingRecoveryID else {
return
}
if hadApprovalSurface {
await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .resolved)
}
await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .resolved)
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
}
@@ -4449,17 +4401,10 @@ extension NodeAppModel {
private func clearPendingExecApprovalPromptIfMatches(_ approvalId: String) {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
self.clearNotificationPermissionGuidancePromptIfMatches(normalizedApprovalID)
guard self.pendingExecApprovalPrompt?.id == normalizedApprovalID else { return }
self.dismissPendingExecApprovalPrompt()
}
private func clearNotificationPermissionGuidancePromptIfMatches(_ approvalId: String) {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.pendingNotificationPermissionGuidancePrompt?.approvalId == normalizedApprovalID else { return }
self.pendingNotificationPermissionGuidancePrompt = nil
}
private nonisolated static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
guard let gatewayError = error as? GatewayResponseError else { return false }
if gatewayError.code != "INVALID_REQUEST" {
@@ -5165,20 +5110,6 @@ extension NodeAppModel {
self.pendingExecApprovalPrompt
}
func _test_pendingNotificationPermissionGuidancePrompt() -> NotificationPermissionGuidancePrompt? {
self.pendingNotificationPermissionGuidancePrompt
}
func _debug_presentNotificationPermissionGuidancePromptForScreenshot() {
self.resetExecApprovalNotificationGuidanceSuppression()
self.pendingNotificationPermissionGuidancePrompt =
NotificationPermissionGuidancePrompt(approvalId: "screenshot-exec-approval")
}
func _test_resetExecApprovalNotificationGuidanceSuppression() {
self.resetExecApprovalNotificationGuidanceSuppression()
}
func _test_recordPendingWatchExecApprovalRecoveryID(_ approvalId: String) {
self.appendPendingWatchExecApprovalRecoveryID(approvalId)
}
@@ -5208,14 +5139,6 @@ extension NodeAppModel {
isBackgrounded: isBackgrounded)
}
nonisolated static func _test_execApprovalEventID(from payload: AnyCodable) -> String? {
self.execApprovalEventID(from: payload)
}
func _test_handleOperatorGatewayServerEvent(_ event: EventFrame) async {
await self.handleOperatorGatewayServerEvent(event)
}
nonisolated static func _test_watchExecApprovalIDsNeedingFetch(
candidateIDs: [String],
cachedApprovalIDs: [String]) -> [String]
@@ -5312,6 +5235,24 @@ extension NodeAppModel {
func _test_restartGatewaySessionsAfterForegroundStaleConnection() async {
await self.restartGatewaySessionsAfterForegroundStaleConnection()
}
func _test_handleSuccessfulBootstrapGatewayOnboarding() async {
await self.handleSuccessfulBootstrapGatewayOnboarding(
url: URL(string: "wss://gateway.example")!,
stableID: "test-gateway",
token: nil,
password: nil,
nodeOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios",
clientMode: "node",
clientDisplayName: nil),
sessionBox: nil)
}
}
#endif
// swiftlint:enable type_body_length file_length

View File

@@ -18,7 +18,6 @@ enum GatewayOnboardingReset {
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
DeviceAuthStore.clearToken(deviceId: deviceId, role: "node")
DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator")
DeviceAuthStore.clearAll(profile: .shareExtension)
GatewaySettingsStore.clearLastGatewayConnection(defaults: defaults)
GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults)

View File

@@ -4,9 +4,5 @@
<dict>
<key>aps-environment</key>
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -409,7 +409,7 @@ enum WatchPromptNotificationBridge {
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty || !body.isEmpty else { return }
guard await self.isNotificationAuthorizationAllowed() else { return }
guard await self.requestNotificationAuthorizationIfNeeded() else { return }
let normalizedActions = (params.actions ?? []).compactMap { action -> OpenClawWatchAction? in
let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -516,10 +516,29 @@ enum WatchPromptNotificationBridge {
}
}
private static func isNotificationAuthorizationAllowed() async -> Bool {
private static func requestNotificationAuthorizationIfNeeded() async -> Bool {
let center = UNUserNotificationCenter.current()
let status = await self.notificationAuthorizationStatus(center: center)
return self.isAuthorizationStatusAllowed(status)
switch status {
case .authorized, .provisional, .ephemeral:
return true
case .notDetermined:
let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
if !granted { return false }
let updatedStatus = await self.notificationAuthorizationStatus(center: center)
if self.isAuthorizationStatusAllowed(updatedStatus) {
// Refresh APNs registration immediately after the first permission grant so the
// gateway can receive a push registration without requiring an app relaunch.
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
return self.isAuthorizationStatusAllowed(updatedStatus)
case .denied:
return false
@unknown default:
return false
}
}
private static func isAuthorizationStatusAllowed(_ status: UNAuthorizationStatus) -> Bool {
@@ -616,9 +635,6 @@ struct OpenClawApp: App {
UserDefaults.standard.set(true, forKey: "gateway.hasConnectedOnce")
UserDefaults.standard.set(true, forKey: "onboarding.quickSetupDismissed")
appModel.enterScreenshotFixtureMode()
if Self.screenshotNotificationGuidanceEnabled {
appModel._debug_presentNotificationPermissionGuidancePromptForScreenshot()
}
}
#endif
OpenClawAppModelRegistry.appModel = appModel
@@ -670,14 +686,6 @@ struct OpenClawApp: App {
#endif
}
private static var screenshotNotificationGuidanceEnabled: Bool {
#if DEBUG
ProcessInfo.processInfo.arguments.contains("--openclaw-screenshot-notification-guidance")
#else
false
#endif
}
@MainActor
private func applyAppearancePreference() {
let style = self.appearancePreference.userInterfaceStyle

View File

@@ -22,20 +22,6 @@ struct PushBuildConfig {
let apnsEnvironment: PushAPNsEnvironment
static let current = PushBuildConfig()
static let openClawHostedRelayHost = "ios-push-relay.openclaw.ai"
var usesOpenClawHostedRelay: Bool {
guard self.transport == .relay, self.distribution == .official else { return false }
guard let relayBaseURL = self.relayBaseURL,
let components = URLComponents(url: relayBaseURL, resolvingAgainstBaseURL: false)
else {
return false
}
return components.scheme?.lowercased() == "https"
&& components.host?.lowercased() == Self.openClawHostedRelayHost
&& components.user == nil
&& components.password == nil
}
init(bundle: Bundle = .main) {
self.transport = Self.readEnum(

View File

@@ -24,8 +24,6 @@ struct RootTabs: View {
AppAppearancePreference.system.rawValue
@State private var selectedTab: AppTab = Self.initialTab
@State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination
@State private var selectedSettingsRoute: SettingsRoute? = Self.initialSidebarDestination.settingsRoute
@State private var selectedSettingsRouteRequestID: Int = 0
@State private var isSidebarVisible: Bool = Self.initialSidebarVisibility ?? false
@State private var sidebarVisibilityUserOverridden: Bool = Self.initialSidebarVisibility != nil
@State private var isSidebarDrawerLayout: Bool = false
@@ -41,7 +39,6 @@ struct RootTabs: View {
@State private var didApplyInitialAppearance: Bool = false
@State private var didApplyInitialChatSession: Bool = false
@State private var handledGatewaySetupRequestID: Int = 0
@State private var suppressedExecApprovalPromptIDForNotificationSettings: String?
private static var initialTab: AppTab {
let arguments = ProcessInfo.processInfo.arguments
@@ -164,10 +161,8 @@ struct RootTabs: View {
.tabItem { Label("Agent", systemImage: "person.2.fill") }
.tag(AppTab.agent)
SettingsProTab(
initialRoute: self.selectedSettingsRoute,
onRouteChange: self.handleSettingsRouteChange)
.id(self.settingsTabViewID)
SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)
.id(self.selectedSidebarDestination.settingsRoute.map { "\($0)" } ?? "settings")
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
.tag(AppTab.settings)
}
@@ -240,7 +235,7 @@ struct RootTabs: View {
private var sidebarDetailShell: some View {
self.sidebarDetail
.id(self.sidebarDetailShellID)
.id(self.selectedSidebarDestination.id)
}
private var sidebarColumn: some View {
@@ -468,21 +463,11 @@ struct RootTabs: View {
headerLeadingAction: self.sidebarHeaderLeadingAction,
gatewayAction: { self.selectSidebarDestination(.gateway) })
case .settings:
if let selectedSettingsRoute {
SettingsProTab(
directRoute: selectedSettingsRoute,
headerLeadingAction: self.sidebarHeaderLeadingAction,
onRouteChange: self.handleSettingsRouteChange)
} else {
SettingsProTab(
headerLeadingAction: self.sidebarHeaderLeadingAction,
onRouteChange: self.handleSettingsRouteChange)
}
SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)
case .gateway:
SettingsProTab(
directRoute: self.selectedSettingsRoute ?? self.selectedSidebarDestination.settingsRoute ?? .gateway,
headerLeadingAction: self.sidebarHeaderLeadingAction,
onRouteChange: self.handleSettingsRouteChange)
directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway,
headerLeadingAction: self.sidebarHeaderLeadingAction)
}
}
@@ -507,21 +492,6 @@ struct RootTabs: View {
return UIDevice.current.userInterfaceIdiom
}
private var sidebarDetailShellID: String {
let routeID = self.selectedSettingsRoute.map { "\($0)" } ?? "root"
return "\(self.selectedSidebarDestination.id):\(routeID):\(self.selectedSettingsRouteRequestID)"
}
private var settingsTabViewID: String {
let routeID = self.selectedSettingsRoute.map { "\($0)" } ?? "settings"
return "\(routeID):\(self.selectedSettingsRouteRequestID)"
}
private var activeExecApprovalPromptSuppressionID: String? {
guard self.selectedTab == .settings, self.selectedSettingsRoute == .notifications else { return nil }
return self.suppressedExecApprovalPromptIDForNotificationSettings
}
private var shouldCollapseSidebarAfterSelection: Bool {
Self.shouldCollapseSidebarAfterSelection(
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
@@ -735,11 +705,6 @@ struct RootTabs: View {
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.maybeOpenSettingsForGatewaySetup()
}
.onChange(of: self.appModel.pendingExecApprovalPrompt?.id) { _, newValue in
if newValue != self.suppressedExecApprovalPromptIDForNotificationSettings {
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
}
}
}
private func rootPresentation(_ content: some View) -> some View {
@@ -777,12 +742,7 @@ struct RootTabs: View {
}
.gatewayTrustPromptAlert()
.deepLinkAgentPromptAlert()
.execApprovalPromptDialog(
suppressedApprovalID: self.activeExecApprovalPromptSuppressionID)
.notificationPermissionGuidanceDialog(openNotifications: { approvalId in
self.suppressedExecApprovalPromptIDForNotificationSettings = approvalId
self.selectSettingsRoute(.notifications)
})
.execApprovalPromptDialog()
}
private var appearancePreference: AppAppearancePreference {
@@ -914,15 +874,9 @@ struct RootTabs: View {
private func homeCanvasName(for agent: AgentSummary) -> String {
self.normalized(agent.name) ?? agent.id
}
}
extension RootTabs {
private func selectSidebarDestination(_ destination: SidebarDestination) {
if destination.settingsRoute != .notifications {
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
}
self.selectedSidebarDestination = destination
self.selectedSettingsRoute = destination.settingsRoute
self.selectedTab = destination.appTab
guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return }
withAnimation(.easeInOut(duration: 0.22)) {
@@ -930,31 +884,6 @@ extension RootTabs {
}
}
private func selectSettingsRoute(_ route: SettingsRoute) {
if route != .notifications {
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
}
self.selectedSettingsRoute = route
self.selectedSettingsRouteRequestID &+= 1
self.selectedSidebarDestination = .settings
self.selectedTab = .settings
guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return }
withAnimation(.easeInOut(duration: 0.22)) {
self.setSidebarVisible(false)
}
}
private func handleSettingsRouteChange(_ route: SettingsRoute?) {
guard route != .notifications else { return }
if route == nil {
self.selectedSettingsRoute = nil
if self.selectedTab == .settings {
self.selectedSidebarDestination = .settings
}
}
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
}
private func showSidebar() {
self.sidebarVisibilityUserOverridden = true
withAnimation(.easeInOut(duration: 0.22)) {

View File

@@ -16,6 +16,7 @@ enum NotificationAuthorizationStatus {
protocol NotificationCentering: Sendable {
func authorizationStatus() async -> NotificationAuthorizationStatus
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
func add(_ request: UNNotificationRequest) async throws
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async
@@ -47,6 +48,10 @@ struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
}
}
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
try await self.center.requestAuthorization(options: options)
}
func add(_ request: UNNotificationRequest) async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
self.center.add(request) { error in

View File

@@ -50,7 +50,6 @@ Sources/Gateway/GatewayDiscoveryModel.swift
Sources/Gateway/GatewayHealthMonitor.swift
Sources/Gateway/GatewayProblemView.swift
Sources/Gateway/GatewayQuickSetupSheet.swift
Sources/Gateway/NotificationPermissionGuidanceDialog.swift
Sources/Gateway/GatewayServiceResolver.swift
Sources/Gateway/GatewaySettingsStore.swift
Sources/Gateway/GatewayTrustPromptAlert.swift
@@ -110,10 +109,10 @@ Sources/Voice/VoiceWakePreferences.swift
ShareExtension/ShareViewController.swift
ActivityWidget/OpenClawActivityWidgetBundle.swift
ActivityWidget/OpenClawLiveActivity.swift
WatchApp/Sources/OpenClawWatchApp.swift
WatchApp/Sources/WatchConnectivityReceiver.swift
WatchApp/Sources/WatchInboxStore.swift
WatchApp/Sources/WatchInboxView.swift
WatchExtension/Sources/OpenClawWatchApp.swift
WatchExtension/Sources/WatchConnectivityReceiver.swift
WatchExtension/Sources/WatchInboxStore.swift
WatchExtension/Sources/WatchInboxView.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift

View File

@@ -14,6 +14,10 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
self.authorization
}
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
true
}
func add(_ request: UNNotificationRequest) async throws {
self.addedRequests.append(request)
}

View File

@@ -200,16 +200,25 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
private final class MockBootstrapNotificationCenter: NotificationCentering, @unchecked Sendable {
var status: NotificationAuthorizationStatus = .notDetermined
var addCalls = 0
var requestAuthorizationResult = false
var requestAuthorizationCalls = 0
func authorizationStatus() async -> NotificationAuthorizationStatus {
self.status
}
func add(_: UNNotificationRequest) async throws {
self.addCalls += 1
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
self.requestAuthorizationCalls += 1
if self.requestAuthorizationResult {
self.status = .authorized
} else {
self.status = .denied
}
return self.requestAuthorizationResult
}
func add(_: UNNotificationRequest) async throws {}
func removePendingNotificationRequests(withIdentifiers _: [String]) async {}
func removeDeliveredNotifications(withIdentifiers _: [String]) async {}
@@ -1151,35 +1160,6 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
isBackgrounded: false))
}
@Test func execApprovalEventIDDecodesGatewayPayload() {
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " approval-1 "])) == "approval-1")
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " "])) == nil)
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["other": "approval-1"])) == nil)
}
@Test @MainActor func operatorGatewayResolvedEventClearsPendingApprovalPrompt() async throws {
let appModel = NodeAppModel()
try appModel._test_presentExecApprovalPrompt(
#require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-event-resolved",
commandText: "echo clear",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: nil,
agentId: nil,
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000)))
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
type: "event",
event: ExecApprovalNotificationBridge.resolvedKind,
payload: AnyCodable(["id": "approval-event-resolved"]),
seq: nil,
stateversion: nil))
#expect(appModel._test_pendingExecApprovalPrompt() == nil)
}
@Test func watchExecApprovalHydrateFetchesOnlyMissingIDs() {
let idsToFetch = NodeAppModel._test_watchExecApprovalIDsNeedingFetch(
candidateIDs: ["cached", "pending", "cached", "other", "", " pending "],
@@ -1221,65 +1201,13 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
hasStoredOperatorToken: false))
}
@Test @MainActor func operatorGatewayRequestedEventShowsNotificationGuidanceWhenNotificationsOff() async throws {
@Test @MainActor func successfulBootstrapOnboardingRequestsNotificationAuthorization() async {
let center = MockBootstrapNotificationCenter()
center.status = .notDetermined
let appModel = NodeAppModel(notificationCenter: center)
appModel._test_resetExecApprovalNotificationGuidanceSuppression()
defer { appModel._test_resetExecApprovalNotificationGuidanceSuppression() }
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
type: "event",
event: ExecApprovalNotificationBridge.requestedKind,
payload: AnyCodable(["id": "approval-notifications-off"]),
seq: nil,
stateversion: nil))
await appModel._test_handleSuccessfulBootstrapGatewayOnboarding()
let prompt = try #require(appModel._test_pendingNotificationPermissionGuidancePrompt())
#expect(prompt.approvalId == "approval-notifications-off")
}
@Test @MainActor func suppressedOperatorGatewayRequestedEventDoesNotShowNotificationGuidance() async {
let center = MockBootstrapNotificationCenter()
center.status = .denied
let appModel = NodeAppModel(notificationCenter: center)
appModel._test_resetExecApprovalNotificationGuidanceSuppression()
defer { appModel._test_resetExecApprovalNotificationGuidanceSuppression() }
appModel.dismissNotificationPermissionGuidancePrompt(suppressFuture: true)
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
type: "event",
event: ExecApprovalNotificationBridge.requestedKind,
payload: AnyCodable(["id": "approval-suppressed"]),
seq: nil,
stateversion: nil))
#expect(appModel._test_pendingNotificationPermissionGuidancePrompt() == nil)
}
@Test @MainActor func operatorGatewayResolvedEventClearsNotificationGuidancePrompt() async throws {
let center = MockBootstrapNotificationCenter()
center.status = .denied
let appModel = NodeAppModel(notificationCenter: center)
appModel._test_resetExecApprovalNotificationGuidanceSuppression()
defer { appModel._test_resetExecApprovalNotificationGuidanceSuppression() }
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
type: "event",
event: ExecApprovalNotificationBridge.requestedKind,
payload: AnyCodable(["id": "approval-guidance-resolved"]),
seq: nil,
stateversion: nil))
_ = try #require(appModel._test_pendingNotificationPermissionGuidancePrompt())
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
type: "event",
event: ExecApprovalNotificationBridge.resolvedKind,
payload: AnyCodable(["id": "approval-guidance-resolved"]),
seq: nil,
stateversion: nil))
#expect(appModel._test_pendingNotificationPermissionGuidancePrompt() == nil)
#expect(center.requestAuthorizationCalls == 1)
}
@Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() throws {
@@ -1341,78 +1269,6 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
#expect(res.error?.message.contains("CAMERA_DISABLED") == true)
}
@Test @MainActor func systemNotifyReturnsUnavailableWhenNotificationsOff() async throws {
let center = MockBootstrapNotificationCenter()
center.status = .notDetermined
let appModel = NodeAppModel(notificationCenter: center)
let params = OpenClawSystemNotifyParams(title: "Approval", body: "Review request")
let paramsData = try JSONEncoder().encode(params)
let req = BridgeInvokeRequest(
id: "notify-off",
command: OpenClawSystemCommand.notify.rawValue,
paramsJSON: String(decoding: paramsData, as: UTF8.self))
let res = await appModel._test_handleInvoke(req)
#expect(res.ok == false)
#expect(res.error?.code == .unavailable)
#expect(res.error?.message == "NOT_AUTHORIZED: notifications")
#expect(center.addCalls == 0)
}
@Test @MainActor func systemNotifySchedulesWhenNotificationsAreAlreadyAllowed() async throws {
let center = MockBootstrapNotificationCenter()
center.status = .authorized
let appModel = NodeAppModel(notificationCenter: center)
let params = OpenClawSystemNotifyParams(title: "Approval", body: "Review request")
let paramsData = try JSONEncoder().encode(params)
let req = BridgeInvokeRequest(
id: "notify-on",
command: OpenClawSystemCommand.notify.rawValue,
paramsJSON: String(decoding: paramsData, as: UTF8.self))
let res = await appModel._test_handleInvoke(req)
#expect(res.ok)
#expect(center.addCalls == 1)
}
@Test @MainActor func chatPushWithoutSpeechReturnsUnavailableWhenNotificationsOff() async throws {
let center = MockBootstrapNotificationCenter()
center.status = .notDetermined
let appModel = NodeAppModel(notificationCenter: center)
let params = OpenClawChatPushParams(text: "Build finished", speak: false)
let paramsData = try JSONEncoder().encode(params)
let req = BridgeInvokeRequest(
id: "chat-push-off",
command: OpenClawChatCommand.push.rawValue,
paramsJSON: String(decoding: paramsData, as: UTF8.self))
let res = await appModel._test_handleInvoke(req)
#expect(res.ok == false)
#expect(res.error?.code == .unavailable)
#expect(res.error?.message == "NOT_AUTHORIZED: notifications")
#expect(center.addCalls == 0)
}
@Test @MainActor func chatPushSchedulesWhenNotificationsAreAlreadyAllowed() async throws {
let center = MockBootstrapNotificationCenter()
center.status = .authorized
let appModel = NodeAppModel(notificationCenter: center)
let params = OpenClawChatPushParams(text: "Build finished", speak: false)
let paramsData = try JSONEncoder().encode(params)
let req = BridgeInvokeRequest(
id: "chat-push-on",
command: OpenClawChatCommand.push.rawValue,
paramsJSON: String(decoding: paramsData, as: UTF8.self))
let res = await appModel._test_handleInvoke(req)
#expect(res.ok)
#expect(center.addCalls == 1)
}
@Test @MainActor func handleInvokeRejectsInvalidScreenFormat() async {
let appModel = NodeAppModel()
let params = OpenClawScreenRecordParams(format: "gif")

View File

@@ -1,8 +1,8 @@
import Foundation
import Testing
struct RootTabsSourceGuardTests {
@Test func `hidden sidebar reveal uses destination header without reserved rail`() throws {
@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)
@@ -38,7 +38,7 @@ struct RootTabsSourceGuardTests {
#expect(!source.contains("shouldShowOverviewHeaderSidebarReveal"))
}
@Test func `i pad split uses sliding sidebar while portrait keeps drawer overlay`() throws {
@Test func iPadSplitUsesSlidingSidebarWhilePortraitKeepsDrawerOverlay() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let splitContent = try Self.extract(
source,
@@ -67,7 +67,7 @@ struct RootTabsSourceGuardTests {
#expect(!drawerContent.contains("NavigationSplitView"))
}
@Test func `sidebar keeps navigation model destination only`() throws {
@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(
@@ -114,7 +114,7 @@ struct RootTabsSourceGuardTests {
#expect(navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.docs])"))
}
@Test func `sidebar routes use destination headers instead of repeated product branding`() throws {
@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)
@@ -148,7 +148,7 @@ struct RootTabsSourceGuardTests {
#expect(!docsSource.contains("Text(\"OpenClaw Docs\")"))
}
@Test func `agents direct route keeps single sidebar control`() throws {
@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)
@@ -165,7 +165,7 @@ struct RootTabsSourceGuardTests {
#expect(dreamingSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
}
@Test func `routed headers use shared adaptive layout`() throws {
@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)
@@ -187,7 +187,7 @@ struct RootTabsSourceGuardTests {
#expect(settingsSource.contains("OpenClawAdaptiveHeaderRow("))
}
@Test func `phone hub keeps docs as destination only`() throws {
@Test func phoneHubKeepsDocsAsDestinationOnly() throws {
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
#expect(source.contains("case .docs:"))
@@ -198,7 +198,7 @@ struct RootTabsSourceGuardTests {
#expect(!source.contains("https://docs.openclaw.ai"))
}
@Test func `root shell preview matrix covers phone and I pad states`() throws {
@Test func rootShellPreviewMatrixCoversPhoneAndIPadStates() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\n \"Shell iPhone portrait\""))
@@ -211,7 +211,7 @@ struct RootTabsSourceGuardTests {
#expect(source.contains("#Preview(\n \"Shell iPad gateway error\""))
}
@Test func `shared chat preview matrix covers connection states`() throws {
@Test func sharedChatPreviewMatrixCoversConnectionStates() throws {
let source = try String(contentsOf: Self.sharedChatPreviewSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Chat connected\")"))
@@ -226,7 +226,7 @@ struct RootTabsSourceGuardTests {
#expect(source.contains("Gateway not connected. Check Tailscale and retry."))
}
@Test func `phone hub keeps content above floating tab bar`() throws {
@Test func phoneHubKeepsContentAboveFloatingTabBar() throws {
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
#expect(source.contains(".safeAreaPadding(.bottom, self.bottomScrollInset)"))
@@ -235,7 +235,7 @@ struct RootTabsSourceGuardTests {
#expect(!source.contains("bottomTabBarClearance"))
}
@Test func `phone hub header stays task first`() throws {
@Test func phoneHubHeaderStaysTaskFirst() throws {
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
#expect(source.contains("private var gatewayActionRow: some View"))
@@ -253,7 +253,7 @@ struct RootTabsSourceGuardTests {
#expect(!source.contains("private func metric(label:"))
}
@Test func `workboard uses real gateway methods`() throws {
@Test func workboardUsesRealGatewayMethods() throws {
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
#expect(source.contains("workboard.cards.list"))
@@ -268,7 +268,7 @@ struct RootTabsSourceGuardTests {
#expect(!source.contains("Multi-column queue control"))
}
@Test func `workboard create action surfaces unavailable reasons`() throws {
@Test func workboardCreateActionSurfacesUnavailableReasons() throws {
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
let createFunction = try Self.extract(
source,
@@ -295,7 +295,7 @@ struct RootTabsSourceGuardTests {
#expect(createFunction.contains("return true"))
}
@Test func `task scope controls send real gateway params`() throws {
@Test func taskScopeControlsSendRealGatewayParams() throws {
let source = try Self.iPadTaskFeatureScreensSource()
#expect(source.contains("private var boardScopeMenu: some View"))
@@ -314,7 +314,7 @@ struct RootTabsSourceGuardTests {
"params: EmptyParams(),\n timeoutSeconds: 20)\n let response = try JSONDecoder().decode(IPadSkillProposalManifest.self"))
}
@Test func `compact task rows keep phone native actions`() throws {
@Test func compactTaskRowsKeepPhoneNativeActions() throws {
let source = try Self.iPadTaskFeatureScreensSource()
let compactControls = try Self.extract(
source,
@@ -348,7 +348,7 @@ struct RootTabsSourceGuardTests {
#expect(compactControls.contains("Label(\"Dispatch\""))
}
@Test func `skill workshop uses kanban lanes on wide I pad`() throws {
@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(
@@ -376,7 +376,7 @@ struct RootTabsSourceGuardTests {
#expect(previewSource.contains("status: \"manual_QA\""))
}
@Test func `compact task rows have populated phone previews`() throws {
@Test func compactTaskRowsHavePopulatedPhonePreviews() throws {
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Workboard phone queue rows\")"))
@@ -387,7 +387,7 @@ struct RootTabsSourceGuardTests {
#expect(source.contains("IPadSkillWorkshopPreviewFixtures.proposals"))
}
@Test func `task screen preview matrices cover primary states`() throws {
@Test func taskScreenPreviewMatricesCoverPrimaryStates() throws {
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Workboard states\")"))
@@ -412,7 +412,7 @@ struct RootTabsSourceGuardTests {
#expect(source.contains("\"manual_QA\""))
}
@Test func `activity preview matrix covers connection states`() throws {
@Test func activityPreviewMatrixCoversConnectionStates() throws {
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Activity states\")"))
@@ -426,7 +426,7 @@ struct RootTabsSourceGuardTests {
#expect(source.contains("title: \"Loading sessions\""))
}
@Test func `routed feature screens reuse shared pro components`() throws {
@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)
@@ -445,22 +445,20 @@ struct RootTabsSourceGuardTests {
#expect(componentsSource.contains("struct ProStatusRow"))
}
@Test func `activity screen stays split from task feature screens`() throws {
@Test func activityScreenStaysSplitFromTaskFeatureScreens() throws {
let taskSource = try Self.iPadTaskFeatureScreensSource()
let activitySource = try String(contentsOf: Self.iPadActivityScreenSourceURL(), encoding: .utf8)
let appModelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
#expect(activitySource.contains("struct IPadActivityScreen: View"))
#expect(activitySource.contains("self.appModel.makeChatTransport()"))
#expect(appModelSource.contains("return IOSGatewayChatTransport(gateway: self.operatorSession)"))
#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 `routed feature chrome stays split from task feature screens`() throws {
@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)
@@ -472,7 +470,7 @@ struct RootTabsSourceGuardTests {
#expect(projectSource.contains("IPadSidebarScreenChrome.swift in Sources"))
}
@Test func `routed feature chrome keeps gateway pill actionable`() throws {
@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)
@@ -488,18 +486,14 @@ struct RootTabsSourceGuardTests {
.count == 1)
}
@Test func `routed gateway pills open gateway settings`() throws {
@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 settingsTabSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let notificationGuidanceSource = try String(
contentsOf: Self.notificationPermissionGuidanceDialogSourceURL(),
encoding: .utf8)
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 1)
@@ -518,39 +512,15 @@ struct RootTabsSourceGuardTests {
#expect(docsSource.contains("let gatewayAction: (() -> Void)?"))
#expect(settingsSource.contains("NavigationLink(value: SettingsRoute.gateway)"))
#expect(rootSource.contains("case .settings:"))
#expect(rootSource
.matches(of: /SettingsProTab\(\s*headerLeadingAction: self\.sidebarHeaderLeadingAction,/)
.count >= 1)
#expect(rootSource
.contains(
"directRoute: self.selectedSettingsRoute ?? self.selectedSidebarDestination.settingsRoute ?? .gateway"))
#expect(rootSource.matches(of: /SettingsProTab\(\s*initialRoute: self\.selectedSettingsRoute,/).count == 1)
#expect(rootSource.contains(".id(self.settingsTabViewID)"))
#expect(rootSource.contains("@State private var selectedSettingsRouteRequestID: Int = 0"))
#expect(rootSource.contains("self.selectedSettingsRouteRequestID &+= 1"))
#expect(rootSource.contains("@State private var suppressedExecApprovalPromptIDForNotificationSettings"))
#expect(rootSource.contains("private var activeExecApprovalPromptSuppressionID: String?"))
#expect(rootSource.contains("suppressedApprovalID: self.activeExecApprovalPromptSuppressionID"))
#expect(rootSource.contains("if destination.settingsRoute != .notifications"))
#expect(rootSource.contains("if route != .notifications"))
#expect(rootSource.contains("if route == nil"))
#expect(rootSource.contains("self.selectedSettingsRoute = nil"))
#expect(rootSource.contains("self.selectedSidebarDestination = .settings"))
#expect(rootSource.contains("self.suppressedExecApprovalPromptIDForNotificationSettings = approvalId"))
#expect(rootSource.contains("onRouteChange: self.handleSettingsRouteChange"))
#expect(rootSource.contains("private func handleSettingsRouteChange(_ route: SettingsRoute?)"))
#expect(settingsTabSource.contains("let onRouteChange: ((SettingsRoute?) -> Void)?"))
#expect(settingsTabSource.contains("self.onRouteChange?(self.navigationPath.last)"))
#expect(notificationGuidanceSource.contains("onSuppressFuture"))
#expect(notificationGuidanceSource.contains("suppressFuture: true"))
#expect(notificationGuidanceSource.contains("Text(\"Don't show again\")"))
#expect(rootSource.contains("private func selectSettingsRoute(_ route: SettingsRoute)"))
#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(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
}
@Test func `gateway settings keeps pairing trust diagnostics and tailscale actions`() throws {
@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)
@@ -596,7 +566,7 @@ struct RootTabsSourceGuardTests {
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
}
@Test func `gateway settings preview matrix covers primary states`() throws {
@Test func gatewaySettingsPreviewMatrixCoversPrimaryStates() throws {
let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8)
#expect(supportSource.contains("#Preview(\"Gateway settings states\")"))
@@ -615,15 +585,12 @@ struct RootTabsSourceGuardTests {
#expect(supportSource.contains("self.previewButton(\"Diagnose\""))
}
@Test func `native chat uses gateway transport`() throws {
@Test func nativeChatUsesGatewayTransport() throws {
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
let settingsSectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let appModelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
#expect(chatSource.matches(of: /self\.appModel\.makeChatTransport\(\)/).count == 2)
#expect(appModelSource.contains("return IOSGatewayChatTransport(gateway: self.operatorSession)"))
#expect(settingsSectionsSource.contains("Message routing and external channel clients."))
#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."))
@@ -636,13 +603,6 @@ struct RootTabsSourceGuardTests {
.appendingPathComponent("Sources/RootTabs.swift")
}
private static func nodeAppModelSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Model/NodeAppModel.swift")
}
private static func phoneHubSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
@@ -786,13 +746,6 @@ struct RootTabsSourceGuardTests {
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
}
private static func notificationPermissionGuidanceDialogSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Gateway/NotificationPermissionGuidanceDialog.swift")
}
private static func settingsProTabActionsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()

View File

@@ -8,7 +8,7 @@ import Testing
gatewayConnected: false,
discoveredGatewayCount: 0,
talkConfigLoaded: false,
notificationsAllowed: false) == [
notificationStatusText: "Not Set") == [
.gatewayOffline,
.discoveryUnavailable,
.notificationsUnavailable,
@@ -21,12 +21,12 @@ import Testing
gatewayConnected: true,
discoveredGatewayCount: 1,
talkConfigLoaded: false,
notificationsAllowed: true) == [.talkConfigMissing])
notificationStatusText: "Allowed") == [.talkConfigMissing])
#expect(
SettingsDiagnostics.issueCount(
gatewayConnected: true,
discoveredGatewayCount: 1,
talkConfigLoaded: true,
notificationsAllowed: true) == 0)
notificationStatusText: "Allowed") == 0)
}
}

View File

@@ -3,10 +3,6 @@ import OpenClawKit
import Testing
@Suite struct ShareToAgentDeepLinkTests {
@Test func appGroupIdentifierUsesCanonicalOpenClawGroup() {
#expect(OpenClawAppGroup.canonicalIdentifier == "group.ai.openclawfoundation.app.shared")
}
@Test func buildMessageIncludesSharedFields() {
let payload = SharedContentPayload(
title: "Article",

View File

@@ -20,9 +20,9 @@
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key>
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
<key>WKWatchKitApp</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

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

View File

@@ -1146,7 +1146,7 @@ private enum WatchNativeTextInput {
suggestions: [String],
onSubmit: @escaping (String) -> Void)
{
WKApplication.shared().visibleInterfaceController?.presentTextInputController(
WKExtension.shared().visibleInterfaceController?.presentTextInputController(
withSuggestions: suggestions,
allowedInputMode: .allowEmoji)
{ results in

View File

@@ -293,8 +293,6 @@ def capture_watch_screenshot
Dir[File.join(output_dir, "Apple Watch*-*.png")].each { |path| FileUtils.rm_f(path) }
FileUtils.rm_rf(derived_data_path)
# Single-target watch apps only expose generic simulator build destinations in Xcode.
# Keep the selected UDID for install/launch/screenshot below.
sh(
xcodebuild_shell_join([
"xcodebuild",
@@ -305,7 +303,7 @@ def capture_watch_screenshot
"-configuration",
"Debug",
"-destination",
"generic/platform=watchOS Simulator",
"platform=watchOS Simulator,id=#{udid}",
"-derivedDataPath",
derived_data_path,
"build",
@@ -313,8 +311,10 @@ def capture_watch_screenshot
)
UI.user_error!("Watch screenshot build did not produce #{app_path}.") unless File.exist?(app_path)
extension_path = File.join(app_path, "PlugIns", "OpenClawWatchExtension.appex")
watch_app_identifier = bundle_identifier_for_product(app_path)
screenshot_mode_bundle_identifiers = [watch_app_identifier]
watch_extension_identifier = bundle_identifier_for_product(extension_path)
screenshot_mode_bundle_identifiers = [watch_app_identifier, watch_extension_identifier]
sh("#{shell_join(["xcrun", "simctl", "boot", udid])} >/dev/null 2>&1 || true")
sh(shell_join(["xcrun", "simctl", "bootstatus", udid, "-b"]))
@@ -492,9 +492,6 @@ def produce_services_for_target(target)
if target.fetch("capabilities").include?("PUSH_NOTIFICATIONS")
services[:push_notification] = "on"
end
if target.fetch("capabilities").include?("APP_GROUPS")
services[:app_group] = "on"
end
services
end
@@ -570,15 +567,6 @@ def profile_plist_value(profile_path, key_path)
end
end
def profile_plist_array_values(profile_path, key_path)
raw = profile_plist_value(profile_path, key_path)
return [] unless raw
raw.lines.map(&:strip).reject do |line|
line.empty? || line == "Array {" || line == "}"
end
end
def validate_match_profile_capabilities!(target)
capabilities = target.fetch("capabilities")
return if capabilities.empty?
@@ -594,17 +582,6 @@ def validate_match_profile_capabilities!(target)
)
end
end
if capabilities.include?("APP_GROUPS")
expected_app_groups = target.fetch("appGroups")
actual_app_groups = profile_plist_array_values(profile_path, "Entitlements:com.apple.security.application-groups")
missing = expected_app_groups - actual_app_groups
unless missing.empty?
UI.user_error!(
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing App Groups #{missing.join(", ")}; actual groups: #{actual_app_groups.empty? ? "missing" : actual_app_groups.join(", ")}."
)
end
end
end
def sync_app_store_signing!(readonly:)

View File

@@ -65,7 +65,7 @@ pnpm ios:release:signing:check
pnpm ios:release:signing:setup
```
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app and share extension also require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
Shared encrypted signing storage:

View File

@@ -1,5 +1,3 @@
Maintenance update for the current OpenClaw release.
OpenClaw is now available on iPhone.
- Added Apple Watch controls for common agent actions.
- Improved Gateway setup, notification settings, and share-extension identity handling.
- Updated the Watch app integration for current Xcode compatibility.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, share content from iOS, and bring device capabilities like camera, location, screen, and notifications into your private automation workflows.

View File

@@ -65,8 +65,6 @@ targets:
embed: true
- target: OpenClawActivityWidget
embed: true
# A companion watch application belongs in the standard Watch bundle location.
# PlugIns is for extension products and breaks paired watch installation.
- target: OpenClawWatchApp
- package: OpenClawKit
- package: OpenClawKit
@@ -90,7 +88,7 @@ targets:
exit 1
fi
swiftformat --lint --config "$SRCROOT/../../config/swiftformat" \
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchApp,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../swabble" \
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchExtension,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../swabble" \
--filelist "$SRCROOT/SwiftSources.input.xcfilelist"
- name: SwiftLint
basedOnDependencyAnalysis: false
@@ -142,7 +140,6 @@ targets:
- openclaw
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
OpenClawCanonicalVersion: "$(OPENCLAW_IOS_VERSION)"
OpenClawAppGroupIdentifier: "$(OPENCLAW_APP_GROUP_ID)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
UILaunchScreen: {}
UIApplicationSceneManifest:
@@ -195,7 +192,6 @@ targets:
settings:
base:
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_ENTITLEMENTS: ShareExtension/OpenClawShareExtension.entitlements
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ENABLE_APPINTENTS_METADATA: NO
@@ -210,7 +206,6 @@ targets:
properties:
CFBundleDisplayName: OpenClaw Share
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
OpenClawAppGroupIdentifier: "$(OPENCLAW_APP_GROUP_ID)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
@@ -256,17 +251,13 @@ targets:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
OpenClawWatchApp:
type: application
type: application.watchapp2
platform: watchOS
deploymentTarget: "11.0"
sources:
- path: WatchApp
excludes:
- Info.plist
dependencies:
- sdk: AppIntents.framework
- sdk: WatchConnectivity.framework
- sdk: UserNotifications.framework
- target: OpenClawWatchExtension
configFiles:
Debug: Config/Signing.xcconfig
Release: Config/Signing.xcconfig
@@ -283,8 +274,6 @@ targets:
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_APP_PROFILE)"
SWIFT_STRICT_CONCURRENCY: complete
SWIFT_VERSION: "6.0"
info:
path: WatchApp/Info.plist
properties:
@@ -292,7 +281,42 @@ targets:
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
WKApplication: true
WKWatchKitApp: true
OpenClawWatchExtension:
type: watchkit2-extension
platform: watchOS
deploymentTarget: "11.0"
sources:
- path: WatchExtension/Sources
- path: WatchExtension/Assets.xcassets
dependencies:
- sdk: AppIntents.framework
- sdk: WatchConnectivity.framework
- sdk: UserNotifications.framework
configFiles:
Debug: Config/Signing.xcconfig
Release: Config/Signing.xcconfig
attributes:
DevelopmentTeam: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
settings:
base:
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_EXTENSION_PROFILE)"
info:
path: WatchExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionAttributes:
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
NSExtensionPointIdentifier: com.apple.watchkit
OpenClawTests:
type: bundle.unit-test

View File

@@ -1,3 +1,3 @@
{
"version": "2026.6.9"
"version": "2026.6.2"
}

View File

@@ -1,32 +0,0 @@
import AppKit
import WebKit
extension CanvasWindowController {
// MARK: - WKUIDelegate
/// Bridges `<input type="file">` clicks in canvas HTML to a native `NSOpenPanel`.
/// Without a `WKUIDelegate`, WebKit silently drops the request and file-picker
/// buttons in canvas pages do nothing.
@MainActor
func webView(
_ webView: WKWebView,
runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void)
{
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = parameters.allowsDirectories
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
panel.resolvesAliases = true
if let window = self.window {
panel.beginSheetModal(for: window) { response in
completionHandler(response == .OK ? panel.urls : nil)
}
return
}
panel.begin { response in
completionHandler(response == .OK ? panel.urls : nil)
}
}
}

View File

@@ -5,7 +5,7 @@ import OpenClawKit
import WebKit
@MainActor
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
let sessionKey: String
private let root: URL
private let sessionDir: URL
@@ -159,7 +159,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, WK
}
self.webView.navigationDelegate = self
self.webView.uiDelegate = self
self.window?.delegate = self
self.container.onClose = { [weak self] in
self?.hideCanvas()

View File

@@ -19,7 +19,7 @@ private final class DashboardWindowDragRegionView: NSView {
}
@MainActor
final class DashboardWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
final class DashboardWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
private let webView: WKWebView
private var currentURL: URL
private var auth: DashboardWindowAuth
@@ -44,37 +44,9 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
super.init(window: window)
self.webView.navigationDelegate = self
self.webView.uiDelegate = self
self.window?.delegate = self
}
// MARK: - WKUIDelegate
/// Bridges `<input type="file">` clicks in the embedded Control UI to a native
/// `NSOpenPanel`; without a `WKUIDelegate`, WebKit silently drops the request
/// and "Choose image" / file-picker buttons do nothing.
func webView(
_ webView: WKWebView,
runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void)
{
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = parameters.allowsDirectories
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
panel.resolvesAliases = true
if let window = self.window {
panel.beginSheetModal(for: window) { response in
completionHandler(response == .OK ? panel.urls : nil)
}
return
}
panel.begin { response in
completionHandler(response == .OK ? panel.urls : nil)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.6.9</string>
<string>2026.6.2</string>
<key>CFBundleVersion</key>
<string>2026060900</string>
<string>2026060200</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -21,12 +21,10 @@ private struct DeviceAuthStoreFile: Codable {
}
public enum DeviceAuthStore {
public static func loadToken(
deviceId: String,
role: String,
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry?
{
guard let store = readStore(profile: profile), store.deviceId == deviceId else { return nil }
private static let fileName = "device-auth.json"
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
guard let store = readStore(), store.deviceId == deviceId else { return nil }
let role = self.normalizeRole(role)
return store.tokens[role]
}
@@ -35,11 +33,10 @@ public enum DeviceAuthStore {
deviceId: String,
role: String,
token: String,
scopes: [String] = [],
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry
scopes: [String] = []) -> DeviceAuthEntry
{
let normalizedRole = self.normalizeRole(role)
var next = self.readStore(profile: profile)
var next = self.readStore()
if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
@@ -53,25 +50,17 @@ public enum DeviceAuthStore {
}
next?.tokens[normalizedRole] = entry
if let store = next {
self.writeStore(store, profile: profile)
self.writeStore(store)
}
return entry
}
public static func clearToken(
deviceId: String,
role: String,
profile: GatewayDeviceIdentityProfile = .primary)
{
guard var store = readStore(profile: profile), store.deviceId == deviceId else { return }
public static func clearToken(deviceId: String, role: String) {
guard var store = readStore(), store.deviceId == deviceId else { return }
let normalizedRole = self.normalizeRole(role)
guard store.tokens[normalizedRole] != nil else { return }
store.tokens.removeValue(forKey: normalizedRole)
self.writeStore(store, profile: profile)
}
public static func clearAll(profile: GatewayDeviceIdentityProfile = .primary) {
try? FileManager.default.removeItem(at: self.fileURL(profile: profile))
self.writeStore(store)
}
private static func normalizeRole(_ role: String) -> String {
@@ -85,14 +74,14 @@ public enum DeviceAuthStore {
return Array(Set(trimmed)).sorted()
}
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
private static func fileURL() -> URL {
DeviceIdentityPaths.stateDirURL()
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(profile.authFileName, isDirectory: false)
.appendingPathComponent(self.fileName, isDirectory: false)
}
private static func readStore(profile: GatewayDeviceIdentityProfile) -> DeviceAuthStoreFile? {
let url = self.fileURL(profile: profile)
private static func readStore() -> DeviceAuthStoreFile? {
let url = self.fileURL()
guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
return nil
@@ -101,8 +90,8 @@ public enum DeviceAuthStore {
return decoded
}
private static func writeStore(_ store: DeviceAuthStoreFile, profile: GatewayDeviceIdentityProfile) {
let url = self.fileURL(profile: profile)
private static func writeStore(_ store: DeviceAuthStoreFile) {
let url = self.fileURL()
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),

View File

@@ -1,29 +1,6 @@
import CryptoKit
import Foundation
public enum GatewayDeviceIdentityProfile: String, Sendable {
case primary
case shareExtension
var identityFileName: String {
switch self {
case .primary:
"device.json"
case .shareExtension:
"share-device.json"
}
}
var authFileName: String {
switch self {
case .primary:
"device-auth.json"
case .shareExtension:
"share-device-auth.json"
}
}
}
public struct DeviceIdentity: Codable, Sendable {
public var deviceId: String
public var publicKey: String
@@ -42,32 +19,6 @@ enum DeviceIdentityPaths {
private static let stateDirEnv = ["OPENCLAW_STATE_DIR"]
static func stateDirURL() -> URL {
self.stateDirURL(
overrideURL: self.stateDirOverrideURL(),
legacyStateDirURL: self.legacyStateDirURL(),
appGroupStateDirURL: self.appGroupStateDirURL(),
temporaryDirectory: FileManager.default.temporaryDirectory)
}
static func stateDirURL(
overrideURL: URL?,
legacyStateDirURL: URL?,
appGroupStateDirURL: URL?,
temporaryDirectory: URL) -> URL
{
if let overrideURL {
return overrideURL
}
if let appGroupStateDirURL {
return appGroupStateDirURL
}
if let legacyStateDirURL {
return legacyStateDirURL
}
return temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
}
private static func stateDirOverrideURL() -> URL? {
for key in self.stateDirEnv {
if let raw = getenv(key) {
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
@@ -76,49 +27,34 @@ enum DeviceIdentityPaths {
}
}
}
return nil
}
private static func legacyStateDirURL() -> URL? {
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
return appSupport.appendingPathComponent("OpenClaw", isDirectory: true)
}
return nil
}
private static func appGroupStateDirURL() -> URL? {
guard
let containerURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: OpenClawAppGroup.identifier)
else {
return nil
}
return containerURL.appendingPathComponent("OpenClaw", isDirectory: true)
return FileManager.default.temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
}
}
public enum DeviceIdentityStore {
private static let fileName = "device.json"
private static let ed25519SPKIPrefix = Data([
0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65,
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
0x70, 0x03, 0x21, 0x00,
])
private static let ed25519PKCS8PrivatePrefix = Data([
0x30, 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2B, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
])
public static func loadOrCreate() -> DeviceIdentity {
self.loadOrCreate(profile: .primary)
}
public static func loadOrCreate(profile: GatewayDeviceIdentityProfile) -> DeviceIdentity {
self.loadOrCreate(fileURL: self.fileURL(profile: profile))
self.loadOrCreate(fileURL: self.fileURL())
}
static func loadOrCreate(fileURL url: URL) -> DeviceIdentity {
if let data = try? Data(contentsOf: url) {
switch self.decodeStoredIdentity(data) {
case let .identity(decoded):
case .identity(let decoded):
return decoded
case .recognizedInvalid:
return self.generate()
@@ -207,7 +143,7 @@ public enum DeviceIdentityStore {
let privateKeyData = Data(base64Encoded: identity.privateKey)
else { return nil }
guard publicKeyData.count == 32, privateKeyData.count == 32,
guard publicKeyData.count == 32 && privateKeyData.count == 32,
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
else { return nil }
return DeviceIdentity(
@@ -275,11 +211,11 @@ public enum DeviceIdentityStore {
}
}
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
private static func fileURL() -> URL {
let base = DeviceIdentityPaths.stateDirURL()
return base
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(profile.identityFileName, isDirectory: false)
.appendingPathComponent(self.fileName, isDirectory: false)
}
}

View File

@@ -107,7 +107,6 @@ public struct GatewayConnectOptions: Sendable {
public var clientId: String
public var clientMode: String
public var clientDisplayName: String?
public var deviceIdentityProfile: GatewayDeviceIdentityProfile
/// When false, the connection omits the signed device identity payload and cannot use
/// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
/// role/scoped sessions such as operator UI clients.
@@ -123,7 +122,6 @@ public struct GatewayConnectOptions: Sendable {
clientId: String,
clientMode: String,
clientDisplayName: String?,
deviceIdentityProfile: GatewayDeviceIdentityProfile = .primary,
includeDeviceIdentity: Bool = true)
{
self.role = role
@@ -135,7 +133,6 @@ public struct GatewayConnectOptions: Sendable {
self.clientId = clientId
self.clientMode = clientMode
self.clientDisplayName = clientDisplayName
self.deviceIdentityProfile = deviceIdentityProfile
self.includeDeviceIdentity = includeDeviceIdentity
}
}
@@ -439,15 +436,13 @@ public actor GatewayChannelActor {
let clientId = options.clientId
let clientMode = options.clientMode
let role = options.role
let deviceIdentityProfile = options.deviceIdentityProfile
let requestedScopes = options.scopes
let scopesAreExplicit = options.scopesAreExplicit
let includeDeviceIdentity = options.includeDeviceIdentity
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate(profile: deviceIdentityProfile) : nil
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
let selectedAuth = self.selectConnectAuth(
role: role,
includeDeviceIdentity: includeDeviceIdentity,
deviceIdentityProfile: deviceIdentityProfile,
deviceId: identity?.deviceId,
requestedScopes: requestedScopes)
let scopes = self.resolveConnectScopes(
@@ -537,11 +532,7 @@ public actor GatewayChannelActor {
try await self.task?.send(.data(data))
do {
let response = try await self.waitForConnectResponse(reqId: reqId)
try await self.handleConnectResponse(
response,
identity: identity,
role: role,
deviceIdentityProfile: deviceIdentityProfile)
try await self.handleConnectResponse(response, identity: identity, role: role)
self.pendingDeviceTokenRetry = false
self.deviceTokenRetryBudgetUsed = false
} catch {
@@ -559,10 +550,7 @@ public actor GatewayChannelActor {
self.shouldClearStoredDeviceTokenAfterRetry(error)
{
// Retry failed with an explicit device-token mismatch; clear stale local token.
DeviceAuthStore.clearToken(
deviceId: identity.deviceId,
role: role,
profile: deviceIdentityProfile)
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
}
throw error
}
@@ -571,7 +559,6 @@ public actor GatewayChannelActor {
private func selectConnectAuth(
role: String,
includeDeviceIdentity: Bool,
deviceIdentityProfile: GatewayDeviceIdentityProfile,
deviceId: String?,
requestedScopes: [String]) -> SelectedConnectAuth
{
@@ -581,7 +568,7 @@ public actor GatewayChannelActor {
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let storedEntry =
(includeDeviceIdentity && deviceId != nil)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role, profile: deviceIdentityProfile)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)
: nil
let storedToken = storedEntry?.token
let storedScopes = storedEntry?.scopes ?? []
@@ -769,8 +756,7 @@ public actor GatewayChannelActor {
deviceId: String,
role: String,
token: String,
scopes: [String],
deviceIdentityProfile: GatewayDeviceIdentityProfile)
scopes: [String])
{
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
return
@@ -779,8 +765,7 @@ public actor GatewayChannelActor {
deviceId: deviceId,
role: role,
token: token,
scopes: filteredScopes,
profile: deviceIdentityProfile)
scopes: filteredScopes)
}
private func persistIssuedDeviceToken(
@@ -788,8 +773,7 @@ public actor GatewayChannelActor {
deviceId: String,
role: String,
token: String,
scopes: [String],
deviceIdentityProfile: GatewayDeviceIdentityProfile)
scopes: [String])
{
if authSource == .bootstrapToken {
guard self.shouldPersistBootstrapHandoffTokens() else {
@@ -799,23 +783,20 @@ public actor GatewayChannelActor {
deviceId: deviceId,
role: role,
token: token,
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
scopes: scopes)
return
}
_ = DeviceAuthStore.storeToken(
deviceId: deviceId,
role: role,
token: token,
scopes: scopes,
profile: deviceIdentityProfile)
scopes: scopes)
}
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity?,
role: String,
deviceIdentityProfile: GatewayDeviceIdentityProfile) async throws
role: String) async throws
{
if res.ok == false {
let error = res.error
@@ -874,8 +855,7 @@ public actor GatewayChannelActor {
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
scopes: scopes)
}
if self.shouldPersistBootstrapHandoffTokens(),
let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable]
@@ -893,8 +873,7 @@ public actor GatewayChannelActor {
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
scopes: scopes)
}
}
}

View File

@@ -162,7 +162,6 @@ public actor GatewayNodeSession {
let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines)
let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines)
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let deviceIdentityProfile = options.deviceIdentityProfile.rawValue
let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0"
let permissions = options.permissions
.map { key, value in
@@ -180,7 +179,6 @@ public actor GatewayNodeSession {
clientId,
clientMode,
clientDisplayName,
deviceIdentityProfile,
includeDeviceIdentity,
permissions,
].joined(separator: "|")

View File

@@ -1,11 +0,0 @@
import Foundation
public enum OpenClawAppGroup {
public static let canonicalIdentifier = "group.ai.openclawfoundation.app.shared"
public static var identifier: String {
let raw = Bundle.main.object(forInfoDictionaryKey: "OpenClawAppGroupIdentifier") as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? self.canonicalIdentifier : trimmed
}
}

View File

@@ -69,17 +69,6 @@
"fileName"
]
},
"api": {
"emoji": "🌐",
"title": "API",
"detailKeys": [
"url",
"endpoint",
"path",
"method",
"name"
]
},
"browser": {
"emoji": "🌐",
"title": "Browser",

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