Compare commits

..

21 Commits

Author SHA1 Message Date
Peter Steinberger
7641ec8ba7 fix(codex): restore runtime policy metadata 2026-06-20 22:31:28 -07:00
Peter Steinberger
28cbeaaa80 fix(codex): repair startup and side-question blockers 2026-06-20 20:47:55 -07:00
Peter Steinberger
e773981088 fix(codex): preserve sidecar migration agent owner 2026-06-20 20:31:06 -07:00
Peter Steinberger
9d4d07175d fix(codex): restore side-question native policy 2026-06-20 20:12:39 -07:00
Peter Steinberger
cdb7e64994 fix(codex): restore web search provider import 2026-06-20 20:04:52 -07:00
Peter Steinberger
7daba184b5 fix(codex): format provider capability lease test 2026-06-20 19:51:24 -07:00
Peter Steinberger
3d1935dcc1 refactor(codex): simplify native context ownership 2026-06-20 19:50:40 -07:00
Peter Steinberger
b832153d0a refactor(agents): isolate native hook provider policy 2026-06-20 19:50:40 -07:00
Peter Steinberger
9bd2b4b34b test(codex): type detached delivery fixture 2026-06-20 19:50:40 -07:00
Peter Steinberger
02abd3adb5 fix(codex): fence stale completion recovery 2026-06-20 19:50:40 -07:00
Peter Steinberger
0dda56e0f6 fix(codex): serialize detached completion delivery 2026-06-20 19:50:40 -07:00
Peter Steinberger
365d78605d fix(codex): preserve clients after terminal turn failures 2026-06-20 19:50:40 -07:00
Peter Steinberger
a2c64b08ff docs(codex): clarify subagent recovery owner 2026-06-20 19:50:39 -07:00
Peter Steinberger
90decc657d test(codex): narrow monitor fixture errors 2026-06-20 19:50:39 -07:00
Peter Steinberger
e670de672d test(codex): update generation reclaim fixture 2026-06-20 19:50:39 -07:00
Peter Steinberger
c4aba64b58 fix(codex): close runtime ownership races 2026-06-20 19:50:39 -07:00
Peter Steinberger
3a024f6a8d fix(codex): finalize runtime integration 2026-06-20 19:50:39 -07:00
Peter Steinberger
c866b087eb refactor(codex): remove stale binding lease type 2026-06-20 19:50:39 -07:00
Peter Steinberger
620cca1d7f fix(codex): resolve diagnostics sessions by agent 2026-06-20 19:50:39 -07:00
Peter Steinberger
353012b8a8 fix(codex): keep media runtime inside plugin package 2026-06-20 19:50:39 -07:00
Peter Steinberger
a37c6c935a refactor(codex): unify app-server runtime ownership 2026-06-20 19:50:38 -07:00
2927 changed files with 57462 additions and 127293 deletions

View File

@@ -15,7 +15,7 @@ committed `inventory/` report tree.
This skill owns the operational workflow for:
- `taxonomy.yaml`
- `qa/maturity-scores.yaml`
- `docs/maturity-scores.yaml`
- `docs/concepts/qa-e2e-automation.md`
- `qa/scenarios/index.yaml`
@@ -37,35 +37,28 @@ out of this repo. If a score needs private evidence, use the redacted
coverage IDs. Do not promote generic IDs into standalone feature names.
- Avoid duplicate coverage-ID bundles under different feature names in one
category.
- `qa/maturity-scores.yaml` is the committed aggregate source for Quality,
Completeness, and LTS review state.
- `extensions/qa-lab/src/scorecard-taxonomy.ts` exports
`qaMaturityScoresSchema` and `readValidatedQaMaturityScoreSources`; use those
QA Lab utilities to validate score output.
- Generated public docs are `docs/maturity/scorecard.md` and
`docs/maturity/taxonomy.md`; both come from `pnpm maturity:render`. Do not
hand-edit generated Markdown to change score results.
- `qa-evidence.json` artifacts provide per-run QA scorecard evidence. Release
profile artifacts are the source of truth for Coverage. They can enrich
generated artifact docs, but they are not committed as inventory.
- `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.
- `qa-evidence.json` artifacts provide per-run QA scorecard evidence. They can
enrich generated artifact docs, but they are not committed as inventory.
## Commands
Run from the openclaw repo root.
Validate taxonomy YAML structure and the maturity score schema after source
edits:
Validate YAML structure after source edits:
```bash
node --import tsx --input-type=module <<'NODE'
import fs from "node:fs";
import YAML from "yaml";
import { readValidatedQaMaturityScoreSources } from "./extensions/qa-lab/src/scorecard-taxonomy.ts";
for (const file of ["taxonomy.yaml", "qa/scenarios/index.yaml"]) {
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"));
}
readValidatedQaMaturityScoreSources();
NODE
```
@@ -90,17 +83,17 @@ When asked to score or refresh a surface:
`.agents/skills/claw-score/references/completeness/`.
3. Gather public repo evidence from docs, source, tests, and QA scenario
metadata.
4. Prefer existing release profile `qa-evidence.json` artifacts for executed
proof.
5. Update `qa/maturity-scores.yaml` only for Quality, Completeness, and LTS
review state backed by public or redacted artifact evidence.
6. Run the schema validation command from this skill.
4. Prefer existing `qa-evidence.json` artifacts for executed proof. Do not use
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.
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 `qa/maturity-scores.yaml`.
keep score data in `docs/maturity-scores.yaml`.
## Default Completeness Process
@@ -146,7 +139,7 @@ Default guidance:
Default Completeness bands:
- `Clawesome` (95-100): complete across expected workflows, variants, and
- `Lovable` (95-100): complete across expected workflows, variants, and
recovery branches, with only minor polish gaps.
- `Stable` (80-95): the expected workflow set is broadly present, with only
bounded missing branches.
@@ -159,20 +152,19 @@ Default Completeness bands:
## Score Semantics
- Coverage: deterministic release validation coverage derived from the release
profile `qa-evidence.json.scorecard` feature fulfillment data.
- Coverage: public or redacted proof that the feature is exercised by docs,
tests, QA scenarios, live lanes, or release evidence.
- Quality: reliability, maintainability, operator safety, and regression
confidence for the category.
- Completeness: how much of the intended operator-visible workflow exists for
the category. Use the default completeness process plus any surface-specific
variation before changing this score.
- LTS: derived from Quality, release-evidence Coverage, and
`human_lts_override`; do not hand-edit generated Markdown to change LTS
status.
- LTS: derived from score thresholds and `human_lts_override`; do not hand-edit
generated Markdown to change LTS status.
Bands:
- `Clawesome`: 95-100
- `Lovable`: 95-100
- `Stable`: 80-95
- `Beta`: 70-80
- `Alpha`: 50-70

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

@@ -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

@@ -1,76 +0,0 @@
name: Docs bug report
description: Report documentation defects (incorrect, missing, outdated, or contradictory docs).
title: "[Docs Bug]: "
labels:
- bug
- docs
body:
- type: markdown
attributes:
value: |
Report a documentation defect with concrete evidence from current docs behavior/content.
Please only report one documentation defect per submission.
- type: textarea
id: summary
attributes:
label: Summary
description: One-sentence statement of what is wrong in the docs.
placeholder: The WhatsApp config example defines duplicate top-level keys in one JSON5 block.
validations:
required: true
- type: input
id: doc_paths
attributes:
label: Affected docs path(s) or URL(s)
description: Repo-relative docs file path(s) or published docs URL(s).
placeholder: docs/gateway/config-channels.md or https://docs.openclaw.ai/gateway/config-channels
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce / verify
description: Minimal steps to observe the docs defect in the current docs.
placeholder: |
1. Open docs/gateway/config-channels.md
2. Go to the WhatsApp example block
3. Observe duplicate top-level key definitions
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected docs behavior/content
description: What the docs should say/show instead.
placeholder: The example should use a single merged top-level object with no duplicate keys.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual docs behavior/content
description: What the docs currently say/show.
placeholder: The snippet defines the same top-level key twice in one object.
validations:
required: true
- type: textarea
id: impact
attributes:
label: Impact
description: Who is affected and practical consequence.
placeholder: Users who copy-paste the snippet can end up with ambiguous config behavior.
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Evidence
description: Links/snippets/screenshots proving the docs defect.
placeholder: Include exact file links and line ranges.
validations:
required: true
- type: textarea
id: additional_information
attributes:
label: Additional information
description: Optional context, related issues/PRs, or constraints.

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

View File

@@ -1,23 +0,0 @@
# OpenClaw Maturity Scorecard Agent
You are refreshing the OpenClaw maturity score source for a release scorecard.
Goal: use the `$claw-score` skill to refresh `qa/maturity-scores.yaml` for every active surface in `taxonomy.yaml`, using the current repository and the release evidence artifacts in `.artifacts/maturity-evidence`.
Allowed tracked paths:
- `qa/maturity-scores.yaml`
Hard limits:
- Do not edit generated docs, taxonomy, workflows, scripts, package metadata, lockfiles, tests, or application code.
- Do not render docs. The workflow renders docs after validating the score source.
- Keep the score source schema valid for QA Lab maturity score validation.
Required workflow:
1. Use the `$claw-score` skill before editing.
2. Read `taxonomy.yaml`, any existing maturity score file, and the release evidence artifacts.
3. Refresh scores for every active surface in `taxonomy.yaml`.
4. Run the QA Lab maturity score validation used by this repository.
5. If no defensible score update is possible, leave a valid `qa/maturity-scores.yaml` and explain the uncertainty in the final message.

17
.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:
@@ -251,12 +252,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 +270,7 @@
- ".github/workflows/opengrep-*.yml"
- ".semgrepignore"
- "docs/cli/security.md"
- "docs/gateway/security/**"
- "docs/gateway/security.md"
- "security/**"
"extensions: admin-http-rpc":

View File

@@ -198,19 +198,10 @@ jobs:
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"
@@ -270,6 +261,6 @@ jobs:
- name: Run Testbox
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
if: always()
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -116,19 +116,10 @@ jobs:
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"
@@ -188,6 +179,6 @@ jobs:
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: always()
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -33,7 +33,7 @@ jobs:
contents: read
name: "check"
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ fromJSON(inputs.timeout_minutes || '120') }}
timeout-minutes: ${{ fromJSON(inputs.timeout_minutes || '30') }}
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
@@ -105,19 +105,10 @@ jobs:
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"
@@ -177,6 +168,6 @@ jobs:
- name: Run Testbox
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
if: always()
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

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
@@ -100,7 +79,6 @@ jobs:
run_macos_node: ${{ steps.manifest.outputs.run_macos_node }}
macos_node_matrix: ${{ steps.manifest.outputs.macos_node_matrix }}
run_macos_swift: ${{ steps.manifest.outputs.run_macos_swift }}
run_ios_build: ${{ steps.manifest.outputs.run_ios_build }}
run_android_job: ${{ steps.manifest.outputs.run_android_job }}
android_matrix: ${{ steps.manifest.outputs.android_matrix }}
steps:
@@ -205,7 +183,6 @@ jobs:
OPENCLAW_CI_DOCS_CHANGED: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.docs_scope.outputs.docs_changed }}
OPENCLAW_CI_RUN_NODE: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_node || 'false' }}
OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_macos || 'false' }}
OPENCLAW_CI_RUN_IOS_BUILD: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ios_build || 'false' }}
OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}
OPENCLAW_CI_RUN_WINDOWS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_windows || 'false' }}
OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_only || 'false' }}
@@ -220,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,
@@ -269,8 +246,6 @@ jobs:
const runPluginContractShards = runNodeFull || runNodeFastPluginContracts;
const runMacos =
parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository;
const runIosBuild =
parseBoolean(process.env.OPENCLAW_CI_RUN_IOS_BUILD) && !docsOnly && isCanonicalRepository;
const runAndroid =
parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository;
const runWindows =
@@ -297,23 +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,
env: shard.env,
includePatterns: shard.includePatterns,
requires_dist: shard.requiresDist,
runner: shard.runner,
timeout_minutes: shard.timeoutMinutes,
}))
: [];
const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist);
@@ -350,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,
@@ -365,7 +328,6 @@ jobs:
runMacos ? [{ check_name: "macos-node", runtime: "node", task: "test" }] : [],
),
run_macos_swift: runMacos,
run_ios_build: runIosBuild,
run_android_job: runAndroid,
android_matrix: createMatrix(
runAndroid
@@ -392,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
@@ -597,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'] }}
@@ -858,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
@@ -948,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
@@ -1029,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
@@ -1178,13 +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
# The canonical main path waits for the admission debounce above, so
# modestly widen this large matrix without recreating registration bursts.
max-parallel: 16
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
steps:
- name: Checkout
@@ -1243,9 +1198,7 @@ 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_ENV_JSON: ${{ toJson(matrix.env) }}
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "300000"
@@ -1259,55 +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 ?? "[]"),
env: JSON.parse(process.env.OPENCLAW_NODE_TEST_ENV_JSON ?? "null"),
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 (plan.env && typeof plan.env === "object" && !Array.isArray(plan.env)) {
for (const [key, value] of Object.entries(plan.env)) {
if (typeof value === "string") {
childEnv[key] = value;
}
}
}
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
@@ -1322,7 +1248,6 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 8
matrix:
include:
- check_name: check-guards
@@ -1339,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
@@ -1460,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
@@ -1835,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
@@ -1847,7 +1763,6 @@ jobs:
shell: bash
strategy:
fail-fast: false
max-parallel: 2
matrix: ${{ fromJson(needs.preflight.outputs.checks_windows_matrix) }}
steps:
- name: Checkout
@@ -2167,76 +2082,6 @@ jobs:
done
exit 1
ios-build:
permissions:
contents: read
name: "ios-build"
needs: [preflight]
if: needs.preflight.outputs.run_ios_build == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 45
steps:
- name: Checkout
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
run: |
set -euo pipefail
git init "$GITHUB_WORKSPACE"
git -C "$GITHUB_WORKSPACE" config gc.auto 0
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
fetch_timeout_seconds=90
fetch_checkout_ref() {
git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
local fetch_pid="$!"
local elapsed=0
while kill -0 "$fetch_pid" 2>/dev/null; do
if [ "$elapsed" -ge "$fetch_timeout_seconds" ]; then
kill -TERM "$fetch_pid" 2>/dev/null || true
sleep 10
kill -KILL "$fetch_pid" 2>/dev/null || true
wait "$fetch_pid" || true
return 124
fi
sleep 1
elapsed=$((elapsed + 1))
done
wait "$fetch_pid"
}
fetch_checkout_ref
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Select Xcode 26
run: |
set -euo pipefail
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
if [ -d "$xcode_app/Contents/Developer" ]; then
sudo xcode-select -s "$xcode_app/Contents/Developer"
break
fi
done
xcodebuild -version
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
if [[ "$xcode_version" != 26.* ]]; then
echo "error: expected Xcode 26.x, got $xcode_version" >&2
exit 1
fi
swift --version
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Install iOS Swift tooling
run: brew install xcodegen swiftlint swiftformat
- name: Build iOS app
run: pnpm ios:build
android:
permissions:
contents: read
@@ -2247,7 +2092,6 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 2
matrix: ${{ fromJson(needs.preflight.outputs.android_matrix) }}
steps:
- name: Checkout
@@ -2310,7 +2154,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/.android-sdk
key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-36-build-tools-36.0.0
key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-37.0-build-tools-36.0.0
restore-keys: |
${{ runner.os }}-android-sdk-v1-
@@ -2340,7 +2184,7 @@ jobs:
yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null
sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \
"platform-tools" \
"platforms;android-36" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Run Android ${{ matrix.task }}
@@ -2388,7 +2232,6 @@ jobs:
- checks-windows
- macos-node
- macos-swift
- ios-build
- android
if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04

View File

@@ -81,27 +81,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 }}
@@ -231,11 +213,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 }}
@@ -254,6 +232,10 @@ jobs:
. "$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 \

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"
@@ -152,7 +152,7 @@ jobs:
quality-shards:
name: Select Critical Quality shards
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 5
outputs:
agent: ${{ steps.detect.outputs.agent }}
@@ -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/*)
@@ -333,7 +333,7 @@ jobs:
name: Critical Quality (core-auth-secrets)
needs: quality-shards
if: ${{ needs.quality-shards.outputs.core_auth_secrets == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'core-auth-secrets') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -356,7 +356,7 @@ jobs:
name: Critical Quality (config-boundary)
needs: quality-shards
if: ${{ needs.quality-shards.outputs.config == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'config-boundary') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -379,7 +379,7 @@ jobs:
name: Critical Quality (gateway-runtime-boundary)
needs: quality-shards
if: ${{ needs.quality-shards.outputs.gateway == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'gateway-runtime-boundary') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -402,7 +402,7 @@ jobs:
name: Critical Quality (channel-runtime-boundary)
needs: quality-shards
if: ${{ needs.quality-shards.outputs.channel == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'channel-runtime-boundary') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -425,7 +425,7 @@ jobs:
name: Critical Quality (network-runtime-boundary)
needs: quality-shards
if: ${{ needs.quality-shards.outputs.network_runtime == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'network-runtime-boundary') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -509,7 +509,7 @@ jobs:
name: Critical Quality (agent-runtime-boundary)
needs: quality-shards
if: ${{ needs.quality-shards.outputs.agent == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'agent-runtime-boundary') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -532,7 +532,7 @@ jobs:
name: Critical Quality (mcp-process-runtime-boundary)
needs: quality-shards
if: ${{ needs.quality-shards.outputs.mcp_process == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'mcp-process-runtime-boundary') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -555,7 +555,7 @@ jobs:
name: Critical Quality (memory-runtime-boundary)
needs: quality-shards
if: ${{ needs.quality-shards.outputs.memory == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'memory-runtime-boundary') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -578,7 +578,7 @@ jobs:
name: Critical Quality (session-diagnostics-boundary)
needs: quality-shards
if: ${{ needs.quality-shards.outputs.session_diagnostics == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'session-diagnostics-boundary') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -601,7 +601,7 @@ jobs:
name: Critical Quality (plugin-sdk-reply-runtime)
needs: quality-shards
if: ${{ needs.quality-shards.outputs.plugin_sdk_reply == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'plugin-sdk-reply-runtime') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -624,7 +624,7 @@ jobs:
name: Critical Quality (provider-runtime-boundary)
needs: quality-shards
if: ${{ needs.quality-shards.outputs.provider == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'provider-runtime-boundary') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -646,7 +646,7 @@ jobs:
ui-control-plane:
name: Critical Quality (ui-control-plane)
if: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.profile == 'all') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -668,7 +668,7 @@ jobs:
web-media-runtime-boundary:
name: Critical Quality (web-media-runtime-boundary)
if: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.profile == 'all') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -691,7 +691,7 @@ jobs:
name: Critical Quality (plugin-boundary)
needs: quality-shards
if: ${{ needs.quality-shards.outputs.plugin == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'plugin-boundary') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
@@ -714,7 +714,7 @@ jobs:
name: Critical Quality (plugin-sdk-package-contract)
needs: quality-shards
if: ${{ needs.quality-shards.outputs.plugin_sdk_package == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'plugin-sdk-package-contract') }}
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout

View File

@@ -22,6 +22,12 @@ on:
push:
branches:
- main
paths:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "packages/**"
- "src/**"
schedule:
- cron: "0 6 * * *"
@@ -49,32 +55,32 @@ jobs:
include:
- language: javascript-typescript
category: core-auth-secrets
runs_on: ubuntu-24.04
runs_on: blacksmith-8vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-core-auth-secrets-critical-security.yml
- language: javascript-typescript
category: channel-runtime-boundary
runs_on: ubuntu-24.04
runs_on: blacksmith-8vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-channel-runtime-boundary-critical-security.yml
- language: javascript-typescript
category: network-ssrf-boundary
runs_on: ubuntu-24.04
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-network-ssrf-boundary-critical-security.yml
- language: javascript-typescript
category: mcp-process-tool-boundary
runs_on: ubuntu-24.04
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
- language: javascript-typescript
category: plugin-trust-boundary
runs_on: ubuntu-24.04
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-plugin-trust-boundary-critical-security.yml
- language: actions
category: actions
runs_on: ubuntu-24.04
runs_on: blacksmith-8vcpu-ubuntu-2404
timeout_minutes: 10
config_file: ./.github/codeql/codeql-actions-critical-security.yml
steps:

View File

@@ -171,19 +171,10 @@ jobs:
set -euo pipefail
node_bin="$(dirname "$(node -p 'process.execPath')")"
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"
@@ -499,7 +490,7 @@ jobs:
if (-not $env:CRABBOX_ID -or $env:CRABBOX_ID -notmatch '^[A-Za-z0-9._-]+$') {
Write-Error "Invalid crabbox_id"
}
$actionsRoot = "C:\ProgramData\crabbox\actions"
$actionsRoot = Join-Path $HOME ".crabbox\actions"
New-Item -ItemType Directory -Force $actionsRoot | Out-Null
$state = Join-Path $actionsRoot "$env:CRABBOX_ID.env"
$envFile = Join-Path $actionsRoot "$env:CRABBOX_ID.env.ps1"
@@ -555,7 +546,7 @@ jobs:
if ($env:CRABBOX_KEEP_ALIVE_MINUTES -match '^[0-9]+$') {
$minutes = [int]$env:CRABBOX_KEEP_ALIVE_MINUTES
}
$stop = Join-Path "C:\ProgramData\crabbox\actions" "$env:CRABBOX_ID.stop"
$stop = Join-Path $HOME ".crabbox\actions\$env:CRABBOX_ID.stop"
$deadline = (Get-Date).AddMinutes($minutes)
while ((Get-Date) -lt $deadline) {
if (Test-Path $stop) {
@@ -593,19 +584,10 @@ jobs:
fi
node_bin="$(dirname "$(node -p 'process.execPath')")"
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"

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,10 +764,80 @@ 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 }}
@@ -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

View File

@@ -27,8 +27,10 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const zlib = require("node:zlib");
const childProcess = require("node:child_process");
const marker = "<!-- openclaw-ios-periphery-dead-code -->";
const run = context.payload.workflow_run;
@@ -124,7 +126,10 @@ jobs:
archive_format: "zip",
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ios-periphery-"));
const archivePath = path.join(dir, "artifact.zip");
const archiveBuffer = Buffer.from(archive.data);
fs.writeFileSync(archivePath, archiveBuffer);
const allowedArtifactFiles = new Set([
"periphery.json",
@@ -235,59 +240,19 @@ jobs:
return;
}
entries.set(name, {
compressedSize,
compressionMethod,
localHeaderOffset: readUInt32(offset + 42),
uncompressedSize,
});
entries.set(name, { uncompressedSize });
offset = nextOffset;
}
const readZipEntry = (name, entry) => {
const localHeaderOffset = entry.localHeaderOffset;
if (
localHeaderOffset + 30 > archiveBuffer.length ||
readUInt32(localHeaderOffset) !== 0x04034b50
) {
throw new Error(`${name} has an invalid local header.`);
}
const localNameLength = readUInt16(localHeaderOffset + 26);
const localExtraLength = readUInt16(localHeaderOffset + 28);
const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength;
const dataEnd = dataStart + entry.compressedSize;
if (dataEnd > archiveBuffer.length) {
throw new Error(`${name} exceeds archive bounds.`);
}
const compressed = archiveBuffer.subarray(dataStart, dataEnd);
let contents;
if (entry.compressionMethod === 0) {
contents = compressed;
} else {
try {
contents = zlib.inflateRawSync(compressed, { maxOutputLength: maxEntryBytes });
} catch (error) {
if (error && error.code === "ERR_BUFFER_TOO_LARGE") {
throw new Error(`${name} exceeded the per-file size limit while reading.`);
}
throw error;
}
}
if (contents.length !== entry.uncompressedSize || contents.length > maxEntryBytes) {
throw new Error(`${name} exceeded the per-file size limit while reading.`);
}
return contents.toString("utf8");
};
const files = new Map();
for (const [name, entry] of entries) {
let contents;
try {
contents = readZipEntry(name, entry);
} catch (error) {
core.warning(`Skipping ${artifactName}; ${error instanceof Error ? error.message : String(error)}`);
const contents = childProcess.execFileSync("unzip", ["-p", archivePath, name], {
encoding: "utf8",
maxBuffer: Math.max(1, entry.uncompressedSize + 1024),
timeout: 5000,
});
if (Buffer.byteLength(contents, "utf8") > maxEntryBytes) {
core.warning(`Skipping ${artifactName}; ${name} exceeded the per-file size limit while reading.`);
return;
}
files.set(name, contents);

View File

@@ -220,7 +220,7 @@ jobs:
with:
name: ios-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ios-periphery
if-no-files-found: error
if-no-files-found: warn
retention-days: 14
- name: Fail on dead code

View File

@@ -171,4 +171,4 @@ jobs:
name: mantis-discord-smoke-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/mantis/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn

View File

@@ -540,7 +540,7 @@ jobs:
name: mantis-discord-status-reactions-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Create Mantis GitHub App token
id: mantis_app_token

View File

@@ -547,7 +547,7 @@ jobs:
with:
name: mantis-discord-thread-attachment-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
if-no-files-found: error
if-no-files-found: warn
retention-days: 14
- name: Create Mantis GitHub App token

View File

@@ -458,7 +458,7 @@ jobs:
name: mantis-slack-desktop-smoke-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Create Mantis GitHub App token
id: mantis_app_token

View File

@@ -556,7 +556,7 @@ jobs:
name: mantis-telegram-desktop-proof-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.inspect.outputs.output_dir }}
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Create Mantis GitHub App token
id: mantis_app_token

View File

@@ -506,7 +506,7 @@ jobs:
name: mantis-telegram-live-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Create Mantis GitHub App token
id: mantis_app_token

View File

@@ -1,464 +0,0 @@
name: Maturity scorecard
on:
workflow_dispatch:
inputs:
qa_evidence_run_id:
description: Optional workflow run id containing qa-evidence.json
required: false
type: string
ref:
description: OpenClaw branch, tag, or SHA containing the maturity score source
required: true
default: main
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
workflow_call:
inputs:
qa_evidence_run_id:
description: Optional workflow run id containing qa-evidence.json
required: false
default: ""
type: string
ref:
description: OpenClaw branch, tag, or SHA containing the maturity score source
required: true
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
secrets:
OPENAI_API_KEY:
description: OpenAI API key used by live QA profile scenarios
required: true
OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY:
description: Optional OpenAI API key used by maturity scorecard agent steps
required: false
GH_APP_PRIVATE_KEY:
description: Optional GitHub App private key for generated docs PR creation
required: false
GH_APP_PRIVATE_KEY_FALLBACK:
description: Optional fallback GitHub App private key for generated docs PR creation
required: false
permissions:
actions: read
contents: read
concurrency:
group: ${{ format('{0}-{1}-{2}', github.workflow, inputs.ref, inputs.qa_evidence_run_id || github.run_id) }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
jobs:
validate_selected_ref:
name: Validate selected ref
runs-on: ubuntu-24.04
outputs:
selected_revision: ${{ steps.validate.outputs.selected_revision }}
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Validate selected ref
id: validate
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
INPUT_REF: ${{ inputs.ref }}
shell: bash
run: |
set -euo pipefail
selected_revision="$(git rev-parse HEAD)"
expected_sha="${EXPECTED_SHA,,}"
trusted_reason=""
if [[ -n "${expected_sha// }" && ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "expected_sha must be a full 40-character SHA; got: ${EXPECTED_SHA}" >&2
exit 1
fi
if [[ -n "${expected_sha// }" && "${selected_revision,,}" != "$expected_sha" ]]; then
echo "Ref '${INPUT_REF}' resolved to ${selected_revision}, expected ${EXPECTED_SHA}." >&2
exit 1
fi
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
trusted_reason="main-ancestor"
elif git tag --points-at "$selected_revision" | grep -Eq '^v'; then
trusted_reason="release-tag"
elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then
git fetch --no-tags origin "+refs/heads/${INPUT_REF}:refs/remotes/origin/${INPUT_REF}"
release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")"
if [[ "$selected_revision" == "$release_branch_sha" ]]; then
trusted_reason="release-branch-head"
fi
fi
if [[ -z "$trusted_reason" ]]; then
echo "Ref '${INPUT_REF}' resolved to $selected_revision, which is not trusted for this secret-bearing maturity scorecard run." >&2
echo "Allowed refs must be on main, point to a release tag, or match a release branch head." >&2
exit 1
fi
echo "selected_revision=$selected_revision" >> "$GITHUB_OUTPUT"
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
{
echo "### Target"
echo
echo "- Requested ref: \`${INPUT_REF}\`"
echo "- Resolved SHA: \`$selected_revision\`"
echo "- Trust reason: \`$trusted_reason\`"
} >> "$GITHUB_STEP_SUMMARY"
generate_qa_evidence:
name: Generate full taxonomy QA evidence
needs: validate_selected_ref
if: ${{ inputs.qa_evidence_run_id == '' }}
uses: ./.github/workflows/qa-profile-evidence.yml
with:
ref: ${{ inputs.ref }}
expected_sha: ${{ needs.validate_selected_ref.outputs.selected_revision }}
qa_profile: release
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
publish:
name: Publish maturity docs PR
needs:
- validate_selected_ref
- generate_qa_evidence
if: ${{ always() && needs.validate_selected_ref.result == 'success' && (inputs.qa_evidence_run_id != '' || needs.generate_qa_evidence.result == 'success') }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
actions: read
contents: read
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
- name: Download provided QA evidence artifact
if: ${{ inputs.qa_evidence_run_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
run: |
set -euo pipefail
mkdir -p .artifacts/maturity-evidence
gh run download "$QA_EVIDENCE_RUN_ID" \
--repo "$GITHUB_REPOSITORY" \
--dir .artifacts/maturity-evidence
- name: Download generated QA evidence artifact
if: ${{ inputs.qa_evidence_run_id == '' }}
env:
GENERATED_ARTIFACT_NAME: ${{ needs.generate_qa_evidence.outputs.artifact_name }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [[ -z "${GENERATED_ARTIFACT_NAME:-}" ]]; then
echo "Generated QA evidence workflow did not expose an artifact name." >&2
exit 1
fi
mkdir -p .artifacts/maturity-evidence
gh run download "$GITHUB_RUN_ID" \
--repo "$GITHUB_REPOSITORY" \
--name "$GENERATED_ARTIFACT_NAME" \
--dir .artifacts/maturity-evidence
- name: Require one QA evidence file
id: evidence
env:
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
run: |
set -euo pipefail
mapfile -t evidence_paths < <(find .artifacts/maturity-evidence -type f -name qa-evidence.json | sort)
if [[ "${#evidence_paths[@]}" -eq 0 ]]; then
echo "Expected a qa-evidence.json file in the downloaded QA evidence artifact." >&2
exit 1
fi
if [[ "${#evidence_paths[@]}" -gt 1 ]]; then
echo "Expected exactly one qa-evidence.json file, found ${#evidence_paths[@]}:" >&2
printf '%s\n' "${evidence_paths[@]}" >&2
exit 1
fi
echo "qa_evidence_path=${evidence_paths[0]}" >> "$GITHUB_OUTPUT"
{
echo "### QA evidence"
echo
echo "- Evidence path: \`${evidence_paths[0]}\`"
echo "- Evidence source run: \`${QA_EVIDENCE_RUN_ID:-$GITHUB_RUN_ID}\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Validate QA evidence manifest
env:
QA_EVIDENCE_PATH: ${{ steps.evidence.outputs.qa_evidence_path }}
TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_revision }}
run: |
set -euo pipefail
node --input-type=module <<'NODE'
import fs from "node:fs";
import path from "node:path";
const evidencePath = process.env.QA_EVIDENCE_PATH;
const targetSha = process.env.TARGET_SHA;
if (!evidencePath) {
throw new Error("QA_EVIDENCE_PATH is required");
}
if (!targetSha) {
throw new Error("TARGET_SHA is required");
}
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
if (evidence.profile !== "release") {
throw new Error(`qa-evidence.json profile must be release, got ${JSON.stringify(evidence.profile)}`);
}
const artifactDir = path.dirname(evidencePath);
const manifestNames = fs
.readdirSync(artifactDir)
.filter((name) => name.endsWith("qa-profile-evidence-manifest.json"))
.sort();
if (manifestNames.length !== 1) {
throw new Error(
`Expected exactly one QA profile evidence manifest next to qa-evidence.json, found ${manifestNames.length}`,
);
}
const manifestPath = path.join(artifactDir, manifestNames[0]);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const manifestProfile = manifest.qaProfile ?? evidence.profile;
if (manifestProfile !== "release") {
throw new Error(`QA evidence manifest profile must be release, got ${JSON.stringify(manifestProfile)}`);
}
if (manifest.targetSha !== targetSha) {
throw new Error(`QA evidence manifest targetSha ${manifest.targetSha} does not match selected ref ${targetSha}`);
}
NODE
- name: Ensure maturity scorecard agent key exists
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "Missing OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2
exit 1
fi
- name: Run Codex maturity scorecard agent
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
env:
MATURITY_EVIDENCE_DIR: .artifacts/maturity-evidence
MATURITY_SCORES_PATH: qa/maturity-scores.yaml
MATURITY_TAXONOMY_PATH: taxonomy.yaml
with:
openai-api-key: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/maturity-scorecard-agent.md
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
effort: high
sandbox: workspace-write
safety-strategy: drop-sudo
- name: Enforce focused maturity score patch
run: |
set -euo pipefail
git restore --staged :/
allowed='^qa/maturity-scores\.yaml$'
bad_tracked="$(
git diff --name-only HEAD -- | while IFS= read -r path; do
if [[ ! "$path" =~ $allowed ]]; then
printf '%s\n' "$path"
fi
done
)"
if [[ -n "$bad_tracked" ]]; then
echo "Maturity scorecard agent touched forbidden tracked paths:"
printf '%s\n' "$bad_tracked"
exit 1
fi
bad_untracked="$(
git ls-files --others --exclude-standard | while IFS= read -r path; do
if [[ "$path" != "qa/maturity-scores.yaml" ]]; then
printf '%s\n' "$path"
fi
done
)"
if [[ -n "$bad_untracked" ]]; then
echo "Maturity scorecard agent created forbidden untracked paths:"
printf '%s\n' "$bad_untracked"
exit 1
fi
if [[ ! -f qa/maturity-scores.yaml ]]; then
echo "Maturity scorecard agent must produce qa/maturity-scores.yaml." >&2
exit 1
fi
- name: Validate maturity score sources
run: |
node --import tsx --input-type=module <<'NODE'
import { readValidatedQaMaturityScoreSources } from "./extensions/qa-lab/src/scorecard-taxonomy.ts";
const { warnings } = readValidatedQaMaturityScoreSources({
scoresPath: "qa/maturity-scores.yaml",
taxonomyPath: "taxonomy.yaml",
});
for (const warning of warnings) {
console.error(`warning: ${warning}`);
}
NODE
- name: Render artifact docs
run: |
set -euo pipefail
pnpm maturity:render -- \
--output-dir .artifacts/maturity-docs \
--static-assets-dir .artifacts/maturity-docs/assets/maturity \
--scores qa/maturity-scores.yaml \
--evidence-dir .artifacts/maturity-evidence \
--strict-inputs
{
echo "### Maturity scorecard docs"
echo
echo "- Source validation: passed"
echo "- Artifact docs: \`.artifacts/maturity-docs\`"
echo "- Strict inputs: \`true\`"
echo "- QA evidence: included"
} >> "$GITHUB_STEP_SUMMARY"
- name: Render committed docs preview
run: |
set -euo pipefail
pnpm maturity:render -- \
--output-dir docs \
--scores qa/maturity-scores.yaml \
--evidence-dir .artifacts/maturity-evidence \
--strict-inputs
- name: Create generated docs PR app token
if: ${{ github.event_name == 'workflow_dispatch' }}
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- name: Create generated docs PR fallback app token
if: ${{ github.event_name == 'workflow_dispatch' && steps.app-token.outcome == 'failure' }}
id: app-token-fallback
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
permission-contents: write
permission-pull-requests: write
- name: Open generated docs PR
if: ${{ github.event_name == 'workflow_dispatch' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
REF_INPUT: ${{ inputs.ref }}
run: |
set -euo pipefail
if [[ -z "${GH_TOKEN:-}" ]]; then
echo "Maturity scorecard PR creation requires the OpenClaw GitHub App token secrets." >&2
exit 1
fi
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
{
echo
echo "- Pull request: skipped; generated scorecard matches selected ref"
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
evidence_run_id="${QA_EVIDENCE_RUN_ID:-$GITHUB_RUN_ID}"
branch="automation/maturity-scorecard-${evidence_run_id}"
base_branch="${REF_INPUT:-main}"
if ! git ls-remote --exit-code --heads origin "$base_branch" >/dev/null 2>&1; then
base_branch="main"
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
gh auth setup-git
git fetch --no-tags --depth=1 origin "refs/heads/${branch}:refs/remotes/origin/${branch}" || true
git switch -C "$branch"
git add qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md
git commit -m "docs: update maturity scorecard"
git push --force-with-lease origin "$branch"
body_file=".artifacts/maturity-scorecard-pr-body.md"
mkdir -p "$(dirname "$body_file")"
cat > "$body_file" <<BODY
## Summary
- render maturity scorecard docs from \`qa/maturity-scores.yaml\` and release QA evidence
- maturity source ref: ${REF_INPUT}
- QA evidence run: ${evidence_run_id}
## Verification
- QA Lab maturity score validation passed
- Maturity scorecard workflow rendered docs from release profile qa-evidence.json artifacts with strict inputs
BODY
pr_url="$(gh pr list --head "$branch" --state open --json url --jq '.[0].url // ""')"
if [[ -n "$pr_url" ]]; then
gh pr edit "$pr_url" \
--title "docs: update maturity scorecard" \
--body-file "$body_file"
else
pr_url="$(gh pr create \
--base "$base_branch" \
--head "$branch" \
--title "docs: update maturity scorecard" \
--body-file "$body_file")"
fi
{
echo
echo "- Pull request: ${pr_url}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload maturity docs artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: maturity-scorecard-docs-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/maturity-docs/
retention-days: 30
if-no-files-found: error

View File

@@ -273,4 +273,4 @@ jobs:
name: npm-telegram-beta-e2e-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn

View File

@@ -151,39 +151,11 @@ jobs:
echo "present=false" >> "$GITHUB_OUTPUT"
fi
- name: Resolve OpenClaw target ref
id: target
if: steps.lane.outputs.run == 'true'
env:
GH_TOKEN: ${{ github.token }}
TARGET_REF_INPUT: ${{ inputs.target_ref }}
shell: bash
run: |
set -euo pipefail
requested="${TARGET_REF_INPUT:-}"
if [[ -z "$requested" ]]; then
echo "checkout_ref=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "tested_ref=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
exit 0
fi
encoded_ref="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$requested")"
if ! resolved_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${encoded_ref}" --jq '.sha')"; then
echo "::error::Unable to resolve OpenClaw target_ref '${requested}'." >&2
exit 1
fi
if [[ ! "$resolved_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::OpenClaw target_ref '${requested}' resolved to invalid SHA '${resolved_sha}'." >&2
exit 1
fi
echo "checkout_ref=${resolved_sha}" >> "$GITHUB_OUTPUT"
echo "tested_ref=${requested}" >> "$GITHUB_OUTPUT"
- name: Checkout OpenClaw
if: steps.lane.outputs.run == 'true'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ steps.target.outputs.checkout_ref }}
ref: ${{ inputs.target_ref || github.ref }}
fetch-depth: 1
persist-credentials: false

View File

@@ -44,11 +44,6 @@ on:
required: false
default: false
type: boolean
run_maturity_scorecard:
description: Render advisory maturity scorecard release docs; default release checks rely on dedicated package, QA, live, and E2E gates
required: false
default: false
type: boolean
rerun_group:
description: Release check group to run
required: false
@@ -111,7 +106,6 @@ jobs:
mode: ${{ steps.inputs.outputs.mode }}
release_profile: ${{ steps.inputs.outputs.release_profile }}
run_release_soak: ${{ steps.inputs.outputs.run_release_soak }}
run_maturity_scorecard: ${{ steps.inputs.outputs.run_maturity_scorecard }}
rerun_group: ${{ steps.inputs.outputs.rerun_group }}
live_suite_filter: ${{ steps.inputs.outputs.live_suite_filter }}
cross_os_suite_filter: ${{ steps.inputs.outputs.cross_os_suite_filter }}
@@ -285,7 +279,6 @@ jobs:
RELEASE_MODE_INPUT: ${{ inputs.mode }}
RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }}
RELEASE_RUN_RELEASE_SOAK_INPUT: ${{ inputs.run_release_soak }}
RELEASE_RUN_MATURITY_SCORECARD_INPUT: ${{ inputs.run_maturity_scorecard }}
RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }}
RELEASE_LIVE_SUITE_FILTER_INPUT: ${{ inputs.live_suite_filter }}
RELEASE_CROSS_OS_SUITE_FILTER_INPUT: ${{ inputs.cross_os_suite_filter }}
@@ -326,12 +319,6 @@ jobs:
else
run_release_soak=true
fi
run_maturity_scorecard="$(printf '%s' "$RELEASE_RUN_MATURITY_SCORECARD_INPUT" | tr '[:upper:]' '[:lower:]')"
if [[ "$run_maturity_scorecard" != "true" && "$run_maturity_scorecard" != "1" && "$run_maturity_scorecard" != "yes" ]]; then
run_maturity_scorecard=false
else
run_maturity_scorecard=true
fi
release_profile="$RELEASE_PROFILE_INPUT"
if [[ "$release_profile" == "minimum" ]]; then
release_profile=beta
@@ -435,7 +422,6 @@ jobs:
printf 'mode=%s\n' "$RELEASE_MODE_INPUT"
printf 'release_profile=%s\n' "$release_profile"
printf 'run_release_soak=%s\n' "$run_release_soak"
printf 'run_maturity_scorecard=%s\n' "$run_maturity_scorecard"
printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT"
printf 'live_suite_filter=%s\n' "$RELEASE_LIVE_SUITE_FILTER_INPUT"
printf 'cross_os_suite_filter=%s\n' "$RELEASE_CROSS_OS_SUITE_FILTER_INPUT"
@@ -458,7 +444,6 @@ jobs:
RELEASE_MODE: ${{ inputs.mode }}
RELEASE_PROFILE: ${{ steps.inputs.outputs.release_profile }}
RUN_RELEASE_SOAK: ${{ steps.inputs.outputs.run_release_soak }}
RUN_MATURITY_SCORECARD: ${{ steps.inputs.outputs.run_maturity_scorecard }}
RELEASE_RERUN_GROUP: ${{ inputs.rerun_group }}
RELEASE_LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }}
RELEASE_CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
@@ -476,7 +461,6 @@ jobs:
echo "- Cross-OS mode: \`${RELEASE_MODE}\`"
echo "- Release profile: \`${RELEASE_PROFILE}\`"
echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`"
echo "- Maturity scorecard docs: \`${RUN_MATURITY_SCORECARD}\`"
echo "- Rerun group: \`${RELEASE_RERUN_GROUP}\`"
if [[ -n "${RELEASE_LIVE_SUITE_FILTER// }" ]]; then
echo "- Live suite filter: \`${RELEASE_LIVE_SUITE_FILTER}\`"
@@ -733,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 }}
@@ -783,20 +768,6 @@ jobs:
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
maturity_scorecard_release_checks:
name: Render maturity scorecard release docs
needs: [resolve_target]
if: contains(fromJSON('["all","qa"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.run_maturity_scorecard == 'true'
permissions:
actions: read
contents: read
uses: ./.github/workflows/maturity-scorecard.yml
with:
ref: ${{ needs.resolve_target.outputs.ref }}
expected_sha: ${{ needs.resolve_target.outputs.revision }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
qa_lab_parity_lane_release_checks:
name: Run QA Lab parity lane (${{ matrix.lane }})
needs: [resolve_target]
@@ -883,7 +854,7 @@ jobs:
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -989,7 +960,7 @@ jobs:
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -1161,7 +1132,7 @@ jobs:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -1271,13 +1242,13 @@ jobs:
--output .artifacts/qa-e2e/runtime-parity-standard-report/qa-runtime-tool-coverage-report.md
- name: Upload runtime tool coverage artifacts
if: ${{ always() && steps.verify_runtime_parity_status.outputs.ready == 'true' }}
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/runtime-parity-standard-report/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
qa_live_matrix_release_checks:
name: Run QA Lab live Matrix lane
@@ -1357,7 +1328,7 @@ jobs:
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -1497,7 +1468,7 @@ jobs:
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -1637,7 +1608,7 @@ jobs:
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -1780,7 +1751,7 @@ jobs:
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -1920,7 +1891,7 @@ jobs:
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -1976,7 +1947,6 @@ jobs:
- docker_e2e_release_checks
- package_acceptance_release_checks
- qa_lab_parity_lane_release_checks
- maturity_scorecard_release_checks
- qa_lab_parity_report_release_checks
- qa_lab_runtime_parity_release_checks
- runtime_tool_coverage_release_checks
@@ -2062,7 +2032,6 @@ jobs:
"docker_e2e_release_checks=${{ needs.docker_e2e_release_checks.result }}" \
"package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \
"qa_lab_parity_lane_release_checks=${{ needs.qa_lab_parity_lane_release_checks.result }}" \
"maturity_scorecard_release_checks=${{ needs.maturity_scorecard_release_checks.result }}" \
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \
"qa_lab_runtime_parity_release_checks=${{ needs.qa_lab_runtime_parity_release_checks.result }}" \
"runtime_tool_coverage_release_checks=${{ needs.runtime_tool_coverage_release_checks.result }}" \

View File

@@ -1466,9 +1466,9 @@ jobs:
fi
- name: Upload postpublish evidence
if: ${{ always() && inputs.publish_openclaw_npm }}
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: openclaw-release-postpublish-evidence-${{ inputs.tag }}
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
if-no-files-found: error
if-no-files-found: ignore

View File

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

View File

@@ -66,5 +66,5 @@ jobs:
with:
name: opengrep-full-sarif
path: .opengrep-out/precise.sarif
if-no-files-found: error
if-no-files-found: warn
retention-days: 30

View File

@@ -97,5 +97,5 @@ jobs:
with:
name: opengrep-pr-diff-sarif
path: .opengrep-out/precise.sarif
if-no-files-found: error
if-no-files-found: warn
retention-days: 30

View File

@@ -226,7 +226,7 @@ jobs:
name: qa-parity-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
run_live_runtime_token_efficiency:
name: Run live runtime token-efficiency lane
@@ -315,7 +315,7 @@ jobs:
name: qa-live-runtime-token-efficiency-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
run_live_matrix:
name: Run Matrix live QA lane
@@ -391,7 +391,7 @@ jobs:
name: qa-live-matrix-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
run_live_matrix_sharded:
name: Run Matrix live QA lane (${{ matrix.profile }})
@@ -475,7 +475,7 @@ jobs:
name: qa-live-matrix-${{ matrix.profile }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
run_live_telegram:
name: Run Telegram live QA lane with Convex leases
@@ -570,7 +570,7 @@ jobs:
name: qa-live-telegram-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
run_live_discord:
name: Run Discord live QA lane with Convex leases
@@ -665,7 +665,7 @@ jobs:
name: qa-live-discord-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
run_live_whatsapp:
name: Run WhatsApp live QA lane with Convex leases
@@ -763,7 +763,7 @@ jobs:
name: qa-live-whatsapp-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
run_live_slack:
name: Run Slack live QA lane with Convex leases
@@ -859,4 +859,4 @@ jobs:
name: qa-live-slack-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: error
if-no-files-found: warn

View File

@@ -1,385 +0,0 @@
name: QA Profile Evidence
run-name: ${{ format('QA Profile Evidence {0} {1}', inputs.qa_profile, inputs.ref) }}
on:
workflow_dispatch:
inputs:
ref:
description: OpenClaw branch, tag, or SHA to run
required: true
default: main
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
qa_profile:
description: Taxonomy QA profile id to run (for example release or all)
required: true
default: release
type: string
workflow_call:
inputs:
ref:
description: OpenClaw branch, tag, or SHA to run
required: true
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
qa_profile:
description: Taxonomy QA profile id to run
required: true
type: string
secrets:
OPENAI_API_KEY:
description: OpenAI API key used by live QA profile scenarios
required: true
outputs:
artifact_name:
description: Uploaded QA profile evidence artifact name
value: ${{ jobs.run_qa_profile.outputs.artifact_name }}
qa_profile:
description: Taxonomy QA profile id that produced the evidence
value: ${{ jobs.run_qa_profile.outputs.qa_profile }}
qa_exit_code:
description: Exit code from the QA profile run; non-zero evidence is still uploaded
value: ${{ jobs.run_qa_profile.outputs.qa_exit_code }}
qa_passed:
description: Whether the QA profile command exited successfully
value: ${{ jobs.run_qa_profile.outputs.qa_passed }}
target_sha:
description: Resolved OpenClaw SHA that produced the evidence
value: ${{ jobs.run_qa_profile.outputs.target_sha }}
trusted_reason:
description: Trust reason accepted before the secret-bearing QA job
value: ${{ jobs.run_qa_profile.outputs.trusted_reason }}
qa_evidence_path:
description: Path to qa-evidence.json inside the uploaded artifact
value: ${{ jobs.run_qa_profile.outputs.qa_evidence_path }}
permissions:
contents: read
concurrency:
group: qa-profile-evidence-${{ inputs.qa_profile }}-${{ inputs.expected_sha || inputs.ref }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
jobs:
authorize_actor:
name: Authorize workflow actor
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
// Reusable workflow jobs inherit the caller event but run as
// github-actions[bot]; selected ref validation still gates secrets.
if (context.actor === "github-actions[bot]") {
core.info("Skipping manual actor permission check for a reusable workflow call.");
core.setOutput("authorized", "true");
return;
}
if (context.eventName !== "workflow_dispatch") {
core.info(`Skipping manual actor permission check for ${context.eventName}.`);
core.setOutput("authorized", "true");
return;
}
const allowed = new Set(["admin", "maintain", "write"]);
const { owner, repo } = context.repo;
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: context.actor,
});
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
validate_selected_ref:
name: Validate selected ref
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
selected_revision: ${{ steps.validate.outputs.selected_revision }}
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Validate selected ref
id: validate
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
INPUT_REF: ${{ inputs.ref }}
shell: bash
run: |
set -euo pipefail
selected_revision="$(git rev-parse HEAD)"
expected_sha="${EXPECTED_SHA,,}"
trusted_reason=""
if [[ -n "${expected_sha// }" && ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "expected_sha must be a full 40-character SHA; got: ${EXPECTED_SHA}" >&2
exit 1
fi
if [[ -n "${expected_sha// }" && "${selected_revision,,}" != "$expected_sha" ]]; then
echo "Ref '${INPUT_REF}' resolved to ${selected_revision}, expected ${EXPECTED_SHA}." >&2
exit 1
fi
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
trusted_reason="main-ancestor"
elif git tag --points-at "$selected_revision" | grep -Eq '^v'; then
trusted_reason="release-tag"
elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then
git fetch --no-tags origin "+refs/heads/${INPUT_REF}:refs/remotes/origin/${INPUT_REF}"
release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")"
if [[ "$selected_revision" == "$release_branch_sha" ]]; then
trusted_reason="release-branch-head"
fi
fi
if [[ -z "$trusted_reason" ]]; then
echo "Ref '${INPUT_REF}' resolved to $selected_revision, which is not trusted for this secret-bearing QA evidence run." >&2
echo "Allowed refs must be on main, point to a release tag, or match a release branch head." >&2
exit 1
fi
echo "selected_revision=$selected_revision" >> "$GITHUB_OUTPUT"
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
{
echo "### Target"
echo
echo "- Requested ref: \`${INPUT_REF}\`"
echo "- Resolved SHA: \`$selected_revision\`"
echo "- Trust reason: \`$trusted_reason\`"
} >> "$GITHUB_STEP_SUMMARY"
run_qa_profile:
name: Generate QA profile evidence
needs: validate_selected_ref
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
outputs:
artifact_name: ${{ steps.evidence.outputs.artifact_name }}
qa_profile: ${{ steps.profile.outputs.profile }}
qa_exit_code: ${{ steps.evidence.outputs.qa_exit_code }}
qa_passed: ${{ steps.evidence.outputs.qa_passed }}
target_sha: ${{ steps.evidence.outputs.target_sha }}
trusted_reason: ${{ steps.evidence.outputs.trusted_reason }}
qa_evidence_path: ${{ steps.evidence.outputs.qa_evidence_path }}
environment: qa-live-shared
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
- name: Validate QA profile input
id: profile
env:
QA_PROFILE: ${{ inputs.qa_profile }}
shell: bash
run: |
set -euo pipefail
node --import tsx --input-type=module <<'NODE'
import fs from "node:fs";
import { readQaScorecardTaxonomyReport } from "./extensions/qa-lab/src/scorecard-taxonomy.ts";
const requested = process.env.QA_PROFILE?.trim() ?? "";
if (!/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(requested)) {
throw new Error(`qa_profile must use a taxonomy profile id, got ${JSON.stringify(process.env.QA_PROFILE)}`);
}
const taxonomy = readQaScorecardTaxonomyReport([]);
const profile = taxonomy.profiles.find((entry) => entry.id === requested);
if (!profile) {
const available = taxonomy.profiles.map((entry) => entry.id).join(", ");
throw new Error(`Unknown QA profile ${requested}. Available profiles: ${available}`);
}
fs.appendFileSync(process.env.GITHUB_OUTPUT, `profile=${profile.id}\n`);
NODE
echo "QA profile: \`${QA_PROFILE}\`" >> "$GITHUB_STEP_SUMMARY"
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: node scripts/build-all.mjs qaRuntime
- name: Ensure Playwright Chromium
run: node scripts/ensure-playwright-chromium.mjs
- name: Run QA profile
id: run_profile
env:
QA_PROFILE: ${{ steps.profile.outputs.profile }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
shell: bash
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/profile-${QA_PROFILE}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
qa_exit_code=0
pnpm openclaw qa run \
--repo-root . \
--qa-profile "${QA_PROFILE}" \
--output-dir "${output_dir}" || qa_exit_code=$?
echo "qa_exit_code=${qa_exit_code}" >> "$GITHUB_OUTPUT"
- name: Validate QA profile evidence
id: evidence
if: always()
env:
ARTIFACT_NAME: qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }}
OUTPUT_DIR: ${{ steps.run_profile.outputs.output_dir }}
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
QA_PROFILE: ${{ steps.profile.outputs.profile }}
REQUESTED_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_revision }}
TRUSTED_REASON: ${{ needs.validate_selected_ref.outputs.trusted_reason }}
shell: bash
run: |
set -euo pipefail
node --input-type=module <<'NODE'
import fs from "node:fs";
import path from "node:path";
const outputDir = process.env.OUTPUT_DIR;
if (!outputDir) {
throw new Error("OUTPUT_DIR is required");
}
if (!process.env.QA_EXIT_CODE) {
throw new Error("QA_EXIT_CODE is required");
}
const evidencePath = path.join(outputDir, "qa-evidence.json");
const payload = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
if (payload.profile !== process.env.QA_PROFILE) {
throw new Error(`qa-evidence.json profile must be ${process.env.QA_PROFILE}, got ${JSON.stringify(payload.profile)}`);
}
if (!payload.scorecard || !Array.isArray(payload.scorecard.categoryReports)) {
throw new Error("QA profile qa-evidence.json must include scorecard.categoryReports");
}
if (payload.scorecard.categoryReports.length === 0) {
throw new Error("QA profile qa-evidence.json scorecard has no category reports");
}
const manifest = {
artifactName: process.env.ARTIFACT_NAME,
generatedAt: new Date().toISOString(),
qaProfile: process.env.QA_PROFILE,
qaExitCode: Number(process.env.QA_EXIT_CODE),
qaPassed: process.env.QA_EXIT_CODE === "0",
requestedRef: process.env.REQUESTED_REF,
targetSha: process.env.TARGET_SHA,
trustedReason: process.env.TRUSTED_REASON,
evidenceMode: payload.evidenceMode,
qaEvidencePath: "qa-evidence.json",
scorecard: {
categories: payload.scorecard.categories,
features: payload.scorecard.features,
categoryReports: payload.scorecard.categoryReports.length,
},
};
fs.writeFileSync(
path.join(outputDir, "qa-profile-evidence-manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
NODE
echo "artifact_name=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT"
echo "qa_profile=${QA_PROFILE}" >> "$GITHUB_OUTPUT"
echo "qa_exit_code=${QA_EXIT_CODE}" >> "$GITHUB_OUTPUT"
if [[ "$QA_EXIT_CODE" == "0" ]]; then
echo "qa_passed=true" >> "$GITHUB_OUTPUT"
else
echo "qa_passed=false" >> "$GITHUB_OUTPUT"
echo "::warning::QA profile '${QA_PROFILE}' completed with exit code ${QA_EXIT_CODE}; evidence was still validated and uploaded."
fi
echo "target_sha=${TARGET_SHA}" >> "$GITHUB_OUTPUT"
echo "trusted_reason=${TRUSTED_REASON}" >> "$GITHUB_OUTPUT"
echo "qa_evidence_path=qa-evidence.json" >> "$GITHUB_OUTPUT"
{
echo "### QA profile evidence"
echo
echo "- Artifact: \`${ARTIFACT_NAME}\`"
echo "- QA profile: \`${QA_PROFILE}\`"
echo "- QA exit code: \`${QA_EXIT_CODE}\`"
echo "- Target SHA: \`${TARGET_SHA}\`"
echo "- Evidence path: \`${OUTPUT_DIR}/qa-evidence.json\`"
echo "- Manifest: \`${OUTPUT_DIR}/qa-profile-evidence-manifest.json\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload QA profile evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }}
path: ${{ steps.run_profile.outputs.output_dir }}
retention-days: 30
if-no-files-found: error
- name: Fail if QA profile failed
if: always()
env:
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
QA_PROFILE: ${{ steps.profile.outputs.profile }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${QA_EXIT_CODE:-}" ]]; then
echo "QA profile did not report an exit code." >&2
exit 1
fi
if [[ "$QA_EXIT_CODE" != "0" ]]; then
echo "QA profile '${QA_PROFILE}' failed with exit code ${QA_EXIT_CODE}." >&2
exit "$QA_EXIT_CODE"
fi

View File

@@ -24,9 +24,7 @@ jobs:
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
# Old PR events can carry a stale base SHA that predates current
# trusted checker scripts. Use the workflow revision instead.
ref: ${{ github.workflow_sha }}
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
id: app-token

42
.github/workflows/tui-pty.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: TUI PTY
on:
pull_request:
paths:
- "src/tui/**"
- "scripts/dev/tui-pty-test-watch.ts"
- "scripts/test-projects.test-support.mjs"
- "package.json"
- "pnpm-lock.yaml"
- "test/scripts/test-projects.test.ts"
- "test/vitest/vitest.test-shards.mjs"
- "test/vitest/vitest.tui-pty.config.ts"
- ".github/workflows/tui-pty.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
tui-pty:
runs-on: ubuntu-24.04
timeout-minutes: 8
env:
OPENCLAW_TUI_PTY_INCLUDE_LOCAL: "1"
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run TUI PTY tests
run: timeout --kill-after=30s 240s node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts

View File

@@ -150,7 +150,6 @@ jobs:
git --version
- name: Run Testbox
if: always()
shell: bash
run: |
set -euo pipefail

View File

@@ -297,10 +297,6 @@ jobs:
if: ${{ always() && !cancelled() && inputs.require_wsl2 }}
run: |
if ($env:OPENCLAW_WSL2_PROBE_OK -ne "true") {
if ($env:OPENCLAW_WSL2_RESTART_REQUIRED -eq "true") {
Write-Error "WSL2 probe enabled required Windows features, but the runner needs a reboot before WSL2 can start."
exit 1
}
Write-Error "WSL2 probe failed or WSL2 is unavailable on this Windows runner."
exit 1
}

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

@@ -8,7 +8,6 @@ Skills own workflows; root owns hard policy and routing.
- Repo: `https://github.com/openclaw/openclaw`
- Replies: repo-root refs only: `extensions/telegram/src/index.ts:80`. No absolute paths, no `~/`.
- Docs/user-visible work: `pnpm docs:list`, then read relevant docs only.
- Existing-solutions preflight: before proposing or building a custom system, feature, workflow, tool, integration, or automation, do a lightweight check for open-source projects, maintained libraries, existing OpenClaw plugins, or free platforms that already solve it well enough. Prefer those when adequate. Build custom only when existing options are unsuitable, too expensive, unmaintained, unsafe, non-compliant, or the user explicitly asks for custom. Avoid paid-service recommendations unless the user explicitly approves spend. Keep this to a brief preflight gate, not a broad research assignment.
- Fix/triage answers need source, tests, current/shipped behavior, and dependency contract proof.
- Reviews/answers: high confidence required. Default to exhaustive relevant codebase search/read, including owners, callers, siblings, tests, docs, and upstream/dependency contracts before verdict. Diff-only review is insufficient.
- Review default: read the whole changed function/module plus callers, callees, sibling implementations, adjacent tests, scoped docs, and dependency/Codex contracts before saying `good`, `bad`, `best fix`, `proof sufficient`, or posting a comment. If challenged, keep reading first; do not defend the earlier verdict until the missing path is checked.

View File

@@ -2,418 +2,41 @@
Docs: https://docs.openclaw.ai
## 2026.6.11
### Highlights
- **More capable channel control:** Slack relay mode, native Mattermost `/oc_queue`, and per-DM model overrides make channel operations easier to automate and tune. (#94707, #95546, #95120) Thanks @sjf-oa, @amknight, @xydigit-zt, @thomaszta, and @gandalf-at-lerian.
- **Richer operator workflows:** `openclaw agent --message-file` and the RAFT CLI wake bridge add practical file-driven and remote wake-up paths. (#93351, #95497) Thanks @ooiuuii and @vincentkoc.
- **Safer plugin distribution:** additional official plugins are externalized cleanly, with bundled plugin icon metadata available to installed clients. (#95683, #95845) Thanks @vincentkoc and @Patrick-Erichsen.
- **Stronger mobile operations:** Android settings detail panels improve configuration visibility and control on mobile. (#95148) Thanks @Tosko4.
- **More reliable agent turns:** Codex partial deltas, harness activation, and long-context prompt-cache stability reduce lost progress and inconsistent runs. (#95404, #95652, #95624) Thanks @agonza1 and @vincentkoc.
### Changes
- **Gateway and plugin tooling:** channel identity hook context and per-agent usage-cost reporting give integrations and operators more precise routing and accounting. (#91903, #94483) Thanks @lanzhi-lee, @vincentkoc, and @ly-wang19.
- **Provider and model coverage:** catalog parsing, reasoning controls, provider model resolution, and encrypted reasoning support now handle more live provider variants. (#95283, #95710, #95268, #95744, #95686, #93956) Thanks @ZengWen-DT, @vincentkoc, @Marvinthebored, @Darren2030, @daniel-alejandro-t, @parveshsaini, @geraint0923, @fuller-stack-dev, and @jason-allen-oneal.
### Fixes
- **Channel delivery:** Telegram progress rendering, webhook lifecycle, reaction directives, duplicate mirror writes, queued update draining, and WhatsApp durable reply targets are now more reliable. (#95532, #93002, #95183, #94506, #94977, #95069, #95577, #95007, #95914) Thanks @amknight, @snowzlmbot, @zhangguiping-xydt, @shadow-enthusiast, @xialonglee, @travellingsoldier85, @obviyus, @hugenshen, @Cuttingwater, @heichaowo, @LiuwqGit, @freidrich-goldenflow, @mcaxtr, and @vincentkoc.
- **WhatsApp and message identity:** native quotes, Baileys group reliability, and approval reactions across JID drift now preserve the intended conversation context. (#95483, #94338, #95935) Thanks @mcaxtr, @xialonglee, and @octopuslabs-fl.
- **Gateway and session safety:** stuck release claims, draining-state reporting, remote probe timeouts, malformed paired access lists, and non-delivery session identity are handled without silent routing loss. (#95299, #94915, #89859, #92178, #95467) Thanks @mikasa0818, @kriegerbangerz-ship-it, @markoub, @vincentkoc, @maxschachere, @mushuiyu886, @gozzbb2, @wangmiao0668000666, @ly-wang19, @EmilioNicolas, and @yetval.
- **Agent and fallback behavior:** aborted runs stop cleanly, provider response bodies stay bounded, Claude CLI credit failures continue through fallback, and Codex usage-limit responses classify correctly. (#94412, #95218, #95508, #95420, #95418, #95417, #95400) Thanks @szsip239, @vincentkoc, @Alix-007, @mikasa0818, @sallyom, @riazrahaman, and @jason-allen-oneal.
- **Provider and model edge cases:** OpenRouter IDs, Ollama discovery and embeddings, Gemini freshness, and model-catalog prefixes now resolve against the right runtime metadata. (#95268, #94811, #93956, #95682, #95744) Thanks @Darren2030, @daniel-alejandro-t, @mushuiyu886, @jason-allen-oneal, @Sunjae-k, @parveshsaini, @vincentkoc, and @shakkernerd.
- **Configuration and UI guardrails:** non-interactive configure fails closed, TLS paths reject empty values, memory artifacts are sanitized, and the UI uses the patched DOMPurify release. (#94238, #94054, #95791, #95691) Thanks @ruomuxydt, @NianJiuZst, @miorbnli, @vincentkoc, @SweetSophia, and @YB0y.
- **Cron and delivery validation:** no-config delivery checks, thread-aware dedupe, and pending recurring runs retain their intended destinations. (#95754, #95794, #94323) Thanks @vincentkoc and @yetval.
### Complete contribution record
This audited record covers the complete v2026.6.10..911f853b7fc4d819e2175b001662a01eba30453d history: 305 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
#### Pull requests
- **PR #95406** test(qa): make release scorecard categories explicit. Thanks @RomneyDa.
- **PR #94700** test: fold HTTP API script proof into QA Lab. Thanks @RomneyDa.
- **PR #95499** fix(test): unit-fast flow mocks. Thanks @RomneyDa.
- **PR #95308** fix(ci): filter ClawSweeper comment dispatches before token minting. Thanks @vincentkoc.
- **PR #95532** fix(telegram): materialize rich message line breaks as <br>. Related #95409. Thanks @amknight and @snowzlmbot.
- **PR #91786** fix(plugins): reconcile managed npm root overrides with managed peer pins. Related #91772. Thanks @amknight and @mkdelta221.
- **PR #93002** Fix Telegram progress draft cleanup before tool output. Related #90753. Thanks @zhangguiping-xydt and @shadow-enthusiast.
- **PR #95175** fix: route mobile exec approvals to reviewer device. Thanks @joshavant.
- **PR #94506** fix(telegram): stop clearing registered webhook on channel restart. Related #90254. Thanks @xialonglee and @travellingsoldier85.
- **PR #95183** fix(telegram): materialize streaming progress placeholders. Related #95004. Thanks @snowzlmbot and @obviyus.
- **PR #95483** fix(whatsapp): preserve native quote replies. Thanks @mcaxtr.
- **PR #94338** fix(whatsapp): wire missing Baileys retry/cache hooks for group message reliability. Related #7433. Thanks @xialonglee and @mcaxtr and @octopuslabs-fl.
- **PR #94707** feat(slack): add relay mode for incoming messages. Thanks @sjf-oa.
- **PR #94977** fix(telegram): honor outbound reaction directives. Related #71140. Thanks @hugenshen and @Cuttingwater.
- **PR #95069** fix(telegram): skip mirror write when primary reply already exists (#94930). Thanks @heichaowo.
- **PR #95550** Preserve inherited channel account policies during migration. Thanks @amknight.
- **PR #95390** fix #95378: https://github.com/openclaw/openclaw/issues/95378. Thanks @mikasa0818 and @obviyus and @MaiDuy708.
- **PR #93143** fix(imessage): keep split-send coalescing opt-in. Thanks @omarshahine.
- **PR #95008** fix(claude-cli): also disable native background Bash and Monitor in --print runs. Thanks @anagnorisis2peripeteia.
- **PR #94545** fix: keep trusted policies with hook registry. Thanks @jesse-merhi.
- **PR #95007** fix(telegram): render progress draft rows as plain readable text. Related #95002. Thanks @snowzlmbot.
- **PR #95624** fix(agents): keep long-context tool-result prompts cache-stable. Thanks @vincentkoc.
- **PR #95625** fix(ci): smooth PR runner-registration bursts. Thanks @vincentkoc.
- **PR #95572** fix(agents): reject bind specs with extra colon segments. Thanks @ly-wang19.
- **PR #95653** test(agents): add large prompt cache coverage. Thanks @vincentkoc.
- **PR #95640** Consolidate iOS notification permission UX. Thanks @joshavant.
- **PR #95546** feat(mattermost): register /oc_queue as a native slash command. Thanks @amknight.
- **PR #95019** fix(skills): point gog brew install at homebrew-core gogcli (#95017). Thanks @ZengWen-DT and @vincentkoc and @Sedrak-Hovhannisyan.
- **PR #93378** test(telegram): keep live polling leases protected. Related #93375. Thanks @mmyzwl and @Yachiyo1680.
- **PR #95084** fix(googlechat): sanitize internal tool-trace lines from outbound text (#90684). Thanks @jailbirt and @studentzhou-svg.
- **PR #95278** Avoid copying process.env in ingress queue state DB opens. Related #94571. Thanks @kaka-srp.
- **PR #95577** fix #86957: drain worker-spooled Telegram updates immediately. Thanks @LiuwqGit and @freidrich-goldenflow.
- **PR #95128** fix(compaction): count user-message image blocks in cut-point estimator. Thanks @yetval.
- **PR #93887** fix(ssh): reject hostnames with stray leading or trailing colons in parseSshTarget. Thanks @miorbnli.
- **PR #95191** docs(plugins): document subagent_ended hook fields. Related #95186. Thanks @MaHaoHao-ch and @ken-jo.
- **PR #95102** fix(config): add stdio to McpServerSchema transport union. Related #95082. Thanks @lzyyzznl and @ken-jo.
- **PR #95465** fix(sdk): type-narrow manifest.files in pack staging root helper. Thanks @wangmiao0668000666.
- **PR #95664** refactor(plugins): move owner skills into plugins. Thanks @vincentkoc.
- **PR #95299** fix #95248: OpenClaw release_lane is a no-op when claim is held by a live worker; stuck Telegram inbound events block agent response until gateway restart. Thanks @mikasa0818 and @kriegerbangerz-ship-it.
- **PR #94687** fix(gateway): accept port for health and probe. Related #79100. Thanks @BryanTegomoh and @ozthedivine.
- **PR #95649** fix(ci): bundle test shards and right-size runners. Thanks @vincentkoc.
- **PR #95243** fix(docs): show inline read_when hints in docs:list. Thanks @hugenshen and @vincentkoc.
- **PR #95283** fix(openai-completions): seal native reasoning before the answer under /reasoning on. Related #95280. Thanks @ZengWen-DT and @vincentkoc and @Marvinthebored.
- **PR #95497** feat(raft): add CLI wake bridge channel. Thanks @vincentkoc.
- **PR #95459** fix(cron): use main-session systemEvent for silent quick-create preset. Related #95073. Thanks @ZOOWH and @vincentkoc and @vporton.
- **PR #95503** fix #89466: [Bug]: Control UI chat input text not cleared after sending. Thanks @zhangguiping-xydt and @vincentkoc and @zhong18804784882.
- **PR #95684** fix(skills): harden ClawHub update policy. Thanks @vincentkoc.
- **PR #95683** feat(plugins): externalize additional official plugins. Thanks @vincentkoc.
- **PR #95681** fix(ci): debounce canonical main runner admission. Thanks @vincentkoc.
- **PR #95652** fix(agents): activate selected harness plugins. Thanks @vincentkoc.
- **PR #95404** fix(codex): stream non-final-answer assistant deltas as partials. Related #95422. Thanks @agonza1 and @vincentkoc.
- **PR #58993** fix(googlechat): support spaceType field for DM vs Space detection. Thanks @Starhappysh and @vincentkoc.
- **PR #94148** fix(doctor): prevent non-interactive --fix from auto-restarting gateway. Related #78217. Thanks @zhangguiping-xydt and @esqandil.
- **PR #89859** fix(gateway): honor remote status probe timeout. Related #65355. Thanks @mushuiyu886 and @gozzbb2.
- **PR #95466** fix(ci): increase timeouts in flaky process-group signal test. Thanks @jason-allen-oneal.
- **PR #95720** fix(matrix): prevent double bootstrapCrossSigning reset in forced reset. Related #78396. Thanks @vincentkoc and @jteddy.
- **PR #95707** fix(synology-chat): remove duplicate local deliver timeout. Thanks @vincentkoc.
- **PR #95706** fix(whatsapp): remove dead watchdog timeout clamp. Thanks @vincentkoc.
- **PR #95719** fix(cli): sync capability inspect metadata flags with registered options. Thanks @vincentkoc.
- **PR #95721** fix(active-memory): exclude dreaming-narrative session keys from eligibility gate. Related #78500. Thanks @vincentkoc and @vishutdhar.
- **PR #95602** test: save ~79 CI hours/mo in gateway session utils. Thanks @zats and @vincentkoc.
- **PR #94412** fix(agent-core): stop loop after aborted tool run. Thanks @szsip239 and @vincentkoc.
- **PR #94915** fix(gateway): report draining state in readiness. Related #78136. Thanks @markoub and @vincentkoc and @maxschachere.
- **PR #95691** fix(ui): bump dompurify to patched release. Thanks @vincentkoc.
- **PR #95710** fix(vercel-ai-gateway): resolve dynamic model selections. Thanks @vincentkoc.
- **PR #94072** fix(agents): count message-tool source reply as user-facing reply for tool error warnings. Related #93875. Thanks @chenyangjun-xy and @vincentkoc and @hoyanhan.
- **PR #94784** fix(doctor): stop promising --fix for working isolated shell-prompt cron jobs (#94655). Thanks @ZengWen-DT and @altaywtf and @geekoagent.
- **PR #93504** fix(device-pairing): guard role normalization against non-string entries. Thanks @ly-wang19.
- **PR #92178** fix(gateway): normalize malformed paired access lists. Related #90654. Thanks @wangmiao0668000666 and @ly-wang19 and @EmilioNicolas.
- **PR #83041** Fix config patch restart-required notices. Related #46797. Thanks @xuruiray and @Stache73.
- **PR #95754** fix(cron): preserve no-config delivery validation. Thanks @vincentkoc.
- **PR #93351** feat(cli): add --message-file to openclaw agent. Thanks @ooiuuii.
- **PR #95485** fix(ui): roll values near 1M over from k to M in compact token format. Thanks @NarahariRaghava and @vincentkoc.
- **PR #94622** fix(build): allow tsdown heap override. Thanks @tayoun.
- **PR #76668** meta(issue-template): add dedicated docs bug report form. Related #76664. Thanks @WadydX.
- **PR #91193** fix(cli): document Commander rawArgs internal API dependency in action-reparse.ts. Related #83893. Thanks @whiteyzy and @davinci282828.
- **PR #77339** fix(auto-reply): clear runtime model cache on reset. Related #77322. Thanks @mjamiv and @ZaynL.
- **PR #89628** Speed up precomputed command help startup. Thanks @yyzquwu.
- **PR #95087** refactor: add memory and QMD session identity mapping. Thanks @jalehman.
- **PR #89323** fix(web-ui): skip hidden subagent picker pages. Related #89249. Thanks @giodl73-repo and @originsecured-do.
- **PR #84794** Clean up isolated cron sessions after runs. Related #84707. Thanks @TurboTheTurtle and @bottenbenny.
- **PR #95794** fix(cron): compare thread IDs when deduping failure destinations. Thanks @vincentkoc.
- **PR #95791** fix(session-memory): sanitize model artifacts before saving memory. Thanks @vincentkoc and @SweetSophia and @YB0y.
- **PR #95805** fix(agents): normalize hallucinated Office file extensions. Related #93326. Thanks @vincentkoc and @lzyyzznl and @xzh-icenter and @bhnan.
- **PR #89612** fix: include persisted plugin contracts for migrations. Related #89609. Thanks @zerone0x and @mugabuga.
- **PR #89981** fix(diagnostics-otel): keep full model id on spans instead of collapsing to "unknown". Thanks @mycarrysun and @vincentkoc.
- **PR #90489** fix(sessions): clarify cross-agent visibility guidance. Related #90443. Thanks @sahibzada-allahyar and @vincentkoc and @ramitrkar-hash.
- **PR #95383** fix(sdk): classify failed/blocked tool events as tool.call.failed. Thanks @ly-wang19.
- **PR #95827** fix(plugin-sdk): bound live model catalog success body.
- **PR #95792** fix(onboard): refresh provider plugin registry after setup installs. Related #95765. Thanks @snowzlmbot.
- **PR #93943** fix(provider-usage): honor proxy env for usage fetch. Related #78714. Thanks @TurboTheTurtle and @TnzGit.
- **PR #89648** fix(agents): restore model-fetch info logs. Related #89300. Thanks @xiaobao-k8s and @Enominera.
- **PR #93841** fix(ui): render persisted history text blocks. Related #90241. Thanks @mushuiyu886 and @pronzcw.
- **PR #93502** docs: fix docs metadata spellcheck. Thanks @harjothkhara.
- **PR #86608** docs: add existing-solutions preflight guardrail. Thanks @cablackmon.
- **PR #95322** fix(reply): preserve usage footer across rollover. Thanks @litang9.
- **PR #91559** fix(agents): clean Gemini tool schemas by model id. Related #91542. Thanks @Pick-cat and @qiukui666.
- **PR #94701** Fix embedded-run recovery after refreshed session activity. Thanks @imadal1n.
- **PR #95472** [AI] fix(main-session): skip current-gen abort controllers for completed sessions. Thanks @xydt-tanshanshan.
- **PR #95401** fix(lmstudio): canonicalize variant model keys. Thanks @MonkeyLeeT.
- **PR #94323** fix(cron): stop add/remove from dropping a due recurring job's pending run. Thanks @yetval.
- **PR #94443** fix(memory-wiki): retry transient source-page rewrite race instead of aborting wiki_status. Related #92134. Thanks @ZengWen-DT and @cknzraposo.
- **PR #95120** feat(channels): add directUserId support for per-DM model override. Related #53638. Thanks @xydigit-zt and @thomaszta and @gandalf-at-lerian.
- **PR #91049** fix: handle terminal chat send acknowledgements. Related #91048. Thanks @nxmxbbd.
- **PR #95081** fix: keep text transform runtime imports hashed. Related #95057. Thanks @849261680 and @YvesLaRose.
- **PR #93356** fix(plugins): cache plugin setup registry to kill the /models CPU storm. Thanks @obuchowski.
- **PR #85341** refactor: internalize OpenClaw agent runtime.
- **PR #94233** fix(model-fallback): coalesce auth decision logs. Related #56979. Thanks @goutamadwant and @yanan1991.
- **PR #92399** fix(llm): collapse cumulative openai-responses message snapshots instead of concatenating [AI-assisted]. Related #91959. Thanks @amersheeny and @phoenixyy.
- **PR #94811** fix(ollama): honor memory embedding output dimensionality. Thanks @mushuiyu886.
- **PR #84708** fix(agents): recover message-tool mirror replay poison. Thanks @anyech.
- **PR #95274** fix(memory): preserve Windows QMD command paths. Related #92302. Thanks @ly85206559 and @Ardooken.
- **PR #95686** fix(xai): request encrypted reasoning include for all reasoning models. Thanks @geraint0923 and @fuller-stack-dev.
- **PR #82909** fix(telegram): repair message cache reply types. Thanks @lidge-jun.
- **PR #94942** fix(matrix): prune finished fake-indexeddb transactions to prevent OOM. Related #90455. Thanks @xzh-icenter and @yar-sh.
- **PR #93994** fix(setup): point non-interactive health hints at onboard flags. Related #93947. Thanks @zhouhe-xydt and @NianJiuZst.
- **PR #93956** fix(ollama): skip auto-discovery for remote/cloud base URLs. Thanks @jason-allen-oneal.
- **PR #93547** fix(acp): recover stale persistent sessions by structured resume-required code [AI-assisted]. Related #87830. Thanks @amersheeny and @chouzz.
- **PR #94204** fix(control-ui): rewrite manifest hrefs for configured base path. Related #94157. Thanks @hugenshen and @xrow.
- **PR #95637** fix(qqbot): recognize GFM table separators with one or two dashes. Thanks @ly-wang19.
- **PR #95467** fix(sessions): keep bound channel identity across non-delivery turns. Thanks @yetval.
- **PR #93919** perf(plugins): cache existence probes within bundle manifest scan [AI-assisted]. Related #76209. Thanks @ml12580 and @shenhonglong456-ai.
- **PR #94726** fix(google): add gemini-3.5-flash model catalog entry. Related #94723. Thanks @ajwan8998 and @anguslogan01.
- **PR #94238** fix(config): fail closed when configure runs without an interactive TTY (#93953). Thanks @ruomuxydt and @NianJiuZst.
- **PR #89949** fix(media): pin requester delivery route when task starts. Related #86034. Thanks @wangwllu and @tianxiaochannel-oss88.
- **PR #94470** chore: sync yuanbao plugin catalog to 2.15.0. Thanks @jase-283.
- **PR #93585** fix(daemon): keep systemd gateway running after child OOM. Thanks @snowzlm.
- **PR #94442** fix(imessage): strip leading echo corruption markers in the persisted echo cache. Thanks @ly-wang19.
- **PR #93511** fix(imessage): normalize leading NUL echo-cache prefixes. Thanks @vincentkoc and @ly-wang19.
- **PR #87121** test(cli): add banner emission reset helper. Related #83903. Thanks @lizuju and @davinci282828.
- **PR #89172** fix(feishu): show voice message duration via upload duration. Related #53798. Thanks @areslp and @kinrocW.
- **PR #95118** fix: bound agents load their own inbound workspace context. Related #92903. Thanks @849261680 and @axjing.
- **PR #94752** fix(reply): clarify stale model override resets. Related #94713. Thanks @snowzlmbot and @Gr4via.
- **PR #89716** fix(providers): strip cache-boundary marker from non-Anthropic prompts. Thanks @masatohoshino.
- **PR #95268** fix(openrouter): expand short canonical model IDs to upstream API slugs (fixes #95198). Thanks @Darren2030 and @daniel-alejandro-t.
- **PR #90475** fix(telegram): keep bot reply answers anchored to current message. Thanks @moeedahmed.
- **PR #94219** fix(control-ui): restore provider usage quota pill in sidebar session switcher (fixes #93041). Thanks @Pick-cat and @jazzroutine.
- **PR #93369** fix(cron): expose per-job fallbacks in CLI. Related #90302. Thanks @849261680 and @Walliiee.
- **PR #87912** Handle Codex toolResult blocks in tool-result truncation. Thanks @AdrianIp0204.
- **PR #95744** fix(model-catalog): strip manifest model-id prefixes by the matched length. Related #95743. Thanks @parveshsaini.
- **PR #95300** fix(cli): expose --count on infer image edit, matching image generate. Thanks @ly-wang19.
- **PR #94156** fix: expose OpenAI image quality and moderation CLI options. Thanks @lastguru-net and @ly-wang19.
- **PR #94062** fix(agents): classify generic "LLM request failed." as transient time…. Related #93931. Thanks @hugenshen and @hyphae-bot.
- **PR #93965** fix(opencode-go): streaming completes when provider ends responses. Related #93610. Thanks @zhangguiping-xydt and @ForceConstant.
- **PR #89017** fix(webchat): sessions persist after reconnects. Related #87700. Thanks @zhangguiping-xydt and @asicoe.
- **PR #94627** fix(ios): centralize app accent colors. Thanks @zats.
- **PR #94054** fix(gateway.tls): reject empty/whitespace certPath and keyPath. Thanks @miorbnli.
- **PR #95674** fix(cron): trim trailing whitespace from recognized job object keys. Related #95407. Thanks @zw-xysk and @Nassiel.
- **PR #95857** Fix SQLite user version guardrail allowlist. Thanks @RomneyDa.
- **PR #95858** feat(qa): expose active memory toggles to scenarios. Thanks @RomneyDa.
- **PR #84326** Doctor: expose sandbox registry findings. Thanks @giodl73-repo.
- **PR #73079** fix(minimax): request hex TTS output explicitly. Thanks @efe-arv.
- **PR #94483** feat(gateway-cli): scope usage-cost by agent. Thanks @ly-wang19.
- **PR #91502** feat(qa): crabline channel driver. Thanks @RomneyDa.
- **PR #95872** Move TUI PTY tests into CI node shard. Thanks @RomneyDa.
- **PR #89800** fix(agents): resolve webchat current session status. Related #89773. Thanks @sweetcornna and @killo3967.
- **PR #95870** fix(ci): restore macOS and Windows QA gates. Thanks @vincentkoc.
- **PR #94445** fix(agents): keep cron cloud idle watchdog enabled. Thanks @bek91.
- **PR #91506** fix(qa): reserve shared QA suite flags across runners. Thanks @RomneyDa.
- **PR #95876** ci: add manual release QA profile evidence workflow. Thanks @RomneyDa.
- **PR #95879** fix(ci): use workflow revision for proof checks. Thanks @vincentkoc.
- **PR #93113** fix(memory-core): report active dreaming phases in status. Related #67868. Thanks @AgentArcLab and @mrossit.
- **PR #95837** Simplify color mode button labels. Thanks @SannidhyaSah and @hannesrudolph.
- **PR #95797** fix: /status is too verbose for pinned model sessions. Thanks @Solvely-Colin.
- **PR #90223** test: make qqbot symlinked media helper test robust on Windows. Thanks @aniruddhaadak80.
- **PR #95557** improve: refresh Android overview control surface. Thanks @Solvely-Colin and @joshavant.
- **PR #95593** fix: route Android exec approvals to in-app inbox. Thanks @Solvely-Colin.
- **PR #95570** fix(cli): resolve trajectory export stores consistently. Related #95568. Thanks @youngting520.
- **PR #95880** ci: generalize QA profile evidence workflow. Thanks @RomneyDa.
- **PR #95218** fix(agents): bound provider JSON response reads. Thanks @Alix-007.
- **PR #95614** fix(memory-wiki): preserve human notes block on source re-ingest. Thanks @yetval.
- **PR #95148** feat(android): add settings detail panels. Thanks @Tosko4.
- **PR #95893** Add iOS push sandbox profiles and relay tooling. Thanks @joshavant.
- **PR #95723** fix(control-ui): exclude disabled cron jobs from Overview failed count. Related #95716. Thanks @ZengWen-DT and @voytas75.
- **PR #78715** Fix minor grammar issue in plugin documentation (capabilities plural). Thanks @mehrazmorshed.
- **PR #95890** fix(ci): restore QA workflow gates. Thanks @vincentkoc.
- **PR #95661** fix(discord): reserve closing-fence space on fence-closing lines. Thanks @ly-wang19.
- **PR #94272** ci: add maturity scorecard renderer. Thanks @RomneyDa.
- **PR #95901** ci: add maturity scorecard renderer. Thanks @RomneyDa.
- **PR #95914** fix(whatsapp): preserve durable reply target. Thanks @mcaxtr.
- **PR #95697** improve: reduce hot-path linear scans and redundant I/O. Thanks @vincentkoc.
- **PR #95916** fix(memory): improve node:sqlite unavailable guidance. Thanks @vincentkoc and @rrrrrredy.
- **PR #78884** docs: document local avatar file size limit. Related #65312. Thanks @wangjieweb3-design and @wraxle-geargrind.
- **PR #95909** fix(ci): use available Android SDK platform. Thanks @vincentkoc.
- **PR #68389** plugins: clarify allowlist warning when entries don't match discovered ids. Related #68352. Thanks @lyfuci and @JIRBOY.
- **PR #95898** ci: simplify maturity scorecard QA evidence inputs. Thanks @RomneyDa.
- **PR #95928** fix(ci): honor reusable QA evidence failure policy. Thanks @vincentkoc.
- **PR #95508** fix #95489: [Bug]: claude-cli out-of-credits error bypasses model fallback chain — error text delivered as final response. Thanks @mikasa0818 and @sallyom and @riazrahaman.
- **PR #95420** fix(agents): bound OpenRouter model catalog response reads. Thanks @Alix-007 and @sallyom.
- **PR #95103** fix(gateway): bound pricing catalog streams. Thanks @vincentkoc and @sallyom.
- **PR #95108** fix(agents): bound Anthropic error streams. Thanks @vincentkoc and @sallyom.
- **PR #95418** fix(agents): bound OpenRouter model-scan catalog success body. Thanks @Alix-007.
- **PR #95922** fix(ci): finalize testbox sessions after setup failures. Thanks @vincentkoc.
- **PR #95417** fix(agents): bound Google prompt cache response reads. Thanks @Alix-007.
- **PR #95935** fix(whatsapp): resolve approval reactions across JID drift. Thanks @mcaxtr.
- **PR #84340** Doctor: expose extra gateway service findings. Thanks @giodl73-repo.
- **PR #95946** fix(ci): finalize Windows Testbox after setup failures. Thanks @vincentkoc.
- **PR #95947** feat(qa): add "all" taxonomy profile. Thanks @RomneyDa.
- **PR #91903** feat(plugin-sdk): add extensible channel identity hook context. Thanks @lanzhi-lee and @vincentkoc.
- **PR #95930** docs(copilot): refresh harness parity notes. Thanks @vincentkoc.
- **PR #95952** fix(ci): require QA live evidence artifacts. Thanks @vincentkoc.
- **PR #95666** Fix memory-wiki bridge self-import loop. Related #95657. Thanks @TurboTheTurtle and @vincentkoc and @Johannes0402.
- **PR #91724** fix(agents): infer runtime provider from qualified model ids. Thanks @yu-xin-c and @vincentkoc.
- **PR #95957** improve: speed up provider tool-call streaming. Thanks @vincentkoc.
- **PR #95541** fix(cli): show working commands for pinned plugin drift. Thanks @ooiuuii and @vincentkoc.
- **PR #95933** docs: update maturity scorecard. Thanks @RomneyDa.
- **PR #78105** fix(plugins): make empty-allowlist actionable for new users. Related #68780. Thanks @pahuchi-joe and @JIRBOY.
- **PR #95961** fix(ci): require live proof evidence artifacts. Thanks @vincentkoc.
- **PR #95400** fix(model-fallback): classify Codex usage-limit payloads. Thanks @jason-allen-oneal and @sallyom.
- **PR #95944** perf(qa-lab): speed up unified QA suites. Thanks @RomneyDa.
- **PR #95971** ci: fail QA profile evidence on QA failures. Thanks @RomneyDa.
- **PR #95845** feat: add bundled plugin icon manifest URLs. Thanks @Patrick-Erichsen.
- **PR #95975** fix(ci): require iOS Periphery evidence artifact. Thanks @vincentkoc.
- **PR #95972** docs: update ClawHub skill route references. Thanks @Patrick-Erichsen.
- **PR #90537** Warn on generated wrapper overwrites and status diagnostics. Related #90518. Thanks @TurboTheTurtle and @vincentkoc.
- **PR #93567** fix(cron): normalize run-log jobId on write to match read-side validation. Thanks @Alix-007 and @vincentkoc.
- **PR #95967** perf(ci): widen main test fanout and move codeql off blacksmith. Thanks @vincentkoc.
- **PR #95983** fix(ci): require OpenGrep SARIF artifacts. Thanks @vincentkoc.
- **PR #64490** CLI: escape zsh completion descriptions. Thanks @EdenKangdw.
- **PR #95682** Fix Gemini day freshness time range handling. Thanks @Sunjae-k and @vincentkoc.
- **PR #93374** fix(agents): suggest recovery for unknown tool ids. Related #92273. Thanks @mushuiyu886 and @vincentkoc and @poison.
- **PR #95991** fix(ci): require release QA evidence artifacts. Thanks @vincentkoc.
- **PR #89886** fix(context-engine): forward abortSignal through delegation bridge to runtime compaction. Related #89868. Thanks @openperf and @vincentkoc and @lykeion-dev.
- **PR #95999** fix(release): require postpublish evidence artifact. Thanks @vincentkoc.
- **PR #96003** test(qa): harden all-profile QA evidence scenarios. Thanks @RomneyDa.
- **PR #95860** fix(compaction): trim prefix when transcript ends in an oversized tool result. Related #78478. Thanks @yetval and @wzhgba.
- **PR #95934** fix(xiaomi): correct mimo-v2.5 and mimo-v2.5-pro max output tokens to 128K. Thanks @idootop.
- **PR #95782** fix(sessions): honor configured store for outbound transcript mirrors. Related #95781. Thanks @youngting520.
- **PR #96005** feat(copilot): wire harness parity helpers. Thanks @vincentkoc.
- **PR #95886** fix: avoid false macOS update failures during gateway shutdown. Thanks @fuller-stack-dev.
- **PR #91906** perf: skip subagent live stream parsing. Thanks @lanzhi-lee.
- **PR #87861** fix(model-usage): coerce numeric-string costs and ignore non-finite values. Related #37878. Thanks @coder999999999 and @vincentkoc and @shuofengzhang.
- **PR #96030** fix(qa-lab): avoid duplicate child evidence files. Thanks @RomneyDa.
- **PR #94369** fix(memory-wiki): exclude durable reference pages from stale report. Thanks @SunnyShu0925 and @vincentkoc.
- **PR #94578** Fix recent session resume with long headers. Related #94577. Thanks @rohitjavvadi and @vincentkoc.
- **PR #95919** ci: add Codex maturity scorecard agent. Thanks @RomneyDa.
- **PR #92356** fix(heartbeat): skip reasoning payloads when selecting heartbeat reply. Related #92260. Thanks @tangtaizong666 and @vincentkoc and @jmpei.
- **PR #94328** fix(agents): keep post-compaction user re-issue of a kept-tail prompt during compaction rotation. Thanks @yetval and @vincentkoc.
- **PR #95432** fix(reply): suppress per-message finals across multi-message block streaming. Thanks @yetval and @vincentkoc.
- **PR #95431** fix(auto-reply): keep drain/restart-abort reply paths silent. Thanks @moeedahmed and @vincentkoc.
- **PR #96017** test(qa): gate maturity docs on passing evidence. Thanks @RomneyDa.
- **PR #95552** feat(mattermost): persist participated threads for mention-free follow-ups. Thanks @amknight.
- **PR #95480** fix #89231: [Bug]: Windows installer-created scheduled task launches gateway.cmd with visible console — should use windowless launcher. Thanks @mikasa0818 and @vincentkoc and @CameronWeller.
- **PR #96049** fix(copilot): preserve compaction metadata. Thanks @vincentkoc.
- **PR #96044** docs: rename top maturity tier to Clawesome. Thanks @RomneyDa.
- **PR #92154** Gate private QQBot group commands. Thanks @sliverp.
- **PR #95484** fix: assistant reply lost between compaction summary and first kept user in successor transcript. Related #76729. Thanks @maweibin and @vincentkoc and @njuboy11.
- **PR #95094** ci: add release QA profile evidence. Thanks @RomneyDa.
- **PR #96057** docs: redesign maturity scorecard pages. Thanks @vincentkoc.
- **PR #96014** perf(agents): index displaced tool results. Thanks @vincentkoc.
- **PR #96019** perf(usage): bound session log retention. Thanks @vincentkoc.
- **PR #96013** perf(anthropic): index active stream blocks. Thanks @vincentkoc.
- **PR #96061** docs: place maturity pages under release reference. Thanks @vincentkoc.
- **PR #95589** fix: npm plugin updates break running gateway imports. Thanks @ooiuuii and @vincentkoc.
- **PR #96068** fix(acpx): consume acpx 0.11.1 model capability errors. Related #95869. Thanks @vincentkoc and @SabaTech-dev.
- **PR #89912** refactor: add transcript update identity contract. Thanks @jalehman.
- **PR #96055** fix(maint): use rebase PR landing. Thanks @vincentkoc.
- **PR #96062** feat(copilot): mirror native plan and subagent events. Thanks @vincentkoc.
- **PR #96032** fix(acpx): detect wrapper orphan on any PPID change, not just init reparenting. Thanks @t2wei and @vincentkoc.
- **PR #96124** chore(acpx): bump bundled client to 0.11.2. Thanks @vincentkoc.
- **PR #84352** Fix WebChat dispatch failure session status. Thanks @jesse-merhi.
- **PR #89518** refactor: migrate plugin transcript mirrors. Thanks @jalehman.
- **PR #90439** refactor: add embedded run session target seam. Thanks @jalehman.
- **PR #95699** perf(gateway): drop redundant per-access session-key case scan. Thanks @jzakirov and @jalehman.
- **PR #95992** fix(skills): accept owner-qualified verify refs. Thanks @Patrick-Erichsen.
- **PR #95987** fix(plugins): remove Simple Icons color paths. Thanks @Patrick-Erichsen.
- **PR #96162** refactor: use accessor-backed transcript corpus for memory. Thanks @jalehman.
- **PR #96181** Defer iOS local network permission until onboarding. Thanks @joshavant.
- **PR #95475** test(cli): isolate service env in run and update suites. Thanks @kklouzal.
- **PR #95226** fix(infra): bound ClawHub fetchJson and error response bodies. Thanks @Alix-007.
- **PR #95240** fix(matrix): bound non-raw JSON response body in transport. Thanks @Alix-007.
- **PR #91742** fix(memory): abort orphaned embedding work when memory_search times out. Related #91718. Thanks @dreamhunter2333 and @NOVA-Openclaw.
- **PR #93394** fix(memory): abort orphaned qmd search subprocess when memory_search times out. Thanks @Alix-007.
- **PR #89911** refactor: migrate bundled transcript target lookups. Thanks @jalehman.
- **PR #96191** refactor: route plugin host hook state through accessor. Thanks @jalehman.
- **PR #96179** fix: route gateway history through session accessor target. Thanks @jalehman.
- **PR #96201** refactor: add abort target session accessor. Thanks @jalehman.
- **PR #96193** fix(memory-core): migrate dreaming cleanup lifecycle. Thanks @jalehman.
- **PR #96195** fix: bridge ACP metadata to session accessors. Thanks @jalehman.
- **PR #96182** refactor: migrate agent session accessors. Thanks @jalehman.
- **PR #96218** refactor: guard reply session initialization. Thanks @jalehman.
- **PR #96213** refactor(gateway): add alias mutation accessor. Thanks @jalehman.
- **PR #96204** refactor: migrate command session persistence to accessor. Thanks @jalehman.
- **PR #96206** refactor: route live model fresh reads through session accessor. Thanks @jalehman.
- **PR #96220** fix(whatsapp): quote current follow-up in durable replies. Thanks @mcaxtr.
- **PR #96226** fix(macos): drop Textual from chat packaging. Thanks @vincentkoc.
- **PR #96212** fix(codex): deliver generated images from remote app-server. Thanks @sjf-oa.
- **PR #96235** fix(crabbox): require Xcode for macOS proof. Thanks @vincentkoc.
- **PR #96145** fix: UI glitch: config is not visible. Related #94202. Thanks @sunlit-deng and @vporton.
- **PR #96072** perf(browser): index role snapshot references. Thanks @vincentkoc.
- **PR #96085** perf(codex): index rollout transcript ids. Thanks @vincentkoc.
- **PR #96087** perf(reply): hoist direct-send fragment index. Thanks @vincentkoc.
- **PR #94154** fix(gateway): resolve plugin-registered gateway methods through live registry. Related #94127. Thanks @Pick-cat and @vincentkoc and @BryceMurray.
- **PR #95393** fix #92582: Bug: doctor falsely warns local memory embeddings are not ready. Thanks @mikasa0818 and @vincentkoc and @neekolascmd.
- **PR #96246** fix(qa): accept pnpm separator for lab up. Thanks @vincentkoc.
- **PR #94949** fix(ports): route isPortBusy through checkPortInUse to catch IPv4-only occupants. Related #94426. Thanks @sunlit-deng and @vincentkoc and @wangwllu.
- **PR #94562** fix(workboard): hide archived cards in CLI list by default. Related #94555. Thanks @ZengWen-DT and @vincentkoc and @ecican.
- **PR #96243** fix(nextcloud-talk): ignore signed non-message webhook events. Related #81566. Thanks @arkyu2077 and @vincentkoc and @rafaelmgbh.
- **PR #96140** fix(exec): preserve turn-source routing target in approval followups for plugin channels. Related #96103. Thanks @yetval and @vincentkoc and @lansenger-pm.
- **PR #96258** ci: move CodeQL quality scans to hosted runners. Thanks @vincentkoc.
- **PR #96271** chore(release): close out 2026.6.10 on main. Thanks @vincentkoc.
- **PR #96233** fix(agents): run heartbeat_prompt_contribution on harness prompt builds. Thanks @azogheb and @vincentkoc.
- **PR #55018** fix: avoid O(N²) shallow-copy in mapSensitivePaths schema traversal. Thanks @xdhuangyandi and @vincentkoc and @huangyandi-red.
- **PR #95831** fix: compact Codex OAuth OpenAI sessions without API keys. Related #95693. Thanks @sallyom and @YUI-TIEN.
- **PR #96244** fix(auto-reply): align channel intro wording with chat_type. Related #95645. Thanks @arkyu2077 and @vincentkoc and @iloveleon19.
## 2026.6.10
### Highlights
- **Automatic fast mode for talks:** OpenClaw can enable fast mode for short conversational turns, then return to normal mode for longer runs with bounded fallback and delivery behavior. (#85104) Thanks @alexph-dev and @vincentkoc.
- **More reliable model routing:** Zai model synthesis, GLM overload failover, and native reasoning-level selection now follow the active model catalog more consistently. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.
- **Safer session and channel state:** channel switches reset stale origin fields, and cron delivery awareness stays attached to the target session. (#95328, #93580) Thanks @ZengWen-DT, @jalehman, @gorkem2020, and @scotthuang.
- **Trusted policies survive hook composition:** composed hook registries keep the trusted tool policies required by approval-sensitive flows. (#94545) Thanks @jesse-merhi.
### Changes
- **Agent and channel runtime:** fast-mode state now survives retries, fallback transitions, progress events, and embedded/CLI/ACP normalization; session and channel routing retain the current target and delivery context. (#85104, #93580, #95328) Thanks @alexph-dev, @vincentkoc, @scotthuang, @ZengWen-DT, @jalehman, and @gorkem2020.
- **Provider behavior:** model catalogs now supply the correct Zai base URL, overload classification, and native reasoning controls for live-discovered models. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.
### Fixes
- **Fast-mode and policy correctness:** fallback cutoffs and reset notices are bounded, repeated progress events remain visible, Codex service-tier state is normalized, and trusted policies are not lost when hook registries are composed. (#85104, #94545) Thanks @alexph-dev, @vincentkoc, and @jesse-merhi.
- **Model and delivery edge cases:** Zai and GLM failover paths use the right runtime metadata, while stale channel-origin state no longer leaks across session changes. (#94461, #93241, #95328) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @ZengWen-DT, @jalehman, and @gorkem2020.
- **Provider plugin onboarding:** setup refreshes provider plugin registry metadata after installing setup-selected provider plugins, so auth continuation uses the newly installed provider instead of stale registry state. (#95792) Thanks @snowzlmbot.
### Complete contribution record
This audited record covers the complete v2026.6.9..HEAD history: 12 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
#### Pull requests
- **PR #86627** Keep core doctor health in contribution order. Thanks @giodl73-repo.
- **PR #93580** fix: preserve cron delivery awareness for target sessions. Thanks @scotthuang and @jalehman.
- **PR #95030** refactor: add SDK transcript identity target API. Thanks @jalehman.
- **PR #94838** refactor(copilot): complete harness lifecycle parity. Thanks @vincentkoc.
- **PR #95328** fix(sessions): reset stale per-channel origin fields on channel switch. Related #95325. Thanks @ZengWen-DT and @jalehman and @gorkem2020.
- **PR #94461** fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models. Related #94269. Thanks @Pandah97 and @chrysb.
- **PR #93241** fix(agents): classify Zhipu GLM overload as overloaded for failover. Related #93211. Thanks @0xghost42 and @zhengli0922.
- **PR #94067** fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models. Related #93835. Thanks @openperf and @civiltox.
- **PR #94136** fix(zai): expose GLM-5.2 reasoning levels [AI-assisted]. Thanks @BorClaw.
- **PR #85104** feat: fast talks auto mode. Related #85087. Thanks @alexph-dev.
- **PR #94545** fix: keep trusted policies with hook registry. Thanks @jesse-merhi.
- **PR #95792** fix(onboard): refresh provider plugin registry after setup installs. Related #95765. Thanks @snowzlmbot.
## 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.
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93088, #93281) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, and @aaajiao.
- **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.
- **Standalone official provider plugins:** external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is intentionally npm-only because its ClawHub package name is unavailable. (#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.
- Plugins and installs: externalized official providers publish as independent npm packages, Gateway discovers installed channel plugins at startup, and StepFun installs exclusively from npm. (#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.
- Codex and skills: add automatic plugin approvals, preserve ClawHub skill provenance, and expose remote-node execution to Codex when a node is connected. (#92625, #93283, #93654) Thanks @kevinslin, @momothemage, @nmccready-tars, @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.
### 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.
- Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, and @vincentkoc.
- Channels and replies: fix Telegram rich delivery and ingress recovery, preserve WhatsApp auth and media error reporting, keep Mattermost thread replies intact, and harden Discord action handling. (#93286, #93364, #93281, #93076, #93334, #93424, #93488) Thanks @obviyus, @NianJiuZst, @mcaxtr, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, and @vincentkoc.
- 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.
- Operations and updates: harden official plugin recovery, restart managed Gateways after failed update handoff, avoid Node-specific npm prefixes, and keep package validation paths reliable. (#93325, #92111, #93650) Thanks @vincentkoc, @yetval, @ofan, and @yaanfpv.
### 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.
This audited record covers the complete v2026.6.8..HEAD~1 history: 375 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
#### Pull requests
- **PR #92154** fix(qqbot): gate private group commands and close strict command visibility gaps. Thanks @sliverp.
- **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.
@@ -434,7 +57,6 @@ This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs.
- **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.
@@ -553,7 +175,7 @@ This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs.
- **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 #93418** fix(telegram): forward Bot API 10.1 rich_message content to agent. Related #93410. Thanks @xzh-xydt 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.
@@ -790,53 +412,6 @@ This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs.
- **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

View File

@@ -61,7 +61,7 @@ We prioritize secure defaults, but also expose clear knobs for trusted high-powe
## Plugins & Memory
OpenClaw has an extensive plugin API.
Core stays lean; optional capabilities should usually ship as plugins.
Core stays lean; optional capability should usually ship as plugins.
We are generally slimming down core while expanding what plugins can do.
If a useful feature cannot be built as a plugin yet, we welcome PRs and design discussions that extend the plugin API instead of adding one-off core behavior.

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.10
OPENCLAW_ANDROID_VERSION_CODE=2026061001
OPENCLAW_ANDROID_VERSION_NAME=2026.6.2
OPENCLAW_ANDROID_VERSION_CODE=2026060201

View File

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

View File

@@ -1,16 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<permission
android:name="${applicationId}.permission.RUN_VOICE_E2E"
android:protectionLevel="signature" />
<uses-permission android:name="${applicationId}.permission.RUN_VOICE_E2E" />
<application>
<receiver
android:name=".VoiceE2eReceiver"
android:permission="${applicationId}.permission.RUN_VOICE_E2E"
android:exported="false">
android:exported="true">
<intent-filter>
<action android:name="ai.openclaw.app.debug.RUN_VOICE_E2E" />
</intent-filter>

View File

@@ -1,160 +0,0 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
data class GatewayExecApprovalSummary(
val id: String,
val commandText: String,
val commandPreview: String?,
val allowedDecisions: List<String>,
val host: String?,
val nodeId: String?,
val agentId: String?,
val createdAtMs: Long?,
val expiresAtMs: Long?,
val resolvingDecision: String? = null,
val errorText: String? = null,
)
internal fun parseGatewayExecApprovalListPayload(
payloadJson: String,
json: Json,
): List<GatewayExecApprovalSummary> =
try {
(json.parseToJsonElement(payloadJson) as? JsonArray)
?.mapNotNull(::parseGatewayExecApprovalListEntry)
?.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
.orEmpty()
} catch (_: Throwable) {
emptyList()
}
internal fun parseGatewayExecApprovalListEntry(item: JsonElement): GatewayExecApprovalSummary? {
val obj = item.asObjectOrNull() ?: return null
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return null
val request = obj["request"].asObjectOrNull()
val commandText = gatewayExecApprovalListCommandText(obj, request)
return GatewayExecApprovalSummary(
id = id,
commandText = commandText,
commandPreview = gatewayExecApprovalListCommandPreview(obj, request, commandText),
allowedDecisions = emptyList(),
host =
request
?.get("host")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
nodeId =
request
?.get("nodeId")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
agentId =
request
?.get("agentId")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
createdAtMs = obj.long("createdAtMs"),
expiresAtMs = obj.long("expiresAtMs"),
)
}
internal fun parseGatewayExecApprovalDetail(
obj: JsonObject,
createdAtMs: Long?,
): GatewayExecApprovalSummary? {
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return null
return GatewayExecApprovalSummary(
id = id,
commandText =
obj["commandText"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: "Command request",
commandPreview =
obj["commandPreview"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
allowedDecisions = gatewayExecApprovalAllowedDecisions(obj),
host = obj["host"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
nodeId = obj["nodeId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
agentId = obj["agentId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
createdAtMs = createdAtMs,
expiresAtMs = obj.long("expiresAtMs"),
)
}
private fun gatewayExecApprovalListCommandText(obj: JsonObject, request: JsonObject?): String =
obj["commandText"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: request
?.get("command")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: "Command request"
private fun gatewayExecApprovalListCommandPreview(
obj: JsonObject,
request: JsonObject?,
commandText: String,
): String? {
val preview =
obj["commandPreview"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: request
?.get("commandPreview")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
return preview?.takeIf { it != commandText }
}
private fun gatewayExecApprovalAllowedDecisions(request: JsonObject?): List<String> {
val explicit = parseGatewayExecApprovalDecisions(request?.get("allowedDecisions") as? JsonArray)
if (explicit.isNotEmpty()) return explicit
val allowed =
if (request
?.get("ask")
.asStringOrNull()
?.trim()
?.lowercase() == "always"
) {
listOf("allow-once", "deny")
} else {
listOf("allow-once", "allow-always", "deny")
}
val unavailable = parseGatewayExecApprovalDecisions(request?.get("unavailableDecisions") as? JsonArray).toSet()
return allowed.filterNot { it == "allow-always" && it in unavailable }
}
private fun parseGatewayExecApprovalDecisions(items: JsonArray?): List<String> =
items
?.mapNotNull { item ->
when (item.asStringOrNull()?.trim()) {
"allow-once" -> "allow-once"
"allow-always" -> "allow-always"
"deny" -> "deny"
else -> null
}
}?.distinct()
.orEmpty()
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()

View File

@@ -204,9 +204,6 @@ class MainViewModel(
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls }
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = runtimeState(initial = emptyList()) { it.execApprovals }
val execApprovalsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.execApprovalsRefreshing }
val execApprovalsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.execApprovalsErrorText }
val canvas: CanvasController
get() = ensureRuntime().canvas
@@ -540,17 +537,6 @@ class MainViewModel(
ensureRuntime().refreshNodesDevices()
}
fun refreshExecApprovals() {
ensureRuntime().refreshExecApprovals()
}
fun resolveExecApproval(
id: String,
decision: String,
) {
ensureRuntime().resolveExecApproval(id = id, decision = decision)
}
fun refreshChannels() {
ensureRuntime().refreshChannels()
}

View File

@@ -14,7 +14,6 @@ import ai.openclaw.app.gateway.GatewayTlsProbeFailure
import ai.openclaw.app.gateway.GatewayTlsProbeResult
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
import ai.openclaw.app.gateway.normalizeGatewayTlsFingerprint
import ai.openclaw.app.gateway.parseChatSendAck
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
import ai.openclaw.app.node.A2UIHandler
import ai.openclaw.app.node.CalendarHandler
@@ -74,9 +73,7 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.util.Collections
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
/**
@@ -402,15 +399,6 @@ class NodeRuntime(
private val _nodesDevicesErrorText = MutableStateFlow<String?>(null)
val nodesDevicesErrorText: StateFlow<String?> = _nodesDevicesErrorText.asStateFlow()
private val nodeApprovalRefreshGuard = GatewayNodeApprovalRefreshGuard()
private val _execApprovals = MutableStateFlow<List<GatewayExecApprovalSummary>>(emptyList())
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = _execApprovals.asStateFlow()
private val _execApprovalsRefreshing = MutableStateFlow(false)
val execApprovalsRefreshing: StateFlow<Boolean> = _execApprovalsRefreshing.asStateFlow()
private val _execApprovalsErrorText = MutableStateFlow<String?>(null)
val execApprovalsErrorText: StateFlow<String?> = _execApprovalsErrorText.asStateFlow()
private val execApprovalsRefreshSeq = AtomicLong(0)
private val execApprovalsStateLock = Any()
private val resolvedExecApprovalIds = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
private val _channelsSummary = MutableStateFlow(GatewayChannelsSummary(channels = emptyList()))
val channelsSummary: StateFlow<GatewayChannelsSummary> = _channelsSummary.asStateFlow()
private val _channelsRefreshing = MutableStateFlow(false)
@@ -460,7 +448,6 @@ class NodeRuntime(
micCapture.onGatewayConnectionChanged(true)
scope.launch {
subscribeOperatorSessionEvents()
refreshExecApprovalsFromGateway()
refreshHomeCanvasOverviewIfConnected()
if (voiceReplySpeakerLazy.isInitialized()) {
voiceReplySpeaker.refreshConfig()
@@ -490,11 +477,6 @@ class NodeRuntime(
pendingDevices = emptyList(),
pairedDevices = emptyList(),
)
invalidateExecApprovalRefreshes()
resolvedExecApprovalIds.clear()
_execApprovals.value = emptyList()
_execApprovalsRefreshing.value = false
_execApprovalsErrorText.value = null
_channelsSummary.value = GatewayChannelsSummary(channels = emptyList())
_dreamingSummary.value = GatewayDreamingSummary()
_healthLogsSummary.value = GatewayHealthLogsSummary()
@@ -650,11 +632,7 @@ class NodeRuntime(
put("idempotencyKey", JsonPrimitive(idempotencyKey))
}
val response = operatorSession.request("chat.send", params.toString())
val ack = parseChatSendAck(json, response)
ack.copy(runId = ack.runId ?: idempotencyKey)
},
refreshAfterTerminalSuccess = {
chat.refresh()
parseChatSendRunId(response) ?: idempotencyKey
},
speakAssistantReply = { text ->
// Voice-tab replies should speak through the dedicated reply speaker.
@@ -842,24 +820,6 @@ class NodeRuntime(
}
}
fun refreshExecApprovals() {
scope.launch {
refreshExecApprovalsFromGateway()
}
}
fun resolveExecApproval(
id: String,
decision: String,
) {
val normalizedId = id.trim()
val normalizedDecision = decision.trim()
if (normalizedId.isEmpty() || normalizedDecision.isEmpty()) return
scope.launch {
resolveExecApprovalOnGateway(id = normalizedId, decision = normalizedDecision)
}
}
fun refreshChannels() {
scope.launch {
refreshChannelsFromGateway()
@@ -1035,9 +995,6 @@ class NodeRuntime(
_isForeground.value = value
if (value) {
reconnectPreferredGatewayOnForeground()
scope.launch {
refreshExecApprovalsFromGateway()
}
} else {
stopManualVoiceSession()
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Background, throttleRecentSuccess = true)
@@ -1867,47 +1824,11 @@ class NodeRuntime(
if (event == "update.available") {
_gatewayUpdateAvailable.value = parseGatewayUpdateAvailable(payloadJson)
}
handleExecApprovalGatewayEvent(event = event, payloadJson = payloadJson)
micCapture.handleGatewayEvent(event, payloadJson)
talkMode.handleGatewayEvent(event, payloadJson)
chat.handleGatewayEvent(event, payloadJson)
}
private fun handleExecApprovalGatewayEvent(
event: String,
payloadJson: String?,
) {
when (event) {
"exec.approval.requested" -> {
val approvalId = parseExecApprovalEventId(payloadJson)
approvalId?.let(resolvedExecApprovalIds::remove)
scope.launch {
if (approvalId == null) {
refreshExecApprovalsFromGateway()
} else {
refreshExecApprovalFromGateway(approvalId)
}
}
}
"exec.approval.resolved" -> {
val approvalId = parseExecApprovalEventId(payloadJson) ?: return
markExecApprovalResolved(approvalId)
}
}
}
private fun parseExecApprovalEventId(payloadJson: String?): String? =
try {
payloadJson
?.let { json.parseToJsonElement(it).asObjectOrNull() }
?.get("id")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
} catch (_: Throwable) {
null
}
private fun parseGatewayUpdateAvailable(payloadJson: String?): GatewayUpdateAvailableSummary? {
return try {
val root = payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() }
@@ -1922,6 +1843,15 @@ class NodeRuntime(
}
}
private fun parseChatSendRunId(response: String): String? {
return try {
val root = json.parseToJsonElement(response).asObjectOrNull() ?: return null
root["runId"].asStringOrNull()
} catch (_: Throwable) {
null
}
}
private fun parseTalkSessionId(response: String): String {
val root = json.parseToJsonElement(response).asObjectOrNull()
val sessionId =
@@ -2154,196 +2084,6 @@ class NodeRuntime(
}
}
private suspend fun refreshExecApprovalsFromGateway() {
val refreshGeneration = execApprovalsRefreshSeq.incrementAndGet()
_execApprovalsRefreshing.value = true
_execApprovalsErrorText.value = null
if (!operatorConnected) {
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
_execApprovals.value = emptyList()
_execApprovalsRefreshing.value = false
}
return
}
try {
val res = operatorSession.request("exec.approval.list", "{}")
val existing = _execApprovals.value.associateBy { it.id }
val rows =
parseGatewayExecApprovalListPayload(res, json)
.filterNot { it.id in resolvedExecApprovalIds }
.map { row ->
val hydrated =
try {
fetchExecApprovalDetailFromGateway(
id = row.id,
createdAtMs = row.createdAtMs ?: System.currentTimeMillis(),
)
} catch (_: Throwable) {
null
} ?: row.copy(errorText = "Could not load approval details. Refresh and try again.")
val current = existing[row.id]
if (current == null) {
hydrated
} else {
hydrated.copy(
resolvingDecision = current.resolvingDecision,
errorText = current.errorText ?: hydrated.errorText,
)
}
}
publishExecApprovalsIfCurrent(refreshGeneration, rows)
} catch (_: Throwable) {
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
_execApprovalsErrorText.value = "Could not load approvals."
}
} finally {
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
_execApprovalsRefreshing.value = false
}
}
}
private suspend fun refreshExecApprovalFromGateway(id: String) {
if (!operatorConnected) return
if (id in resolvedExecApprovalIds) return
try {
val current = _execApprovals.value.firstOrNull { it.id == id }
val row =
fetchExecApprovalDetailFromGateway(
id = id,
createdAtMs = current?.createdAtMs ?: System.currentTimeMillis(),
) ?: return
if (id in resolvedExecApprovalIds) return
invalidateExecApprovalRefreshes()
upsertExecApproval(row)
} catch (_: Throwable) {
refreshExecApprovalsFromGateway()
}
}
private suspend fun fetchExecApprovalDetailFromGateway(
id: String,
createdAtMs: Long,
): GatewayExecApprovalSummary? {
val params = buildJsonObject { put("id", JsonPrimitive(id)) }.toString()
val res = operatorSession.request("exec.approval.get", params)
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null
return parseGatewayExecApprovalDetail(root, createdAtMs = createdAtMs)
}
private suspend fun resolveExecApprovalOnGateway(
id: String,
decision: String,
) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || id in resolvedExecApprovalIds) return
val currentRows = _execApprovals.value
if (currentRows.none { it.id == id }) return
invalidateExecApprovalRefreshes()
_execApprovals.value =
currentRows.map { row ->
if (row.id == id) row.copy(resolvingDecision = decision, errorText = null) else row
}
}
try {
val params =
buildJsonObject {
put("id", JsonPrimitive(id))
put("decision", JsonPrimitive(decision))
}.toString()
operatorSession.request("exec.approval.resolve", params)
markExecApprovalResolved(id)
} catch (_: Throwable) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || id in resolvedExecApprovalIds) return
_execApprovals.value =
_execApprovals.value.map { row ->
if (row.id == id) {
row.copy(resolvingDecision = null, errorText = "Could not resolve approval. Refresh and try again.")
} else {
row
}
}
}
}
}
private fun upsertExecApproval(row: GatewayExecApprovalSummary) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || row.id in resolvedExecApprovalIds) return
if (row.isExpiredExecApproval()) return
val rows = _execApprovals.value
val replaced = rows.any { it.id == row.id }
val nextRows =
(
if (replaced) {
rows.map { current ->
if (current.id == row.id) {
row.copy(
resolvingDecision = current.resolvingDecision,
errorText = current.errorText,
)
} else {
current
}
}
} else {
rows + row
}
).filterActiveExecApprovals()
.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
_execApprovals.value = nextRows
scheduleExecApprovalExpiryPrune(nextRows)
}
}
private fun invalidateExecApprovalRefreshes() {
execApprovalsRefreshSeq.incrementAndGet()
_execApprovalsRefreshing.value = false
}
private fun markExecApprovalResolved(id: String) {
synchronized(execApprovalsStateLock) {
resolvedExecApprovalIds.add(id)
invalidateExecApprovalRefreshes()
_execApprovals.value = _execApprovals.value.filterNot { it.id == id }
}
}
private fun publishExecApprovalsIfCurrent(
refreshGeneration: Long,
rows: List<GatewayExecApprovalSummary>,
) {
synchronized(execApprovalsStateLock) {
if (execApprovalsRefreshSeq.get() == refreshGeneration && operatorConnected) {
val nextRows = rows.filterNot { it.id in resolvedExecApprovalIds }.filterActiveExecApprovals()
_execApprovals.value = nextRows
scheduleExecApprovalExpiryPrune(nextRows)
}
}
}
private fun scheduleExecApprovalExpiryPrune(rows: List<GatewayExecApprovalSummary>) {
val now = System.currentTimeMillis()
val nextExpiry = rows.mapNotNull { it.expiresAtMs }.filter { it > now }.minOrNull() ?: return
scope.launch {
delay((nextExpiry - now + 250).coerceAtLeast(0))
pruneExpiredExecApprovals()
}
}
private fun pruneExpiredExecApprovals() {
synchronized(execApprovalsStateLock) {
_execApprovals.value = _execApprovals.value.filterActiveExecApprovals()
}
}
private fun GatewayExecApprovalSummary.isExpiredExecApproval(nowMs: Long = System.currentTimeMillis()): Boolean = expiresAtMs?.let { it <= nowMs } == true
private fun List<GatewayExecApprovalSummary>.filterActiveExecApprovals(
nowMs: Long = System.currentTimeMillis(),
): List<GatewayExecApprovalSummary> = filterNot { it.isExpiredExecApproval(nowMs) }
private fun invalidateNodeCapabilityApprovalState() {
val refreshGeneration = nodeApprovalRefreshGuard.begin()
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
@@ -2458,19 +2198,12 @@ class NodeRuntime(
}.orEmpty()
private fun parseGatewayLogEntry(line: String): GatewayLogEntry {
val sanitizedLine = sanitizeGatewayLogText(line)
val root =
try {
json.parseToJsonElement(line).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return GatewayLogEntry(
time = null,
level = null,
subsystem = null,
message = sanitizedLine.trim().ifEmpty { "Empty log entry" },
raw = sanitizedLine,
)
} ?: return GatewayLogEntry(time = null, level = null, subsystem = null, message = line.trim().ifEmpty { "Empty log entry" })
val meta = root["_meta"].asObjectOrNull()
val time = root["time"].asStringOrNull() ?: meta?.get("date").asStringOrNull()
val level = normalizeLogLevel(meta?.get("logLevelName").asStringOrNull() ?: meta?.get("level").asStringOrNull())
@@ -2488,7 +2221,7 @@ class NodeRuntime(
?: root["message"].asStringOrNull()
?: line
val normalizedMessage =
sanitizeGatewayLogText(message)
message
.trim()
.replace(Regex("\\s+"), " ")
.take(240)
@@ -2496,9 +2229,8 @@ class NodeRuntime(
return GatewayLogEntry(
time = time,
level = level,
subsystem = subsystem?.let(::sanitizeGatewayLogText)?.trim()?.takeIf { it.isNotEmpty() },
subsystem = subsystem?.trim()?.takeIf { it.isNotEmpty() },
message = normalizedMessage,
raw = sanitizedLine,
)
}
@@ -2587,7 +2319,6 @@ class NodeRuntime(
if (name.isEmpty()) return@mapNotNull null
val missing = obj["missing"].asObjectOrNull()
GatewaySkillSummary(
skillKey = obj["skillKey"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: name,
name = name,
description = obj["description"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
source = obj["source"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "unknown",
@@ -3038,6 +2769,11 @@ internal fun resolveOperatorSessionConnectAuth(
)
}
internal fun shouldConnectOperatorSession(
auth: NodeRuntime.GatewayConnectAuth,
storedOperatorToken: String?,
): Boolean = resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
private enum class HomeCanvasGatewayState {
Connected,
Connecting,
@@ -3110,7 +2846,6 @@ data class GatewaySkillsSummary(
)
data class GatewaySkillSummary(
val skillKey: String,
val name: String,
val description: String?,
val source: String,
@@ -3308,19 +3043,8 @@ data class GatewayLogEntry(
val level: String?,
val subsystem: String?,
val message: String,
val raw: String,
)
private val gatewayAnsiControlPattern = Regex("\\u001B\\[[0-?]*[ -/]*[@-~]")
private val gatewayEscapedAnsiControlPattern = Regex("""\\u001[Bb]\[[0-?]*[ -/]*[@-~]""")
private val gatewayVisibleSgrPattern = Regex("\\[(?:0|\\d{1,3}(?:;\\d{1,3})*)m(?!])")
internal fun sanitizeGatewayLogText(value: String): String =
value
.replace(gatewayAnsiControlPattern, "")
.replace(gatewayEscapedAnsiControlPattern, "")
.replace(gatewayVisibleSgrPattern, "")
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()
private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toDoubleOrNull()

View File

@@ -393,6 +393,12 @@ class SecurePrefs(
return stored?.takeIf { it.isNotEmpty() }
}
/** Saves the paired gateway token under the current Android instance id. */
fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}"
securePrefs.edit { putString(key, token.trim()) }
}
/** Loads the bootstrap token used during gateway setup and device-token handoff. */
fun loadGatewayBootstrapToken(): String? {
val key = "gateway.bootstrapToken.${_instanceId.value}"

View File

@@ -6,6 +6,14 @@ internal fun normalizeMainKey(raw: String?): String {
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
}
/** Accepts only gateway session keys that can represent the main chat stream. */
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return false
if (trimmed == "global") return true
return trimmed.startsWith("agent:")
}
/** Extracts the agent id from canonical agent-scoped main session keys. */
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()

View File

@@ -1,7 +1,6 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.parseChatSendAck
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -20,21 +19,11 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
class ChatController internal constructor(
class ChatController(
private val scope: CoroutineScope,
private val session: GatewaySession,
private val json: Json,
private val requestGateway: suspend (method: String, paramsJson: String?) -> String,
) {
constructor(
scope: CoroutineScope,
session: GatewaySession,
json: Json,
) : this(
scope = scope,
json = json,
requestGateway = { method, paramsJson -> session.request(method, paramsJson) },
)
private var appliedMainSessionKey = "main"
private val _sessionKey = MutableStateFlow("main")
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
@@ -278,9 +267,8 @@ class ChatController internal constructor(
)
}
}
val res = requestGateway("chat.send", params.toString())
val ack = parseChatSendAck(json, res)
val actualRunId = ack.runId ?: runId
val res = session.request("chat.send", params.toString())
val actualRunId = parseRunId(res) ?: runId
if (actualRunId != runId) {
// Gateway may return a canonical run id; move all pending bookkeeping to that id.
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
@@ -291,24 +279,7 @@ class ChatController internal constructor(
_pendingRunCount.value = pendingRuns.size
}
}
if (ack.isTerminal) {
clearPendingRun(actualRunId)
removeOptimisticMessage(actualRunId)
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
if (ack.isTerminalSuccess) {
refreshCurrentHistoryBestEffort()
true
} else {
// Terminal timeout/error means the gateway did not accept a runnable turn.
// Surface failed acceptance instead of letting a cleared composer look successful.
_errorText.value = "Chat failed before the run started; try again."
false
}
} else {
true
}
true
} catch (err: Throwable) {
clearPendingRun(runId)
removeOptimisticMessage(runId)
@@ -332,7 +303,7 @@ class ChatController internal constructor(
put("sessionKey", JsonPrimitive(_sessionKey.value))
put("runId", JsonPrimitive(runId))
}
requestGateway("chat.abort", params.toString())
session.request("chat.abort", params.toString())
} catch (_: Throwable) {
// best-effort
}
@@ -385,7 +356,7 @@ class ChatController internal constructor(
) {
try {
val historyJson =
requestGateway(
session.request(
"chat.history",
buildJsonObject { put("sessionKey", JsonPrimitive(sessionKey)) }.toString(),
)
@@ -420,7 +391,7 @@ class ChatController internal constructor(
put("includeUnknown", JsonPrimitive(false))
if (limit != null && limit > 0) put("limit", JsonPrimitive(limit))
}
val res = requestGateway("sessions.list", params.toString())
val res = session.request("sessions.list", params.toString())
_sessions.value = parseSessions(res)
} catch (_: Throwable) {
// best-effort
@@ -437,7 +408,7 @@ class ChatController internal constructor(
if (!force && last != null && now - last < 10_000) return
lastHealthPollAtMs = now
try {
requestGateway("health", null)
session.request("health", null)
_healthOk.value = true
} catch (_: Throwable) {
_healthOk.value = false
@@ -480,7 +451,7 @@ class ChatController internal constructor(
val currentSessionKey = _sessionKey.value
val currentGeneration = historyLoadGeneration.get()
val historyJson =
requestGateway(
session.request(
"chat.history",
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
)
@@ -538,7 +509,8 @@ class ChatController internal constructor(
}
}
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? = payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? =
payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
private fun handleAgentEvent(payloadJson: String) {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
@@ -660,45 +632,6 @@ class ChatController internal constructor(
optimisticMessagesByRunId.entries.removeAll { entry -> entry.value !in retained }
}
private fun refreshCurrentHistoryBestEffort() {
scope.launch {
try {
val currentSessionKey = _sessionKey.value
val currentGeneration = historyLoadGeneration.get()
val historyJson =
requestGateway(
"chat.history",
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
)
if (
!isCurrentHistoryLoad(
currentSessionKey,
_sessionKey.value,
currentGeneration,
historyLoadGeneration.get(),
)
) {
return@launch
}
val history =
parseHistory(
historyJson,
sessionKey = currentSessionKey,
previousMessages = _messages.value,
)
prunePersistedOptimisticMessages(history.messages)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_sessionId.value = history.sessionId
history.thinkingLevel
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { _thinkingLevel.value = it }
} catch (_: Throwable) {
// best-effort
}
}
}
private fun parseHistory(
historyJson: String,
sessionKey: String,
@@ -746,16 +679,9 @@ class ChatController internal constructor(
): ChatSessionEntry? {
if (obj == null) return null
val key =
obj["key"]
.asStringOrNull()
?.trim()
.orEmpty()
.ifEmpty {
obj["sessionKey"]
.asStringOrNull()
?.trim()
.orEmpty()
}.ifEmpty { fallbackKey?.trim().orEmpty() }
obj["key"].asStringOrNull()?.trim().orEmpty()
.ifEmpty { obj["sessionKey"].asStringOrNull()?.trim().orEmpty() }
.ifEmpty { fallbackKey?.trim().orEmpty() }
if (key.isEmpty()) return null
return ChatSessionEntry(
key = key,
@@ -802,6 +728,17 @@ class ChatController internal constructor(
_sessions.value = _sessions.value.filterNot { it.key == key }
}
private fun parseRunId(resJson: String): String? =
try {
json
.parseToJsonElement(resJson)
.asObjectOrNull()
?.get("runId")
.asStringOrNull()
} catch (_: Throwable) {
null
}
private fun normalizeThinking(raw: String): String =
when (raw.trim().lowercase()) {
"low" -> "low"

View File

@@ -1,46 +0,0 @@
package ai.openclaw.app.gateway
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
internal data class ChatSendAck(
val runId: String?,
val status: String?,
) {
val normalizedStatus: String
get() = status?.trim()?.lowercase().orEmpty()
val isTerminalSuccess: Boolean
get() = normalizedStatus == "ok"
val isTerminalFailure: Boolean
get() = normalizedStatus == "timeout" || normalizedStatus == "error"
val isTerminal: Boolean
get() = isTerminalSuccess || isTerminalFailure
}
internal fun chatSendAckHistorySinceSeconds(
ack: ChatSendAck,
startedAtSeconds: Double,
): Double? = if (ack.isTerminalSuccess) null else startedAtSeconds
internal fun parseChatSendAck(
json: Json,
responseJson: String,
): ChatSendAck =
try {
val obj = json.parseToJsonElement(responseJson).asObjectOrNull()
ChatSendAck(
runId = obj?.get("runId").asStringOrNull(),
status = obj?.get("status").asStringOrNull(),
)
} catch (_: Throwable) {
ChatSendAck(runId = null, status = null)
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.gateway
import android.annotation.TargetApi
import android.content.Context
import android.net.ConnectivityManager
import android.net.DnsResolver
@@ -11,7 +12,6 @@ import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.CancellationSignal
import android.util.Log
import androidx.annotation.RequiresApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -49,8 +49,18 @@ import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private fun createDnsResolver(context: Context): DnsResolver =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
createContextDnsResolver(context)
} else {
createLegacyDnsResolver()
}
@TargetApi(Build.VERSION_CODES.CINNAMON_BUN)
private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null)
@Suppress("DEPRECATION")
private fun createDnsResolver(): DnsResolver = DnsResolver.getInstance()
private fun createLegacyDnsResolver(): DnsResolver = DnsResolver.getInstance()
/**
* Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways.
@@ -61,7 +71,7 @@ class GatewayDiscovery(
) {
private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val dns = createDnsResolver()
private val dns = createDnsResolver(context)
private val serviceType = "_openclaw-gw._tcp."
private val wideAreaDomain = System.getenv("OPENCLAW_WIDE_AREA_DOMAIN")
private val logTag = "OpenClaw/GatewayDiscovery"
@@ -156,6 +166,14 @@ class GatewayDiscovery(
}
}
private fun stopLocalDiscovery() {
try {
nsd.stopServiceDiscovery(discoveryListener)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun startUnicastDiscovery(domain: String) {
unicastJob =
scope.launch(Dispatchers.IO) {
@@ -179,7 +197,7 @@ class GatewayDiscovery(
}
}
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private fun resolveWithServiceInfoCallback(serviceInfo: NsdServiceInfo) {
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
val id = stableId(serviceName, "local.")

View File

@@ -260,6 +260,24 @@ class GatewaySession(
currentConnection?.closeQuietly()
}
fun currentCanvasHostUrl(): String? = pluginSurfaceUrls["canvas"]
/** Refreshes the canvas plugin surface URL and caches the normalized Android-reachable URL. */
suspend fun refreshCanvasHostUrl(timeoutMs: Long = 8_000): String? {
val refreshed =
refreshPluginSurfaceUrl(
method = "node.pluginSurface.refresh",
params = buildJsonObject { put("surface", JsonPrimitive("canvas")) },
timeoutMs = timeoutMs,
)
if (!refreshed.isNullOrBlank()) {
pluginSurfaceUrls = pluginSurfaceUrls + ("canvas" to refreshed)
}
return refreshed
}
fun currentMainSessionKey(): String? = mainSessionKey
/** Sends a best-effort node.event and returns false instead of throwing on failure. */
suspend fun sendNodeEvent(
event: String,
@@ -279,6 +297,28 @@ class GatewaySession(
}
}
private suspend fun refreshPluginSurfaceUrl(
method: String,
params: JsonElement?,
timeoutMs: Long,
): String? {
val conn = currentConnection ?: return null
return try {
val res = conn.request(method, params, timeoutMs)
if (!res.ok) return null
val obj = res.payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() } ?: return null
val raw =
obj["pluginSurfaceUrls"]
.asObjectOrNull()
?.get("canvas")
.asStringOrNull()
normalizeCanvasHostUrl(raw, conn.endpoint, isTlsConnection = conn.tls != null)
} catch (err: Throwable) {
Log.d("OpenClawGateway", "$method failed: ${err.message ?: err::class.java.simpleName}")
null
}
}
/** Sends node.event and preserves the gateway RPC error shape for callers that need diagnostics. */
suspend fun sendNodeEventDetailed(
event: String,

View File

@@ -97,6 +97,8 @@ class CanvasController {
fun currentUrl(): String? = url
fun isDefaultCanvas(): Boolean = url == null
fun setDebugStatusEnabled(enabled: Boolean) {
debugStatusEnabled = enabled
applyDebugStatus()
@@ -203,6 +205,24 @@ class CanvasController {
}
}
suspend fun snapshotPngBase64(maxWidth: Int?): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
try {
val scaled = bmp.scaleForMaxWidth(maxWidth)
try {
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
} finally {
if (scaled !== bmp) scaled.recycle()
}
} finally {
bmp.recycle()
}
}
/** Captures the WebView as PNG/JPEG base64 with optional width and quality bounds. */
suspend fun snapshotBase64(
format: SnapshotFormat,

View File

@@ -4,7 +4,6 @@ import ai.openclaw.app.BuildConfig
import ai.openclaw.app.SensitiveFeatureConfig
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.content.Context
import android.content.Intent
@@ -64,7 +63,7 @@ private class AndroidDeviceAppSource(
val appInfos =
if (includeNonLaunchable) {
visibleInstalledApplications(packageManager)
packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
} else {
launchablePackages.mapNotNull { packageName ->
runCatching { packageManager.getApplicationInfo(packageName, 0) }.getOrNull()
@@ -91,13 +90,6 @@ private class AndroidDeviceAppSource(
.sortedWith(compareBy<DeviceAppEntry> { it.label.lowercase() }.thenBy { it.packageName })
.toList()
}
@SuppressLint("QueryPermissionsNeeded")
private fun visibleInstalledApplications(packageManager: PackageManager): List<ApplicationInfo> {
// Android package visibility intentionally bounds this result to packages the app can see.
// OpenClaw should not request QUERY_ALL_PACKAGES for this optional device-context surface.
return packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
}
}
private data class DeviceAppsRequest(

View File

@@ -109,3 +109,6 @@ fun normalizeMainKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()
return if (trimmed.isEmpty()) null else trimmed
}
/** Returns true only for the canonical main-session key understood by gateway UI. */
fun isCanonicalMainSessionKey(key: String): Boolean = key == "main"

View File

@@ -5,7 +5,6 @@ import ai.openclaw.app.GatewayModelSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawSeparatedColumn
import ai.openclaw.app.ui.design.ClawTextField
@@ -95,11 +94,7 @@ internal fun CommandPalette(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
ClawPlainIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close search",
onClick = onDismiss,
)
CommandIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search", onClick = onDismiss)
Text(text = "Search", style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
CommandAvatar(text = "OC")
}
@@ -267,6 +262,19 @@ private fun CommandSessionListRow(
}
}
@Composable
private fun CommandIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun CommandAvatar(text: String) {
Surface(

View File

@@ -5,7 +5,8 @@ import ai.openclaw.app.GatewayDreamingSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatusRow
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
@@ -91,19 +92,19 @@ private fun DreamingPanel(summary: GatewayDreamingSummary) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
ClawStatusRow(
DreamingHealthRow(
title = "Memory Store",
value = if (summary.storeHealthy) "Healthy" else "Needs attention",
healthy = summary.storeHealthy,
)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(
DreamingHealthRow(
title = "Signal Index",
value = if (summary.phaseSignalHealthy) "Healthy" else "Needs attention",
healthy = summary.phaseSignalHealthy,
)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(
DreamingHealthRow(
title = "Promoted",
value = "${summary.promotedToday} today · ${summary.promotedTotal} total",
healthy = true,
@@ -114,6 +115,23 @@ private fun DreamingPanel(summary: GatewayDreamingSummary) {
}
}
@Composable
private fun DreamingHealthRow(
title: String,
value: String,
healthy: Boolean,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Box(modifier = Modifier.size(7.dp))
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
}
}
@Composable
private fun DreamDiaryPanel(summary: GatewayDreamingSummary) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {

View File

@@ -206,6 +206,9 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
}
}
/** Extracts a setup code from QR scanner text when the embedded endpoint is valid. */
internal fun resolveScannedSetupCode(rawInput: String): String? = resolveScannedSetupCodeResult(rawInput).setupCode
/** Resolves QR scanner text to setup-code or validation error for UI copy. */
internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult {
val setupCode =

View File

@@ -7,10 +7,7 @@ import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawStatusRow
import ai.openclaw.app.ui.design.ClawTheme
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -18,18 +15,13 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
@@ -51,7 +43,6 @@ internal fun HealthLogsSettingsScreen(
val logsSummary by viewModel.healthLogsSummary.collectAsState()
val logsRefreshing by viewModel.healthLogsRefreshing.collectAsState()
val logsErrorText by viewModel.healthLogsErrorText.collectAsState()
var selectedLogEntry by remember { mutableStateOf<GatewayLogEntry?>(null) }
LaunchedEffect(isConnected) {
if (isConnected) {
@@ -61,11 +52,6 @@ internal fun HealthLogsSettingsScreen(
}
}
selectedLogEntry?.let { entry ->
GatewayLogDetailSettingsScreen(entry = entry, onBack = { selectedLogEntry = null })
return
}
SettingsDetailFrame(
title = "Health",
subtitle = "Gateway status, phone node readiness, and recent log stream.",
@@ -107,46 +93,7 @@ internal fun HealthLogsSettingsScreen(
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary, onLogClick = { selectedLogEntry = it })
}
}
@Composable
private fun GatewayLogDetailSettingsScreen(
entry: GatewayLogEntry,
onBack: () -> Unit,
) {
BackHandler(onBack = onBack)
SettingsDetailFrame(
title = "Log Entry",
subtitle = "Readable gateway log detail.",
icon = Icons.Default.Settings,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Time", compactLogTime(entry.time)),
SettingsMetric("Level", entry.level?.uppercase() ?: "LOG"),
SettingsMetric("Subsystem", entry.subsystem ?: "Unknown"),
),
)
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Message", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = entry.message, style = ClawTheme.type.body, color = ClawTheme.colors.text)
}
}
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Raw", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(
text = entry.raw.take(4_000),
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
}
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary)
}
}
@@ -166,26 +113,41 @@ private fun HealthStatusPanel(
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
ClawStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
HealthStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
HealthStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
HealthStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Models", value = models, healthy = modelsReady)
HealthStatusRow(title = "Models", value = models, healthy = modelsReady)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Voice", value = voice, healthy = voiceReady)
HealthStatusRow(title = "Voice", value = voice, healthy = voiceReady)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Runs", value = runs, healthy = true)
HealthStatusRow(title = "Runs", value = runs, healthy = true)
}
}
}
@Composable
private fun HealthStatusRow(
title: String,
value: String,
healthy: Boolean,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
}
}
@Composable
private fun GatewayLogsPanel(
isConnected: Boolean,
summary: GatewayHealthLogsSummary,
onLogClick: (GatewayLogEntry) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
@@ -208,7 +170,7 @@ private fun GatewayLogsPanel(
val entries = summary.entries.takeLast(12)
Column {
entries.forEachIndexed { index, entry ->
GatewayLogRow(entry = entry, onClick = { onLogClick(entry) })
GatewayLogRow(entry = entry)
if (index != entries.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
@@ -223,16 +185,9 @@ private fun GatewayLogsPanel(
}
@Composable
private fun GatewayLogRow(
entry: GatewayLogEntry,
onClick: () -> Unit,
) {
private fun GatewayLogRow(entry: GatewayLogEntry) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClickLabel = "Open log entry", onClick = onClick)
.padding(horizontal = 10.dp, vertical = 7.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
@@ -244,11 +199,6 @@ private fun GatewayLogRow(
}
}
ClawStatusPill(text = entry.level?.uppercase() ?: "LOG", status = logLevelStatus(entry.level))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = ClawTheme.colors.textSubtle,
)
}
}

View File

@@ -1378,12 +1378,7 @@ private fun rememberPermissionState(
photosGranted = permissions[photosPermission] ?: photosGranted
contactsGranted = permissions[Manifest.permission.READ_CONTACTS] ?: contactsGranted
calendarGranted = permissions[Manifest.permission.READ_CALENDAR] ?: calendarGranted
notificationsGranted =
if (Build.VERSION.SDK_INT >= 33) {
permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
} else {
true
}
notificationsGranted = permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
motionGranted = permissions[Manifest.permission.ACTIVITY_RECOGNITION] ?: motionGranted
smsGranted =
(permissions[Manifest.permission.SEND_SMS] ?: smsGranted) &&

View File

@@ -9,10 +9,14 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LocalOpenClawDarkTheme = staticCompositionLocalOf { true }
/**
* App theme wrapper that installs dynamic Material colors and legacy mobile color tokens.
*/
@@ -30,6 +34,7 @@ fun OpenClawTheme(
CompositionLocalProvider(
LocalMobileColors provides mobileColors,
LocalOpenClawDarkTheme provides isDark,
) {
MaterialTheme(colorScheme = colorScheme, content = content)
}
@@ -50,3 +55,21 @@ internal fun OpenClawSystemBarAppearance(lightAppearance: Boolean) {
}
}
}
/**
* Overlay background token tuned for panels floating over the mobile canvas.
*/
@Composable
fun overlayContainerColor(): Color {
val scheme = MaterialTheme.colorScheme
val isDark = LocalOpenClawDarkTheme.current
val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh
// Light mode keeps overlays away from pure-white glare on the app canvas.
return if (isDark) base else base.copy(alpha = 0.88f)
}
/**
* Overlay icon token kept next to overlayContainerColor for callers outside the design package.
*/
@Composable
fun overlayIconColor(): Color = MaterialTheme.colorScheme.onSurfaceVariant

View File

@@ -2,7 +2,6 @@ package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawTheme
@@ -56,7 +55,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** Session browser for recent and current chat sessions. */
/** Session browser for recent and currently-live chat sessions. */
@Composable
internal fun SessionsScreen(
viewModel: MainViewModel,
@@ -74,7 +73,7 @@ internal fun SessionsScreen(
.let { rows ->
when (filter) {
SessionFilter.Recent -> rows
SessionFilter.Current -> rows.filter { it.key == chatSessionKey }
SessionFilter.Live -> rows.filter { it.key == chatSessionKey }
}
}.let { rows ->
if (recentFirst) {
@@ -93,12 +92,12 @@ internal fun SessionsScreen(
}
ClawScaffold(
contentPadding = PaddingValues(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 4.dp),
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(9.dp),
verticalArrangement = Arrangement.spacedBy(7.dp),
contentPadding = PaddingValues(bottom = 4.dp),
) {
item {
@@ -107,16 +106,16 @@ internal fun SessionsScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
ClawPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
ClawPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 17.4.sp, lineHeight = 21.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
SessionPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
SessionPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
FilterPill(text = "Recent", icon = Icons.Outlined.AccessTime, active = filter == SessionFilter.Recent, onClick = { filter = SessionFilter.Recent })
FilterPill(text = "Current", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Current, showDot = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Current })
FilterPill(text = "Live", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Live, live = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Live })
}
}
@@ -180,7 +179,7 @@ private fun FilterPill(
text: String,
icon: ImageVector? = null,
active: Boolean = false,
showDot: Boolean = false,
live: Boolean = false,
dropdown: Boolean = false,
onClick: (() -> Unit)? = null,
) {
@@ -199,7 +198,7 @@ private fun FilterPill(
) {
icon?.let { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.text) }
Text(text = text, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
if (showDot) {
if (live) {
Box(modifier = Modifier.size(4.dp).clip(CircleShape).background(ClawTheme.colors.success))
}
if (dropdown) {
@@ -259,7 +258,7 @@ private fun SessionRow(
Text(text = subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
SessionMiniTag(text = "Workspace")
SessionMiniTag(text = if (active) "Current" else "OpenClaw")
SessionMiniTag(text = if (active) "Active" else "OpenClaw")
}
}
}
@@ -274,6 +273,19 @@ private fun SessionRow(
}
}
@Composable
private fun SessionPlainIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun SessionOutlineIconButton(
icon: ImageVector,
@@ -308,21 +320,21 @@ private fun SessionMiniTag(text: String) {
private enum class SessionFilter {
Recent,
Current,
Live,
}
/** Empty-state title selected by the active session browser filter. */
private fun emptySessionTitle(filter: SessionFilter): String =
when (filter) {
SessionFilter.Recent -> "No sessions yet"
SessionFilter.Current -> "No current session"
SessionFilter.Live -> "No live session"
}
/** Empty-state body selected by the active session browser filter. */
private fun emptySessionBody(filter: SessionFilter): String =
when (filter) {
SessionFilter.Recent -> "Start a new conversation and it will show up here."
SessionFilter.Current -> "Open Chat to start or resume the current session."
SessionFilter.Live -> "Open Chat to start or resume the current session."
}
/** Formats session timestamps for compact mobile metadata. */

View File

@@ -4,7 +4,6 @@ import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayCronJobSummary
import ai.openclaw.app.GatewayExecApprovalSummary
import ai.openclaw.app.GatewayUsageProviderSummary
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
@@ -15,7 +14,6 @@ import ai.openclaw.app.ui.design.ClawDetailRow
import ai.openclaw.app.ui.design.ClawIconBadge
import ai.openclaw.app.ui.design.ClawListPanel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawSecondaryButton
@@ -92,6 +90,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
@@ -107,7 +106,6 @@ internal enum class SettingsRoute {
Profile,
Voice,
Agents,
ProvidersModels,
Approvals,
CronJobs,
Usage,
@@ -138,7 +136,6 @@ internal fun SettingsDetailScreen(
SettingsRoute.Profile -> ProfileSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Voice -> VoiceSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Agents -> AgentsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.ProvidersModels -> ProvidersModelsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Approvals -> ApprovalsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.CronJobs -> CronJobsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Usage -> UsageSettingsScreen(viewModel = viewModel, onBack = onBack)
@@ -302,62 +299,29 @@ private fun ApprovalsSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val execApprovals by viewModel.execApprovals.collectAsState()
val execApprovalsRefreshing by viewModel.execApprovalsRefreshing.collectAsState()
val execApprovalsErrorText by viewModel.execApprovalsErrorText.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val issueCount = execApprovals.count { it.errorText != null } + pendingToolCalls.count { it.isError == true }
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshExecApprovals()
}
}
val waitingCount = pendingToolCalls.count { it.isError != true }
val issueCount = pendingToolCalls.count { it.isError == true }
SettingsDetailFrame(title = "Approvals", subtitle = "Review actions that need your attention.", icon = Icons.Default.Lock, onBack = onBack) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Gateway Pending", execApprovals.size.toString()),
SettingsMetric("Session Activity", pendingToolCalls.size.toString()),
SettingsMetric("Pending", waitingCount.toString()),
SettingsMetric("Issues", issueCount.toString()),
SettingsMetric("Active Runs", pendingRunCount.toString()),
),
)
ClawSecondaryButton(
text = if (execApprovalsRefreshing) "Refreshing" else "Refresh",
onClick = viewModel::refreshExecApprovals,
enabled = isConnected && !execApprovalsRefreshing,
modifier = Modifier.fillMaxWidth(),
)
if (execApprovalsErrorText != null) {
ClawPanel {
Text(text = execApprovalsErrorText ?: "", style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
if (!isConnected) {
if (pendingToolCalls.isEmpty()) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Gateway disconnected.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Connect the gateway to load approval requests in the app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
} else if (execApprovals.isEmpty()) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "No gateway approvals.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Exec approval requests will appear here while this phone is connected.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(text = "Nothing needs approval.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "OpenClaw will show action requests here when a session pauses for review.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
} else {
ExecApprovalsPanel(approvals = execApprovals, onResolve = viewModel::resolveExecApproval)
}
if (pendingToolCalls.isNotEmpty()) {
Text(text = "Session activity", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Chat tool calls waiting in the active session remain visible here.", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
SessionToolCallsPanel(toolCalls = pendingToolCalls)
ApprovalsPanel(toolCalls = pendingToolCalls)
}
}
}
@@ -856,7 +820,6 @@ private fun GatewaySettingsScreen(
var bootstrapTokenInput by remember { mutableStateOf("") }
var passwordInput by remember { mutableStateOf("") }
var validationText by remember { mutableStateOf<String?>(null) }
var showSetupCodeHelp by remember { mutableStateOf(false) }
SettingsDetailFrame(title = "Gateway", subtitle = "Connection between this phone and OpenClaw.", icon = Icons.Default.Cloud, onBack = onBack) {
SettingsMetricPanel(
@@ -877,17 +840,7 @@ private fun GatewaySettingsScreen(
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Pair New Gateway", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Clear this phone's saved gateway access and scan a fresh setup code.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.weight(1f), icon = Icons.Default.QrCode2)
ClawSecondaryButton(text = "Setup Code", onClick = { showSetupCodeHelp = !showSetupCodeHelp }, modifier = Modifier.weight(1f), icon = Icons.Default.Info)
}
if (showSetupCodeHelp) {
Text(
text = "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.",
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.fillMaxWidth(), icon = Icons.Default.QrCode2)
}
}
ClawPanel {
@@ -1108,11 +1061,7 @@ internal fun SettingsDetailFrame(
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
item {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
ClawPlainIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
onClick = onBack,
)
SettingsBackButton(onClick = onBack)
Text(text = title, style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
SettingsIconMark(icon = icon)
}
@@ -1149,70 +1098,7 @@ internal data class SettingsMetric(
)
@Composable
private fun ExecApprovalsPanel(
approvals: List<GatewayExecApprovalSummary>,
onResolve: (String, String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
approvals.forEach { approval ->
ExecApprovalCard(approval = approval, onResolve = onResolve)
}
}
}
@Composable
private fun ExecApprovalCard(
approval: GatewayExecApprovalSummary,
onResolve: (String, String) -> Unit,
) {
val resolving = approval.resolvingDecision != null
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(9.dp)) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = approval.commandText, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 2, overflow = TextOverflow.Ellipsis)
approval.commandPreview?.let { preview ->
Text(text = preview, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
ClawStatusPill(text = if (resolving) "Sending" else "Review", status = if (resolving) ClawStatus.Warning else ClawStatus.Success)
}
Text(text = execApprovalMetadata(approval), style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 2, overflow = TextOverflow.Ellipsis)
approval.errorText?.let { errorText ->
Text(text = errorText, style = ClawTheme.type.caption, color = ClawTheme.colors.warning)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if ("allow-once" in approval.allowedDecisions) {
ClawPrimaryButton(
text = if (approval.resolvingDecision == "allow-once") "Allowing" else "Allow Once",
onClick = { onResolve(approval.id, "allow-once") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
}
if ("allow-always" in approval.allowedDecisions) {
ClawSecondaryButton(
text = if (approval.resolvingDecision == "allow-always") "Saving" else "Always",
onClick = { onResolve(approval.id, "allow-always") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
}
if ("deny" in approval.allowedDecisions) {
ClawSecondaryButton(
text = if (approval.resolvingDecision == "deny") "Denying" else "Deny",
onClick = { onResolve(approval.id, "deny") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@Composable
private fun SessionToolCallsPanel(toolCalls: List<ChatPendingToolCall>) {
private fun ApprovalsPanel(toolCalls: List<ChatPendingToolCall>) {
ClawListPanel(items = toolCalls) { toolCall ->
ApprovalListRow(toolCall = toolCall)
}
@@ -1345,30 +1231,6 @@ private fun approvalSubtitle(
return if (minutes < 1) "Waiting for review" else "Waiting ${minutes}m"
}
private fun execApprovalMetadata(approval: GatewayExecApprovalSummary): String {
val target =
when {
approval.host == "node" && approval.nodeId != null -> "Node ${approval.nodeId.take(8)}"
approval.host != null -> approval.host.replaceFirstChar { it.uppercaseChar() }
else -> "Gateway"
}
val agent = approval.agentId?.let { "Agent ${it.take(8)}" }
val age = approval.createdAtMs?.let { "Waiting ${formatApprovalDuration(System.currentTimeMillis() - it)}" }
val expires = approval.expiresAtMs?.let { "Expires ${formatApprovalDuration(it - System.currentTimeMillis())}" }
return listOfNotNull(target, agent, age, expires).joinToString(" · ")
}
private fun formatApprovalDuration(deltaMs: Long): String {
val safeDelta = deltaMs.coerceAtLeast(0L)
val minutes = safeDelta / 60_000L
val hours = minutes / 60L
return when {
minutes < 1 -> "soon"
hours < 1 -> "${minutes}m"
else -> "${hours}h"
}
}
/** Builds the dense cron-job subtitle from schedule, next wake, and prompt preview. */
private fun cronJobSubtitle(job: GatewayCronJobSummary): String = "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}"
@@ -1532,6 +1394,15 @@ internal fun SettingsMetricPanel(rows: List<SettingsMetric>) {
}
}
@Composable
private fun SettingsBackButton(onClick: () -> Unit) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun SettingsIconMark(icon: ImageVector) {
Surface(

View File

@@ -1253,6 +1253,16 @@ private fun settingsPrimaryButtonColors() =
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
/** Destructive button colors for permission and capability settings actions. */
@Composable
private fun settingsDangerButtonColors() =
ButtonDefaults.buttonColors(
containerColor = mobileDanger,
contentColor = Color.White,
disabledContainerColor = mobileDanger.copy(alpha = 0.45f),
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
/** Opens this app's Android settings page for permissions that require system UI. */
private fun openAppSettings(context: Context) {
val intent =

View File

@@ -10,24 +10,17 @@ import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextBadge
import ai.openclaw.app.ui.design.ClawTheme
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -44,7 +37,6 @@ internal fun SkillsSettingsScreen(
val skills = skillsSummary.skills
val readyCount = skills.count { skillReady(it) }
val needsSetupCount = skills.count { skillNeedsSetup(it) }
var selectedSkillKey by remember { mutableStateOf<String?>(null) }
LaunchedEffect(isConnected) {
if (isConnected) {
@@ -52,17 +44,6 @@ internal fun SkillsSettingsScreen(
}
}
selectedSkillKey?.let { skillKey ->
val selectedSkill = skills.firstOrNull { it.skillKey == skillKey }
SkillDetailSettingsScreen(
skill = selectedSkill,
skillKey = skillKey,
isConnected = isConnected,
onBack = { selectedSkillKey = null },
)
return
}
SettingsDetailFrame(
title = "Skills",
subtitle = "Installed capabilities available to OpenClaw.",
@@ -102,117 +83,25 @@ internal fun SkillsSettingsScreen(
Text(text = "Skills installed on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
else -> SkillsPanel(skills = skills, onSkillClick = { selectedSkillKey = it.skillKey })
else -> SkillsPanel(skills = skills)
}
}
}
@Composable
private fun SkillDetailSettingsScreen(
skill: GatewaySkillSummary?,
skillKey: String,
isConnected: Boolean,
onBack: () -> Unit,
) {
BackHandler(onBack = onBack)
SettingsDetailFrame(
title = skill?.name ?: skillKey,
subtitle = "Inspect installed skill capability and setup state.",
icon = Icons.Default.Settings,
onBack = onBack,
) {
skill?.let { summary ->
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Status", skillStatusText(summary)),
SettingsMetric("Source", skillSourceLabel(summary)),
SettingsMetric("Missing", summary.missingCount.toString()),
),
)
SkillSetupPanel(summary)
}
SkillDetailPanel(skill = skill, isConnected = isConnected)
}
}
@Composable
private fun SkillSetupPanel(skill: GatewaySkillSummary) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Setup", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = skillConfigurationText(skill), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
@Composable
private fun SkillDetailPanel(
skill: GatewaySkillSummary?,
isConnected: Boolean,
) {
if (!isConnected) {
ClawPanel {
Text(text = "Connect the gateway to load skill details.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
return
}
if (skill == null) {
ClawPanel {
Text(text = "Skill detail is not available in the current skills status.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
return
}
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Skill Key", skill.skillKey),
SettingsMetric("Display", skill.name),
SettingsMetric("Source", skillSourceLabel(skill)),
SettingsMetric("Install Options", skill.installCount.toString()),
),
)
skill.description?.let { description ->
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Description", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = description, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
}
@Composable
private fun SkillsPanel(
skills: List<GatewaySkillSummary>,
onSkillClick: (GatewaySkillSummary) -> Unit,
) {
private fun SkillsPanel(skills: List<GatewaySkillSummary>) {
ClawListPanel(items = skills) { skill ->
SkillListRow(skill = skill, onClick = { onSkillClick(skill) })
SkillListRow(skill = skill)
}
}
@Composable
private fun SkillListRow(
skill: GatewaySkillSummary,
onClick: () -> Unit,
) {
private fun SkillListRow(skill: GatewaySkillSummary) {
ClawDetailRow(
title = skill.name,
subtitle = skillSubtitle(skill),
modifier = Modifier.clickable(onClickLabel = "Open skill detail", onClick = onClick),
leading = { ClawTextBadge(text = skillBadge(skill)) },
trailing = {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = ClawTheme.colors.textSubtle,
)
}
},
trailing = { ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) },
)
}
@@ -246,15 +135,6 @@ private fun skillSubtitle(skill: GatewaySkillSummary): String {
return listOfNotNull(skill.description, skillSourceLabel(skill), issue).joinToString(" · ")
}
private fun skillConfigurationText(skill: GatewaySkillSummary): String =
when {
skill.disabled -> "This skill is disabled on the gateway. Android shows detail only; enable or configure it from desktop or CLI."
skill.blockedByAllowlist -> "This skill is blocked by the gateway allowlist. Android can inspect it, but allowlist changes stay on desktop or CLI."
skill.missingCount > 0 -> "This skill needs ${skill.missingCount} setup item(s). Android shows what is installed; setup/config changes stay on desktop or CLI."
!skill.eligible -> "This skill is installed but not currently eligible to run. Use desktop or CLI for configuration changes."
else -> "Ready on this gateway. Android detail is read-only; install, update, and configuration changes stay on desktop or CLI."
}
private fun skillSourceLabel(skill: GatewaySkillSummary): String =
when (skill.source) {
"openclaw-bundled" -> if (skill.bundled) "Built-in" else "Bundled"

View File

@@ -1,10 +1,8 @@
package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.VoiceCaptureMode
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
@@ -70,7 +68,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -180,8 +177,8 @@ fun VoiceScreen(
Modifier
.fillMaxSize()
.imePadding()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(9.dp),
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
VoiceHeader(
statusText = voiceAttentionStatus ?: if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.",
@@ -270,12 +267,12 @@ private fun DictationScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = "Dictation", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
Text(text = "Transcribe then send", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
ClawPlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
VoicePlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
}
Surface(
@@ -407,7 +404,7 @@ private fun TalkSessionScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Realtime Talk", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
@@ -426,7 +423,7 @@ private fun TalkSessionScreen(
)
}
}
ClawPlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
VoicePlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
}
Surface(
@@ -550,19 +547,14 @@ private fun VoiceHeader(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
painter = painterResource(id = R.drawable.openclaw_logo),
contentDescription = null,
modifier = Modifier.size(25.dp),
tint = ClawTheme.colors.text,
)
Text(
text = "OpenClaw",
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
text = "O P E N C L A W",
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
)
ClawPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
VoicePlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
VoiceAvatar(text = "OC")
}
Row(
modifier = Modifier.fillMaxWidth(),
@@ -570,7 +562,7 @@ private fun VoiceHeader(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text)
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
Text(
text = statusText,
style = ClawTheme.type.body,
@@ -579,7 +571,7 @@ private fun VoiceHeader(
overflow = TextOverflow.Ellipsis,
)
}
ClawPlainIconButton(
VoicePlainIconButton(
icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
onClick = onToggleSpeaker,
@@ -588,6 +580,34 @@ private fun VoiceHeader(
}
}
@Composable
private fun VoiceAvatar(text: String) {
Surface(
modifier = Modifier.size(34.dp),
shape = CircleShape,
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
}
}
}
@Composable
private fun VoicePlainIconButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun VoiceHero(
gatewayStatus: String,
@@ -841,10 +861,8 @@ private fun VoiceOrb(
Surface(
modifier = Modifier.size(112.dp),
shape = CircleShape,
color = if (active || listening || speaking) Color(0xFF1976D2) else Color(0xFF123B63),
contentColor = Color.White,
tonalElevation = 3.dp,
shadowElevation = 7.dp,
color = if (active) ClawTheme.colors.surfacePressed else ClawTheme.colors.surface,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -857,7 +875,7 @@ private fun VoiceOrb(
},
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = Color.White,
tint = ClawTheme.colors.text,
)
Waveform(active = active)
}
@@ -874,7 +892,7 @@ private fun Waveform(active: Boolean) {
Modifier
.size(width = 2.dp, height = (if (active) height else 6 + index % 3 * 3).dp)
.clip(RoundedCornerShape(999.dp))
.background(if (active) Color.White else Color.White.copy(alpha = 0.52f)),
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
)
}
}

View File

@@ -1,7 +1,6 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatMessageContent
import ai.openclaw.app.chat.ChatPendingToolCall
@@ -40,7 +39,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Mic
@@ -65,7 +63,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -156,11 +153,12 @@ fun ChatScreen(
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
.padding(horizontal = 18.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
ChatHeader(
sessionTitle = currentSessionTitle(sessionKey = sessionKey, sessions = sessions),
thinkingLevel = thinkingLevel,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
onMore = {
@@ -263,11 +261,11 @@ private fun ChatSessionSwitcher(
if (sessions.size > choices.size) {
Surface(
onClick = onOpenSessions,
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
modifier = Modifier.heightIn(min = 36.dp),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f),
color = ClawTheme.colors.canvas,
contentColor = ClawTheme.colors.textMuted,
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.7f)),
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
@@ -290,11 +288,11 @@ private fun ChatSessionChip(
) {
Surface(
onClick = onClick,
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
modifier = Modifier.heightIn(min = 36.dp),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = if (active) ClawTheme.colors.surfacePressed.copy(alpha = 0.9f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f),
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.7f)),
color = if (active) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised,
contentColor = if (active) ClawTheme.colors.primaryText else ClawTheme.colors.text,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.primary else ClawTheme.colors.border),
) {
Text(
text = text,
@@ -309,56 +307,48 @@ private fun ChatSessionChip(
@Composable
private fun ChatHeader(
sessionTitle: String,
thinkingLevel: String,
healthOk: Boolean,
pendingRunCount: Int,
onMore: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(modifier = Modifier.size(ClawTheme.spacing.touchTarget))
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(
painter = painterResource(id = R.drawable.openclaw_logo),
contentDescription = null,
modifier = Modifier.size(25.dp),
tint = ClawTheme.colors.text,
)
Text(
text = "OpenClaw",
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
text = sessionTitle,
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
ModelPill(
text =
when {
pendingRunCount > 0 -> "Working"
healthOk -> "Ready"
else -> "Offline"
healthOk -> "auto"
else -> "offline"
},
status =
when {
pendingRunCount > 0 -> ClawStatus.Warning
healthOk -> ClawStatus.Success
healthOk -> ClawStatus.Neutral
else -> ClawStatus.Danger
},
)
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
}
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Chat", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, maxLines = 1)
Text(
text = sessionTitle,
style = ClawTheme.type.caption.copy(fontSize = 13.sp, lineHeight = 17.sp),
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
}
}
@@ -375,13 +365,7 @@ private fun ModelPill(
}
Surface(
shape = RoundedCornerShape(ClawTheme.radii.pill),
color =
when (status) {
ClawStatus.Success -> ClawTheme.colors.successSoft
ClawStatus.Warning -> ClawTheme.colors.warningSoft
ClawStatus.Danger -> ClawTheme.colors.dangerSoft
ClawStatus.Neutral -> ClawTheme.colors.surfaceRaised
},
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.textMuted,
border = BorderStroke(1.dp, borderColor),
) {
@@ -593,15 +577,13 @@ private fun ChatBubble(
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Surface(
modifier = Modifier.fillMaxWidth(if (isUser) 0.84f else 0.94f),
modifier = Modifier.fillMaxWidth(if (isUser) 0.64f else 0.56f),
shape = RoundedCornerShape(7.dp),
color = if (isUser) ClawTheme.colors.surfacePressed.copy(alpha = 0.86f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.84f),
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.45f)),
tonalElevation = 1.dp,
shadowElevation = 2.dp,
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Column(modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.5.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text =
when {
@@ -782,7 +764,7 @@ private fun ChatContextMeter(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textSubtle)
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
Text(
text = contextMeterLabel(contextUsage, thinkingLevel),
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
@@ -954,7 +936,7 @@ internal fun resolveChatContextUsage(
sessionKey = sessionKey,
mainSessionKey = mainSessionKey,
)
}
}
return ChatContextUsage(
totalTokens = entry?.totalTokens,
totalTokensFresh = entry?.totalTokensFresh,
@@ -991,6 +973,24 @@ private fun userFacingChatError(error: String): String {
}
}
/** Normalizes persisted thinking values into compact UI labels. */
private fun thinkingDisplay(value: String): String =
when (value.lowercase(Locale.US)) {
"low" -> "Low"
"medium" -> "Medium"
"high" -> "High"
else -> "Off"
}
/** Converts displayed thinking labels back to gateway request values. */
private fun thinkingValue(display: String): String =
when (display.lowercase(Locale.US)) {
"low" -> "low"
"medium" -> "medium"
"high" -> "high"
else -> "off"
}
/** Cycles through context budget presets from the compact composer control. */
private fun nextThinkingValue(value: String): String =
when (value.lowercase(Locale.US)) {

View File

@@ -185,53 +185,6 @@ internal fun ClawIconButton(
}
}
/** Transparent circular icon button for low-emphasis toolbar actions. */
@Composable
internal fun ClawPlainIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
shape = CircleShape,
color = Color.Transparent,
contentColor = ClawTheme.colors.text,
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
/** Compact label/value row for health and readiness summaries. */
@Composable
internal fun ClawStatusRow(
title: String,
value: String,
healthy: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Text(
text = title,
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
maxLines = 1,
)
ClawStatusPill(
text = value,
status = if (healthy) ClawStatus.Success else ClawStatus.Warning,
)
}
}
/** Compact status chip with a semantic color dot. */
@Composable
internal fun ClawStatusPill(

View File

@@ -95,17 +95,15 @@ internal fun ClawBottomNav(
Box(modifier = modifier.fillMaxWidth().background(ClawTheme.colors.canvas)) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = ClawTheme.colors.surface.copy(alpha = 0.92f),
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.42f)),
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
border = BorderStroke(1.dp, ClawTheme.colors.border),
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
tonalElevation = 2.dp,
shadowElevation = 8.dp,
) {
Row(
modifier =
Modifier
.windowInsetsPadding(safeInsets)
.padding(horizontal = 8.dp, vertical = 6.dp),
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -133,13 +131,13 @@ private fun ClawBottomNavItem(
onClick = onClick,
modifier = modifier.heightIn(min = 48.dp),
shape = RoundedCornerShape(ClawTheme.radii.control),
color = if (selected) ClawTheme.colors.surfacePressed.copy(alpha = 0.72f) else Color.Transparent,
contentColor = if (selected) ClawTheme.colors.text else ClawTheme.colors.textMuted,
color = if (selected) ClawTheme.colors.primary else Color.Transparent,
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
) {
Column(
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(imageVector = item.icon, contentDescription = item.label, modifier = Modifier.size(18.dp))
Text(text = item.label, style = ClawTheme.type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)

View File

@@ -27,11 +27,31 @@ internal fun ClawPanel(
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.82f),
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = null,
tonalElevation = 2.dp,
shadowElevation = 4.dp,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(contentPadding)) {
content()
}
}
}
/**
* Bottom-sheet container with the app surface treatment and top-only rounding.
*/
@Composable
internal fun ClawSheetSurface(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(18.dp),
content: @Composable () -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
color = ClawTheme.colors.surface,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(contentPadding)) {
content()

View File

@@ -4,6 +4,7 @@ import ai.openclaw.app.ui.LocalMobileColors
import ai.openclaw.app.ui.darkMobileColors
import ai.openclaw.app.ui.lightMobileColors
import ai.openclaw.app.ui.mobileFontFamily
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
@@ -189,6 +190,12 @@ internal fun ClawDesignTheme(
}
}
/**
* Returns the system dark-mode preference for callers that expose theme selection.
*/
@Composable
internal fun rememberClawDarkPreference(): Boolean = isSystemInDarkTheme()
private fun clawTypography(fontFamily: FontFamily) =
ClawTypography(
display =

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.ChatSendAck
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
@@ -44,7 +43,7 @@ data class VoiceConversationEntry(
)
/** Coordinates live mic transcription, queued sends, and assistant audio replies. */
internal class MicCaptureManager(
class MicCaptureManager(
private val context: Context,
private val scope: CoroutineScope,
private val createTranscriptionSession: suspend () -> String,
@@ -55,12 +54,11 @@ internal class MicCaptureManager(
) -> Unit,
private val closeTranscriptionSession: suspend (sessionId: String) -> Unit,
/**
* Send [message] to the gateway and return the full chat.send ACK.
* Send [message] to the gateway and return the run ID.
* [onRunIdKnown] is called with the idempotency key *before* the network
* round-trip so [pendingRunId] is set before any chat events can arrive.
*/
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> ChatSendAck,
private val refreshAfterTerminalSuccess: suspend () -> Unit = {},
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> String?,
private val speakAssistantReply: suspend (String) -> Unit = {},
) {
companion object {
@@ -485,30 +483,24 @@ internal class MicCaptureManager(
scope.launch {
try {
val ack =
val runId =
sendToGateway(next) { earlyRunId ->
// Called with the idempotency key before chat.send fires so that
// pendingRunId is populated before any chat events can arrive.
pendingRunId = earlyRunId
}
val runId = ack.runId
// Update to the real runId if the gateway returned a different one.
if (runId != null && runId != pendingRunId) pendingRunId = runId
when {
ack.isTerminalSuccess -> {
completePendingTurn()
refreshAfterTerminalSuccess()
}
ack.isTerminalFailure -> {
completePendingTurn()
_statusText.value = "Send failed: Chat failed before the run started; try again."
}
runId == null -> {
completePendingTurn()
}
else -> {
armPendingRunTimeout(runId)
}
if (runId == null) {
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
removeFirstQueuedMessage()
publishQueue()
_isSending.value = false
pendingAssistantEntryId = null
sendQueuedIfIdle()
} else {
armPendingRunTimeout(runId)
}
} catch (err: Throwable) {
pendingRunTimeoutJob?.cancel()

View File

@@ -1,9 +1,6 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.ChatSendAck
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.chatSendAckHistorySinceSeconds
import ai.openclaw.app.gateway.parseChatSendAck
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
@@ -111,6 +108,7 @@ class TalkModeManager internal constructor(
private const val tag = "TalkMode"
private const val realtimeSampleRateHz = 24_000
private const val realtimeAudioFrameMs = 100
private const val listenWatchdogMs = 12_000L
private const val chatFinalWaitMs = 45_000L
private const val maxCachedRunCompletions = 128
private const val maxConversationEntries = 40
@@ -383,20 +381,11 @@ class TalkModeManager internal constructor(
reloadConfig()
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
val prompt = buildPrompt(command)
val ack = sendChat(prompt, session)
val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id")
if (ack.isTerminalFailure) {
_statusText.value = if (ack.normalizedStatus == "error") "Chat error" else "Aborted"
return@launch
}
val ok = if (ack.isTerminalSuccess) true else waitForChatFinal(runId)
val runId = sendChat(prompt, session)
val ok = waitForChatFinal(runId)
val assistant =
consumeRunText(runId)
?: waitForAssistantText(
session,
chatSendAckHistorySinceSeconds(ack, startedAt),
if (ok) 12_000 else 25_000,
)
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
if (!assistant.isNullOrBlank()) {
val playbackToken = playbackGeneration.incrementAndGet()
cancelActivePlayback()
@@ -409,9 +398,8 @@ class TalkModeManager internal constructor(
}
} catch (err: Throwable) {
Log.w(tag, "speakWakeCommand failed: ${err.message}")
} finally {
onComplete()
}
onComplete()
}
}
@@ -1616,26 +1604,16 @@ class TalkModeManager internal constructor(
try {
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}")
val ack = sendChat(prompt, session)
val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id")
Log.d(tag, "chat.send ok runId=$runId status=${ack.status}")
if (ack.isTerminalFailure) {
_statusText.value = if (ack.normalizedStatus == "error") "Chat error" else "Aborted"
start()
return
}
val ok = if (ack.isTerminalSuccess) true else waitForChatFinal(runId)
val runId = sendChat(prompt, session)
Log.d(tag, "chat.send ok runId=$runId")
val ok = waitForChatFinal(runId)
if (!ok) {
Log.w(tag, "chat final timeout runId=$runId; attempting history fallback")
}
// Use text cached from the final event first — avoids chat.history polling
val assistant =
consumeRunText(runId)
?: waitForAssistantText(
session,
chatSendAckHistorySinceSeconds(ack, startedAt),
if (ok) 12_000 else 25_000,
)
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
if (assistant.isNullOrBlank()) {
_statusText.value = "No reply"
Log.w(tag, "assistant text timeout runId=$runId")
@@ -1701,7 +1679,7 @@ class TalkModeManager internal constructor(
private suspend fun sendChat(
message: String,
session: GatewaySession,
): ChatSendAck {
): String {
val runId = UUID.randomUUID().toString()
armPendingRun(runId)
val params =
@@ -1714,15 +1692,11 @@ class TalkModeManager internal constructor(
}
try {
val res = session.request("chat.send", params.toString())
val parsed = parseChatSendAck(json, res)
val actualRunId = parsed.runId ?: runId
if (actualRunId != runId) {
pendingRunId = actualRunId
val parsed = parseRunId(res) ?: runId
if (parsed != runId) {
pendingRunId = parsed
}
if (parsed.isTerminal) {
clearPendingRun(actualRunId)
}
return parsed.copy(runId = actualRunId)
return parsed
} catch (err: Throwable) {
clearPendingRun(runId)
throw err
@@ -1803,7 +1777,7 @@ class TalkModeManager internal constructor(
private suspend fun waitForAssistantText(
session: GatewaySession,
sinceSeconds: Double?,
sinceSeconds: Double,
timeoutMs: Long,
): String? {
val deadline = SystemClock.elapsedRealtime() + timeoutMs

View File

@@ -3,11 +3,13 @@
<uses-permission
android:name="android.permission.READ_MEDIA_IMAGES"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_MEDIA_VIDEO"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:node="remove" />
</manifest>

View File

@@ -33,44 +33,44 @@ class GatewayBootstrapAuthTest {
@Test
fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() {
assertFalse(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
storedOperatorToken = "",
) != null,
),
)
assertFalse(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
) != null,
),
)
}
@Test
fun connectsOperatorSessionWhenSharedPasswordOrStoredAuthExists() {
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
) != null,
),
)
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = "shared-password"),
storedOperatorToken = null,
) != null,
),
)
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = "stored-token",
) != null,
),
)
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null),
storedOperatorToken = null,
) != null,
),
)
}

View File

@@ -1,101 +0,0 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GatewayExecApprovalParsingTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun parsesGatewayExecApprovalListPayload() {
val rows =
parseGatewayExecApprovalListPayload(
"""
[
{
"id": "approval-2",
"createdAtMs": 20,
"expiresAtMs": 120,
"request": {
"host": "node",
"nodeId": "node-1",
"agentId": "agent-1",
"command": "Sanitized command",
"commandPreview": "Sanitized preview",
"systemRunPlan": {
"commandText": "/bin/sh -lc 'echo secret'",
"commandPreview": "echo secret"
},
"allowedDecisions": ["allow-once", "deny"]
}
},
{
"id": "approval-1",
"createdAtMs": 10,
"expiresAtMs": 110,
"request": {
"host": "gateway",
"command": "pnpm test --token secret",
"commandPreview": "pnpm test",
"unavailableDecisions": ["allow-always"]
}
}
]
""".trimIndent(),
json,
)
assertEquals(listOf("approval-1", "approval-2"), rows.map { it.id })
assertEquals("pnpm test --token secret", rows[0].commandText)
assertEquals("pnpm test", rows[0].commandPreview)
assertEquals(emptyList<String>(), rows[0].allowedDecisions)
assertEquals("Sanitized command", rows[1].commandText)
assertEquals("Sanitized preview", rows[1].commandPreview)
assertEquals("node-1", rows[1].nodeId)
assertEquals("agent-1", rows[1].agentId)
}
@Test
fun parsesGatewayExecApprovalGetPayload() {
val root =
json
.parseToJsonElement(
"""
{
"id": "approval-1",
"commandText": "rm -rf build",
"commandPreview": "rm build",
"allowedDecisions": ["allow-once", "allow-always", "deny"],
"host": "gateway",
"nodeId": null,
"agentId": "agent-main",
"expiresAtMs": 200
}
""".trimIndent(),
).asObjectOrNull()
requireNotNull(root)
val row = parseGatewayExecApprovalDetail(root, createdAtMs = 100)
requireNotNull(row)
assertEquals("approval-1", row.id)
assertEquals("rm -rf build", row.commandText)
assertEquals("rm build", row.commandPreview)
assertEquals(listOf("allow-once", "allow-always", "deny"), row.allowedDecisions)
assertEquals("gateway", row.host)
assertNull(row.nodeId)
assertEquals("agent-main", row.agentId)
assertEquals(100L, row.createdAtMs)
assertEquals(200L, row.expiresAtMs)
}
@Test
fun ignoresMalformedGatewayExecApprovalListPayload() {
assertTrue(parseGatewayExecApprovalListPayload("""{"approvals":[]}""", json).isEmpty())
assertTrue(parseGatewayExecApprovalListPayload("not json", json).isEmpty())
}
}

View File

@@ -1,46 +0,0 @@
package ai.openclaw.app
import org.junit.Assert.assertEquals
import org.junit.Test
class GatewayLogTextTest {
@Test
fun sanitizeGatewayLogTextRemovesAnsiSgrSequences() {
assertEquals(
"hindsight: Skipping retain",
sanitizeGatewayLogText("\u001B[38;5;103mhindsight:\u001B[0m Skipping retain"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesVisibleSgrFragments() {
assertEquals(
"hindsight: Skipping retain",
sanitizeGatewayLogText("[38;5;103mhindsight:[0m Skipping retain"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesSingleParameterVisibleSgrFragments() {
assertEquals(
"error and bold",
sanitizeGatewayLogText("[31merror[0m and [1mbold[0m"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesJsonEscapedAnsiSgrSequences() {
assertEquals(
"""{"1":"hindsight: Skipping retain"}""",
sanitizeGatewayLogText("""{"1":"\u001b[38;5;103mhindsight:\u001b[0m Skipping retain"}"""),
)
}
@Test
fun sanitizeGatewayLogTextKeepsPlainBracketedText() {
assertEquals(
"cache ttl [5m] expired",
sanitizeGatewayLogText("cache ttl [5m] expired"),
)
}
}

View File

@@ -1,144 +0,0 @@
package ai.openclaw.app.chat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatControllerTerminalAckTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalTimeoutAckRemovesOptimisticUserEchoAndSurfacesFailedAcceptance() =
runTest {
var requestedMethod: String? = null
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
requestedMethod = method
"""{"runId":"run-timeout","status":"timeout"}"""
},
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that times out before start",
thinkingLevel = "off",
attachments = emptyList(),
)
assertFalse(accepted)
assertEquals("chat.send", requestedMethod)
assertEquals(0, controller.pendingRunCount.value)
assertEquals("Chat failed before the run started; try again.", controller.errorText.value)
assertFalse(controller.messages.value.hasUserText("message that times out before start"))
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun nonTerminalStartedAckRetainsOptimisticUserEchoAndPendingRun() =
runTest {
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { _, _ -> """{"runId":"run-started","status":"started"}""" },
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that started",
thinkingLevel = "off",
attachments = emptyList(),
)
assertTrue(accepted)
assertEquals(1, controller.pendingRunCount.value)
assertNull(controller.errorText.value)
assertTrue(controller.messages.value.hasUserText("message that started"))
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalOkAckClearsOptimisticUserEchoAndRefreshesHistory() =
runTest {
val requestedMethods = mutableListOf<String>()
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
requestedMethods += method
when (method) {
"chat.send" -> """{"runId":"run-ok","status":"ok"}"""
"chat.history" ->
"""
{
"sessionId": "session-1",
"messages": [
{ "role": "assistant", "content": "cached success reply", "timestamp": 1 }
]
}
""".trimIndent()
else -> "{}"
}
},
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that already completed",
thinkingLevel = "off",
attachments = emptyList(),
)
advanceUntilIdle()
assertTrue(accepted)
assertEquals(listOf("chat.send", "chat.history"), requestedMethods)
assertEquals(0, controller.pendingRunCount.value)
assertNull(controller.errorText.value)
assertFalse(controller.messages.value.hasUserText("message that already completed"))
assertTrue(controller.messages.value.any { message -> message.role == "assistant" && message.content.any { part -> part.text == "cached success reply" } })
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalErrorAckRemovesOptimisticUserEchoAndSurfacesErrorText() =
runTest {
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { _, _ -> """{"runId":"run-error","status":"error"}""" },
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that errors before start",
thinkingLevel = "off",
attachments = emptyList(),
)
assertFalse(accepted)
assertEquals(0, controller.pendingRunCount.value)
assertEquals("Chat failed before the run started; try again.", controller.errorText.value)
assertFalse(controller.messages.value.hasUserText("message that errors before start"))
}
private fun List<ChatMessage>.hasUserText(text: String): Boolean =
any { message ->
message.role == "user" && message.content.any { part -> part.text == text }
}
}

View File

@@ -1,68 +0,0 @@
package ai.openclaw.app.gateway
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatSendAckTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun parseChatSendAckPreservesNonTerminalStartedStatus() {
val ack = parseChatSendAck(json, """{"runId":"run-1","status":"started"}""")
assertEquals("run-1", ack.runId)
assertEquals("started", ack.normalizedStatus)
assertFalse(ack.isTerminal)
}
@Test
fun parseChatSendAckMarksOkAsTerminalSuccess() {
val ack = parseChatSendAck(json, """{"runId":"run-ok","status":" ok "}""")
assertEquals("run-ok", ack.runId)
assertEquals("ok", ack.normalizedStatus)
assertTrue(ack.isTerminal)
assertTrue(ack.isTerminalSuccess)
assertFalse(ack.isTerminalFailure)
}
@Test
fun parseChatSendAckMarksTimeoutAndErrorAsTerminalFailures() {
val timeout = parseChatSendAck(json, """{"runId":"run-timeout","status":"timeout"}""")
val error = parseChatSendAck(json, """{"runId":"run-error","status":" error "}""")
assertEquals("run-timeout", timeout.runId)
assertTrue(timeout.isTerminal)
assertFalse(timeout.isTerminalSuccess)
assertTrue(timeout.isTerminalFailure)
assertEquals("run-error", error.runId)
assertTrue(error.isTerminal)
assertFalse(error.isTerminalSuccess)
assertTrue(error.isTerminalFailure)
}
@Test
fun cachedOkAckUsesUnfilteredHistoryFallback() {
val startedAt = 123.0
val ok = parseChatSendAck(json, """{"runId":"run-ok","status":"ok"}""")
val started = parseChatSendAck(json, """{"runId":"run-started","status":"started"}""")
assertNull(chatSendAckHistorySinceSeconds(ok, startedAt))
assertEquals(startedAt, chatSendAckHistorySinceSeconds(started, startedAt) ?: -1.0, 0.0)
}
@Test
fun parseChatSendAckToleratesMalformedPayloads() {
val ack = parseChatSendAck(json, "not-json")
assertNull(ack.runId)
assertEquals("", ack.normalizedStatus)
assertFalse(ack.isTerminal)
assertFalse(ack.isTerminalSuccess)
assertFalse(ack.isTerminalFailure)
}
}

View File

@@ -204,18 +204,17 @@ class GatewayConfigResolverTest {
}
@Test
fun resolveScannedSetupCodeResultAcceptsRawSetupCode() {
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCodeResult(setupCode)
val resolved = resolveScannedSetupCode(setupCode)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
assertEquals(setupCode, resolved)
}
@Test
fun resolveScannedSetupCodeResultAcceptsQrJsonPayload() {
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val qrJson =
@@ -228,55 +227,49 @@ class GatewayConfigResolverTest {
}
""".trimIndent()
val resolved = resolveScannedSetupCodeResult(qrJson)
val resolved = resolveScannedSetupCode(qrJson)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
assertEquals(setupCode, resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsInvalidInput() {
val resolved = resolveScannedSetupCodeResult("not-a-valid-setup-code")
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
fun resolveScannedSetupCodeRejectsInvalidInput() {
val resolved = resolveScannedSetupCode("not-a-valid-setup-code")
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsJsonWithInvalidSetupCode() {
fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() {
val qrJson = """{"setupCode":"invalid"}"""
val resolved = resolveScannedSetupCodeResult(qrJson)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
val resolved = resolveScannedSetupCode(qrJson)
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsJsonWithNonStringSetupCode() {
fun resolveScannedSetupCodeRejectsJsonWithNonStringSetupCode() {
val qrJson = """{"setupCode":{"nested":"value"}}"""
val resolved = resolveScannedSetupCodeResult(qrJson)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
val resolved = resolveScannedSetupCode(qrJson)
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsNonLoopbackCleartextGateway() {
fun resolveScannedSetupCodeRejectsNonLoopbackCleartextGateway() {
val setupCode =
encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCodeResult(setupCode)
val resolved = resolveScannedSetupCode(setupCode)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, resolved.error)
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultAcceptsPrivateLanCleartextGateway() {
fun resolveScannedSetupCodeAcceptsPrivateLanCleartextGateway() {
val setupCode =
encodeSetupCode("""{"url":"ws://192.168.31.100:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCodeResult(setupCode)
val resolved = resolveScannedSetupCode(setupCode)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
assertEquals(setupCode, resolved)
}
@Test

View File

@@ -1,14 +1,12 @@
package ai.openclaw.app.ui
import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayChannelSummary
import ai.openclaw.app.GatewayChannelsSummary
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPendingDeviceSummary
import ai.openclaw.app.ui.design.ClawStatus
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import org.junit.Assert.assertEquals
@@ -107,7 +105,7 @@ class ShellScreenLogicTest {
assertEquals(listOf("Approvals", "Channels", "Nodes & Devices", "Providers"), rows.map { it.title })
val providersRow = rows.single { it.title == "Providers" }
assertEquals(Tab.Settings, providersRow.tab)
assertEquals(SettingsRoute.ProvidersModels, providersRow.settingsRoute)
assertEquals(SettingsRoute.Gateway, providersRow.settingsRoute)
}
@Test
@@ -159,206 +157,10 @@ class ShellScreenLogicTest {
assertEquals("Node approval pending", rows.single().subtitle)
}
@Test
fun overviewHeaderStateReflectsGatewayConnectionAndAttention() {
assertEquals(OverviewHeaderState("Offline", ClawStatus.Neutral), overviewHeaderState(isConnected = false, hasAttention = true))
assertEquals(OverviewHeaderState("Needs attention", ClawStatus.Warning), overviewHeaderState(isConnected = true, hasAttention = true))
assertEquals(OverviewHeaderState("Online", ClawStatus.Success), overviewHeaderState(isConnected = true, hasAttention = false))
}
@Test
fun overviewHeaderRouteUsesFirstAttentionDestination() {
assertEquals(SettingsRoute.Gateway, overviewHeaderRoute(emptyList()))
assertEquals(
SettingsRoute.Approvals,
overviewHeaderRoute(
listOf(
HomeAttentionRow("Approvals", "2 pending", Icons.Default.Settings, Tab.Settings, SettingsRoute.Approvals),
HomeAttentionRow("Nodes & Devices", "Review node access", Icons.Default.Settings, Tab.Settings, SettingsRoute.NodesDevices),
),
),
)
}
@Test
fun overviewMetricCardsUseRealGatewayNodeApprovalAndSessionCounts() {
val cards =
overviewMetricCardSpecs(
isConnected = true,
hasAttention = true,
nodesDevicesSummary =
GatewayNodesDevicesSummary(
nodes =
listOf(
GatewayNodeSummary(
id = "android-node",
displayName = "Android",
remoteIp = null,
version = null,
deviceFamily = "Android",
paired = true,
connected = true,
approvalState = GatewayNodeApprovalState.PendingReapproval,
pendingRequestId = "node-request",
capabilities = emptyList(),
commands = emptyList(),
),
),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
),
pendingApprovals = 2,
sessionCount = 4,
)
assertEquals(listOf("Gateway", "Nodes", "Approvals", "Sessions"), cards.map { it.title })
assertEquals("Online", cards.single { it.title == "Gateway" }.value)
assertEquals("Review highlighted items", cards.single { it.title == "Gateway" }.subtitle)
assertEquals("1/1", cards.single { it.title == "Nodes" }.value)
assertEquals("Review node access", cards.single { it.title == "Nodes" }.subtitle)
assertEquals(ClawStatus.Warning, cards.single { it.title == "Nodes" }.status)
assertEquals(1f, cards.single { it.title == "Nodes" }.progressFraction ?: 0f, 0.001f)
assertEquals("2", cards.single { it.title == "Approvals" }.value)
assertEquals("4", cards.single { it.title == "Sessions" }.value)
}
@Test
fun overviewNodeCardShowsRoundedOnlinePercentWhenNoNodeApprovalIsPending() {
val cards =
overviewMetricCardSpecs(
isConnected = true,
hasAttention = false,
nodesDevicesSummary =
GatewayNodesDevicesSummary(
nodes =
(1..3).map { index ->
GatewayNodeSummary(
id = "node-$index",
displayName = "Node $index",
remoteIp = null,
version = null,
deviceFamily = null,
paired = true,
connected = index <= 2,
approvalState = GatewayNodeApprovalState.Approved,
pendingRequestId = null,
capabilities = emptyList(),
commands = emptyList(),
)
},
pendingDevices = emptyList(),
pairedDevices = emptyList(),
),
pendingApprovals = 0,
sessionCount = 0,
)
val nodes = cards.single { it.title == "Nodes" }
assertEquals("2/3", nodes.value)
assertEquals("67% online", nodes.subtitle)
assertEquals(2f / 3f, nodes.progressFraction ?: 0f, 0.001f)
}
@Test
fun overviewGatewayCardOnlyClaimsNominalWhenNoAttentionExists() {
val cards =
overviewMetricCardSpecs(
isConnected = true,
hasAttention = false,
nodesDevicesSummary = emptyNodesDevices(),
pendingApprovals = 0,
sessionCount = 0,
)
val gateway = cards.single { it.title == "Gateway" }
assertEquals("Healthy", gateway.value)
assertEquals("All systems nominal", gateway.subtitle)
assertEquals(ClawStatus.Success, gateway.status)
}
@Test
fun overviewAgentNameUsesDefaultAgentWhenPresent() {
val agents =
listOf(
GatewayAgentSummary(id = "main", name = "Main", emoji = null),
GatewayAgentSummary(id = "scout", name = "Scout", emoji = "🦾"),
)
assertEquals("Scout", overviewAgentName(agents = agents, defaultAgentId = "scout"))
assertEquals("Main", overviewAgentName(agents = agents, defaultAgentId = null))
assertEquals("OpenClaw", overviewAgentName(agents = emptyList(), defaultAgentId = null))
}
@Test
fun overviewAgentBadgeUsesEmojiBeforeInitials() {
val agents =
listOf(
GatewayAgentSummary(id = "main", name = "Main Agent", emoji = null),
GatewayAgentSummary(id = "scout", name = "Scout", emoji = "🦾"),
)
assertEquals("🦾", overviewAgentBadgeText(agents = agents, defaultAgentId = "scout"))
assertEquals("MA", overviewAgentBadgeText(agents = agents, defaultAgentId = "main"))
assertEquals("OC", overviewAgentBadgeText(agents = emptyList(), defaultAgentId = null))
}
@Test
fun overviewAgentActivityTextUsesRealRuntimeCounts() {
assertEquals(
"Working · 2 active runs",
overviewAgentActivityText(isConnected = true, pendingRunCount = 2, sessionCount = 50, cronJobCount = 19, statusText = "Online and ready"),
)
assertEquals(
"Monitoring · 50 sessions",
overviewAgentActivityText(isConnected = true, pendingRunCount = 0, sessionCount = 50, cronJobCount = 19, statusText = "Online and ready"),
)
assertEquals(
"Gateway offline",
overviewAgentActivityText(isConnected = false, pendingRunCount = 0, sessionCount = 50, cronJobCount = 19, statusText = "Gateway offline"),
)
}
@Test
fun sessionSourceLabelDerivesCompactSourceFromRealSessionKey() {
assertEquals("Telegram", sessionSourceLabel("telegram:8227096397"))
assertEquals("Discord", sessionSourceLabel("discord:1465779285020381361#daily-inf"))
assertEquals("Cron", sessionSourceLabel("Cron: nightly-reflection"))
assertEquals("Telegram", sessionSourceLabel("agent:main:telegram:direct:584667058"))
assertEquals("Discord", sessionSourceLabel("agent:main:discord:channel:1001"))
assertEquals("Slack", sessionSourceLabel("agent:main:slack:channel:C123"))
assertEquals("OpenClaw", sessionSourceLabel("agent:main:node-android"))
assertEquals("OpenClaw", sessionSourceLabel("agent:main:main"))
assertEquals("OpenClaw", sessionSourceLabel("Daily standup"))
}
@Test
fun sessionSourceLabelUsesGatewayChannelLabelsForFutureSources() {
val channels =
GatewayChannelsSummary(
channels =
listOf(
GatewayChannelSummary(
id = "matrix",
label = "Matrix",
accountCount = 1,
enabled = true,
configured = true,
linked = true,
running = true,
connected = true,
error = null,
),
),
)
assertEquals("Matrix", sessionSourceLabel("agent:main:matrix:room:abc", channels))
}
@Test
fun settingsSectionTitlesGroupPowerSettingsByMeaning() {
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.Gateway))
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.NodesDevices))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.ProvidersModels))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.Approvals))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.CronJobs))
assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.PhoneCapabilities))

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.ChatSendAck
import android.Manifest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
@@ -35,7 +34,7 @@ class MicCaptureManagerTest {
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-1")
ChatSendAck(runId = "run-1", status = "started")
null
},
)
@@ -85,7 +84,7 @@ class MicCaptureManagerTest {
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-1")
ChatSendAck(runId = "run-1", status = "started")
"run-1"
},
)
@@ -112,7 +111,7 @@ class MicCaptureManagerTest {
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-voice-e2e")
ChatSendAck(runId = "run-voice-e2e", status = "started")
"run-voice-e2e"
},
)
@@ -135,88 +134,6 @@ class MicCaptureManagerTest {
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalGatewayTimeoutSendDoesNotAcceptDelayedOldRunEvents() =
runTest {
val manager =
createManager(
scope = this,
sendToGateway = { _, onRunIdKnown ->
onRunIdKnown("run-terminal")
ChatSendAck(runId = "run-terminal", status = "timeout")
},
)
manager.onGatewayConnectionChanged(true)
manager.submitTranscribedMessage("terminal ack message")
runCurrent()
assertNull(privateField<String?>(manager, "pendingRunId"))
assertEquals(false, manager.isSending.value)
assertEquals("Send failed: Chat failed before the run started; try again.", manager.statusText.value)
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-terminal", text = "stale reply"))
advanceUntilIdle()
assertEquals(
listOf(VoiceConversationRole.User),
manager.conversation.value.map { it.role },
)
assertEquals(
"terminal ack message",
manager.conversation.value
.single()
.text,
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalGatewayErrorSurfacesFailureWithoutWaitingForRunEvents() =
runTest {
val manager =
createManager(
scope = this,
sendToGateway = { _, onRunIdKnown ->
onRunIdKnown("run-error")
ChatSendAck(runId = "run-error", status = "error")
},
)
manager.onGatewayConnectionChanged(true)
manager.submitTranscribedMessage("terminal error message")
runCurrent()
assertNull(privateField<String?>(manager, "pendingRunId"))
assertEquals(false, manager.isSending.value)
assertEquals("Send failed: Chat failed before the run started; try again.", manager.statusText.value)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalGatewayOkRefreshesHistoryWithoutWaitingForRunEvents() =
runTest {
var refreshCalls = 0
val manager =
createManager(
scope = this,
sendToGateway = { _, onRunIdKnown ->
onRunIdKnown("run-ok")
ChatSendAck(runId = "run-ok", status = "ok")
},
refreshAfterTerminalSuccess = { refreshCalls += 1 },
)
manager.onGatewayConnectionChanged(true)
manager.submitTranscribedMessage("terminal ok message")
runCurrent()
assertNull(privateField<String?>(manager, "pendingRunId"))
assertEquals(false, manager.isSending.value)
assertEquals(1, refreshCalls)
}
@Test
fun pcm16FramesAreEncodedAsPcmuFrames() {
val manager = createManager()
@@ -313,11 +230,10 @@ class MicCaptureManagerTest {
scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
createTranscriptionSession: suspend () -> String = { "transcription-1" },
closeTranscriptionSession: suspend (String) -> Unit = { _ -> },
sendToGateway: suspend (String, (String) -> Unit) -> ChatSendAck = { _, onRunIdKnown ->
sendToGateway: suspend (String, (String) -> Unit) -> String? = { _, onRunIdKnown ->
onRunIdKnown("run-1")
ChatSendAck(runId = "run-1", status = "started")
"run-1"
},
refreshAfterTerminalSuccess: suspend () -> Unit = {},
): MicCaptureManager =
MicCaptureManager(
context =
@@ -329,7 +245,6 @@ class MicCaptureManagerTest {
appendTranscriptionAudio = { _, _, _ -> },
closeTranscriptionSession = closeTranscriptionSession,
sendToGateway = sendToGateway,
refreshAfterTerminalSuccess = refreshAfterTerminalSuccess,
)
private fun setPrivateField(

View File

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

View File

@@ -1,3 +0,0 @@
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.

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

@@ -4,7 +4,7 @@ androidx-activity = "1.13.0"
androidx-benchmark = "1.4.1"
androidx-camera = "1.6.0"
androidx-compose-bom = "2026.05.01"
androidx-core = "1.18.0"
androidx-core = "1.19.0"
androidx-exifinterface = "1.4.2"
androidx-lifecycle = "2.10.0"
androidx-security = "1.1.0"

View File

@@ -164,7 +164,7 @@ run_mode() {
no_connect_flag=false
fi
adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 \
adb shell am broadcast \
-a "$RUN_ACTION" \
-n "$RECEIVER" \
--es mode "$test_mode" \
@@ -224,7 +224,7 @@ adb logcat -d -v time |
tail -250 >"$ARTIFACT_DIR/logcat.txt" || true
if [[ "$CLEANUP" -eq 1 ]]; then
adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
adb shell am broadcast -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
fi
echo "$ARTIFACT_DIR"

View File

@@ -1,4 +1,4 @@
{
"version": "2026.6.10",
"versionCode": 2026061001
"version": "2026.6.2",
"versionCode": 2026060201
}

View File

@@ -2,24 +2,8 @@ parent_config: ../../config/swiftlint.yml
included:
- Sources
- ShareExtension
- ActivityWidget
- WatchApp
- ../shared/OpenClawKit/Sources/OpenClawChatUI
excluded:
- ../macos
- ../shared/ClawdisNodeKit/Sources
type_body_length:
warning: 900
error: 1300
custom_rules:
openclaw_design_colors:
name: "OpenClaw design colors"
excluded:
- Sources/Design/OpenClawBrand.swift
- ../shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift
regex: '(Color\.accentColor|(^|[^A-Za-z0-9_])\.accentColor\b|Color\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|\.(foregroundStyle|tint|fill|stroke|strokeBorder|background)\(\s*\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|Color\(red:\s*0\s*/\s*255\.0,\s*green:\s*122\s*/\s*255\.0,\s*blue:\s*255\s*/\s*255\.0\))'
message: "Use OpenClawBrand or OpenClawChatTheme design tokens instead of raw accent/status colors."
severity: error

View File

@@ -1,185 +0,0 @@
# App Review Notes
Use these steps to exercise the live OpenClaw iOS App Review Gateway.
## Demo Account / Setup
Use the OpenClaw iOS app with the live review Gateway setup code included in
the `Notes` field of this App Review submission.
The setup code is a single generated code string. It already contains the public
Gateway host and setup credential.
## Setup Walkthrough
1. Open the OpenClaw app.
2. Tap `Continue`.
3. On `Connect Gateway`, tap `Set Up Manually`.
4. In the `Setup Code` section, tap the `Paste setup code` field.
5. Paste the setup code string from the App Review submission `Notes` field.
6. Tap `Apply Setup Code`.
7. If `Trust and connect` appears, tap `Trust and connect`.
8. Wait for the `Connected` screen.
9. On `Connected`, tap `Open OpenClaw`.
10. Confirm the `Control` screen shows `Gateway Online`.
11. Tap `Settings`.
12. Tap `Approvals`.
13. Tap `Open Notifications`.
14. Tap `Enable Notifications`.
15. On `Enable OpenClaw Hosted Push Relay?`, tap `Continue`.
16. If iOS asks whether OpenClaw may send notifications, tap `Allow`.
17. Confirm `Notifications` shows `Enabled`.
## Chat
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Start Apple review checklist.
```
Expected result: the assistant replies with the available App Review demos.
## Approval Demo
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Run the approval demo.
```
Expected result: the iPhone shows `Exec approval required` with the harmless
command `printf 'OpenClaw App Review approval demo complete\n'`. Tap
`Allow Once`. The chat then replies:
```text
The approval demo completed.
```
## Talk
1. Tap the `Talk` tab.
2. Tap `Start Talk`.
3. If iOS asks for microphone access, tap `Allow`.
4. If iOS asks for Speech Recognition access, tap `Allow`.
5. Confirm the screen changes to `Ready to talk` and shows `Stop Talk`.
6. Say:
```text
Summarize this review setup in one sentence.
```
Expected result: the assistant responds by voice. Tap `Stop Talk` when done.
## Talk + Background Audio
1. Tap the `Talk` tab.
2. Confirm `Speakerphone` is on.
3. Confirm `Background listening` is on.
4. Tap `Start Talk`.
5. If iOS asks for microphone access, tap `Allow`.
6. If iOS asks for Speech Recognition access, tap `Allow`.
7. Confirm `Stop Talk` is visible.
8. Say:
```text
Tell me when you can hear me.
```
9. While Talk is active, send OpenClaw to the background by returning to the
Home Screen or locking the iPhone. Do not force quit the app.
10. Continue speaking then wait for assistant audio reply.
Expected result: realtime Talk audio continues while OpenClaw is backgrounded.
Reopen OpenClaw, confirm Talk is still active, then tap `Stop Talk`.
## Gateway Status
1. Tap `Control`.
2. Tap `Instances`.
3. Confirm the screen shows `Gateway online`.
4. Confirm at least one `agent` row is connected.
5. Confirm the iPhone review device appears in the connected instances list.
## Push Notification
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Start push notification demo.
```
4. Immediately send OpenClaw to the background and lock the iPhone. Do not
force quit the app.
Expected result: the iPhone Lock Screen receives a visible `OpenClaw`
notification with this body:
```text
OpenClaw App Review push notification demo
```
Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
`Control`, tap `Chat`. Expected chat reply:
```text
The push notification demo completed.
```
## Push Wake / Status
1. Tap the `Chat` tab.
2. Send this exact message:
```text
Start push wake demo.
```
3. Immediately send OpenClaw to the background and lock the iPhone. Do not
force quit the app.
4. Wait for the `OpenClaw` notification on the Lock Screen. It normally appears
about 10 seconds after the message is sent.
5. Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
`Control`, tap `Chat`.
Expected result: the app reconnects to the live Gateway and Chat replies:
```text
The push wake and node status demo completed.
```
## Device Permissions
1. Tap `Settings`.
2. Tap `Permissions`.
3. Confirm these current app controls are available:
- `Camera`
- `Location` with `Off`, `While Using`, and `Always`
- `Keep Awake`
4. Expand `Privacy & Access`.
5. Confirm these request controls are available:
- `Contacts` / `Request Access`
- `Calendar (Add Events)` / `Request Access`
- `Calendar (View Events)` / `Request Full Access`
- `Reminders` / `Request Access`
## Share Sheet
1. Open Safari.
2. Navigate to `https://example.com`.
3. Tap the Safari toolbar `More` button.
4. Tap `Share`.
5. Tap `OpenClaw`.
6. Confirm the OpenClaw share extension appears and shows
`Edit text, then tap Send.` and `Send to OpenClaw`.
7. Tap `Send to OpenClaw`.
Expected result: the OpenClaw share extension sends the shared Safari page to
the live review Gateway and shows `Sent to OpenClaw.` Returning to OpenClaw
Chat shows the shared `Example Domain` page.

View File

@@ -74,30 +74,23 @@ struct OpenClawLiveActivity: Widget {
private func statusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
if state.isConnecting {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(OpenClawActivityStyle.info)
.foregroundStyle(.cyan)
} else if state.isDisconnected {
Image(systemName: "wifi.slash")
.foregroundStyle(OpenClawActivityStyle.danger)
.foregroundStyle(.red)
} else if state.isIdle {
Image(systemName: "checkmark")
.foregroundStyle(OpenClawActivityStyle.ok)
.foregroundStyle(.green)
} else {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(OpenClawActivityStyle.warn)
.foregroundStyle(.orange)
}
}
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
if state.isDisconnected { return OpenClawActivityStyle.danger }
if state.isConnecting { return OpenClawActivityStyle.info }
if state.isIdle { return OpenClawActivityStyle.ok }
return OpenClawActivityStyle.warn
if state.isDisconnected { return .red }
if state.isConnecting { return .cyan }
if state.isIdle { return .green }
return .orange
}
}
private enum OpenClawActivityStyle {
static let info = Color(red: 0, green: 122 / 255.0, blue: 1)
static let danger = Color(red: 185 / 255.0, green: 28 / 255.0, blue: 28 / 255.0)
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
}

View File

@@ -1,19 +1,5 @@
# OpenClaw iOS Changelog
## 2026.6.10 - 2026-06-21
Maintenance update for the current OpenClaw beta release.
- Improved notification cleanup, Watch app compatibility, and native file input handling.
## 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

@@ -12,7 +12,7 @@
"platform": "IOS",
"profileKey": "OPENCLAW_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS", "APP_ATTEST"],
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
},
{

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